From a6ae0c39e633f2da4e91e22725ef50926e05978b Mon Sep 17 00:00:00 2001 From: ryanbr Date: Wed, 12 Aug 2026 23:53:21 +1200 Subject: [PATCH 1/3] sleep(#1210): shadow-instrument the dormant band wake-veto (output-neutral) Follow-up to #1210 / #738. The band sleep_state wake-veto is landed but default-off, so we have no measurement of what it WOULD do on real banded nights - the exact recovered-vs-reverse ratio #1210 says is still too thin (12 nights). This adds an OUTPUT-NEUTRAL shadow: when a band stream is present, the flag is off, and a diagnostic traceSink is collecting, scoring computes what the veto would recover and emits one namespaced trace line per banded night: bandVeto(shadow): startTs= recoveredMin= eff %-> % Nothing changes in the persisted hypnogram or efficiency - the shadow stages are computed for the log only; the flag-gated (unchanged) stages are still what is stored and displayed. Guarded on traceSink, so zero cost in normal operation; it fires only while a diagnostic is being generated, which re-scores the recent window and thus reports the delta for every recent banded night. Honest scope: the line reports MAGNITUDE and FREQUENCY (how often the band disputes our interior wake, and by how much), which is what #1210 item 1 asks to widen. It does NOT settle the SIGN question - whether that move is toward truth. The PSG harness currently measures the recipe UNDER-calling wake overall (bias -4.92 pp), so the flip decision still needs the truth comparison, not just the band. This gathers the band side of that evidence across wearers without any visible change. Retire on the flip. Byte-identical Swift (StrandAnalytics) + Kotlin analytics; the shadow line carries no sleep day= / gate run= / day= token so CaptureAccumulator never counts it as a captured day (pinned by a test both platforms). Plus an end-to-end test that a banded night with interior false-wake emits the line while the returned hypnogram stays byte-identical to the untraced call. No app-target Swift; swift-packages + android unit tests cover it. --- .../Sources/StrandAnalytics/SleepStager.swift | 25 ++++++++++++ .../SleepStagerTests.swift | 34 ++++++++++++++++ .../java/com/noop/analytics/SleepStager.kt | 24 +++++++++++ .../noop/analytics/SleepStagerBandVetoTest.kt | 40 +++++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 13905f931f..d29009b335 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -770,6 +770,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: @@ -1169,6 +1179,21 @@ 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 { + let shadowStages = applyBandStateWakeVeto(rawStages, start: p.start, end: p.end, + bandSleepState: bandSleepState, enabled: true) + 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..ab67cba8f7 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -1181,6 +1181,40 @@ 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") + } + // 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..6ace753986 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -171,6 +171,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 @@ -1253,6 +1262,21 @@ 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()) { + val shadowStages = applyBandStateWakeVeto(rawStages, start = p.start, end = p.end, + bandSleepState = bandSleepState, enabled = true) + 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..4e4031df40 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -2,6 +2,8 @@ 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.assertTrue @@ -188,4 +190,42 @@ class SleepStagerBandVetoTest { assertEquals("default-off: efficiency is untouched by the band stream", noBand[0].efficiency, withBand[0].efficiency, 1e-9) } + + @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=") }) + } } From e9a3d59ef731ff2daf1e02bca618feec1166ab7a Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 13 Aug 2026 00:01:34 +1200 Subject: [PATCH 2/3] sleep(#1210 item 2): add the new-nights-only cutoff to the band wake-veto Complements the shadow instrumentation with the SAFE half of option B: a dormant bandStateWakeVetoCutoffTs (0 = every night). When the veto is eventually flipped on, a non-zero cutoff (a unix second) restricts the correction to sessions whose start is at or after it, so nights already in history keep their raw efficiency - flipping the default can never silently re-score months of banked nights upward. Inclusive boundary. This resolves #1210 item 2 (the retroactive-rescore decision) by building the gate-to-new-nights path, without flipping anything: the flag stays false, so the cutoff is inert. The eventual flip is then a dated one-liner (set enabled = true AND cutoffTs = the flip date) with history provably untouched. The shadow (previous commit) deliberately passes cutoffTs = 0 so it still measures the FULL veto potential across every banded night, history included, for the validation distribution - independent of whatever cutoff the flip later uses. Byte-identical Swift + Kotlin; pinned by a test both platforms (a night before the cutoff is unchanged, one at/after it gets the veto). No app-target Swift. --- .../Sources/StrandAnalytics/SleepStager.swift | 20 ++++++++++++++++-- .../SleepStagerTests.swift | 19 +++++++++++++++++ .../java/com/noop/analytics/SleepStager.kt | 17 ++++++++++++++- .../noop/analytics/SleepStagerBandVetoTest.kt | 21 +++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index d29009b335..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 @@ -796,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 } @@ -1185,8 +1197,12 @@ public enum SleepStager { // (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) + 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 { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift index ab67cba8f7..6ea4bcff3f 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -1215,6 +1215,25 @@ final class SleepStagerTests: XCTestCase { "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 6ace753986..0e098f20a4 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -164,6 +164,14 @@ object SleepStager { * 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 @@ -844,10 +852,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 @@ -1268,8 +1280,11 @@ object SleepStager { // (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) + 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) { 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 4e4031df40..c033a1b5a3 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -6,6 +6,7 @@ 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 @@ -191,6 +192,26 @@ class SleepStagerBandVetoTest { 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 From 178a259f817f06a752e1d88043565d904155c234 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 13 Aug 2026 00:03:50 +1200 Subject: [PATCH 3/3] sleep: fix stale bandStateWakeVetoEnabled doc (Kotlin) - it is default-OFF The Kotlin flag doc read 'Default-ON gate ... Flip to false', contradicting both the actual value (false) and the Swift twin it claims to mirror (default-off, flip to true only with PSG evidence, -4.92 pp under-call). A maintainer reading the Kotlin side would misjudge the shipped state. Align it to reality + the Swift source of truth; note the new-nights cutoff to set alongside the flip. Doc-only, no behaviour change. --- .../src/main/java/com/noop/analytics/SleepStager.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 0e098f20a4..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,11 +157,14 @@ 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