diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 4778b8609e..f8e8c8af40 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -740,6 +740,97 @@ public enum SleepStager { return Double(asleep) / Double(inBlock.count) >= morningReonsetBandAsleepFrac } + // 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 — 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-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, + /// 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" + + /// 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 + /// 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. + /// (band sleep_state veto) + static func applyBandStateWakeVeto(_ stages: [StageSegment], start: Int, end: Int, + 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. + 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 testBandStateWakeVetoDefaultOffLeavesHypnogramUnchangedEndToEnd() { + // 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). A still overnight night with a mid-sleep motion+HR burst + // that NOOP scores as INTERIOR wake, plus an all-"asleep" band threaded end to end: with the + // flag off the band must change NOTHING — byte-identical stages, identical efficiency. This is + // the wiring test's inverse: it proves the band stream reaches the veto AND that the flag gates + // it, so flipping the default is the only change needed to re-enable (the ON-path mechanism is + // covered by the pure `applyBandStateWakeVeto(enabled: true)` tests above). + 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) + 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) /// 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 177f2a1aa4..a6d6a8386f 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 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) */ + 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, + * 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`. (band sleep_state veto) */ + 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,85 @@ object SleepStager { return asleep.toDouble() / inBlock.size.toDouble() >= morningReonsetBandAsleepFrac } + // ── 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 — 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. + + /** + * 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 + * 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`. (band sleep_state veto) + */ + internal fun applyBandStateWakeVeto( + stages: List, start: Long, end: Long, + bandSleepState: List>, + enabled: Boolean = bandStateWakeVetoEnabled, + ): List { + 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. + 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 @@ -1124,13 +1217,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) } + // 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. + 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..a74c70e1db --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerBandVetoTest.kt @@ -0,0 +1,191 @@ +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 + +/** + * 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 + * (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 band sleep_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 + * 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), enabled = true, + ) + assertEquals( + "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"), + 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), enabled = true, + ) + 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), enabled = true, + ) + 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(), 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), enabled = true, + ), + ) + } + + @Test + fun preservesTilingAndOnlyRemovesWake() { + 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), enabled = true, + ) + 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)) + } + + @Test + 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() + 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) + 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) + } +}