Skip to content

fix(sleep): represent unmeasured respiration explicitly in V1 (no label change)#847

Open
vishk23 wants to merge 2 commits into
ryanbr:mainfrom
vishk23:fix/sleep-resp-evidence
Open

fix(sleep): represent unmeasured respiration explicitly in V1 (no label change)#847
vishk23 wants to merge 2 commits into
ryanbr:mainfrom
vishk23:fix/sleep-resp-evidence

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown

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_raw at all, so respSample has 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 are nil too, so rrvIrregular is 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 between classifyOne and the remRejectReason diagnostic — those previously hand-duplicated the predicates and could drift. measuredMidBand and unmeasured are 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 (hrLow is 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 its resp argument: previously a comment with no test, and V2's cache key deliberately omits resp, so it could have gone stale silently. Kotlin gains its first-ever respRateAndRRV coverage.

Parity: Swift + Kotlin, same four-state model, tests both sides.
Tests: StrandAnalytics 1150 passing (12 new); Android testFullDebugUnitTest 3084 passing (30 new across this and the sibling instrumentation branch); one pre-existing unrelated failure (SyncChipStateTest, NoopApplication is not attached) reproduced on pristine upstream/main.

…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 ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 RespEvidence header says "THREE states" and refers to a case called unknown. There are four, and it is unmeasured. 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.
@vishk23 vishk23 changed the title fix(sleep): an unmeasured respiration is no longer asserted as "regular" in V1 fix(sleep): represent unmeasured respiration explicitly in V1 (no label change) Jul 27, 2026
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

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. rrvLo == rrvHi — confirmed, and it did change a label

Added the bar pair as a grid axis rather than a constant: separated (0.5, 1.0), coincident on a value the RRV axis actually hits (0.75, 0.75), coincident elsewhere (0.5, 0.5), inverted (1.0, 0.5) (defensive — of reads the pair, it should not assume low <= high), and each way of having a nil bar up to the 5/MG (nil, nil). 1728 → 12096 cases, plus an assertion that the grid genuinely visits the rrvIrregular && rrvRegular cell, so this can't silently stop covering it. remRejectReason gets the same axis — it reads the irregular bar too, so testing only one side of that seam pinned only half of it.

It fails on the old code exactly as you predicted, in two directions:

deep -> light   move=0.0 hr=45.0 hrVar=nan  rmssd=nan rrv=0.75 bars=(0.75, 0.75)
deep -> rem     move=0.0 hr=45.0 hrVar=20.0 rmssd=nan rrv=0.75 bars=(0.75, 0.75)

It is also more reachable than "short session, coarse signal". RRV is the population std of breath intervals measured in whole seconds (respRateAndRRV, dtS = 1), so it is quantised onto a small discrete lattice — exact ties between epochs are ordinary, and percentile interpolates between order statistics, so p50 and p65 land on the same value whenever a tie run spans them. Stronger still: when only one sleep epoch has a finite RRV — and respRateAndRRV returns NaN freely, under 8 samples, on a flat signal, under 3 peaks, under 2 in-band intervals — percentile returns that single value for every percentile, so the bars coincide by construction and the epoch that set them necessarily sits on both. There's a test for both routes and for the end-to-end path through classifyEpochs.

On deciding it deliberately: I preserved the pre-fix labels, and I want to be explicit that this is a choice, not an oversight. You're right that the old answer prefers deep purely because the deep rule is written before the REM rule, and that isn't a reason. But re-deciding it is a scoring change on exactly the degenerate-distribution nights, and there's nothing staged of that shape to validate against — that's the CLAUDE.md derived-signal rule, and #194 is the precedent for what shipping the plausible-looking answer costs. It would also mean this PR changes labels while claiming not to, which is the property that makes it bisectable.

So the fix is to make the state representable instead of accidental. of is now the total cross-product of the two pre-fix predicates rather than an ordered if chain — an ordered chain is precisely how this was being decided by accident — and the both-cleared cell gets its own case, barsDegenerate, carrying both of its pre-fix answers (waived for depth via contradictsDepth, clearing the irregular bar via the new meetsIrregularBar, which the REM rule now reads instead of == .irregular). Changing the answer later is a one-line edit in one of those two properties with a test that fails loudly, and the case doc says so.

For what it's worth, my read on the eventual answer: a coincident pair means the bars carry no information at that value, so neither "regular" nor "irregular" is really being asserted — which argues the respiration term should be treated as uninformative for that session rather than resolved to either. But that's a third behaviour again, and it needs data.

2. Stale header — fixed

Swift said THREE states and referred to unknown; it's now FIVE and unmeasured, with a table mapping each case to its (rrvIrregular, rrvRegular) cell. The Kotlin header said FOUR (correct at the time) and is now FIVE with the same table. Also dropped a stale "three-state" in the remRejectReason comment.

3. Title — changed

Now fix(sleep): represent unmeasured respiration explicitly in V1 (no label change). Agreed the old one over-claimed for anyone reading a changelog.

Verification

  • StrandAnalytics: 1152 passing, 0 failures (14 in the two resp suites).
  • Android testFullDebugUnitTest: 3067 completed, 3061 passing, 5 skipped, 1 failure — the pre-existing SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySyncExperimental ("NoopApplication is not attached"), which I re-ran with my changes stashed to confirm it's not mine. SleepStager* is 90/90.
  • Both grids verified discriminating, not just green: temporarily restoring the ordered of on each platform makes them fail, then restored.
  • Parity: same five-state model, same factory shape, same tests both sides.

One toolchain note: this branch predates #854, so testFullDebugUnitTest hits the macOS aapt2 dependency-verification failure. I ran with --dependency-verification=off on the CLI and left verification-metadata.xml untouched; it'll resolve on its own once this merges up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants