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
155 changes: 139 additions & 16 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,127 @@ public enum SleepStager {
return Double(sparse) >= cardiacSparseEpochFrac * Double(sleepFeats.count)
}

/// What this epoch's respiration says about depth — FIVE states, because "we did not measure it" is
/// not an observation and must not be spendable as one.
///
/// This used to be two booleans over a NaN RRV, and the `regular` one read
/// `(!f.rrv.isFinite) || (f.rrv <= rrvLo)`: a MISSING respiration reading was converted into a
/// positive assertion that breathing was regular, which is pro-deep. On a WHOOP 5/MG that is not an
/// edge case, it is the only code path — the v18 layout emits no `resp_rate_raw` at all (pinned by
/// `Whoop5HistoricalTests`), so `respSample` has zero rows, every epoch's RRV is NaN, and the
/// fabricated "regular" fires on 100% of epochs. Where a night with real respiration data has ~50% of
/// its epochs clear the `regular` bar (it is the MEDIAN — `stageRRVLowPct` = 50), a 5/MG night has
/// 100% clear it, on no measurement whatsoever.
///
/// The five cases are the exact CROSS-PRODUCT of the two predicates this replaced, so the mapping is
/// total and no case is decided by which bar `of` happens to test first:
///
/// | pre-fix `rrvIrregular` | pre-fix `rrvRegular` | case |
/// |---|---|---|
/// | false | true (via `!isFinite`) | `unmeasured` |
/// | false | true (via the low bar) | `regular` |
/// | true | false | `irregular` |
/// | false | false | `measuredMidBand` |
/// | true | true | `barsDegenerate` |
///
/// `unmeasured` and `measuredMidBand` are deliberately DISTINCT. Both fail the regular and irregular
/// bars, but they mean opposite things — one is "no reading", the other is a real reading that simply
/// sits between the bars — and the classifier already treats them differently (the REM fallback fires
/// only on a missing reading). Collapsing them would silently change the hypnogram.
enum RespEvidence {
/// A finite RRV at or below the session's low bar — breathing measured as regular.
case regular
/// A finite RRV at or above the session's high bar — breathing measured as irregular.
case irregular
/// A finite RRV between the bars: measured, but neither notably regular nor notably irregular.
case measuredMidBand
/// No usable RRV for this epoch. Either the strap has no respiration channel at all (every 5/MG),
/// or this epoch's window had too few / too flat samples to derive one (`respRateAndRRV` returns
/// NaN below 8 samples, on a flat signal, under 3 peaks, or under 2 in-band breath intervals).
case unmeasured
/// A finite RRV that clears BOTH bars at once — at or above the high bar AND at or below the low
/// bar. The session's two percentile bars have collapsed onto this reading, so it satisfies each
/// of them and the pair carries no information at this value.
///
/// Reachable, not hypothetical, and for a structural reason: RRV is the population std of breath
/// intervals measured in WHOLE SECONDS (`respRateAndRRV`, `dtS` = 1), so it is quantised onto a
/// small discrete lattice and exact ties between epochs are ordinary. `percentile` interpolates
/// between order statistics, so p50 and p65 coincide whenever the tie run spans them — and when
/// only ONE sleep epoch has a finite RRV (the rest NaN, which that same function returns freely on
/// short, flat or low-peak windows) `percentile` returns that single value for both, so the epoch
/// that SET the bars necessarily sits on both of them.
///
/// Kept as its own case because a four-state enum cannot represent it: both pre-fix booleans were
/// true here, so whichever bar the factory tested first would silently decide the label. Behaviour
/// is pinned to the pre-fix outcome — waived for depth (`rrvRegular` was true, see
/// `contradictsDepth`) and clearing the REM irregular bar (`rrvIrregular` was true, see
/// `meetsIrregularBar`) — which keeps this change a representation fix.
///
/// That pre-fix outcome resolves to DEEP only because the deep rule is written before the REM rule
/// in `classifyOne`; statement order is not a reason, and re-deciding it is a live question. It is
/// deliberately NOT re-decided here: doing so is a scoring change on the degenerate-distribution
/// nights, and this repo has no staged nights of that shape to validate it against (CLAUDE.md,
/// "validate against the artifact, not one match"). The point of naming the case is that the
/// decision is now a one-line edit in `contradictsDepth` / `meetsIrregularBar` with a test that
/// fails loudly, instead of an invisible consequence of statement order.
case barsDegenerate

/// Total mapping of the two pre-fix predicates. Written as an exhaustive switch on the pair
/// rather than an ordered `if` chain, because an ordered chain is exactly how the `barsDegenerate`
/// case used to be decided by accident.
static func of(_ rrv: Double, lowBar: Double?, highBar: Double?) -> RespEvidence {
guard rrv.isFinite else { return .unmeasured }
let atOrAboveHigh = highBar.map { rrv >= $0 } ?? false
let atOrBelowLow = lowBar.map { rrv <= $0 } ?? false
switch (atOrAboveHigh, atOrBelowLow) {
case (true, true): return .barsDegenerate
case (true, false): return .irregular
case (false, true): return .regular
case (false, false): return .measuredMidBand
}
}

/// Whether respiration CONTRADICTS depth. The deep rule reads this rather than a "regular" flag,
/// because `unmeasured` is not evidence of regular breathing — it is the absence of evidence, and
/// the rule it feeds is "respiration must not rule depth out".
///
/// `unmeasured` is WAIVED here, not because missing respiration is reassuring, but because
/// blocking on it would decode 0 m of deep on every 5/MG night — the exact regression #127/#129
/// fixed for the parallel missing-RMSSD case, whose rule statement already carries the same
/// qualifier ("with high parasympathetic tone WHEN MEASURABLE"). This makes the respiration
/// waiver explicit and equally qualified instead of leaving it implied by a NaN short-circuit.
///
/// `barsDegenerate` is waived too, matching the pre-fix `rrvRegular`; see its doc for why that is
/// preserved rather than re-decided.
///
/// KNOWN LIMITATION, deliberately not changed here: on a 5/MG BOTH waivers can fire at once
/// (sparse R-R leaves RMSSD NaN too), and the deep rule then reduces to stillness + a low HR with
/// no physiological corroboration at all. That is a real weakness, but it is bounded — `hrLow` is
/// a PERCENTILE bar (`stageHRLowPct` = 25), so at most ~25% of sleep epochs can clear it however
/// the respiration term resolves. Narrowing the waiver (e.g. to epoch-level gaps within a night
/// that HAS a respiration channel, versus a device with no channel at all) is a scoring change
/// that needs validation data this repo does not have — see the "validate against the artifact,
/// not one match" rule in CLAUDE.md. `SleepStagerRespEvidenceTests` pins the current behaviour and
/// quantifies the bias so that decision can be made on numbers.
var contradictsDepth: Bool {
switch self {
case .regular, .unmeasured, .barsDegenerate: return false
case .irregular, .measuredMidBand: return true
}
}

/// Whether this epoch clears the session's IRREGULAR bar — the pre-fix `rrvIrregular` predicate,
/// which the main REM rule reads. `barsDegenerate` clears it (it IS at or above the high bar) even
/// though `contradictsDepth` also waives it; that pair of answers is precisely the pre-fix state
/// where both booleans were true, and it is preserved deliberately.
var meetsIrregularBar: Bool {
switch self {
case .irregular, .barsDegenerate: return true
case .regular, .measuredMidBand, .unmeasured: return false
}
}
}

static func classifyOne(_ f: EpochFeatures, hrLo: Double?, hrHi: Double?,
rmssdHi: Double?, hrvarHi: Double?, rrvHi: Double?, rrvLo: Double?,
cardiacSparse: Bool = false) -> String {
Expand All @@ -1717,9 +1838,7 @@ public enum SleepStager {
// Dense 4.0 nights keep the full `hrHigh || hrvarHigh` signal, so their behaviour is unchanged. (#705)
let cardiacActivatedForWake = cardiacSparse ? hrHigh : cardiacActivated

let rrvIrregular = f.rrv.isFinite && rrvHi != nil && f.rrv >= rrvHi!
// Missing respiration (NaN RRV) treated as "regular" (pro-deep bias).
let rrvRegular = (!f.rrv.isFinite) || (rrvLo != nil && f.rrv <= rrvLo!)
let resp = RespEvidence.of(f.rrv, lowBar: rrvLo, highBar: rrvHi)

let still = f.moveFrac <= stageStillMoveFrac
let moving = f.moveFrac >= stageWakeMoveFrac
Expand All @@ -1728,12 +1847,15 @@ public enum SleepStager {
// cardiac half is vetted by HR only (see `cardiacActivatedForWake`), so noisy hrVar no longer
// over-promotes still sleep to wake. (#705)
if moving && (cardiacActivatedForWake || !hasHR) { return "wake" }
// DEEP: still + low HR + regular respiration, with high parasympathetic tone when measurable.
if still && parasympOK && hrLow && rrvRegular { return "deep" }
// REM: still body + activated cardiac + irregular respiration.
if still && cardiacActivated && rrvIrregular { return "rem" }
// REM fallback when respiration unavailable: require BOTH cardiac signals.
if still && hrHigh && hrvarHigh && !f.rrv.isFinite { return "rem" }
// DEEP: still + low HR + respiration that does not RULE OUT depth, with high parasympathetic tone
// when measurable. `contradictsDepth` is where an unmeasured respiration is waived — see its doc
// for why the waiver stays and what it costs.
if still && parasympOK && hrLow && !resp.contradictsDepth { return "deep" }
// REM: still body + activated cardiac + respiration clearing the irregular bar.
if still && cardiacActivated && resp.meetsIrregularBar { return "rem" }
// REM fallback when respiration was never MEASURED (not merely mid-band): require BOTH cardiac
// signals. A mid-band reading is real evidence and does not earn the fallback.
if still && hrHigh && hrvarHigh && resp == .unmeasured { return "rem" }
return "light"
}

Expand Down Expand Up @@ -1865,23 +1987,24 @@ public enum SleepStager {
let hrvarHigh = f.hrVar.isFinite && hrvarHi != nil && f.hrVar >= hrvarHi!
let cardiacActivated = hrHigh || hrvarHigh
let cardiacActivatedForWake = cardiacSparse ? hrHigh : cardiacActivated
let rrvIrregular = f.rrv.isFinite && rrvHi != nil && f.rrv >= rrvHi!
let rrvRegular = (!f.rrv.isFinite) || (rrvLo != nil && f.rrv <= rrvLo!)
// Same respiration evidence the classifier uses, from the same factory — so the diagnostic cannot
// drift from the rule it explains (these predicates were duplicated by hand).
let resp = RespEvidence.of(f.rrv, lowBar: rrvLo, highBar: rrvHi)
let still = f.moveFrac <= stageStillMoveFrac
let moving = f.moveFrac >= stageWakeMoveFrac

// classifyOne precedence: WAKE, then DEEP, then REM (then REM fallback), else LIGHT.
// An epoch that wins WAKE or DEEP was never a REM candidate.
if moving && (cardiacActivatedForWake || !hasHR) { return .wonOtherStage } // → wake
if still && parasympOK && hrLow && rrvRegular { return .wonOtherStage } // → deep
if still && parasympOK && hrLow && !resp.contradictsDepth { return .wonOtherStage } // → deep
// From here the epoch did NOT win wake/deep; it is either REM or falls through to LIGHT.
if still && cardiacActivated && rrvIrregular { return .remEligible }
if still && hrHigh && hrvarHigh && !f.rrv.isFinite { return .remEligible }
if still && cardiacActivated && resp.meetsIrregularBar { return .remEligible }
if still && hrHigh && hrvarHigh && resp == .unmeasured { return .remEligible }
// Not REM → attribute to the FIRST unmet REM precondition (in REM-rule order).
if !still { return .notStill }
if !cardiacActivated { return .noCardiacActivation }
if f.rrv.isFinite { return .respRegular } // resp present but not irregular
return .noRespFallbackBar // resp absent and the no-resp bar unmet
if resp != .unmeasured { return .respRegular } // resp measured but not irregular
return .noRespFallbackBar // resp never measured and the no-resp bar unmet
}

/// Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs the SAME Stage-0→3
Expand Down
Loading