From 58e7c054f11a29f002a64df05e1e5e41bfe8b5d6 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:52:33 -0400 Subject: [PATCH 1/5] Recover strap-disputed false wakes via @73 band-state veto NOOP's EEG-free cardiorespiratory stager over-calls WAKE: it reads still, low-HR but not-quite-asleep epochs as wake far more often than the wearer was actually awake. WHOOP's own per-second sleep-state band (#175) is an independent scored signal, and on real banded nights it scores "asleep" across ~two-thirds of the epochs NOOP calls wake, with the reverse disagreement (NOOP asleep, band wake) an order of magnitude smaller. Add applyBandStateWakeVeto: after staging, reclassify an INTERIOR wake epoch to "light" only when the aligned band state == asleep (2). The leading onset-latency and trailing final-wake blocks are never touched, and only wake is ever turned into sleep (efficiency can only rise). No-op when the band is absent (WHOOP 4.0 / unbanded window) or the flag is off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Default-on via bandStateWakeVetoEnabled. Validated on 12 real banded nights: recovers ~30 min/night of spurious wake at ~2 min/night reverse error. --- .../Sources/StrandAnalytics/SleepStager.swift | 92 +++++++++++++- .../SleepStagerTests.swift | 117 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index cef0f97645..0969964e1b 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -689,6 +689,90 @@ public enum SleepStager { return Double(asleep) / Double(inBlock.count) >= morningReonsetBandAsleepFrac } + // MARK: - H9 @73 band-state WAKE-veto (recover strap-disputed false wakes) + + // NOOP's cardiorespiratory stager is known to OVER-CALL wake: an EEG-free stager reads a still, low-HR + // but not-quite-asleep epoch as wake far more often than the wearer was actually awake. WHOOP's OWN + // per-second sleep-state band (the persisted v18 @81 high-nibble `(sb>>4)&3`: 0 wake/1 still/2 asleep/ + // 3 up — #175, `sleepStateJSON`) is an INDEPENDENT scored signal, not a re-derivation of ours. On real + // banded nights the strap scores "asleep" (`bandStateAsleep`) across ~two-thirds of the epochs NOOP + // calls wake, while the reverse disagreement (NOOP asleep, strap wake) is an order of magnitude smaller. + // So letting the strap's OWN "asleep" verdict VETO an INTERIOR wake call recovers most of the spurious + // wake with near-zero downside. Unlike the H7 CONSUME confirm (which only ever KEEPS a whole borderline + // re-onset session), this operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. + + /// Default-ON gate for the H9 @73 band-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. + public static let bandStateWakeVetoEnabled: Bool = true + + /// The sleep stage a band-vetoed false-wake epoch is reclassified to. `bandStateAsleep` (@73 == 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 + /// minutes feed the recovery gate; the veto must not inflate them). "light" is the honest projection of a + /// bare "asleep". + static let bandVetoRecoverStage: String = "light" + + /// H9 @73 band-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: + /// - ONLY `bandStateAsleep` (2) vetoes — a "still" (1) / "up" (3) / "wake" (0) band reading is LEFT as + /// wake, so the veto never blind-trusts the band, only its explicit "asleep"; + /// - the LEADING wake block (sleep-onset latency, before the first sleep epoch) and the TRAILING wake + /// block (final-morning wake, after the last sleep epoch) are NEVER touched — the veto cannot move + /// sleep onset earlier or final wake later, it only recovers wake FLANKED by sleep; + /// - it only ever turns wake INTO sleep (raising efficiency), never sleep into wake. + /// The band is gridded to the SAME 30 s epochs as `stagesJSON` / `sessionEpochMotion` via + /// `sessionEpochSleepState`, so epoch i here is epoch i of the persisted `sleepStateJSON`. Empty band + /// state, the flag off, or a hypnogram with no interior sleep → returns `stages` UNCHANGED (byte- + /// identical). Applies to whichever stager (V1 or V2) produced `stages`. Pure + deterministic. (H9) + static func applyBandStateWakeVeto(_ stages: [StageSegment], start: Int, end: Int, + bandSleepState: [(ts: Int, state: Int)]) -> [StageSegment] { + guard bandStateWakeVetoEnabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else { + return stages + } + // Per-epoch @73 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 } + let n = states.count + // Epoch i spans [start + i·epochS, …); boundaries sit on 30 s edges, so expanding the segment tiling + // to a per-epoch stage array and re-collapsing it is an exact round-trip (no-op when nothing changes). + func epochStart(_ i: Int) -> Int { start + Int(Double(i) * epochS) } + var labels = [String](repeating: "wake", count: n) + for i in 0.. [StageSegment] { + [ StageSegment(start: 0, end: 60, stage: "wake"), // epochs 0–1 (onset latency) + StageSegment(start: 60, end: 300, stage: "light"), // epochs 2–9 + StageSegment(start: 300, end: 480, stage: "wake"), // epochs 10–15 (interior WASO) + StageSegment(start: 480, end: 900, stage: "light"), // epochs 16–29 + StageSegment(start: 900, end: 960, stage: "wake") ] // epochs 30–31 (final wake) + } + /// One band sample per 30 s epoch carrying the given states (the shape sessionEpochSleepState grids). + private func bandSamples(start: Int, _ states: [Int]) -> [(ts: Int, state: Int)] { + states.enumerated().map { (ts: start + $0.offset * 30, state: $0.element) } + } + private func bandAllAsleep(start: Int, end: Int) -> [(ts: Int, state: Int)] { + let n = max(1, Int(ceil(Double(end - start) / 30.0))) + return (0.. Int = { $0.filter { $0.stage == "wake" }.reduce(0) { $0 + ($1.end - $1.start) } } + XCTAssertLessThan(wake(out), wake(stages), "the veto only ever turns wake into sleep") + } + + func testBandStateWakeVetoRaisesEfficiencyEndToEnd() { + // WIRING PROOF through detectSleep: a still overnight night with a mid-sleep motion+HR burst that + // NOOP scores as INTERIOR wake. With an all-"asleep" @73 band threaded, that interior wake is + // recovered end to end — efficiency rises and no interior wake survives (only onset/final blocks). + let start = nightStart(2) // 02:00 overnight (skips the daytime nap guard) + 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) { // 5-min burst at +3h: high motion + elevated HR + 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 noBand = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(noBand.count, 1) + let withBand = SleepStager.detectSleep(hr: hr, gravity: grav, + bandSleepState: bandAllAsleep(start: start, end: start + dur)) + XCTAssertEqual(withBand.count, 1) + let wake: (SleepSession) -> Int = { $0.stages.filter { $0.stage == "wake" }.reduce(0) { $0 + ($1.end - $1.start) } } + XCTAssertLessThanOrEqual(wake(withBand[0]), wake(noBand[0]), "the @73 veto can only reduce wake") + XCTAssertGreaterThanOrEqual(withBand[0].efficiency, noBand[0].efficiency, + "recovering strap-disputed false wake raises efficiency") + // With an all-asleep band, every recovered epoch is interior, so any surviving wake is an EDGE block. + let segs = withBand[0].stages + for (i, s) in segs.enumerated() where s.stage == "wake" { + XCTAssertTrue(i == 0 || i == segs.count - 1, + "an all-asleep @73 band leaves no INTERIOR wake — only onset/final-wake blocks") + } + } + // MARK: - REM-funnel diagnostic (#688) /// A still, REM-eligible epoch (still + cardiac-activated + irregular resp). The percentile From 913af76a88452ac80059b1369f1c7a4a7dda8800 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:45:03 -0400 Subject: [PATCH 2/5] Kotlin parity: @73 band-state WAKE-veto Byte-equivalent Kotlin twin of the Swift applyBandStateWakeVeto: same guards, same default-on bandStateWakeVetoEnabled flag, same "light" recover stage, same interior-only (leading/trailing wake never touched) reclassification, wired in at the same point in detectSleepUncached (rawStages -> veto -> stages). Adds SleepStagerBandVetoTest mirroring the Swift band-veto tests. Preserves the cross-platform analytics parity contract (Swift <-> Kotlin). --- .../java/com/noop/analytics/SleepStager.kt | 99 ++++++++++++- .../noop/analytics/SleepStagerBandVetoTest.kt | 138 ++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt 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 9f4d46c608..16ed01f8ea 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -157,6 +157,20 @@ object SleepStager { * this conservative. Mirrors Swift `morningReonsetBandAsleepFrac`. (H8 consume) */ const val morningReonsetBandAsleepFrac: Double = 0.6 + /** Default-ON gate for the H9 @73 band-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`. (H9) */ + const val bandStateWakeVetoEnabled: Boolean = true + + /** The sleep stage a band-vetoed false-wake epoch is reclassified to. [bandStateAsleep] (@73 == 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 + * minutes feed the recovery gate; the veto must not inflate them). "light" is the honest projection of a + * bare "asleep". Mirrors Swift `bandVetoRecoverStage`. (H9) */ + const val bandVetoRecoverStage: String = "light" + /** Seconds in a calendar day (for local-hour-of-day arithmetic). */ const val secondsPerDay: Long = 86_400L @@ -788,6 +802,83 @@ object SleepStager { return asleep.toDouble() / inBlock.size.toDouble() >= morningReonsetBandAsleepFrac } + // ── H9 @73 band-state WAKE-veto (recover strap-disputed false wakes) ────────── + // + // NOOP's cardiorespiratory stager is known to OVER-CALL wake: an EEG-free stager reads a still, low-HR + // but not-quite-asleep epoch as wake far more often than the wearer was actually awake. WHOOP's OWN + // per-second sleep-state band (the persisted v18 @81 high-nibble `(sb>>4)&3`: 0 wake/1 still/2 asleep/ + // 3 up — #175, `sleepStateJSON`) is an INDEPENDENT scored signal, not a re-derivation of ours. On real + // banded nights the strap scores "asleep" ([bandStateAsleep]) across ~two-thirds of the epochs NOOP + // calls wake, while the reverse disagreement (NOOP asleep, strap wake) is an order of magnitude smaller. + // So letting the strap's OWN "asleep" verdict VETO an INTERIOR wake call recovers most of the spurious + // wake with near-zero downside. Unlike the H7 CONSUME confirm (which only ever KEEPS a whole borderline + // re-onset session), this operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. + + /** + * H9 @73 band-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: + * - ONLY [bandStateAsleep] (2) vetoes — a "still" (1) / "up" (3) / "wake" (0) band reading is LEFT as + * wake, so the veto never blind-trusts the band, only its explicit "asleep"; + * - the LEADING wake block (sleep-onset latency, before the first sleep epoch) and the TRAILING wake + * block (final-morning wake, after the last sleep epoch) are NEVER touched — the veto cannot move + * sleep onset earlier or final wake later, it only recovers wake FLANKED by sleep; + * - it only ever turns wake INTO sleep (raising efficiency), never sleep into wake. + * The band is gridded to the SAME 30 s epochs as `stagesJSON` / `sessionEpochMotion` via + * [sessionEpochSleepState], so epoch i here is epoch i of the persisted `sleepStateJSON`. Empty band + * state, the flag off, or a hypnogram with no interior sleep -> returns [stages] UNCHANGED (byte- + * identical). Applies to whichever stager (V1 or V2) produced [stages]. Pure + deterministic. + * Mirrors Swift `applyBandStateWakeVeto`. (H9) + */ + internal fun applyBandStateWakeVeto( + stages: List, start: Long, end: Long, + bandSleepState: List>, + ): List { + if (!bandStateWakeVetoEnabled || bandSleepState.isEmpty() || stages.isEmpty() || end <= start) { + return stages + } + // Per-epoch @73 band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. + val states = sessionEpochSleepState(start, end, bandSleepState) + if (states.isEmpty()) return stages + val n = states.size + // Epoch i spans [start + i·epochS, …); boundaries sit on 30 s edges, so expanding the segment tiling + // to a per-epoch stage array and re-collapsing it is an exact round-trip (no-op when nothing changes). + fun epochStart(i: Int): Long = start + (i.toDouble() * epochS).toLong() + val labels = MutableList(n) { "wake" } + for (i in 0 until n) { + val t = epochStart(i) + val seg = stages.firstOrNull { it.start <= t && t < it.end } + ?: stages.firstOrNull { it.start <= t && t <= it.end } + if (seg != null) labels[i] = seg.stage + } + // Interior = [firstSleep, lastSleep]; leading/trailing wake blocks are excluded from the veto. + val onset = labels.indexOfFirst { it != "wake" } + val finalWake = labels.indexOfLast { it != "wake" } + if (onset < 0 || finalWake < 0 || onset > finalWake) return stages // no sleep at all -> nothing to recover + var changed = false + for (i in onset..finalWake) { + if (labels[i] == "wake" && states[i] == bandStateAsleep) { + labels[i] = bandVetoRecoverStage + changed = true + } + } + if (!changed) return stages // the band disputed nothing -> byte-identical hypnogram + // Re-collapse consecutive same-stage epochs back into segments tiling [start, end]. + val out = ArrayList() + for (i in 0 until n) { + val segStart = epochStart(i) + val segEnd = if (i == n - 1) end else epochStart(i + 1) + val last = out.lastOrNull() + if (last != null && last.stage == labels[i]) { + out[out.size - 1].end = segEnd + } else { + out.add(StageSegment(start = segStart, end = segEnd, stage = labels[i])) + } + } + if (out.isNotEmpty()) out[out.size - 1].end = end + return out + } + /** * Off-wrist HR-gap spans (#500). The contiguous HR-coverage gaps of at least [offWristHRGapMin] * minutes WITHIN [p.start, p.end], as concrete [start, end) sub-intervals — a strong wrist-OFF @@ -1121,13 +1212,19 @@ object SleepStager { "daytime=true restingHR=${resting ?: -1} baseline=${baseline?.toInt() ?: -1} nightTail=false")) continue } - val stages = if (useSleepStagerV2) { + val rawStages = if (useSleepStagerV2) { SleepStagerV2.stageSession(start = p.start, end = p.end, grav = grav, hr = hrS, rr = rrS, resp = respS) } else { stageSession(start = p.start, end = p.end, grav = grav, hr = hrS, rr = rrS, resp = respS) } + // H9 @73 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band + // ([bandSleepState]) scored "asleep". No-op when the band is absent (WHOOP 4.0) or the flag is + // off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Efficiency below is then + // computed on the corrected stages, so a night NOOP over-called wake on reports true efficiency. + val stages = applyBandStateWakeVeto(rawStages, start = p.start, end = p.end, + bandSleepState = bandSleepState) val eff = efficiency(start = p.start, end = p.end, stages = stages) val avgHrv = sessionAvgHRV(start = p.start, end = p.end, rr = rrS) sessions.add( diff --git a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt new file mode 100644 index 0000000000..2330acbb5b --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -0,0 +1,138 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.ceil + +/** + * Pins the H9 @73 band-state WAKE-veto ([SleepStager.applyBandStateWakeVeto]). + * + * NOOP's EEG-free cardiorespiratory stager over-calls WAKE. WHOOP's OWN per-second sleep-state band (#175) + * is an independent scored signal; letting its explicit "asleep" ([SleepStager.bandStateAsleep]) verdict + * VETO an INTERIOR wake epoch recovers most of that spurious wake with near-zero downside. These tests pin + * the contract: only asleep(2) vetoes (still/up/wake never do), the leading onset-latency and trailing + * final-wake blocks are never touched, recovery is per-EPOCH, an absent band is a no-op, the output keeps + * tiling [start,end], and the veto only ever turns wake into sleep. Android twin of the Swift H9 + * band-state wake-veto tests in `SleepStagerTests`. + */ +class SleepStagerBandVetoTest { + + /** + * A hypnogram tiling [0, 960] (32 epochs of 30 s): a leading onset-latency wake block, an INTERIOR + * WASO wake block (epochs 10–15 = [300, 480)), and a trailing final-morning wake block — the exact + * shape the veto must treat differently at the edges vs the interior. + */ + private fun vetoHypnoFixture(): List = listOf( + StageSegment(start = 0, end = 60, stage = "wake"), // epochs 0–1 (onset latency) + StageSegment(start = 60, end = 300, stage = "light"), // epochs 2–9 + StageSegment(start = 300, end = 480, stage = "wake"), // epochs 10–15 (interior WASO) + StageSegment(start = 480, end = 900, stage = "light"), // epochs 16–29 + StageSegment(start = 900, end = 960, stage = "wake"), // epochs 30–31 (final wake) + ) + + /** One band sample per 30 s epoch carrying the given [states] (the shape sessionEpochSleepState grids). */ + private fun bandSamples(start: Long, states: List): List> = + states.mapIndexed { i, s -> (start + i * 30L) to s } + + private fun bandAllAsleep(start: Long, end: Long): List> { + val n = maxOf(1, ceil((end - start).toDouble() / 30.0).toInt()) + return (0 until n).map { (start + it * 30L) to 2 } + } + + @Test + fun recoversInteriorFalseWake() { + // The strap's OWN band reads "asleep" (2) across the WHOLE night. The interior WASO block is + // recovered to light (and merges with the flanking light); the leading onset-latency and trailing + // final-wake blocks are NEVER touched even though the band scored them asleep too. + val out = SleepStager.applyBandStateWakeVeto( + vetoHypnoFixture(), start = 0, end = 960, + bandSleepState = bandAllAsleep(start = 0, end = 960), + ) + assertEquals( + "interior @73-asleep wake -> light (merged); onset-latency + final-wake blocks stay wake", + listOf( + StageSegment(start = 0, end = 60, stage = "wake"), + StageSegment(start = 60, end = 900, stage = "light"), + StageSegment(start = 900, end = 960, stage = "wake"), + ), + out, + ) + } + + @Test + fun onlyAsleepStateVetoes() { + // Interior wake epochs 10–15 get band states still(1)/up(3)/wake(0) — none is asleep(2) — so NONE + // is recovered. (Sleep + edge epochs are asleep(2) but the veto only ever looks at wake epochs, and + // the edges are excluded.) The hypnogram is returned byte-identical. + val states = MutableList(32) { 2 } + val block = listOf(1, 1, 3, 3, 0, 0) + for ((k, i) in (10..15).withIndex()) states[i] = block[k] + val out = SleepStager.applyBandStateWakeVeto( + vetoHypnoFixture(), start = 0, end = 960, + bandSleepState = bandSamples(start = 0, states = states), + ) + assertEquals( + "still/up/wake band never vetoes — only the strap's explicit asleep(2) does", + vetoHypnoFixture(), out, + ) + } + + @Test + fun partialInteriorRecovery() { + // Per-EPOCH: within the interior WASO block, only epochs 10–12 are asleep(2); 13–15 are up(3). The + // block splits — [300,390) recovered to light, [390,480) stays wake — proving epoch granularity. + val states = MutableList(32) { 2 } + for (i in 13..15) states[i] = 3 + val out = SleepStager.applyBandStateWakeVeto( + vetoHypnoFixture(), start = 0, end = 960, + bandSleepState = bandSamples(start = 0, states = states), + ) + assertEquals( + "only the asleep-banded sub-run of an interior wake block is recovered", + listOf( + StageSegment(start = 0, end = 60, stage = "wake"), + StageSegment(start = 60, end = 390, stage = "light"), + StageSegment(start = 390, end = 480, stage = "wake"), + StageSegment(start = 480, end = 900, stage = "light"), + StageSegment(start = 900, end = 960, stage = "wake"), + ), + out, + ) + } + + @Test + fun noOpWhenBandAbsent() { + // No band stream (WHOOP 4.0 / unbanded window) → byte-identical hypnogram, whatever the flag. + assertEquals( + "absent band → veto is a no-op", + vetoHypnoFixture(), + SleepStager.applyBandStateWakeVeto( + vetoHypnoFixture(), start = 0, end = 960, bandSleepState = emptyList(), + ), + ) + // Band entirely outside the window grids to empty → also a no-op (never fabricates asleep). + assertEquals( + vetoHypnoFixture(), + SleepStager.applyBandStateWakeVeto( + vetoHypnoFixture(), start = 0, end = 960, bandSleepState = listOf(100_000L to 2), + ), + ) + } + + @Test + fun preservesTilingAndOnlyRemovesWake() { + assertTrue("H9 veto ships default-ON", SleepStager.bandStateWakeVetoEnabled) + val stages = vetoHypnoFixture() + val out = SleepStager.applyBandStateWakeVeto( + stages, start = 0, end = 960, bandSleepState = bandAllAsleep(start = 0, end = 960), + ) + assertEquals(0L, out.first().start) + assertEquals(960L, out.last().end) + for (i in 1 until out.size) { + assertEquals("segments tile [start,end] with no gaps/overlaps", out[i - 1].end, out[i].start) + } + val wake = { segs: List -> segs.filter { it.stage == "wake" }.sumOf { it.end - it.start } } + assertTrue("the veto only ever turns wake into sleep", wake(out) < wake(stages)) + } +} From 5efee28afea56c44295b9b4a14a85d3ca3913a7b Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:32:32 -0400 Subject: [PATCH 3/5] Name the band-state byte correctly and cover the Kotlin veto wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the H9 band-state WAKE-veto. The comments and test messages called the sleep_state source "@73". That is the skin-temperature byte; the band sleep_state this veto reads lives at @81. Since nothing in the code keyed off the mnemonic, drop the byte number from the prose entirely and say "band-state" / "band sleep_state" — the offset belongs with the decoder, not with the stager that consumes an already-decoded stream. Comment-only on both platforms; no behaviour change. Kotlin had only the five pure-function tests of applyBandStateWakeVeto, so the Android WIRING (rawStages -> veto -> efficiency inside detectSleep) was unverified — and with android.yml disabled, no CI covers it either. Mirror the Swift testBandStateWakeVetoRaisesEfficiencyEndToEnd as raisesEfficiencyEndToEnd: a 6 h still 02:00 night with a 5-min motion+HR burst at +3 h that the stager scores as interior wake, run through detectSleep with and without an all-asleep band. On this fixture the unbanded run reports 300 s of interior wake at 0.986 efficiency and the banded run 0 s at 1.0, so the assertions (wake cannot rise, efficiency cannot fall, no interior wake survives an all-asleep band) are exercised, not vacuous. Swift: 1116 tests, 0 failures. Kotlin: testFullDebugUnitTest --tests '*SleepStager*' 83 tests, 0 failures. --- .../Sources/StrandAnalytics/SleepStager.swift | 12 ++-- .../SleepStagerTests.swift | 10 ++-- .../java/com/noop/analytics/SleepStager.kt | 12 ++-- .../noop/analytics/SleepStagerBandVetoTest.kt | 59 +++++++++++++++++-- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 0969964e1b..8dcd9c6d34 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -689,7 +689,7 @@ public enum SleepStager { return Double(asleep) / Double(inBlock.count) >= morningReonsetBandAsleepFrac } - // MARK: - H9 @73 band-state WAKE-veto (recover strap-disputed false wakes) + // MARK: - H9 band-state WAKE-veto (recover strap-disputed false wakes) // NOOP's cardiorespiratory stager is known to OVER-CALL wake: an EEG-free stager reads a still, low-HR // but not-quite-asleep epoch as wake far more often than the wearer was actually awake. WHOOP's OWN @@ -701,20 +701,20 @@ public enum SleepStager { // wake with near-zero downside. Unlike the H7 CONSUME confirm (which only ever KEEPS a whole borderline // re-onset session), this operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. - /// Default-ON gate for the H9 @73 band-state WAKE-veto. Flip to false to fall back to the byte-identical + /// Default-ON gate for the H9 band-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. public static let bandStateWakeVetoEnabled: Bool = true - /// The sleep stage a band-vetoed false-wake epoch is reclassified to. `bandStateAsleep` (@73 == 2) means + /// 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 /// minutes feed the recovery gate; the veto must not inflate them). "light" is the honest projection of a /// bare "asleep". static let bandVetoRecoverStage: String = "light" - /// H9 @73 band-state WAKE-veto. Given a staged hypnogram `stages` (StageSegments tiling `[start, end]`) + /// H9 band-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: /// - ONLY `bandStateAsleep` (2) vetoes — a "still" (1) / "up" (3) / "wake" (0) band reading is LEFT as @@ -732,7 +732,7 @@ public enum SleepStager { guard bandStateWakeVetoEnabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else { return stages } - // Per-epoch @73 band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. + // 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 } let n = states.count @@ -1064,7 +1064,7 @@ public enum SleepStager { hr: hrS, rr: rrS, resp: respS) : stageSession(start: p.start, end: p.end, grav: grav, hr: hrS, rr: rrS, resp: respS) - // H9 @73 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band + // H9 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band // (`bandSleepState`) scored "asleep". No-op when the band is absent (WHOOP 4.0) or the flag is // off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Efficiency below is then // computed on the corrected stages, so a night NOOP over-called wake on reports true efficiency. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift index 23299767e7..fefef87848 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -1054,7 +1054,7 @@ final class SleepStagerTests: XCTestCase { "the persisted+re-expanded band grid drives the H7 confirm end to end") } - // MARK: - H9 @73 band-state WAKE-veto (recover strap-disputed false wakes) + // MARK: - H9 band-state WAKE-veto (recover strap-disputed false wakes) /// A hypnogram tiling [0, 960] (32 epochs of 30 s): a leading onset-latency wake block, an INTERIOR /// WASO wake block (epochs 10–15 = [300, 480)), and a trailing final-morning wake block — the exact @@ -1085,7 +1085,7 @@ final class SleepStagerTests: XCTestCase { StageSegment(start: 0, end: 60, stage: "wake"), StageSegment(start: 60, end: 900, stage: "light"), StageSegment(start: 900, end: 960, stage: "wake"), - ], "interior @73-asleep wake → light (merged); onset-latency + final-wake blocks stay wake") + ], "interior @81-asleep wake → light (merged); onset-latency + final-wake blocks stay wake") } func testBandStateWakeVetoOnlyAsleepStateVetoes() { @@ -1144,7 +1144,7 @@ final class SleepStagerTests: XCTestCase { func testBandStateWakeVetoRaisesEfficiencyEndToEnd() { // WIRING PROOF through detectSleep: a still overnight night with a mid-sleep motion+HR burst that - // NOOP scores as INTERIOR wake. With an all-"asleep" @73 band threaded, that interior wake is + // NOOP scores as INTERIOR wake. With an all-"asleep" band threaded, that interior wake is // recovered end to end — efficiency rises and no interior wake survives (only onset/final blocks). let start = nightStart(2) // 02:00 overnight (skips the daytime nap guard) let dur = 6 * 3600 @@ -1160,14 +1160,14 @@ final class SleepStagerTests: XCTestCase { bandSleepState: bandAllAsleep(start: start, end: start + dur)) XCTAssertEqual(withBand.count, 1) let wake: (SleepSession) -> Int = { $0.stages.filter { $0.stage == "wake" }.reduce(0) { $0 + ($1.end - $1.start) } } - XCTAssertLessThanOrEqual(wake(withBand[0]), wake(noBand[0]), "the @73 veto can only reduce wake") + XCTAssertLessThanOrEqual(wake(withBand[0]), wake(noBand[0]), "the @81 veto can only reduce wake") XCTAssertGreaterThanOrEqual(withBand[0].efficiency, noBand[0].efficiency, "recovering strap-disputed false wake raises efficiency") // With an all-asleep band, every recovered epoch is interior, so any surviving wake is an EDGE block. let segs = withBand[0].stages for (i, s) in segs.enumerated() where s.stage == "wake" { XCTAssertTrue(i == 0 || i == segs.count - 1, - "an all-asleep @73 band leaves no INTERIOR wake — only onset/final-wake blocks") + "an all-asleep band leaves no INTERIOR wake — only onset/final-wake blocks") } } 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 16ed01f8ea..1f364f11ca 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -157,14 +157,14 @@ object SleepStager { * this conservative. Mirrors Swift `morningReonsetBandAsleepFrac`. (H8 consume) */ const val morningReonsetBandAsleepFrac: Double = 0.6 - /** Default-ON gate for the H9 @73 band-state WAKE-veto. Flip to false to fall back to the byte-identical + /** Default-ON gate for the H9 band-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`. (H9) */ const val bandStateWakeVetoEnabled: Boolean = true - /** The sleep stage a band-vetoed false-wake epoch is reclassified to. [bandStateAsleep] (@73 == 2) means + /** 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 * minutes feed the recovery gate; the veto must not inflate them). "light" is the honest projection of a @@ -802,7 +802,7 @@ object SleepStager { return asleep.toDouble() / inBlock.size.toDouble() >= morningReonsetBandAsleepFrac } - // ── H9 @73 band-state WAKE-veto (recover strap-disputed false wakes) ────────── + // ── H9 band-state WAKE-veto (recover strap-disputed false wakes) ────────── // // NOOP's cardiorespiratory stager is known to OVER-CALL wake: an EEG-free stager reads a still, low-HR // but not-quite-asleep epoch as wake far more often than the wearer was actually awake. WHOOP's OWN @@ -815,7 +815,7 @@ object SleepStager { // re-onset session), this operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. /** - * H9 @73 band-state WAKE-veto. Given a staged hypnogram [stages] (StageSegments tiling `[start, end]`) + * H9 band-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: * - ONLY [bandStateAsleep] (2) vetoes — a "still" (1) / "up" (3) / "wake" (0) band reading is LEFT as @@ -837,7 +837,7 @@ object SleepStager { if (!bandStateWakeVetoEnabled || bandSleepState.isEmpty() || stages.isEmpty() || end <= start) { return stages } - // Per-epoch @73 band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. + // 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 val n = states.size @@ -1219,7 +1219,7 @@ object SleepStager { stageSession(start = p.start, end = p.end, grav = grav, hr = hrS, rr = rrS, resp = respS) } - // H9 @73 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band + // H9 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band // ([bandSleepState]) scored "asleep". No-op when the band is absent (WHOOP 4.0) or the flag is // off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Efficiency below is then // computed on the corrected stages, so a night NOOP over-called wake on reports true efficiency. 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 2330acbb5b..96f84509c5 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -1,23 +1,41 @@ package com.noop.analytics +import com.noop.data.GravitySample +import com.noop.data.HrSample import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import kotlin.math.ceil /** - * Pins the H9 @73 band-state WAKE-veto ([SleepStager.applyBandStateWakeVeto]). + * Pins the H9 band-state WAKE-veto ([SleepStager.applyBandStateWakeVeto]). * * NOOP's EEG-free cardiorespiratory stager over-calls WAKE. WHOOP's OWN per-second sleep-state band (#175) * is an independent scored signal; letting its explicit "asleep" ([SleepStager.bandStateAsleep]) verdict * VETO an INTERIOR wake epoch recovers most of that spurious wake with near-zero downside. These tests pin * the contract: only asleep(2) vetoes (still/up/wake never do), the leading onset-latency and trailing * final-wake blocks are never touched, recovery is per-EPOCH, an absent band is a no-op, the output keeps - * tiling [start,end], and the veto only ever turns wake into sleep. Android twin of the Swift H9 - * band-state wake-veto tests in `SleepStagerTests`. + * tiling [start,end], and the veto only ever turns wake into sleep. [raisesEfficiencyEndToEnd] additionally + * drives the whole [SleepStager.detectSleep] path so the Android WIRING (rawStages -> veto -> efficiency), + * not just the pure function, is covered. Android twin of the Swift H9 band-state wake-veto tests in + * `SleepStagerTests`. */ class SleepStagerBandVetoTest { + private val dev = "test" + + /** 2025-06-10 00:00:00 UTC — an arbitrary fixed midnight (ref % 86400 == 0). */ + private val refMidnight = 1_749_513_600L + + /** Unix start at `hourUTC:00:00` on the reference day. tzOffset 0 → local hour == UTC hour. */ + private fun startAtHour(hourUTC: Int): Long = refMidnight + hourUTC * 3_600L + + private fun stillGravity(start: Long, durationS: Int): List = + (0 until durationS).map { GravitySample(deviceId = dev, ts = start + it, x = 0.0, y = 0.0, z = 1.0) } + + private fun hrStream(start: Long, durationS: Int, bpm: Int): List = + (0 until durationS).map { HrSample(deviceId = dev, ts = start + it, bpm = bpm) } + /** * A hypnogram tiling [0, 960] (32 epochs of 30 s): a leading onset-latency wake block, an INTERIOR * WASO wake block (epochs 10–15 = [300, 480)), and a trailing final-morning wake block — the exact @@ -50,7 +68,7 @@ class SleepStagerBandVetoTest { bandSleepState = bandAllAsleep(start = 0, end = 960), ) assertEquals( - "interior @73-asleep wake -> light (merged); onset-latency + final-wake blocks stay wake", + "interior @81-asleep wake -> light (merged); onset-latency + final-wake blocks stay wake", listOf( StageSegment(start = 0, end = 60, stage = "wake"), StageSegment(start = 60, end = 900, stage = "light"), @@ -135,4 +153,37 @@ class SleepStagerBandVetoTest { val wake = { segs: List -> segs.filter { it.stage == "wake" }.sumOf { it.end - it.start } } assertTrue("the veto only ever turns wake into sleep", wake(out) < wake(stages)) } + + @Test + fun raisesEfficiencyEndToEnd() { + // WIRING PROOF through detectSleep: a still overnight night with a mid-sleep motion+HR burst that + // NOOP scores as INTERIOR wake. With an all-"asleep" band threaded, that interior wake is + // recovered end to end — efficiency rises and no interior wake survives (only onset/final blocks). + val start = startAtHour(2) // 02:00 overnight (skips the daytime nap guard) + 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)) { // 5-min burst at +3h: high motion + elevated HR + 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 noBand = SleepStager.detectSleep(hr = hr, gravity = grav) + assertEquals(1, noBand.size) + val withBand = SleepStager.detectSleep( + hr = hr, gravity = grav, + bandSleepState = bandAllAsleep(start = start, end = start + dur), + ) + assertEquals(1, withBand.size) + val wake = { s: DetectedSleep -> s.stages.filter { it.stage == "wake" }.sumOf { it.end - it.start } } + assertTrue("the band-state veto can only reduce wake", wake(withBand[0]) <= wake(noBand[0])) + assertTrue("recovering strap-disputed false wake raises efficiency", + withBand[0].efficiency >= noBand[0].efficiency) + // With an all-asleep band, every recovered epoch is interior, so any surviving wake is an EDGE block. + val segs = withBand[0].stages + for ((i, s) in segs.withIndex()) { + if (s.stage != "wake") continue + assertTrue("an all-asleep band leaves no INTERIOR wake — only onset/final-wake blocks", + i == 0 || i == segs.size - 1) + } + } } From 151968647f5cc2199426dc8fec90595d2a2c8a77 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:49:37 -0400 Subject: [PATCH 4/5] Name the wake-veto after the signal, not a taken hypothesis number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the review. The @73 -> @81 fix landed, but the mnemonic it left behind still misdirects a reader, in the same way and for the same reason. H9 is already taken. It means the restorative-share low-confidence rule (ScoreConfidence.restorativeLowConfidenceShare / highEfficiencyThreshold), across ScoreConfidence.{swift,kt}, AnalyticsEngine, SleepView, and the ChargeEffortRestScoring / AnalyticsEngine tests. SleepStagerTests.swift and SleepStagerSparseGravityTest.kt both already say "the case H9 misses" meaning that rule — so the veto's "H9" made one label mean two unrelated things inside files it already appeared in. Renamed to "band sleep_state WAKE-veto", which is the H8 precedent the review pointed at: name the signal, drop the number. Also in the same class of misdirection: - #175 in the new prose. In this repo #175 is an unrelated merged PR (thread the registry's active strap id into every resolvedSeries caller), so it renders as a live link to the wrong thing. The band sleep-state stream came from ad4cc1f4. New lines now name `sleepStateJSON` and `sessionEpochSleepState` — greppable and unambiguous. Pre-existing #175 mentions elsewhere in these files are upstream's own and left untouched. - "the H7 CONSUME confirm" -> "the H8 consume confirm". The thing being contrasted is bandStateConfirmsAsleep, which the code tags H8 consume; H7 is the morning-stillness nap suppression it feeds. Comments only — no behaviour change, and the Swift and Kotlin comment bodies stay line-for-line equivalent, per the parity contract. --- .../Sources/StrandAnalytics/SleepStager.swift | 26 +++++++++--------- .../SleepStagerTests.swift | 4 +-- .../java/com/noop/analytics/SleepStager.kt | 27 ++++++++++--------- .../noop/analytics/SleepStagerBandVetoTest.kt | 13 ++++----- 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 5085381e67..71fbf30525 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -740,19 +740,20 @@ public enum SleepStager { return Double(asleep) / Double(inBlock.count) >= morningReonsetBandAsleepFrac } - // MARK: - H9 band-state WAKE-veto (recover strap-disputed false wakes) + // MARK: - Band sleep_state WAKE-veto (recover strap-disputed false wakes) // NOOP's cardiorespiratory stager is known to OVER-CALL wake: an EEG-free stager reads a still, low-HR // but not-quite-asleep epoch as wake far more often than the wearer was actually awake. WHOOP's OWN // per-second sleep-state band (the persisted v18 @81 high-nibble `(sb>>4)&3`: 0 wake/1 still/2 asleep/ - // 3 up — #175, `sleepStateJSON`) is an INDEPENDENT scored signal, not a re-derivation of ours. On real - // banded nights the strap scores "asleep" (`bandStateAsleep`) across ~two-thirds of the epochs NOOP - // calls wake, while the reverse disagreement (NOOP asleep, strap wake) is an order of magnitude smaller. - // So letting the strap's OWN "asleep" verdict VETO an INTERIOR wake call recovers most of the spurious - // wake with near-zero downside. Unlike the H7 CONSUME confirm (which only ever KEEPS a whole borderline - // re-onset session), this operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. - - /// Default-ON gate for the H9 band-state WAKE-veto. Flip to false to fall back to the byte-identical + // 3 up — banked as `sleepStateJSON`, gridded by `sessionEpochSleepState`) is an INDEPENDENT scored + // signal, not a re-derivation of ours. On real banded nights the strap scores "asleep" + // (`bandStateAsleep`) across ~two-thirds of the epochs NOOP calls wake, while the reverse disagreement + // (NOOP asleep, strap wake) is an order of magnitude smaller. So letting the strap's OWN "asleep" + // verdict VETO an INTERIOR wake call recovers most of the spurious wake with near-zero downside. + // Unlike the H8 consume confirm (which only ever KEEPS a whole borderline re-onset session), this + // operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. + + /// 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. @@ -765,7 +766,7 @@ public enum SleepStager { /// bare "asleep". static let bandVetoRecoverStage: String = "light" - /// H9 band-state WAKE-veto. Given a staged hypnogram `stages` (StageSegments tiling `[start, end]`) + /// 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: /// - ONLY `bandStateAsleep` (2) vetoes — a "still" (1) / "up" (3) / "wake" (0) band reading is LEFT as @@ -777,7 +778,8 @@ public enum SleepStager { /// The band is gridded to the SAME 30 s epochs as `stagesJSON` / `sessionEpochMotion` via /// `sessionEpochSleepState`, so epoch i here is epoch i of the persisted `sleepStateJSON`. Empty band /// state, the flag off, or a hypnogram with no interior sleep → returns `stages` UNCHANGED (byte- - /// identical). Applies to whichever stager (V1 or V2) produced `stages`. Pure + deterministic. (H9) + /// identical). Applies to whichever stager (V1 or V2) produced `stages`. Pure + deterministic. + /// (band sleep_state veto) static func applyBandStateWakeVeto(_ stages: [StageSegment], start: Int, end: Int, bandSleepState: [(ts: Int, state: Int)]) -> [StageSegment] { guard bandStateWakeVetoEnabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else { @@ -1133,7 +1135,7 @@ public enum SleepStager { hr: hrS, rr: rrS, resp: respS) : stageSession(start: p.start, end: p.end, grav: grav, hr: hrS, rr: rrS, resp: respS) - // H9 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band + // Band sleep_state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band // (`bandSleepState`) scored "asleep". No-op when the band is absent (WHOOP 4.0) or the flag is // off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Efficiency below is then // computed on the corrected stages, so a night NOOP over-called wake on reports true efficiency. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift index fefef87848..1773e158d7 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -1054,7 +1054,7 @@ final class SleepStagerTests: XCTestCase { "the persisted+re-expanded band grid drives the H7 confirm end to end") } - // MARK: - H9 band-state WAKE-veto (recover strap-disputed false wakes) + // MARK: - Band sleep_state WAKE-veto (recover strap-disputed false wakes) /// A hypnogram tiling [0, 960] (32 epochs of 30 s): a leading onset-latency wake block, an INTERIOR /// WASO wake block (epochs 10–15 = [300, 480)), and a trailing final-morning wake block — the exact @@ -1129,7 +1129,7 @@ final class SleepStagerTests: XCTestCase { } func testBandStateWakeVetoPreservesTilingAndOnlyRemovesWake() { - XCTAssertTrue(SleepStager.bandStateWakeVetoEnabled, "H9 veto ships default-ON") + XCTAssertTrue(SleepStager.bandStateWakeVetoEnabled, "band sleep_state veto ships default-ON") let stages = vetoHypnoFixture() let out = SleepStager.applyBandStateWakeVeto(stages, start: 0, end: 960, bandSleepState: bandAllAsleep(start: 0, end: 960)) 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 1f364f11ca..98367cb290 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -157,18 +157,18 @@ object SleepStager { * this conservative. Mirrors Swift `morningReonsetBandAsleepFrac`. (H8 consume) */ const val morningReonsetBandAsleepFrac: Double = 0.6 - /** Default-ON gate for the H9 band-state WAKE-veto. Flip to false to fall back to the byte-identical + /** 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`. (H9) */ + * Mirrors Swift `bandStateWakeVetoEnabled`. (band sleep_state veto) */ const val bandStateWakeVetoEnabled: Boolean = true /** 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 * minutes feed the recovery gate; the veto must not inflate them). "light" is the honest projection of a - * bare "asleep". Mirrors Swift `bandVetoRecoverStage`. (H9) */ + * bare "asleep". Mirrors Swift `bandVetoRecoverStage`. (band sleep_state veto) */ const val bandVetoRecoverStage: String = "light" /** Seconds in a calendar day (for local-hour-of-day arithmetic). */ @@ -802,20 +802,21 @@ object SleepStager { return asleep.toDouble() / inBlock.size.toDouble() >= morningReonsetBandAsleepFrac } - // ── H9 band-state WAKE-veto (recover strap-disputed false wakes) ────────── + // ── Band sleep_state WAKE-veto (recover strap-disputed false wakes) ─────── // // NOOP's cardiorespiratory stager is known to OVER-CALL wake: an EEG-free stager reads a still, low-HR // but not-quite-asleep epoch as wake far more often than the wearer was actually awake. WHOOP's OWN // per-second sleep-state band (the persisted v18 @81 high-nibble `(sb>>4)&3`: 0 wake/1 still/2 asleep/ - // 3 up — #175, `sleepStateJSON`) is an INDEPENDENT scored signal, not a re-derivation of ours. On real - // banded nights the strap scores "asleep" ([bandStateAsleep]) across ~two-thirds of the epochs NOOP - // calls wake, while the reverse disagreement (NOOP asleep, strap wake) is an order of magnitude smaller. - // So letting the strap's OWN "asleep" verdict VETO an INTERIOR wake call recovers most of the spurious - // wake with near-zero downside. Unlike the H7 CONSUME confirm (which only ever KEEPS a whole borderline - // re-onset session), this operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. + // 3 up — banked as `sleepStateJSON`, gridded by [sessionEpochSleepState]) is an INDEPENDENT scored + // signal, not a re-derivation of ours. On real banded nights the strap scores "asleep" + // ([bandStateAsleep]) across ~two-thirds of the epochs NOOP calls wake, while the reverse disagreement + // (NOOP asleep, strap wake) is an order of magnitude smaller. So letting the strap's OWN "asleep" + // verdict VETO an INTERIOR wake call recovers most of the spurious wake with near-zero downside. + // Unlike the H8 consume confirm (which only ever KEEPS a whole borderline re-onset session), this + // operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. /** - * H9 band-state WAKE-veto. Given a staged hypnogram [stages] (StageSegments tiling `[start, end]`) + * 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: * - ONLY [bandStateAsleep] (2) vetoes — a "still" (1) / "up" (3) / "wake" (0) band reading is LEFT as @@ -828,7 +829,7 @@ object SleepStager { * [sessionEpochSleepState], so epoch i here is epoch i of the persisted `sleepStateJSON`. Empty band * state, the flag off, or a hypnogram with no interior sleep -> returns [stages] UNCHANGED (byte- * identical). Applies to whichever stager (V1 or V2) produced [stages]. Pure + deterministic. - * Mirrors Swift `applyBandStateWakeVeto`. (H9) + * Mirrors Swift `applyBandStateWakeVeto`. (band sleep_state veto) */ internal fun applyBandStateWakeVeto( stages: List, start: Long, end: Long, @@ -1219,7 +1220,7 @@ object SleepStager { stageSession(start = p.start, end = p.end, grav = grav, hr = hrS, rr = rrS, resp = respS) } - // H9 band-state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band + // Band sleep_state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band // ([bandSleepState]) scored "asleep". No-op when the band is absent (WHOOP 4.0) or the flag is // off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Efficiency below is then // computed on the corrected stages, so a night NOOP over-called wake on reports true efficiency. 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 96f84509c5..17389f7610 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -8,16 +8,17 @@ import org.junit.Test import kotlin.math.ceil /** - * Pins the H9 band-state WAKE-veto ([SleepStager.applyBandStateWakeVeto]). + * Pins the band sleep_state WAKE-veto ([SleepStager.applyBandStateWakeVeto]). * - * NOOP's EEG-free cardiorespiratory stager over-calls WAKE. WHOOP's OWN per-second sleep-state band (#175) - * is an independent scored signal; letting its explicit "asleep" ([SleepStager.bandStateAsleep]) verdict - * VETO an INTERIOR wake epoch recovers most of that spurious wake with near-zero downside. These tests pin + * NOOP's EEG-free cardiorespiratory stager over-calls WAKE. WHOOP's OWN per-second sleep-state band + * (banked as `sleepStateJSON`) is an independent scored signal; letting its explicit "asleep" + * ([SleepStager.bandStateAsleep]) verdict VETO an INTERIOR wake epoch recovers most of that spurious + * wake with near-zero downside. These tests pin * the contract: only asleep(2) vetoes (still/up/wake never do), the leading onset-latency and trailing * final-wake blocks are never touched, recovery is per-EPOCH, an absent band is a no-op, the output keeps * tiling [start,end], and the veto only ever turns wake into sleep. [raisesEfficiencyEndToEnd] additionally * drives the whole [SleepStager.detectSleep] path so the Android WIRING (rawStages -> veto -> efficiency), - * not just the pure function, is covered. Android twin of the Swift H9 band-state wake-veto tests in + * not just the pure function, is covered. Android twin of the Swift band sleep_state wake-veto tests in * `SleepStagerTests`. */ class SleepStagerBandVetoTest { @@ -140,7 +141,7 @@ class SleepStagerBandVetoTest { @Test fun preservesTilingAndOnlyRemovesWake() { - assertTrue("H9 veto ships default-ON", SleepStager.bandStateWakeVetoEnabled) + assertTrue("band sleep_state veto ships default-ON", SleepStager.bandStateWakeVetoEnabled) val stages = vetoHypnoFixture() val out = SleepStager.applyBandStateWakeVeto( stages, start = 0, end = 960, bandSleepState = bandAllAsleep(start = 0, end = 960), From d3f7ca6c0c7e04f53a8097d538912aed47d23beb Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:29:51 -0400 Subject: [PATCH 5/5] Ship the band sleep_state wake-veto default-OFF: PSG says the recipe under-calls wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the check #738's review asked for: Tools/SleepPSG --section variants against the PhysioNet sleep-accel truth set (31 subjects, 26,773 scored epochs), on current main. The shipped recipe: wake% 4.15 true ~9.1 bias -4.92 pp wake sensitivity 30.8% NOOP already UNDER-calls wake against truth. A veto that converts wake to light moves the population result further from truth, so the band agreement it buys is the wrong target — exactly the conditional the review stated, and the #348 -> #437 lesson (kappa up, wake fraction wrong, reverted in 48 h). The veto still earns its keep on HR-inflated nights (the n=12 that motivated it), so everything stays available behind the flag: - both defaults flip to false (SleepStager.swift / SleepStager.kt) - applyBandStateWakeVeto gains an 'enabled:' parameter defaulting to the flag, so the mechanism tests arm it explicitly and stay fully live - the default assertions invert, carrying the PSG rationale in the message - the end-to-end tests invert to pin the SHIPPED behavior: an all-asleep band threaded through detectSleep changes nothing while the flag is off (byte-identical stages, identical efficiency) — the wiring proof and the gate proof in one test, on both platforms Verified: swift test (StrandAnalytics, full suite + SleepStagerTests 73/73); ./gradlew testFullDebugUnitTest --tests '*SleepStager*' BUILD SUCCESSFUL. Also merges current main (incl. #847's RespEvidence rework) per the review's drift note. --- .../Sources/StrandAnalytics/SleepStager.swift | 19 ++++--- .../SleepStagerTests.swift | 52 +++++++++++-------- .../java/com/noop/analytics/SleepStager.kt | 5 +- .../noop/analytics/SleepStagerBandVetoTest.kt | 45 ++++++++-------- 4 files changed, 68 insertions(+), 53 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 77ad4c6792..f8e8c8af40 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -753,11 +753,15 @@ public enum SleepStager { // Unlike the H8 consume confirm (which only ever KEEPS a whole borderline re-onset session), this // operates per EPOCH on the final hypnogram and only ever turns wake INTO sleep. - /// 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. - public static let bandStateWakeVetoEnabled: Bool = true + /// Default-OFF gate for the band sleep_state WAKE-veto — off until PSG supports it, and the PSG + /// harness currently says the OPPOSITE: against the 31-subject sleep-accel truth set the shipped + /// recipe UNDER-calls wake (wake% 4.15 vs ~9.1 true, bias −4.92 pp, wake sensitivity 30.8%), so a + /// veto that converts wake→light moves the population result AWAY from truth even though it fixes + /// real strap-disputed false wakes on HR-inflated nights (the n=12 that motivated it). Flip to true + /// only with PSG evidence in hand — `sleeppsg --section variants` prints the wake%/bias row this + /// decision keys on. The mechanism stays fully tested behind the flag (tests pass `enabled: true` + /// explicitly). An absent band stream (WHOOP 4.0 / unbanded window) is a no-op regardless. + public static let bandStateWakeVetoEnabled: Bool = false /// 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, @@ -781,8 +785,9 @@ public enum SleepStager { /// identical). Applies to whichever stager (V1 or V2) produced `stages`. Pure + deterministic. /// (band sleep_state veto) static func applyBandStateWakeVeto(_ stages: [StageSegment], start: Int, end: Int, - bandSleepState: [(ts: Int, state: Int)]) -> [StageSegment] { - guard bandStateWakeVetoEnabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else { + bandSleepState: [(ts: Int, state: Int)], + enabled: Bool = bandStateWakeVetoEnabled) -> [StageSegment] { + guard enabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else { return stages } // Per-epoch band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift index 96ff5607cc..49d4a901f8 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -1082,7 +1082,8 @@ final class SleepStagerTests: XCTestCase { // recovered to light (and merges with the flanking light); the leading onset-latency and trailing // final-wake blocks are NEVER touched even though the band scored them asleep too. let out = SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), start: 0, end: 960, - bandSleepState: bandAllAsleep(start: 0, end: 960)) + bandSleepState: bandAllAsleep(start: 0, end: 960), + enabled: true) XCTAssertEqual(out, [ StageSegment(start: 0, end: 60, stage: "wake"), StageSegment(start: 60, end: 900, stage: "light"), @@ -1097,7 +1098,8 @@ final class SleepStagerTests: XCTestCase { var states = [Int](repeating: 2, count: 32) for (k, i) in (10...15).enumerated() { states[i] = [1, 1, 3, 3, 0, 0][k] } let out = SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), start: 0, end: 960, - bandSleepState: bandSamples(start: 0, states)) + bandSleepState: bandSamples(start: 0, states), + enabled: true) XCTAssertEqual(out, vetoHypnoFixture(), "still/up/wake band never vetoes — only the strap's explicit asleep(2) does") } @@ -1108,7 +1110,8 @@ final class SleepStagerTests: XCTestCase { var states = [Int](repeating: 2, count: 32) for i in 13...15 { states[i] = 3 } let out = SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), start: 0, end: 960, - bandSleepState: bandSamples(start: 0, states)) + bandSleepState: bandSamples(start: 0, states), + enabled: true) XCTAssertEqual(out, [ StageSegment(start: 0, end: 60, stage: "wake"), StageSegment(start: 60, end: 390, stage: "light"), @@ -1121,20 +1124,26 @@ final class SleepStagerTests: XCTestCase { func testBandStateWakeVetoNoOpWhenBandAbsent() { // No band stream (WHOOP 4.0 / unbanded window) → byte-identical hypnogram, whatever the flag. XCTAssertEqual( - SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), start: 0, end: 960, bandSleepState: []), - vetoHypnoFixture(), "absent band → veto is a no-op") + SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), start: 0, end: 960, bandSleepState: [], + enabled: true), + vetoHypnoFixture(), "absent band → veto is a no-op even when armed") // Band entirely outside the window grids to empty → also a no-op (never fabricates asleep). XCTAssertEqual( SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), start: 0, end: 960, - bandSleepState: [(ts: 100_000, state: 2)]), + bandSleepState: [(ts: 100_000, state: 2)], + enabled: true), vetoHypnoFixture()) } func testBandStateWakeVetoPreservesTilingAndOnlyRemovesWake() { - XCTAssertTrue(SleepStager.bandStateWakeVetoEnabled, "band sleep_state veto ships default-ON") + XCTAssertFalse(SleepStager.bandStateWakeVetoEnabled, + "band sleep_state veto ships default-OFF until PSG supports it — the harness " + + "currently measures the shipped recipe UNDER-calling wake (bias −4.92 pp), " + + "so converting wake→light by default would move away from truth") let stages = vetoHypnoFixture() let out = SleepStager.applyBandStateWakeVeto(stages, start: 0, end: 960, - bandSleepState: bandAllAsleep(start: 0, end: 960)) + bandSleepState: bandAllAsleep(start: 0, end: 960), + enabled: true) XCTAssertEqual(out.first?.start, 0) XCTAssertEqual(out.last?.end, 960) for i in 1.. Int = { $0.stages.filter { $0.stage == "wake" }.reduce(0) { $0 + ($1.end - $1.start) } } - XCTAssertLessThanOrEqual(wake(withBand[0]), wake(noBand[0]), "the @81 veto can only reduce wake") - XCTAssertGreaterThanOrEqual(withBand[0].efficiency, noBand[0].efficiency, - "recovering strap-disputed false wake raises efficiency") - // With an all-asleep band, every recovered epoch is interior, so any surviving wake is an EDGE block. - let segs = withBand[0].stages - for (i, s) in segs.enumerated() where s.stage == "wake" { - XCTAssertTrue(i == 0 || i == segs.count - 1, - "an all-asleep band leaves no INTERIOR wake — only onset/final-wake blocks") - } + XCTAssertEqual(withBand[0].stages, noBand[0].stages, + "default-off: an all-asleep band changes NOTHING — byte-identical hypnogram") + XCTAssertEqual(withBand[0].efficiency, noBand[0].efficiency, accuracy: 1e-9, + "default-off: efficiency is untouched by the band stream") } // MARK: - REM-funnel diagnostic (#688) 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 d2400a4218..a6d6a8386f 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -162,7 +162,7 @@ object SleepStager { * 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) */ - const val bandStateWakeVetoEnabled: Boolean = true + const val bandStateWakeVetoEnabled: Boolean = false /** 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, @@ -834,8 +834,9 @@ object SleepStager { internal fun applyBandStateWakeVeto( stages: List, start: Long, end: Long, bandSleepState: List>, + enabled: Boolean = bandStateWakeVetoEnabled, ): List { - if (!bandStateWakeVetoEnabled || bandSleepState.isEmpty() || stages.isEmpty() || end <= start) { + if (!enabled || bandSleepState.isEmpty() || stages.isEmpty() || end <= start) { return stages } // Per-epoch band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON. 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 17389f7610..a74c70e1db 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -3,6 +3,7 @@ package com.noop.analytics import com.noop.data.GravitySample import com.noop.data.HrSample import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import kotlin.math.ceil @@ -66,7 +67,7 @@ class SleepStagerBandVetoTest { // final-wake blocks are NEVER touched even though the band scored them asleep too. val out = SleepStager.applyBandStateWakeVeto( vetoHypnoFixture(), start = 0, end = 960, - bandSleepState = bandAllAsleep(start = 0, end = 960), + bandSleepState = bandAllAsleep(start = 0, end = 960), enabled = true, ) assertEquals( "interior @81-asleep wake -> light (merged); onset-latency + final-wake blocks stay wake", @@ -89,7 +90,7 @@ class SleepStagerBandVetoTest { for ((k, i) in (10..15).withIndex()) states[i] = block[k] val out = SleepStager.applyBandStateWakeVeto( vetoHypnoFixture(), start = 0, end = 960, - bandSleepState = bandSamples(start = 0, states = states), + bandSleepState = bandSamples(start = 0, states = states), enabled = true, ) assertEquals( "still/up/wake band never vetoes — only the strap's explicit asleep(2) does", @@ -105,7 +106,7 @@ class SleepStagerBandVetoTest { for (i in 13..15) states[i] = 3 val out = SleepStager.applyBandStateWakeVeto( vetoHypnoFixture(), start = 0, end = 960, - bandSleepState = bandSamples(start = 0, states = states), + bandSleepState = bandSamples(start = 0, states = states), enabled = true, ) assertEquals( "only the asleep-banded sub-run of an interior wake block is recovered", @@ -127,24 +128,29 @@ class SleepStagerBandVetoTest { "absent band → veto is a no-op", vetoHypnoFixture(), SleepStager.applyBandStateWakeVeto( - vetoHypnoFixture(), start = 0, end = 960, bandSleepState = emptyList(), + vetoHypnoFixture(), start = 0, end = 960, bandSleepState = emptyList(), enabled = true, ), ) // Band entirely outside the window grids to empty → also a no-op (never fabricates asleep). assertEquals( vetoHypnoFixture(), SleepStager.applyBandStateWakeVeto( - vetoHypnoFixture(), start = 0, end = 960, bandSleepState = listOf(100_000L to 2), + vetoHypnoFixture(), start = 0, end = 960, bandSleepState = listOf(100_000L to 2), enabled = true, ), ) } @Test fun preservesTilingAndOnlyRemovesWake() { - assertTrue("band sleep_state veto ships default-ON", SleepStager.bandStateWakeVetoEnabled) + assertFalse( + "band sleep_state veto ships default-OFF until PSG supports it — the harness currently " + + "measures the shipped recipe UNDER-calling wake (bias -4.92 pp), so converting " + + "wake->light by default would move away from truth", + SleepStager.bandStateWakeVetoEnabled, + ) val stages = vetoHypnoFixture() val out = SleepStager.applyBandStateWakeVeto( - stages, start = 0, end = 960, bandSleepState = bandAllAsleep(start = 0, end = 960), + stages, start = 0, end = 960, bandSleepState = bandAllAsleep(start = 0, end = 960), enabled = true, ) assertEquals(0L, out.first().start) assertEquals(960L, out.last().end) @@ -156,10 +162,12 @@ class SleepStagerBandVetoTest { } @Test - fun raisesEfficiencyEndToEnd() { - // WIRING PROOF through detectSleep: a still overnight night with a mid-sleep motion+HR burst that - // NOOP scores as INTERIOR wake. With an all-"asleep" band threaded, that interior wake is - // recovered end to end — efficiency rises and no interior wake survives (only onset/final blocks). + fun defaultOffLeavesHypnogramUnchangedEndToEnd() { + // WIRING PROOF through detectSleep, for the SHIPPED default (OFF until PSG supports the veto — + // the harness currently measures the recipe UNDER-calling wake against truth, bias -4.92 pp, so + // default-on would move away from it). With the flag off an all-"asleep" band must change + // NOTHING end to end; the ON-path mechanism is covered by the pure enabled=true tests above. + // Byte-parity twin of Swift testBandStateWakeVetoDefaultOffLeavesHypnogramUnchangedEndToEnd. val start = startAtHour(2) // 02:00 overnight (skips the daytime nap guard) val dur = 6 * 3600 val grav = stillGravity(start, dur).toMutableList() @@ -175,16 +183,9 @@ class SleepStagerBandVetoTest { bandSleepState = bandAllAsleep(start = start, end = start + dur), ) assertEquals(1, withBand.size) - val wake = { s: DetectedSleep -> s.stages.filter { it.stage == "wake" }.sumOf { it.end - it.start } } - assertTrue("the band-state veto can only reduce wake", wake(withBand[0]) <= wake(noBand[0])) - assertTrue("recovering strap-disputed false wake raises efficiency", - withBand[0].efficiency >= noBand[0].efficiency) - // With an all-asleep band, every recovered epoch is interior, so any surviving wake is an EDGE block. - val segs = withBand[0].stages - for ((i, s) in segs.withIndex()) { - if (s.stage != "wake") continue - assertTrue("an all-asleep band leaves no INTERIOR wake — only onset/final-wake blocks", - i == 0 || i == segs.size - 1) - } + assertEquals("default-off: an all-asleep band changes NOTHING — byte-identical hypnogram", + noBand[0].stages, withBand[0].stages) + assertEquals("default-off: efficiency is untouched by the band stream", + noBand[0].efficiency, withBand[0].efficiency, 1e-9) } }