diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 13905f931f..f24819bd4c 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -763,6 +763,14 @@ public enum SleepStager { /// explicitly). An absent band stream (WHOOP 4.0 / unbanded window) is a no-op regardless. public static let bandStateWakeVetoEnabled: Bool = false + /// #1210 item 2 (retroactive-rescore story): the new-nights-only cutoff. When the veto is flipped on + /// (`bandStateWakeVetoEnabled = true`), a NON-ZERO cutoff (a unix second) restricts the correction to + /// sessions whose `start >= cutoffTs` — nights already in history keep their raw efficiency, so flipping + /// the default cannot silently re-score months of banked nights upward. `0` (the default) applies the + /// veto to every banded night (the "just ship the one-time shift" path). Inert while the flag is off. + /// Set this to the flip date at the same time as the flag. Mirrors Kotlin `bandStateWakeVetoCutoffTs`. + public static let bandStateWakeVetoCutoffTs: Int = 0 + /// The sleep stage a band-vetoed false-wake epoch is reclassified to. `bandStateAsleep` (band sleep_state == 2) means /// only "asleep" — the band carries NO light/deep/REM resolution — so the veto maps it to the generic, /// most-common sleep stage rather than inventing deep/REM detail the strap never asserted (deep/REM @@ -770,6 +778,16 @@ public enum SleepStager { /// bare "asleep". static let bandVetoRecoverStage: String = "light" + /// #1210: a TRACE-ONLY line reporting what the (dormant) band wake-veto WOULD recover on a banded night, + /// so a validator gathers the recovered-minutes distribution across real nights without any output change + /// (the persisted hypnogram stays the flag-gated, unchanged one). Namespaced `bandVeto(shadow):` and free + /// of a `day=` / `t=…s` token so `CaptureAccumulator` never counts it as a captured day. Mirrors Kotlin + /// `bandVetoShadowLine`. (band sleep_state veto) + static func bandVetoShadowLine(startTs: Int, recoveredMin: Double, rawEff: Double, shadowEff: Double) -> String { + "bandVeto(shadow): startTs=\(startTs) recoveredMin=\(Int(recoveredMin.rounded())) " + + "eff \(Int((rawEff * 100).rounded()))%->\(Int((shadowEff * 100).rounded()))%" + } + /// Band sleep_state WAKE-veto. Given a staged hypnogram `stages` (StageSegments tiling `[start, end]`) /// and the strap's OWN per-timestamp band sleep_state, reclassify INTERIOR wake epochs the strap itself /// scored "asleep" (`bandStateAsleep`) to `bandVetoRecoverStage`. Conservative by construction: @@ -786,10 +804,14 @@ public enum SleepStager { /// (band sleep_state veto) static func applyBandStateWakeVeto(_ stages: [StageSegment], start: Int, end: Int, bandSleepState: [(ts: Int, state: Int)], - enabled: Bool = bandStateWakeVetoEnabled) -> [StageSegment] { + enabled: Bool = bandStateWakeVetoEnabled, + cutoffTs: Int = bandStateWakeVetoCutoffTs) -> [StageSegment] { guard enabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else { return stages } + // #1210 item 2: new-nights-only gate. A non-zero cutoff spares nights that started before it (history + // keeps its raw efficiency); `0` applies to every banded night. Inert while the flag is off. + if cutoffTs > 0, start < cutoffTs { return stages } // Per-epoch band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. let states = sessionEpochSleepState(start: start, end: end, sleepState: bandSleepState) if states.isEmpty { return stages } @@ -1169,6 +1191,25 @@ public enum SleepStager { traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, verdict: .kept, gate: "accepted", detail: "spanMin=\(spanMin) eff=\(round2(eff)) restingHR=\(resting ?? -1) daytime=\(isDaytime)")) + // #1210 shadow: the band wake-veto is dormant (default-off), but its recovered-vs-reverse ratio + // can only come from banded nights. When a band stream is present, compute what the veto WOULD + // recover and trace it — OUTPUT-NEUTRAL: `stages`/`eff` persisted above are the flag-gated + // (unchanged) values and nothing reads `shadowStages`. Guarded on `traceSink`, so it fires ONLY + // while a diagnostic is collecting (zero production cost). Retire once the flip (#1210) lands. + if let traceSink, !bandStateWakeVetoEnabled, !bandSleepState.isEmpty { + // `cutoffTs: 0` on purpose: the shadow measures the FULL veto potential across EVERY banded + // night (history included) to build the validation distribution, independent of whatever + // new-nights cutoff the eventual flip uses. + let shadowStages = applyBandStateWakeVeto(rawStages, start: p.start, end: p.end, + bandSleepState: bandSleepState, + enabled: true, cutoffTs: 0) + let shadowEff = efficiency(start: p.start, end: p.end, stages: shadowStages) + let recoveredMin = (shadowEff - eff) * Double(p.end - p.start) / 60.0 + if recoveredMin >= 1.0 { + traceSink(bandVetoShadowLine(startTs: p.start, recoveredMin: recoveredMin, + rawEff: eff, shadowEff: shadowEff)) + } + } // A run that does NOT continue the chain re-anchors it on this run's onset. if !continuesChain { chainFromOvernight = isOvernightOnset(p.start, tzOffsetSeconds: tzOffsetSeconds) } chainPrevEnd = p.end diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift index 49d4a901f8..6ea4bcff3f 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -1181,6 +1181,59 @@ final class SleepStagerTests: XCTestCase { "default-off: efficiency is untouched by the band stream") } + func testBandVetoShadowLineIsNamespacedAndUncounted() { + // #1210 shadow: the validation line formats the recovered delta and is NEVER miscounted as a captured + // sleep day (carries no `sleep day=` / `gate run=` / `day=` token). Byte-identical format to Kotlin. + let line = SleepStager.bandVetoShadowLine(startTs: 1_723_000_000, recoveredMin: 31.4, + rawEff: 0.742, shadowEff: 0.808) + XCTAssertEqual(line, "bandVeto(shadow): startTs=1723000000 recoveredMin=31 eff 74%->81%") + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .sleep, reportText: line, tzOffsetSeconds: 0), 0, + "shadow line must not be counted as a captured sleep day") + } + + func testBandVetoShadowTraceReportsRecoveryOutputNeutral() { + // #1210 shadow: veto DORMANT (default-off) + a band present + a collecting traceSink -> a + // `bandVeto(shadow):` line quantifying what the veto WOULD recover, WITHOUT changing the persisted + // hypnogram (detectSleep's documented trace contract). Same burst fixture as the default-off wiring + // test. (The line reports MAGNITUDE only; whether the move is toward truth needs the PSG harness.) + let start = nightStart(2) + let dur = 6 * 3600 + var grav = stillGravity(start: start, durationS: dur) + var hr = hrStream(start: start, durationS: dur, bpm: 50) + for i in (3 * 3600)..<(3 * 3600 + 5 * 60) { // interior 5-min burst -> false wake + grav[i] = GravitySample(ts: start + i, x: Double(i % 2) * 0.5, y: 0, z: 1.0) + hr[i] = HRSample(ts: start + i, bpm: 95) + } + let band = bandAllAsleep(start: start, end: start + dur) + let untraced = SleepStager.detectSleep(hr: hr, gravity: grav, bandSleepState: band) + var lines: [String] = [] + let traced = SleepStager.detectSleep(hr: hr, gravity: grav, bandSleepState: band, + traceSink: { lines.append($0) }) + XCTAssertEqual(traced[0].stages, untraced[0].stages, + "shadow trace must not change the persisted hypnogram") + XCTAssertTrue(lines.contains { $0.hasPrefix("bandVeto(shadow):") && $0.contains("recoveredMin=") }, + "a banded night with interior false-wake emits a shadow line") + } + + func testBandVetoCutoffSparesNightsBeforeCutoff() { + // #1210 item 2: with the veto ON, a non-zero cutoff spares a night that STARTED before it (history + // keeps its raw hypnogram) and applies to one starting at/after it. Boundary is inclusive. Kotlin twin. + let base = 1_000_000 + let fixture = vetoHypnoFixture().map { + StageSegment(start: $0.start + base, end: $0.end + base, stage: $0.stage) + } + let band = bandAllAsleep(start: base, end: base + 960) + let recovered = SleepStager.applyBandStateWakeVeto(fixture, start: base, end: base + 960, + bandSleepState: band, enabled: true, cutoffTs: 0) + XCTAssertNotEqual(recovered, fixture, "sanity: the veto does change this fixture") + XCTAssertEqual(SleepStager.applyBandStateWakeVeto(fixture, start: base, end: base + 960, + bandSleepState: band, enabled: true, cutoffTs: base + 1), + fixture, "a night starting before the cutoff keeps its raw hypnogram") + XCTAssertEqual(SleepStager.applyBandStateWakeVeto(fixture, start: base, end: base + 960, + bandSleepState: band, enabled: true, cutoffTs: base), + recovered, "a night starting at/after the cutoff (inclusive) gets the veto") + } + // MARK: - REM-funnel diagnostic (#688) /// A still, REM-eligible epoch (still + cardiac-activated + irregular resp). The percentile diff --git a/android/app/src/main/java/com/noop/analytics/SleepStager.kt b/android/app/src/main/java/com/noop/analytics/SleepStager.kt index 6d6ddb88d2..5a2067fbe6 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -157,13 +157,24 @@ object SleepStager { * this conservative. Mirrors Swift `morningReonsetBandAsleepFrac`. (H8 consume) */ const val morningReonsetBandAsleepFrac: Double = 0.6 - /** Default-ON gate for the band sleep_state WAKE-veto. Flip to false to fall back to the byte-identical - * pre-veto hypnogram. [bandStateAsleep] is WHOOP's OWN banked verdict (not a signal we re-derive), which - * is why vetoing false-wakes with it is well-founded; it stays a single flip-point + fully tested. An - * absent band stream (WHOOP 4.0 / unbanded window) makes the veto a no-op regardless of this flag. - * Mirrors Swift `bandStateWakeVetoEnabled`. (band sleep_state veto) */ + /** Default-OFF gate for the band sleep_state WAKE-veto (the SHIPPED state; the pre-veto hypnogram is what + * users see). [bandStateAsleep] is WHOOP's OWN banked verdict (not a signal we re-derive), so vetoing + * false-wakes with it is well-founded on the n=12 that motivated it — but the PSG harness measures the + * recipe UNDER-calling wake overall (bias -4.92 pp), so a veto that converts wake->light moves the + * population result AWAY from truth. Flip to true only with PSG evidence in hand (see #1210), and set + * [bandStateWakeVetoCutoffTs] alongside to spare history. It stays a single flip-point + fully tested + * (tests pass `enabled = true` explicitly). An absent band stream (WHOOP 4.0 / unbanded window) makes the + * veto a no-op regardless of this flag. Mirrors Swift `bandStateWakeVetoEnabled`. (band sleep_state veto) */ const val bandStateWakeVetoEnabled: Boolean = false + /** #1210 item 2 (retroactive-rescore story): the new-nights-only cutoff. When the veto is flipped on + * ([bandStateWakeVetoEnabled] = true), a NON-ZERO cutoff (a unix second) restricts the correction to + * sessions whose `start >= cutoffTs` — nights already in history keep their raw efficiency, so flipping + * the default cannot silently re-score months of banked nights upward. `0` (the default) applies the veto + * to every banded night (the "just ship the one-time shift" path). Inert while the flag is off. Set this + * to the flip date at the same time as the flag. Byte-identical to Swift `bandStateWakeVetoCutoffTs`. */ + const val bandStateWakeVetoCutoffTs: Long = 0L + /** The sleep stage a band-vetoed false-wake epoch is reclassified to. [bandStateAsleep] (band sleep_state == 2) means * only "asleep" — the band carries NO light/deep/REM resolution — so the veto maps it to the generic, * most-common sleep stage rather than inventing deep/REM detail the strap never asserted (deep/REM @@ -171,6 +182,15 @@ object SleepStager { * bare "asleep". Mirrors Swift `bandVetoRecoverStage`. (band sleep_state veto) */ const val bandVetoRecoverStage: String = "light" + /** #1210: a TRACE-ONLY line reporting what the (dormant) band wake-veto WOULD recover on a banded night, + * so a validator gathers the recovered-minutes distribution across real nights without any output change + * (the persisted hypnogram stays the flag-gated, unchanged one). Namespaced `bandVeto(shadow):` and free + * of a `day=` / `t=…s` token so `CaptureAccumulator` never counts it as a captured day. Byte-identical to + * Swift `bandVetoShadowLine`. (band sleep_state veto) */ + internal fun bandVetoShadowLine(startTs: Long, recoveredMin: Double, rawEff: Double, shadowEff: Double): String = + "bandVeto(shadow): startTs=$startTs recoveredMin=${Math.round(recoveredMin)} " + + "eff ${Math.round(rawEff * 100)}%->${Math.round(shadowEff * 100)}%" + /** Seconds in a calendar day (for local-hour-of-day arithmetic). */ const val secondsPerDay: Long = 86_400L @@ -835,10 +855,14 @@ object SleepStager { stages: List, start: Long, end: Long, bandSleepState: List>, enabled: Boolean = bandStateWakeVetoEnabled, + cutoffTs: Long = bandStateWakeVetoCutoffTs, ): List { if (!enabled || bandSleepState.isEmpty() || stages.isEmpty() || end <= start) { return stages } + // #1210 item 2: new-nights-only gate. A non-zero cutoff spares nights that started before it (history + // keeps its raw efficiency); `0` applies to every banded night. Inert while the flag is off. + if (cutoffTs > 0L && start < cutoffTs) return stages // Per-epoch band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. val states = sessionEpochSleepState(start, end, bandSleepState) if (states.isEmpty()) return stages @@ -1253,6 +1277,24 @@ object SleepStager { traceSink?.invoke(SleepStagerTrace.runLine(runIndex, p.start, p.end, SleepStagerTrace.Verdict.KEPT, "accepted", "spanMin=$spanMin eff=${SleepStagerTrace.round2(eff)} restingHR=${resting ?: -1} daytime=$isDaytime")) + // #1210 shadow: the band wake-veto is dormant (default-off), but its recovered-vs-reverse ratio + // can only come from banded nights. When a band stream is present, compute what the veto WOULD + // recover and trace it — OUTPUT-NEUTRAL: `stages`/`eff` persisted above are the flag-gated + // (unchanged) values and nothing reads `shadowStages`. Guarded on `traceSink`, so it fires ONLY + // while a diagnostic is collecting (zero production cost). Retire once the flip (#1210) lands. + if (traceSink != null && !bandStateWakeVetoEnabled && bandSleepState.isNotEmpty()) { + // cutoffTs = 0 on purpose: the shadow measures the FULL veto potential across EVERY banded + // night (history included) to build the validation distribution, independent of whatever + // new-nights cutoff the eventual flip uses. + val shadowStages = applyBandStateWakeVeto(rawStages, start = p.start, end = p.end, + bandSleepState = bandSleepState, enabled = true, cutoffTs = 0L) + val shadowEff = efficiency(start = p.start, end = p.end, stages = shadowStages) + val recoveredMin = (shadowEff - eff) * (p.end - p.start).toDouble() / 60.0 + if (recoveredMin >= 1.0) { + traceSink(bandVetoShadowLine(startTs = p.start, recoveredMin = recoveredMin, + rawEff = eff, shadowEff = shadowEff)) + } + } // A run that does NOT continue the chain re-anchors it on this run's onset. if (!continuesChain) chainFromOvernight = isOvernightOnset(p.start, tzOffsetSeconds) chainPrevEnd = p.end diff --git a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt index a74c70e1db..c033a1b5a3 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -2,8 +2,11 @@ package com.noop.analytics import com.noop.data.GravitySample import com.noop.data.HrSample +import com.noop.testcentre.CaptureAccumulator +import com.noop.testcentre.TestDomain import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test import kotlin.math.ceil @@ -188,4 +191,62 @@ class SleepStagerBandVetoTest { assertEquals("default-off: efficiency is untouched by the band stream", noBand[0].efficiency, withBand[0].efficiency, 1e-9) } + + @Test + fun cutoffSparesNightsBeforeCutoff() { + // #1210 item 2: with the veto ON, a non-zero cutoff spares a night that STARTED before it (history + // keeps its raw hypnogram) and applies to one starting at/after it. Boundary is inclusive. Swift twin. + val base = 1_000_000L + val fixture = vetoHypnoFixture().map { + StageSegment(start = it.start + base, end = it.end + base, stage = it.stage) + } + val band = bandAllAsleep(start = base, end = base + 960) + val recovered = SleepStager.applyBandStateWakeVeto(fixture, start = base, end = base + 960, + bandSleepState = band, enabled = true, cutoffTs = 0L) + assertNotEquals("sanity: the veto does change this fixture", recovered, fixture) + assertEquals("a night starting before the cutoff keeps its raw hypnogram", + fixture, SleepStager.applyBandStateWakeVeto(fixture, start = base, end = base + 960, + bandSleepState = band, enabled = true, cutoffTs = base + 1)) + assertEquals("a night starting at/after the cutoff (inclusive) gets the veto", + recovered, SleepStager.applyBandStateWakeVeto(fixture, start = base, end = base + 960, + bandSleepState = band, enabled = true, cutoffTs = base)) + } + + @Test + fun shadowLineIsNamespacedAndUncountedByCaptureAccumulator() { + // #1210 shadow: the validation line formats the recovered delta and is NEVER miscounted as a captured + // sleep day (carries no `sleep day=` / `gate run=` / `day=` token). Byte-identical format to Swift. + val line = SleepStager.bandVetoShadowLine( + startTs = 1_723_000_000L, recoveredMin = 31.4, rawEff = 0.742, shadowEff = 0.808, + ) + assertEquals("bandVeto(shadow): startTs=1723000000 recoveredMin=31 eff 74%->81%", line) + assertEquals("shadow line must not be counted as a captured sleep day", + 0, CaptureAccumulator.capturedDays(TestDomain.SLEEP, line, 0L)) + } + + @Test + fun shadowTraceReportsRecoveryOutputNeutral() { + // #1210 shadow: veto DORMANT (default-off) + a band present + a collecting traceSink -> a + // `bandVeto(shadow):` line quantifying what the veto WOULD recover, WITHOUT changing the persisted + // hypnogram (detectSleep's documented trace contract). Same burst fixture as the default-off wiring + // test. (The line reports MAGNITUDE only; whether the move is toward truth needs the PSG harness.) + val start = startAtHour(2) + val dur = 6 * 3600 + val grav = stillGravity(start, dur).toMutableList() + val hr = hrStream(start, dur, 50).toMutableList() + for (i in (3 * 3600) until (3 * 3600 + 5 * 60)) { // interior 5-min burst -> false wake + grav[i] = GravitySample(deviceId = dev, ts = start + i, x = (i % 2) * 0.5, y = 0.0, z = 1.0) + hr[i] = HrSample(deviceId = dev, ts = start + i, bpm = 95) + } + val band = bandAllAsleep(start = start, end = start + dur) + val untraced = SleepStager.detectSleep(hr = hr, gravity = grav, bandSleepState = band) + val lines = mutableListOf() + val traced = SleepStager.detectSleep( + hr = hr, gravity = grav, bandSleepState = band, traceSink = { lines.add(it) }, + ) + assertEquals("shadow trace must not change the persisted hypnogram", + untraced[0].stages, traced[0].stages) + assertTrue("a banded night with interior false-wake emits a shadow line", + lines.any { it.startsWith("bandVeto(shadow):") && it.contains("recoveredMin=") }) + } }