Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -763,13 +763,31 @@ public enum SleepStager {
/// explicitly). An absent band stream (WHOOP 4.0 / unbanded window) is a no-op regardless.
public static let bandStateWakeVetoEnabled: Bool = false

/// #1210 item 2 (retroactive-rescore story): the new-nights-only cutoff. When the veto is flipped on
/// (`bandStateWakeVetoEnabled = true`), a NON-ZERO cutoff (a unix second) restricts the correction to
/// sessions whose `start >= cutoffTs` — nights already in history keep their raw efficiency, so flipping
/// the default cannot silently re-score months of banked nights upward. `0` (the default) applies the
/// veto to every banded night (the "just ship the one-time shift" path). Inert while the flag is off.
/// Set this to the flip date at the same time as the flag. Mirrors Kotlin `bandStateWakeVetoCutoffTs`.
public static let bandStateWakeVetoCutoffTs: Int = 0

/// The sleep stage a band-vetoed false-wake epoch is reclassified to. `bandStateAsleep` (band sleep_state == 2) means
/// only "asleep" — the band carries NO light/deep/REM resolution — so the veto maps it to the generic,
/// most-common sleep stage rather than inventing deep/REM detail the strap never asserted (deep/REM
/// 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"

/// #1210: a TRACE-ONLY line reporting what the (dormant) band wake-veto WOULD recover on a banded night,
/// so a validator gathers the recovered-minutes distribution across real nights without any output change
/// (the persisted hypnogram stays the flag-gated, unchanged one). Namespaced `bandVeto(shadow):` and free
/// of a `day=` / `t=…s` token so `CaptureAccumulator` never counts it as a captured day. Mirrors Kotlin
/// `bandVetoShadowLine`. (band sleep_state veto)
static func bandVetoShadowLine(startTs: Int, recoveredMin: Double, rawEff: Double, shadowEff: Double) -> String {
"bandVeto(shadow): startTs=\(startTs) recoveredMin=\(Int(recoveredMin.rounded())) "
+ "eff \(Int((rawEff * 100).rounded()))%->\(Int((shadowEff * 100).rounded()))%"
}

/// Band sleep_state WAKE-veto. Given a staged hypnogram `stages` (StageSegments tiling `[start, end]`)
/// and the strap's OWN per-timestamp band sleep_state, reclassify INTERIOR wake epochs the strap itself
/// scored "asleep" (`bandStateAsleep`) to `bandVetoRecoverStage`. Conservative by construction:
Expand All @@ -786,10 +804,14 @@ public enum SleepStager {
/// (band sleep_state veto)
static func applyBandStateWakeVeto(_ stages: [StageSegment], start: Int, end: Int,
bandSleepState: [(ts: Int, state: Int)],
enabled: Bool = bandStateWakeVetoEnabled) -> [StageSegment] {
enabled: Bool = bandStateWakeVetoEnabled,
cutoffTs: Int = bandStateWakeVetoCutoffTs) -> [StageSegment] {
guard enabled, !bandSleepState.isEmpty, !stages.isEmpty, end > start else {
return stages
}
// #1210 item 2: new-nights-only gate. A non-zero cutoff spares nights that started before it (history
// keeps its raw efficiency); `0` applies to every banded night. Inert while the flag is off.
if cutoffTs > 0, start < cutoffTs { return stages }
// Per-epoch band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON.
let states = sessionEpochSleepState(start: start, end: end, sleepState: bandSleepState)
if states.isEmpty { return stages }
Expand Down Expand Up @@ -1169,6 +1191,25 @@ public enum SleepStager {
traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end,
verdict: .kept, gate: "accepted",
detail: "spanMin=\(spanMin) eff=\(round2(eff)) restingHR=\(resting ?? -1) daytime=\(isDaytime)"))
// #1210 shadow: the band wake-veto is dormant (default-off), but its recovered-vs-reverse ratio
// can only come from banded nights. When a band stream is present, compute what the veto WOULD
// recover and trace it — OUTPUT-NEUTRAL: `stages`/`eff` persisted above are the flag-gated
// (unchanged) values and nothing reads `shadowStages`. Guarded on `traceSink`, so it fires ONLY
// while a diagnostic is collecting (zero production cost). Retire once the flip (#1210) lands.
if let traceSink, !bandStateWakeVetoEnabled, !bandSleepState.isEmpty {
// `cutoffTs: 0` on purpose: the shadow measures the FULL veto potential across EVERY banded
// night (history included) to build the validation distribution, independent of whatever
// new-nights cutoff the eventual flip uses.
let shadowStages = applyBandStateWakeVeto(rawStages, start: p.start, end: p.end,
bandSleepState: bandSleepState,
enabled: true, cutoffTs: 0)
let shadowEff = efficiency(start: p.start, end: p.end, stages: shadowStages)
let recoveredMin = (shadowEff - eff) * Double(p.end - p.start) / 60.0
if recoveredMin >= 1.0 {
traceSink(bandVetoShadowLine(startTs: p.start, recoveredMin: recoveredMin,
rawEff: eff, shadowEff: shadowEff))
}
}
// A run that does NOT continue the chain re-anchors it on this run's onset.
if !continuesChain { chainFromOvernight = isOvernightOnset(p.start, tzOffsetSeconds: tzOffsetSeconds) }
chainPrevEnd = p.end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,59 @@ final class SleepStagerTests: XCTestCase {
"default-off: efficiency is untouched by the band stream")
}

func testBandVetoShadowLineIsNamespacedAndUncounted() {
// #1210 shadow: the validation line formats the recovered delta and is NEVER miscounted as a captured
// sleep day (carries no `sleep day=` / `gate run=` / `day=` token). Byte-identical format to Kotlin.
let line = SleepStager.bandVetoShadowLine(startTs: 1_723_000_000, recoveredMin: 31.4,
rawEff: 0.742, shadowEff: 0.808)
XCTAssertEqual(line, "bandVeto(shadow): startTs=1723000000 recoveredMin=31 eff 74%->81%")
XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .sleep, reportText: line, tzOffsetSeconds: 0), 0,
"shadow line must not be counted as a captured sleep day")
}

func testBandVetoShadowTraceReportsRecoveryOutputNeutral() {
// #1210 shadow: veto DORMANT (default-off) + a band present + a collecting traceSink -> a
// `bandVeto(shadow):` line quantifying what the veto WOULD recover, WITHOUT changing the persisted
// hypnogram (detectSleep's documented trace contract). Same burst fixture as the default-off wiring
// test. (The line reports MAGNITUDE only; whether the move is toward truth needs the PSG harness.)
let start = nightStart(2)
let dur = 6 * 3600
var grav = stillGravity(start: start, durationS: dur)
var hr = hrStream(start: start, durationS: dur, bpm: 50)
for i in (3 * 3600)..<(3 * 3600 + 5 * 60) { // interior 5-min burst -> false wake
grav[i] = GravitySample(ts: start + i, x: Double(i % 2) * 0.5, y: 0, z: 1.0)
hr[i] = HRSample(ts: start + i, bpm: 95)
}
let band = bandAllAsleep(start: start, end: start + dur)
let untraced = SleepStager.detectSleep(hr: hr, gravity: grav, bandSleepState: band)
var lines: [String] = []
let traced = SleepStager.detectSleep(hr: hr, gravity: grav, bandSleepState: band,
traceSink: { lines.append($0) })
XCTAssertEqual(traced[0].stages, untraced[0].stages,
"shadow trace must not change the persisted hypnogram")
XCTAssertTrue(lines.contains { $0.hasPrefix("bandVeto(shadow):") && $0.contains("recoveredMin=") },
"a banded night with interior false-wake emits a shadow line")
}

func testBandVetoCutoffSparesNightsBeforeCutoff() {
// #1210 item 2: with the veto ON, a non-zero cutoff spares a night that STARTED before it (history
// keeps its raw hypnogram) and applies to one starting at/after it. Boundary is inclusive. Kotlin twin.
let base = 1_000_000
let fixture = vetoHypnoFixture().map {
StageSegment(start: $0.start + base, end: $0.end + base, stage: $0.stage)
}
let band = bandAllAsleep(start: base, end: base + 960)
let recovered = SleepStager.applyBandStateWakeVeto(fixture, start: base, end: base + 960,
bandSleepState: band, enabled: true, cutoffTs: 0)
XCTAssertNotEqual(recovered, fixture, "sanity: the veto does change this fixture")
XCTAssertEqual(SleepStager.applyBandStateWakeVeto(fixture, start: base, end: base + 960,
bandSleepState: band, enabled: true, cutoffTs: base + 1),
fixture, "a night starting before the cutoff keeps its raw hypnogram")
XCTAssertEqual(SleepStager.applyBandStateWakeVeto(fixture, start: base, end: base + 960,
bandSleepState: band, enabled: true, cutoffTs: base),
recovered, "a night starting at/after the cutoff (inclusive) gets the veto")
}

// MARK: - REM-funnel diagnostic (#688)

/// A still, REM-eligible epoch (still + cardiac-activated + irregular resp). The percentile
Expand Down
52 changes: 47 additions & 5 deletions android/app/src/main/java/com/noop/analytics/SleepStager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,20 +157,40 @@ object SleepStager {
* this conservative. Mirrors Swift `morningReonsetBandAsleepFrac`. (H8 consume) */
const val morningReonsetBandAsleepFrac: Double = 0.6

/** Default-ON gate for the band sleep_state WAKE-veto. Flip to false to fall back to the byte-identical
* pre-veto hypnogram. [bandStateAsleep] is WHOOP's OWN banked verdict (not a signal we re-derive), which
* is why vetoing false-wakes with it is well-founded; it stays a single flip-point + fully tested. An
* absent band stream (WHOOP 4.0 / unbanded window) makes the veto a no-op regardless of this flag.
* Mirrors Swift `bandStateWakeVetoEnabled`. (band sleep_state veto) */
/** Default-OFF gate for the band sleep_state WAKE-veto (the SHIPPED state; the pre-veto hypnogram is what
* users see). [bandStateAsleep] is WHOOP's OWN banked verdict (not a signal we re-derive), so vetoing
* false-wakes with it is well-founded on the n=12 that motivated it — but the PSG harness measures the
* recipe UNDER-calling wake overall (bias -4.92 pp), so a veto that converts wake->light moves the
* population result AWAY from truth. Flip to true only with PSG evidence in hand (see #1210), and set
* [bandStateWakeVetoCutoffTs] alongside to spare history. It stays a single flip-point + fully tested
* (tests pass `enabled = true` explicitly). An absent band stream (WHOOP 4.0 / unbanded window) makes the
* veto a no-op regardless of this flag. Mirrors Swift `bandStateWakeVetoEnabled`. (band sleep_state veto) */
const val bandStateWakeVetoEnabled: Boolean = false

/** #1210 item 2 (retroactive-rescore story): the new-nights-only cutoff. When the veto is flipped on
* ([bandStateWakeVetoEnabled] = true), a NON-ZERO cutoff (a unix second) restricts the correction to
* sessions whose `start >= cutoffTs` — nights already in history keep their raw efficiency, so flipping
* the default cannot silently re-score months of banked nights upward. `0` (the default) applies the veto
* to every banded night (the "just ship the one-time shift" path). Inert while the flag is off. Set this
* to the flip date at the same time as the flag. Byte-identical to Swift `bandStateWakeVetoCutoffTs`. */
const val bandStateWakeVetoCutoffTs: Long = 0L

/** The sleep stage a band-vetoed false-wake epoch is reclassified to. [bandStateAsleep] (band sleep_state == 2) means
* only "asleep" — the band carries NO light/deep/REM resolution — so the veto maps it to the generic,
* most-common sleep stage rather than inventing deep/REM detail the strap never asserted (deep/REM
* 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"

/** #1210: a TRACE-ONLY line reporting what the (dormant) band wake-veto WOULD recover on a banded night,
* so a validator gathers the recovered-minutes distribution across real nights without any output change
* (the persisted hypnogram stays the flag-gated, unchanged one). Namespaced `bandVeto(shadow):` and free
* of a `day=` / `t=…s` token so `CaptureAccumulator` never counts it as a captured day. Byte-identical to
* Swift `bandVetoShadowLine`. (band sleep_state veto) */
internal fun bandVetoShadowLine(startTs: Long, recoveredMin: Double, rawEff: Double, shadowEff: Double): String =
"bandVeto(shadow): startTs=$startTs recoveredMin=${Math.round(recoveredMin)} " +
"eff ${Math.round(rawEff * 100)}%->${Math.round(shadowEff * 100)}%"

/** Seconds in a calendar day (for local-hour-of-day arithmetic). */
const val secondsPerDay: Long = 86_400L

Expand Down Expand Up @@ -835,10 +855,14 @@ object SleepStager {
stages: List<StageSegment>, start: Long, end: Long,
bandSleepState: List<Pair<Long, Int>>,
enabled: Boolean = bandStateWakeVetoEnabled,
cutoffTs: Long = bandStateWakeVetoCutoffTs,
): List<StageSegment> {
if (!enabled || bandSleepState.isEmpty() || stages.isEmpty() || end <= start) {
return stages
}
// #1210 item 2: new-nights-only gate. A non-zero cutoff spares nights that started before it (history
// keeps its raw efficiency); `0` applies to every banded night. Inert while the flag is off.
if (cutoffTs > 0L && start < cutoffTs) return stages
// Per-epoch band on the 30 s stagesJSON grid — byte-identical to the persisted sleepStateJSON.
val states = sessionEpochSleepState(start, end, bandSleepState)
if (states.isEmpty()) return stages
Expand Down Expand Up @@ -1253,6 +1277,24 @@ object SleepStager {
traceSink?.invoke(SleepStagerTrace.runLine(runIndex, p.start, p.end,
SleepStagerTrace.Verdict.KEPT, "accepted",
"spanMin=$spanMin eff=${SleepStagerTrace.round2(eff)} restingHR=${resting ?: -1} daytime=$isDaytime"))
// #1210 shadow: the band wake-veto is dormant (default-off), but its recovered-vs-reverse ratio
// can only come from banded nights. When a band stream is present, compute what the veto WOULD
// recover and trace it — OUTPUT-NEUTRAL: `stages`/`eff` persisted above are the flag-gated
// (unchanged) values and nothing reads `shadowStages`. Guarded on `traceSink`, so it fires ONLY
// while a diagnostic is collecting (zero production cost). Retire once the flip (#1210) lands.
if (traceSink != null && !bandStateWakeVetoEnabled && bandSleepState.isNotEmpty()) {
// cutoffTs = 0 on purpose: the shadow measures the FULL veto potential across EVERY banded
// night (history included) to build the validation distribution, independent of whatever
// new-nights cutoff the eventual flip uses.
val shadowStages = applyBandStateWakeVeto(rawStages, start = p.start, end = p.end,
bandSleepState = bandSleepState, enabled = true, cutoffTs = 0L)
val shadowEff = efficiency(start = p.start, end = p.end, stages = shadowStages)
val recoveredMin = (shadowEff - eff) * (p.end - p.start).toDouble() / 60.0
if (recoveredMin >= 1.0) {
traceSink(bandVetoShadowLine(startTs = p.start, recoveredMin = recoveredMin,
rawEff = eff, shadowEff = shadowEff))
}
}
// A run that does NOT continue the chain re-anchors it on this run's onset.
if (!continuesChain) chainFromOvernight = isOvernightOnset(p.start, tzOffsetSeconds)
chainPrevEnd = p.end
Expand Down
Loading