Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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..<n {
let t = epochStart(i)
if let seg = stages.first(where: { $0.start <= t && t < $0.end })
?? stages.first(where: { $0.start <= t && t <= $0.end }) {
labels[i] = seg.stage
}
}
// Interior = [firstSleep, lastSleep]; leading/trailing wake blocks are excluded from the veto.
guard let onset = labels.firstIndex(where: { $0 != "wake" }),
let finalWake = labels.lastIndex(where: { $0 != "wake" }), onset <= finalWake else {
return stages // no sleep at all → nothing to recover
}
var changed = false
for i in onset...finalWake where 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].
var out: [StageSegment] = []
for i in 0..<n {
let segStart = epochStart(i)
let segEnd = (i == n - 1) ? end : epochStart(i + 1)
if let last = out.last, last.stage == labels[i] {
out[out.count - 1].end = segEnd
} else {
out.append(StageSegment(start: segStart, end: segEnd, stage: labels[i]))
}
}
if !out.isEmpty { out[out.count - 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
/// proxy. Worn, the strap streams ~1 Hz HR (or PPG-derived HR on a 5/MG), so a real night yields no
Expand Down Expand Up @@ -1048,11 +1139,17 @@ public enum SleepStager {
detail: "daytime=true restingHR=\(resting ?? -1) baseline=\(baseline.map { Int($0) } ?? -1) nightTail=false"))
continue
}
let stages = useSleepStagerV2
let rawStages = useSleepStagerV2
? SleepStagerV2.stageSession(start: p.start, end: p.end, grav: grav,
hr: hrS, rr: rrS, resp: respS)
: 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.
let stages = applyBandStateWakeVeto(rawStages, start: p.start, end: p.end,
bandSleepState: bandSleepState)
let eff = efficiency(start: p.start, end: p.end, stages: stages)
let avgHrv = sessionAvgHRV(start: p.start, end: p.end, rr: rrS)
sessions.append(SleepSession(start: p.start, end: p.end, efficiency: eff,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,131 @@ final class SleepStagerTests: XCTestCase {
"the persisted+re-expanded band grid drives the H7 confirm end to end")
}

// 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
/// shape the veto must treat differently at the edges vs the interior.
private func vetoHypnoFixture() -> [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..<n).map { (ts: start + $0 * 30, state: 2) }
}

func testBandStateWakeVetoRecoversInteriorFalseWake() {
// 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.
let out = SleepStager.applyBandStateWakeVeto(vetoHypnoFixture(), 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"),
StageSegment(start: 900, end: 960, stage: "wake"),
], "interior @81-asleep wake → light (merged); onset-latency + final-wake blocks stay wake")
}

func testBandStateWakeVetoOnlyAsleepStateVetoes() {
// 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.
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),
enabled: true)
XCTAssertEqual(out, vetoHypnoFixture(),
"still/up/wake band never vetoes — only the strap's explicit asleep(2) does")
}

func testBandStateWakeVetoPartialInteriorRecovery() {
// 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.
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),
enabled: true)
XCTAssertEqual(out, [
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"),
], "only the asleep-banded sub-run of an interior wake block is recovered")
}

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: [],
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)],
enabled: true),
vetoHypnoFixture())
}

func testBandStateWakeVetoPreservesTilingAndOnlyRemovesWake() {
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),
enabled: true)
XCTAssertEqual(out.first?.start, 0)
XCTAssertEqual(out.last?.end, 960)
for i in 1..<out.count {
XCTAssertEqual(out[i].start, out[i - 1].end, "segments tile [start,end] with no gaps/overlaps")
}
let wake: ([StageSegment]) -> 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
Expand Down
Loading
Loading