diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift index a7d7c84bdc..a49ec28715 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift @@ -197,8 +197,14 @@ public enum SleepStagerV2 { f.moveFrac <= 0.0 && f.jerkMax <= f.jerkScale * jerkFloorGateMult } - /// Weight of the RSA respiration-regularity term (regular → deep, irregular → REM). - static let respWeight = 0.6 + /// Weight of the RSA respiration-regularity term (regular → deep, irregular → REM). 0.3, halved from 0.6 + /// on PSG with R-R live (PhysioNet DREAMT, n = 100, `Tools/SleepPSG` section 8): the feature does separate + /// the stages the way the term assumes (AUC deep vs REM 0.658, light vs REM 0.597), but at 0.6 a light + /// epoch's z of about ±1 moved it into deep or REM. Per subject, 0.3 raises kappa for 61 and lowers it for + /// 28 (mean 0.204 → 0.216) and REM falls from 21.4 to 18.4 % of sleep against 14.0 % true; the separation + /// itself implies a weight of 0.29–0.35. sleep-accel carries no R-R, so it is unaffected. Kotlin twin: + /// `SleepStagerV2.respWeight`. + static let respWeight = 0.3 /// Transition matrix (rows = from, cols = to). Self-transitions dominate; deep↔rem rare; wake mostly /// to/from light. A priori, not fit. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift index b72c595a69..07b92313ec 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift @@ -280,7 +280,7 @@ final class SleepStagerV2Tests: XCTestCase { // MARK: - 7.0.0: the V2 flag drives the NORMAL detected-night staging path /// A regular R-R stream at ~1 Hz (steady ~1000 ms beats with a small respiratory sinus oscillation), - /// long enough for the V2 recipe to express both early deep and later REM across the night. + /// long enough for the V2 recipe to stage a whole night. private func regularRRLong(start: Int, durationS: Int) -> [RRInterval] { (0.. RRInterval in let rsa = Int(40.0 * sin(2.0 * Double.pi * Double(i) / 4.0)) // ~0.25 Hz breathing @@ -290,7 +290,7 @@ final class SleepStagerV2Tests: XCTestCase { /// 7.0.0 regression: the "Experimental sleep staging (V2)" toggle must affect a NORMAL detected /// night — not only the userEdited self-heal restage. With the flag ON, `detectSleep` stages the - /// accepted window with V2 (deep + REM present); with the flag OFF it returns the EXACT V1 result, so + /// accepted window with V2 (its own hypnogram, not V1's); with the flag OFF it returns the EXACT V1 result, so /// the byte-identical default (and the frozen-golden tests) is preserved. func testDetectSleepThreadsV2FlagIntoNormalNight() { // A 3 h still overnight window (anchored at 01:00 UTC → center ~02:30, clear of the daytime @@ -322,14 +322,19 @@ final class SleepStagerV2Tests: XCTestCase { XCTAssertEqual(v2.start, v1.start) XCTAssertEqual(v2.end, v1.end) // The hypnogram is V2's: it matches a direct V2 stageSession over the accepted span, and (proof - // the flag actually flipped the engine) it expresses both deep and REM. + // the flag actually flipped the engine) it is not V1's. This used to assert that V2 expressed deep + // here, but nothing in this night calls for deep (flat HR, identical breathing in every epoch): 354 of + // its 360 epochs score 0.812972, and the three at each edge, whose beat windows reach past the stream, + // differ in the fourth decimal. The per-night z-score (sd 0.00016) turns that into z of +2.7 to +11.6, + // which the RSA term at 0.6 painted deep. At 0.3 it no longer does. let v2Direct = SleepStagerV2.stageSession(start: v2.start, end: v2.end, grav: grav, hr: hr, rr: rr, resp: []) XCTAssertEqual(v2.stages.map { $0.stage }, v2Direct.map { $0.stage }, "flag ON must produce the V2 hypnogram") - let v2Stages = Set(v2.stages.map { $0.stage }) - XCTAssertTrue(v2Stages.contains("deep"), "V2 night should express deep") - XCTAssertTrue(v2Stages.contains("rem"), "V2 night should express REM") + XCTAssertNotEqual(v2.stages.map { "\($0.start) \($0.end) \($0.stage)" }, + v1.stages.map { "\($0.start) \($0.end) \($0.stage)" }, + "flag ON must not produce the V1 hypnogram") + XCTAssertTrue(v2.stages.contains { $0.stage == "rem" }, "V2 night should express REM") } // MARK: - #277: lock the V2 recipe shape + parity (golden) and the tuned deep-boundary values (directly) @@ -374,7 +379,7 @@ final class SleepStagerV2Tests: XCTestCase { let segs = SleepStagerV2.stageSession(start: start, end: start + dur, grav: grav, hr: hr, rr: rr, resp: []) let golden: [(Int, Int, String)] = [ (0, 5070, "deep"), (5070, 5310, "light"), (5310, 5550, "rem"), - (5550, 10740, "light"), (10740, 16290, "rem"), (16290, 21600, "wake")] + (5550, 10740, "light"), (10740, 16200, "rem"), (16200, 21600, "wake")] XCTAssertEqual(segs.count, golden.count, "segment count") for k in 0..