fix(sleep): represent unmeasured respiration explicitly in V1 (no label change)#847
fix(sleep): represent unmeasured respiration explicitly in V1 (no label change)#847vishk23 wants to merge 2 commits into
Conversation
…ar" in V1
`SleepStager.classifyOne` held two booleans over a NaN RRV, and the regular one
read `(!rrv.isFinite) || (rrv <= rrvLo)`. That converts the ABSENCE of a
respiration reading into a positive assertion that breathing was regular, which is
pro-deep. On a WHOOP 5/MG it is not an edge case, it is the only code path: the
v18 layout emits no `resp_rate_raw` at all (already pinned by
`Whoop5HistoricalTests` / `Whoop5HistoricalDecodeTest`), 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 ~half its depth-shaped epochs
clear that bar — it is the MEDIAN, `stageRRVLowPct` = 50 — a 5/MG night clears it
for all of them, on no measurement whatsoever.
Respiration evidence is now an explicit four-state `RespEvidence`
(regular / irregular / measuredMidBand / unmeasured) built by one factory that
both `classifyOne` and the `remRejectReason` diagnostic call, so the two
hand-duplicated predicate pairs can no longer drift. `unmeasured` and
`measuredMidBand` are deliberately distinct: both fail both bars, but one is "no
reading" and the other is a real reading between them, and the classifier already
treats them differently (the REM fallback fires only on a missing reading).
The deep rule now reads `!resp.contradictsDepth` — "respiration must not rule
depth out" — instead of a "regular" flag, and the waiver for a missing reading
lives in one documented place rather than being implied by a `!isFinite`
short-circuit nobody could see. It parallels the rule statement the missing-RMSSD
case already carries ("with high parasympathetic tone WHEN MEASURABLE", #127/#129).
NO LABEL CHANGES. This is a representation fix, and the tests prove it rather than
asserting it: an exhaustive grid over move/HR/hrVar/RMSSD/RRV/sparse (1728 cases,
plus a no-respiration-channel variant with both bars nil) runs the NEW classifier
against the OLD boolean formulas reproduced verbatim in the test, and requires
every label to match.
What is deliberately NOT changed, and why: on a 5/MG both waivers can fire at once
(sparse R-R leaves RMSSD NaN too), so the deep rule can reduce to stillness plus a
low HR with no physiological corroboration. That is a real weakness. It is also
bounded — `hrLow` is a percentile bar (`stageHRLowPct` = 25), so at most ~25% of
sleep epochs can reach the deep gate however respiration resolves — and narrowing
the waiver (e.g. distinguishing an epoch-level gap within a night that HAS a
respiration channel from a device that has none) is a scoring change that needs
validation data this repo does not have. Per CLAUDE.md's "validate against the
artifact, not one match", that decision should be made on numbers, so the numbers
are now in a test: the deep pass-rate for depth-shaped epochs is 51/100 with a
real RRV distribution and 100/100 with no respiration channel, a ~1.96x
difference.
Also pins that `SleepStagerV2` — the DEFAULT stager — accepts `resp` and never
reads it. That was a comment with no test behind it, so an edit could start or
stop consuming it unnoticed; V2's cache key deliberately omits `resp` and would
silently go stale if it did. The new test asserts byte-identical segments with and
without a populated respiration stream. This also bounds the blast radius of the
bug above: on the shipped default path the raw respiration ADC is not consulted at
all, so the V1 behaviour reaches only users who have turned the V2 experiment off.
Kotlin additionally gains the first `respRateAndRRV` coverage it has ever had.
Both platforms changed together (Swift `SleepStager.swift`, Kotlin
`SleepStager.kt`), all four duplicated predicate sites.
Verified: StrandAnalytics 1150 tests green (12 new Swift), Android
`testFullDebugUnitTest` 3084 tests with only the pre-existing `SyncChipStateTest`
failure (11 new Kotlin); macOS `Strand` scheme builds.
ryanbr
left a comment
There was a problem hiding this comment.
Verified all three claims this rests on: the coercion is real (SleepStager.swift:1722, and duplicated at :1869 — exactly the drift you describe), v18 emits no respiration, and experimentalSleepV2Enabled defaults true on both platforms, so the blast radius really is opt-out users only. Proving equivalence against the old formulas reproduced verbatim is the right method, and quantifying the residual bias (51/100 vs 100/100) instead of hand-waving it is the part I would most like other PRs to copy.
One real gap: the grid cannot see the case where the old two booleans were BOTH true.
rrvHi is p65 and rrvLo is p50 of the same distribution, so they coincide whenever those percentiles land on a tied value — short session, coarse signal, few distinct RRVs. With rrv == lo == hi:
old: rrvIrregular = true AND rrvRegular = true -> deep gate is checked first -> "deep"
new: of() tests irregular before regular -> .irregular -> contradictsDepth -> NOT deep
The 1728-case grid pins rrvLo: 0.5, rrvHi: 1.0 throughout and the no-channel variant sets both nil, so neither reaches it. Please add rrvLo == rrvHi to the grid. If it does flip a label, that is worth deciding deliberately — the old behaviour preferred deep purely because of statement order, which is not obviously the answer you want to preserve.
Two small ones:
- The
RespEvidenceheader says "THREE states" and refers to a case calledunknown. There are four, and it isunmeasured. Looks like the enum grew and the header did not follow. - The title reads as though the 5/MG deep inflation is fixed. Your body is explicit that it is not, deliberately — but a changelog reader sees the title. Something like "represent unmeasured respiration explicitly (no label change)" would match what this actually does.
Nothing else — the measuredMidBand / unmeasured split and the waiver being documented in one place are both right.
Review catch on the equivalence grid: it pinned `rrvLo: 0.5, rrvHi: 1.0` throughout, so it could never reach the combination where the two pre-fix booleans were BOTH true. `rrvRegular` needed `rrv <= rrvLo` and `rrvIrregular` needed `rrv >= rrvHi`, which co-occur exactly when the bars coincide on the reading. The four-state enum could not hold that, and `of` resolved it by testing the high bar first — so those epochs became `.irregular`, `contradictsDepth` fired, and the label moved. It is a real flip, not a theoretical one: with the bar pair added to the grid, deep -> light where the epoch has no cardiac activation and deep -> rem where `hrvarHigh` fires. Coincident bars are reachable 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 land on the same value whenever a tie run spans them. And when only one sleep epoch has a finite RRV — that same function returns NaN freely on short, flat or low-peak windows — `percentile` returns the single value for every percentile, so the epoch that set the bars sits on both by construction. Adds `barsDegenerate` / `BARS_DEGENERATE` as the fifth case, and rewrites `of` as the total cross-product of the two pre-fix predicates rather than an ordered `if` chain, since an ordered chain is how this case was being decided by accident. The REM rule now reads `meetsIrregularBar` instead of comparing to `.irregular`, so the both-bars-cleared state keeps both of its pre-fix answers: waived for depth and clearing the irregular bar. Behaviour is pinned to the pre-fix outcome rather than re-decided. That outcome resolves to DEEP only because the deep rule is written before the REM rule, and statement order is not a reason — but re-deciding it is a scoring change on the degenerate-distribution nights, and there are no staged nights of that shape to validate it against. Naming the case makes it a one-line edit with a failing test instead of an invisible consequence of ordering. Tests: the bar pair is now an axis on both equivalence grids (separated, coincident, coincident elsewhere, inverted, and each way of having a null bar), 1728 -> 12096 cases, with an assertion that the grid actually visits the both-true cell. `remRejectReason` gets the same axis, since it reads `meetsIrregularBar` too. Adds a reachability test covering both routes to coincident bars and the end-to-end path through `classifyEpochs`. Also corrects the Swift doc header, which said THREE states and referred to a case called `unknown`; there are five and it is `unmeasured`. Swift + Kotlin twins, same five-state model, same tests both sides.
|
Thanks — the bar-pair gap was real, and it was a live flip rather than a latent one. All three points addressed in 99037e3. 1.
|
V1's classifier converted a missing RRV into a positive assertion that breathing was regular —
rrvRegular = (!isFinite) || (rrv <= rrvLo)— which is pro-deep.On a WHOOP 5/MG that is the only code path. v18 emits no
resp_rate_rawat all, sorespSamplehas zero rows, every epoch's RRV is NaN, and the fabricated "regular" fires on 100 % of epochs — versus ~50 % on a night with real data, because the bar is the median. Both percentile bars areniltoo, sorrvIrregularis permanently false: the DEEP guard vanishes and the main REM branch is dead.The change. Replaces the two NaN-coercing booleans with an explicit four-state
RespEvidence(regular/irregular/measuredMidBand/unmeasured), built by one factory shared betweenclassifyOneand theremRejectReasondiagnostic — those previously hand-duplicated the predicates and could drift.measuredMidBandandunmeasuredare kept distinct because the classifier already treats them differently; collapsing them into a single "unknown" would have silently changed the hypnogram. The deep rule now reads!resp.contradictsDepth, with the waiver documented in one place.No label changes, and the tests prove it rather than asserting it: an exhaustive 1728-case grid runs the new classifier against the old boolean formulas reproduced verbatim, plus a no-channel variant with both bars nil.
What this deliberately does not do. On a 5/MG both waivers — missing RMSSD (#127/#129) and missing respiration — can fire together, collapsing DEEP to
still && hrLow. Tightening that either zeroes out deep on 5/MG (re-introducing the exact #127/#129 regression) or needs validation data this repo doesn't have. Two things argue for leaving it: the bias is bounded (hrLowis a percentile bar,stageHRLowPct= 25, so ≤ ~25 % of sleep epochs can ever reach the deep gate), and V2 is the default, so V1 only affects users who turned the experiment off. Instead it is quantified in a test — depth-shaped epochs pass the deep gate 51/100 with a real RRV distribution vs 100/100 with no respiration channel (~1.96×) — so the next person tuning the deep gate argues with numbers.Also pins that
SleepStagerV2(the default) ignores itsrespargument: previously a comment with no test, and V2's cache key deliberately omitsresp, so it could have gone stale silently. Kotlin gains its first-everrespRateAndRRVcoverage.Parity: Swift + Kotlin, same four-state model, tests both sides.
Tests:
StrandAnalytics1150 passing (12 new); AndroidtestFullDebugUnitTest3084 passing (30 new across this and the sibling instrumentation branch); one pre-existing unrelated failure (SyncChipStateTest,NoopApplication is not attached) reproduced on pristineupstream/main.