Repository navigation
Measure the REM-latency guard in minutes since onset, not as a fraction of the session (#930) - #934
Conversation
…on of the session (#930)
`SleepStagerV2.cyclePrior` suppressed REM while `c < 0.12` — 12 % of THAT session's
length. First-REM latency is an absolute physiological interval, so the guard's width
scaled with how long the wearer stayed in bed: 7.4–84.5 min, an 11.5x spread, across
one WHOOP 5 user's own 36 recorded nights.
Replaces the step with a graded penalty in minutes since a MEASURED sleep onset:
rem = 1.0 * c - K * clamp01(1 - m / M0) K = 3.0, M0 = 60 min
K is the incumbent step's own magnitude (e^-3 ~ 0.05 on the REM emission, strong
suppression but never a veto). The sleep-accel grid — {cliff, graded} x {fraction,
minutes} x K in 1..8 x threshold, 210 cells — ties on accuracy in every cell, so
nothing discriminates K, and the smallest correct change fixes the units and the shape
without also retuning a magnitude no measurement can justify. M0 = 60 sits inside the
measured-defensible band: against PSG truth a guard reaching 45 min costs 0.36 % of all
real REM (1 of 31 subjects) and one reaching 90 min costs 6.85 % (15 of 31).
The `1.0 * c` ramp and the deep term are untouched and stay fractions of the session —
both describe where in the night you are, which is inherently proportional. Removing
the ramp collapses REM to 1.4 % of night and kappa to 0.143; it is load-bearing.
THIS IS A ROBUSTNESS FIX, NOT AN ACCURACY WIN. On sleep-accel the step is measurably
inert (kappa 0.349 -> 0.349, median first-REM latency 142.0 -> 141.0 min when removed)
because that cohort spans only 2.4x in session length. Replayed over the 36-night
device database (15 stage-locked human-authored references, 9163 epochs): 4-class kappa
0.691 -> 0.689, sleep/wake 0.583 -> 0.582, accuracy 78.2 % both.
kappa is not the guardrail that matters here — #437 reverted #348 for exactly that
reason ("kappa doesn't guard stage-fraction calibration"), so stage fractions are
reported too. Per-night bias vs the human reference (predicted - truth, pp) improves on
three stages and is unchanged on deep: wake -7.26 -> -7.13, light -7.54 -> -6.10, deep
+6.65 -> +6.65, rem +8.15 -> +6.57. On the healthy stratum #348 broke (in-bed >= 5 h,
n = 20) nothing moves more than 0.42 pp and wake moves +0.03 pp; deep is byte-identical
on all 36 nights. 12 of 36 nights change at all, and the change is monotone in session
length — mean REM delta -20.9 pp under 2 h, -2.4 pp at 2-5 h, -0.4 pp at >= 5 h — i.e.
it lands on precisely the short sessions the fractional guard was mis-scaling.
Plumbing: `Epoch` gains `minutesSinceOnset`; `stageEpochs` stages once with the guard
disabled, takes the first sustained non-wake run as onset (5 min = 10 epochs; measured
against PSG onset at bias -3.8 min, MAE 7.4 min, n = 31), then re-applies the guard and
re-runs Viterbi. Only the guard differs between passes, so the cost is one extra
Viterbi, not a second featurisation. The guard is clamped to [0, K] so a pre-onset epoch
cannot be penalised harder than onset itself — #271 can place a window start hours early.
Android twin updated in lockstep per the parity contract, including the constants and
the frozen golden. Tests: Swift 1247 passed / 0 failed; Android 3238 passed / 0 failed
/ 5 skipped (393 suites, counts read from the JUnit XML). Both frozen goldens are
unchanged, so the recipe's pinned end-to-end shape is preserved.
Known and separately tracked, NOT addressed here: median first-REM latency runs ~54 min
late against PSG (142 vs 88.5 min truth; removing the step gives 141, so this term is
not the cause), and deep is under-called 2-3x (4.7-6.2 % predicted vs 14.8 % truth).
Refs #930.
ryanbr
left a comment
There was a problem hiding this comment.
Checked the code rather than re-reading the evidence, since the evidence is the part that already
argues itself.
The twins are exact. minOf(1.0, maxOf(0.0, …)) against min(1.0, max(0.0, …)), the same clamp,
the same constants, and sustainedSleepOnset with identical index arithmetic — `i - onsetSustainedEpochs
- 1
is the run's first epoch, correct on both.Double.POSITIVE_INFINITYand.infinityboth driveremLatencyGuard` to exactly 0, so pass 1 is genuinely unguarded on each platform rather than
approximately so. Pass 2 subtracts only the guard; every other emission term is untouched.
Goldens untouched is the strongest single line in this PR — it is the cheapest available proof the
two implementations still agree epoch-for-epoch after a recipe change. Worth being clear about what it
does not prove: the fixture night is evidently one of the 24 that do not move, so the goldens attest that
nothing ELSE shifted, not that the new path is right. That rests on the new tests and the replay.
The MotionCorroboratedWake edits are constructor updates in both languages, no assertions weakened.
One correction to the description, in your favour. android.yml is no longer disabled — it was
turned back on earlier today, and build-and-test in this PR's green checks IS the Android job. Your
3213 Kotlin tests ran in CI, not only locally. The caveat can go.
Two things worth saying out loud, neither blocking.
Onset is located by a pass that is more REM-permissive than anything that ships — pass 1 has no guard at
all, where the incumbent had -3.0 below c < 0.12. Only awake-vs-not affects the onset rule, so this
only matters where a marginal epoch flips awake→REM and pulls onset earlier; that makes the 60-minute
window start earlier and therefore expire earlier, so it is mildly self-correcting rather than
self-reinforcing. Defensible, and worth a sentence in the file saying the origin is measured under a
prior that never ships.
Epoch.minutesSinceOnset holds minutes since the WINDOW START until stageEpochs re-bases it. The doc
comment says so plainly, but the field name asserts otherwise at every other read site. Given that this
whole issue started with constants a reader could not tell fit from hand-picked, a name that means what
it says is worth something — minutesSinceWindowStart would cost one rename.
The decision that is actually yours, not mine: four sessions of 61-81 min drop from 11.7-35.5 % REM
to 3.7-5.7 %. Under a 60-minute guard a sub-hour nap is suppressed for most of its length. That is the
physiologically right prior and the guard stays soft, so those nights still call 3.7-5.7 % rather than
zero — but it is a visible change to what a nap looks like, it rests on 4 nights, and you note 45 min is
equally defensible on your own cost table. Everything else here moves under half a percentage point.
Approving on the engineering. The units fix is correct, the shape is right, the parity holds, and the
refusal to claim an accuracy win it cannot demonstrate is the reason the rest of it is credible.
… not just kappa (#935) * SleepBench measures stage-fraction calibration and first-REM latency, not just kappa PR #348 fitted the stager to DREAMT. It raised kappa on all three benchmarks with a held-out gap of -0.027 — clean by every ML criterion — and PR #437 reverted it 48 hours later because it re-scored a healthy night from 6% to 23% awake. The revert's own words: "kappa doesn't guard stage-fraction calibration." SleepBench as it landed in #925 reproduces exactly that blind spot. Sections B and C score agreement (accuracy, kappa, per-stage sensitivity/specificity) and wake minutes, none of which constrain how much of the night a recipe spends at each stage. A recipe can raise kappa while systematically reallocating stages, because the epochs it newly gets right can outnumber the epochs it newly mislabels. Adds two sections. Nothing existing is replaced; the old section E is renamed G. E. PER-STAGE FRACTION CALIBRATION. Predicted % of night vs reference % of night for wake/light/deep/REM, as a signed bias in percentage points, per night and aggregated, plus the unsigned MAE so a recipe that over- and under-calls in equal measure cannot pass as well calibrated. - E.0 prints accuracy and kappa on the SAME nights, so the number that is not sufficient sits directly above the numbers that guard it. - E.2 reports a HEALTHY stratum (in-bed >= 5 h) separately. The aggregate hides the failure: #348's healthy-night blowout was ~17 pp of wake, which pooling with short fragmented nights dilutes. - E.3 reports the shipped population's stage fractions over every replayed night with no reference at all, so a distribution shift is catchable on a database with no human labels. F. FIRST-REM LATENCY. Minutes from staged sleep onset to the first REM epoch, per night and as a median/p10/p90/min/max, for each recipe and for the human reference. Calibration pins how MUCH REM a recipe emits, not WHEN; REM in the first minutes after onset is physiologically implausible in a healthy adult. Nights that never reach REM are counted separately rather than folded in as a zero. Reference set. The calibration sections score the stage-locked rows, not section B's set. B excludes an edited night whose stored hypnogram is a byte-exact replay of the CURRENT V2 — right for B's question, but version-dependent by construction: change the recipe and a night can enter or leave the exclusion, silently swapping the denominator underneath a before/after. The stagelock cursor set comes from `cursors` and does not move when the recipe moves. Read-only properties are unchanged: DB.swift is untouched, the open is still SQLITE_OPEN_READONLY | immutable=1, there is no write surface, and the database path is still a required argument. Verification. 13 new unit tests over the pure label-array primitives, no database needed (swift test in Tools/SleepBench). Measured on a real 36-session database with 15 stage-locked human-authored references (9,163 epochs), replaying the same harness against two builds of SleepStagerV2 — this branch's base, and the base plus the REM-latency guard change proposed in #934: V2 4-class kappa 0.691 -> 0.689, accuracy 78.2% -> 78.2% stage bias, pp wake -7.26 -> -7.13 light -7.54 -> -6.10 deep +6.65 -> +6.65 rem +8.15 -> +6.57 first-REM latency median 72.2 -> 83.2 min, minimum 10.0 -> 43.0 min healthy-stratum wake fraction 9.40% -> 9.43% (+0.03 pp) Kappa moved -0.002 and would have called that change nothing. The latency minimum moving off 10 minutes, and the healthy-stratum wake fraction holding to +0.03 pp, are the evidence — and neither was measurable in this repository before this commit. * Stop SleepBench's calibration set scoring V2 against V2's own output Section E's reference set keys on `stagelock` so the denominator cannot move between two builds — the right call, and the reason section B's recipe-dependent exclusion is not reused there. But a `stagelock` cursor proves only that the stages arrived through `edit_sleep_stages`; it does not prove they differ from what V2 emits. On the database this was developed against, 3 of the 15 stage-locked reference nights replay BYTE-EXACT from V2 and a 4th agrees at 99.09%. Those rows carry no information about V2: they hand the incumbent a guaranteed perfect night and charge every alternative recipe for the same nights. The effect is large enough to invert a verdict. Scoring PR #348's parameter set on this database gives 4-class kappa 0.640 against the incumbent's 0.689 on the contaminated 15-night set — #348 loses — and 0.705 against 0.604 on the same set with the three byte-exact nights held out — #348 wins by +0.101. Section E exists precisely to judge a #348-style tune, so a 0.085-kappa thumb on the scale in favour of "change nothing" is the one bias it must not have. Section E cannot simply drop the rows the way section B does without reintroducing the version-dependence it was built to avoid, so the harness names them instead: a new E.-1 audit lists every stage-locked night whose stored hypnogram replays from V2 at >= 95%, flags the byte-exact ones, reports what fraction of the reference set they are, and prints the exact `--exclude` line to pin the same held-out set across both builds of a comparison. Excluding by explicit timestamp keeps the set frozen; excluding by "matches this build's V2" would not. The audit asserts no mechanism for the match, only that a row cannot be an independent reference for the recipe it replays. Verification: `swift test` in Tools/SleepBench = 13 tests, 0 failures; release build clean; run against a 37-session device database copy, E.-1 names the four nights above and every downstream section is unchanged when `--exclude` is not passed. Tools-only — no analytics, no stager, no Android twin, no goldens. Refs #348, #437.
* Run the tool tests that nothing was running Three places in this repo had tests, or code the app's conclusions depend on, that no CI job ever executed. tools/linux-capture carries 212 tests across 8 files — the frame decoder, the HCI extractor, the activity and sync decoders, spot HRV, and the SpO2 promote gate #103 is waiting on. They ran only when someone remembered to. All 212 pass today, so this starts green; the point is that a regression would previously have gone unnoticed by a suite that exists to catch it. Tools/SleepBench is the instrument the sleep work is argued with — #934's stage-fraction tables came out of it — and #935 added 13 tests nothing ran. It path-depends on Packages/StrandAnalytics and Packages/WhoopProtocol, so it also bitrots the first time either changes shape. #935 is the case in point: that tool was scoring V2 against V2's own output for a week and no check could have told anyone. Tools/Backfill has no tests yet, so it is build-only, guarded by a grep for a testTarget rather than assuming one. The Swift side joins swift-packages.yml, whose paths now include Tools/** so a package change can fail its dependents. The Python side is its own ubuntu job: stdlib unittest, no dependencies, and `discover` rather than a hand-listed set so a new test file cannot be silently left unrun — which is the exact failure this closes. app-build.yml stays disabled; app-target Swift is a separate problem. * Fix the Backfill build the new job just caught The tool did not compile: line 57's stage dictionary mixes Double? sources with untyped 0 fallbacks, so Swift could only infer [String: Any] and the emit-module step failed. Nothing built Tools/Backfill, so nobody found out. Annotated [String: Double] — all four sources are Double? on WearableSleepSession, and that is the minute-dict shape SleepStageTotals already decodes. No behaviour change is possible here: the tool could not run at all before this. This is the job earning its place on its first run. * Fix three things the re-review found in this PR pipefail. GitHub's default shell is `bash -e` WITHOUT pipefail, so piping the test run into tee returned tee's status and a failing suite would have exited 0. The count assertion I added would therefore have MASKED every real failure — strictly worse than not adding it. Proven both ways locally: without pipefail a red suite reports exit 0; with it, exit 1. Tools/** was too wide. It matched appchangelog-gen.py, doc_comment_lint.py, the anonymize shell scripts and __pycache__, so editing a Python lint script would have queued ten macos-15 jobs. Narrowed to Tools/SleepBench/** and Tools/Backfill/**, which is what the Swift job actually builds. Tools/test_i18n_audit.py was still unrun — 36 tests covering the audit that gates every PR's translations. i18n-coverage.yml runs `i18n_audit.py --ci` but never its test file. Now covered, from inside Tools/ since the suite imports the module by bare name. Both steps now assert a floor on the collected count, because `discover` exits 0 having found NOTHING: a wrong working-directory would otherwise turn this job green while running no tests, which is the exact failure the PR exists to end. * Write the scratch capture outside the checkout tee wrote out.txt into tools/linux-capture and Tools, neither of which is gitignored — so rehearsing these commands locally leaves a stray file that can be committed by accident. $RUNNER_TEMP keeps the streamed log and leaves the working tree clean.
…on of the session (ryanbr#930) (ryanbr#934) `SleepStagerV2.cyclePrior` suppressed REM while `c < 0.12` — 12 % of THAT session's length. First-REM latency is an absolute physiological interval, so the guard's width scaled with how long the wearer stayed in bed: 7.4–84.5 min, an 11.5x spread, across one WHOOP 5 user's own 36 recorded nights. Replaces the step with a graded penalty in minutes since a MEASURED sleep onset: rem = 1.0 * c - K * clamp01(1 - m / M0) K = 3.0, M0 = 60 min K is the incumbent step's own magnitude (e^-3 ~ 0.05 on the REM emission, strong suppression but never a veto). The sleep-accel grid — {cliff, graded} x {fraction, minutes} x K in 1..8 x threshold, 210 cells — ties on accuracy in every cell, so nothing discriminates K, and the smallest correct change fixes the units and the shape without also retuning a magnitude no measurement can justify. M0 = 60 sits inside the measured-defensible band: against PSG truth a guard reaching 45 min costs 0.36 % of all real REM (1 of 31 subjects) and one reaching 90 min costs 6.85 % (15 of 31). The `1.0 * c` ramp and the deep term are untouched and stay fractions of the session — both describe where in the night you are, which is inherently proportional. Removing the ramp collapses REM to 1.4 % of night and kappa to 0.143; it is load-bearing. THIS IS A ROBUSTNESS FIX, NOT AN ACCURACY WIN. On sleep-accel the step is measurably inert (kappa 0.349 -> 0.349, median first-REM latency 142.0 -> 141.0 min when removed) because that cohort spans only 2.4x in session length. Replayed over the 36-night device database (15 stage-locked human-authored references, 9163 epochs): 4-class kappa 0.691 -> 0.689, sleep/wake 0.583 -> 0.582, accuracy 78.2 % both. kappa is not the guardrail that matters here — ryanbr#437 reverted ryanbr#348 for exactly that reason ("kappa doesn't guard stage-fraction calibration"), so stage fractions are reported too. Per-night bias vs the human reference (predicted - truth, pp) improves on three stages and is unchanged on deep: wake -7.26 -> -7.13, light -7.54 -> -6.10, deep +6.65 -> +6.65, rem +8.15 -> +6.57. On the healthy stratum ryanbr#348 broke (in-bed >= 5 h, n = 20) nothing moves more than 0.42 pp and wake moves +0.03 pp; deep is byte-identical on all 36 nights. 12 of 36 nights change at all, and the change is monotone in session length — mean REM delta -20.9 pp under 2 h, -2.4 pp at 2-5 h, -0.4 pp at >= 5 h — i.e. it lands on precisely the short sessions the fractional guard was mis-scaling. Plumbing: `Epoch` gains `minutesSinceOnset`; `stageEpochs` stages once with the guard disabled, takes the first sustained non-wake run as onset (5 min = 10 epochs; measured against PSG onset at bias -3.8 min, MAE 7.4 min, n = 31), then re-applies the guard and re-runs Viterbi. Only the guard differs between passes, so the cost is one extra Viterbi, not a second featurisation. The guard is clamped to [0, K] so a pre-onset epoch cannot be penalised harder than onset itself — ryanbr#271 can place a window start hours early. Android twin updated in lockstep per the parity contract, including the constants and the frozen golden. Tests: Swift 1247 passed / 0 failed; Android 3238 passed / 0 failed / 5 skipped (393 suites, counts read from the JUnit XML). Both frozen goldens are unchanged, so the recipe's pinned end-to-end shape is preserved. Known and separately tracked, NOT addressed here: median first-REM latency runs ~54 min late against PSG (142 vs 88.5 min truth; removing the step gives 141, so this term is not the cause), and deep is under-called 2-3x (4.7-6.2 % predicted vs 14.8 % truth). Refs ryanbr#930.
… not just kappa (ryanbr#935) * SleepBench measures stage-fraction calibration and first-REM latency, not just kappa PR ryanbr#348 fitted the stager to DREAMT. It raised kappa on all three benchmarks with a held-out gap of -0.027 — clean by every ML criterion — and PR ryanbr#437 reverted it 48 hours later because it re-scored a healthy night from 6% to 23% awake. The revert's own words: "kappa doesn't guard stage-fraction calibration." SleepBench as it landed in ryanbr#925 reproduces exactly that blind spot. Sections B and C score agreement (accuracy, kappa, per-stage sensitivity/specificity) and wake minutes, none of which constrain how much of the night a recipe spends at each stage. A recipe can raise kappa while systematically reallocating stages, because the epochs it newly gets right can outnumber the epochs it newly mislabels. Adds two sections. Nothing existing is replaced; the old section E is renamed G. E. PER-STAGE FRACTION CALIBRATION. Predicted % of night vs reference % of night for wake/light/deep/REM, as a signed bias in percentage points, per night and aggregated, plus the unsigned MAE so a recipe that over- and under-calls in equal measure cannot pass as well calibrated. - E.0 prints accuracy and kappa on the SAME nights, so the number that is not sufficient sits directly above the numbers that guard it. - E.2 reports a HEALTHY stratum (in-bed >= 5 h) separately. The aggregate hides the failure: ryanbr#348's healthy-night blowout was ~17 pp of wake, which pooling with short fragmented nights dilutes. - E.3 reports the shipped population's stage fractions over every replayed night with no reference at all, so a distribution shift is catchable on a database with no human labels. F. FIRST-REM LATENCY. Minutes from staged sleep onset to the first REM epoch, per night and as a median/p10/p90/min/max, for each recipe and for the human reference. Calibration pins how MUCH REM a recipe emits, not WHEN; REM in the first minutes after onset is physiologically implausible in a healthy adult. Nights that never reach REM are counted separately rather than folded in as a zero. Reference set. The calibration sections score the stage-locked rows, not section B's set. B excludes an edited night whose stored hypnogram is a byte-exact replay of the CURRENT V2 — right for B's question, but version-dependent by construction: change the recipe and a night can enter or leave the exclusion, silently swapping the denominator underneath a before/after. The stagelock cursor set comes from `cursors` and does not move when the recipe moves. Read-only properties are unchanged: DB.swift is untouched, the open is still SQLITE_OPEN_READONLY | immutable=1, there is no write surface, and the database path is still a required argument. Verification. 13 new unit tests over the pure label-array primitives, no database needed (swift test in Tools/SleepBench). Measured on a real 36-session database with 15 stage-locked human-authored references (9,163 epochs), replaying the same harness against two builds of SleepStagerV2 — this branch's base, and the base plus the REM-latency guard change proposed in ryanbr#934: V2 4-class kappa 0.691 -> 0.689, accuracy 78.2% -> 78.2% stage bias, pp wake -7.26 -> -7.13 light -7.54 -> -6.10 deep +6.65 -> +6.65 rem +8.15 -> +6.57 first-REM latency median 72.2 -> 83.2 min, minimum 10.0 -> 43.0 min healthy-stratum wake fraction 9.40% -> 9.43% (+0.03 pp) Kappa moved -0.002 and would have called that change nothing. The latency minimum moving off 10 minutes, and the healthy-stratum wake fraction holding to +0.03 pp, are the evidence — and neither was measurable in this repository before this commit. * Stop SleepBench's calibration set scoring V2 against V2's own output Section E's reference set keys on `stagelock` so the denominator cannot move between two builds — the right call, and the reason section B's recipe-dependent exclusion is not reused there. But a `stagelock` cursor proves only that the stages arrived through `edit_sleep_stages`; it does not prove they differ from what V2 emits. On the database this was developed against, 3 of the 15 stage-locked reference nights replay BYTE-EXACT from V2 and a 4th agrees at 99.09%. Those rows carry no information about V2: they hand the incumbent a guaranteed perfect night and charge every alternative recipe for the same nights. The effect is large enough to invert a verdict. Scoring PR ryanbr#348's parameter set on this database gives 4-class kappa 0.640 against the incumbent's 0.689 on the contaminated 15-night set — ryanbr#348 loses — and 0.705 against 0.604 on the same set with the three byte-exact nights held out — ryanbr#348 wins by +0.101. Section E exists precisely to judge a ryanbr#348-style tune, so a 0.085-kappa thumb on the scale in favour of "change nothing" is the one bias it must not have. Section E cannot simply drop the rows the way section B does without reintroducing the version-dependence it was built to avoid, so the harness names them instead: a new E.-1 audit lists every stage-locked night whose stored hypnogram replays from V2 at >= 95%, flags the byte-exact ones, reports what fraction of the reference set they are, and prints the exact `--exclude` line to pin the same held-out set across both builds of a comparison. Excluding by explicit timestamp keeps the set frozen; excluding by "matches this build's V2" would not. The audit asserts no mechanism for the match, only that a row cannot be an independent reference for the recipe it replays. Verification: `swift test` in Tools/SleepBench = 13 tests, 0 failures; release build clean; run against a 37-session device database copy, E.-1 names the four nights above and every downstream section is unchanged when `--exclude` is not passed. Tools-only — no analytics, no stager, no Android twin, no goldens. Refs ryanbr#348, ryanbr#437.
* Run the tool tests that nothing was running Three places in this repo had tests, or code the app's conclusions depend on, that no CI job ever executed. tools/linux-capture carries 212 tests across 8 files — the frame decoder, the HCI extractor, the activity and sync decoders, spot HRV, and the SpO2 promote gate ryanbr#103 is waiting on. They ran only when someone remembered to. All 212 pass today, so this starts green; the point is that a regression would previously have gone unnoticed by a suite that exists to catch it. Tools/SleepBench is the instrument the sleep work is argued with — ryanbr#934's stage-fraction tables came out of it — and ryanbr#935 added 13 tests nothing ran. It path-depends on Packages/StrandAnalytics and Packages/WhoopProtocol, so it also bitrots the first time either changes shape. ryanbr#935 is the case in point: that tool was scoring V2 against V2's own output for a week and no check could have told anyone. Tools/Backfill has no tests yet, so it is build-only, guarded by a grep for a testTarget rather than assuming one. The Swift side joins swift-packages.yml, whose paths now include Tools/** so a package change can fail its dependents. The Python side is its own ubuntu job: stdlib unittest, no dependencies, and `discover` rather than a hand-listed set so a new test file cannot be silently left unrun — which is the exact failure this closes. app-build.yml stays disabled; app-target Swift is a separate problem. * Fix the Backfill build the new job just caught The tool did not compile: line 57's stage dictionary mixes Double? sources with untyped 0 fallbacks, so Swift could only infer [String: Any] and the emit-module step failed. Nothing built Tools/Backfill, so nobody found out. Annotated [String: Double] — all four sources are Double? on WearableSleepSession, and that is the minute-dict shape SleepStageTotals already decodes. No behaviour change is possible here: the tool could not run at all before this. This is the job earning its place on its first run. * Fix three things the re-review found in this PR pipefail. GitHub's default shell is `bash -e` WITHOUT pipefail, so piping the test run into tee returned tee's status and a failing suite would have exited 0. The count assertion I added would therefore have MASKED every real failure — strictly worse than not adding it. Proven both ways locally: without pipefail a red suite reports exit 0; with it, exit 1. Tools/** was too wide. It matched appchangelog-gen.py, doc_comment_lint.py, the anonymize shell scripts and __pycache__, so editing a Python lint script would have queued ten macos-15 jobs. Narrowed to Tools/SleepBench/** and Tools/Backfill/**, which is what the Swift job actually builds. Tools/test_i18n_audit.py was still unrun — 36 tests covering the audit that gates every PR's translations. i18n-coverage.yml runs `i18n_audit.py --ci` but never its test file. Now covered, from inside Tools/ since the suite imports the module by bare name. Both steps now assert a floor on the collected count, because `discover` exits 0 having found NOTHING: a wrong working-directory would otherwise turn this job green while running no tests, which is the exact failure the PR exists to end. * Write the scratch capture outside the checkout tee wrote out.txt into tools/linux-capture and Tools, neither of which is gitignored — so rehearsing these commands locally leaves a stray file that can be committed by accident. $RUNNER_TEMP keeps the streamed log and leaves the working tree clean.
Fixes the design bug in
SleepStagerV2.cyclePriordocumented in #930, in the direction that issue and its follow-up comment argued for: graded rather than a cliff, absolute minutes rather than a fraction of session length, measured from a detected onset rather than from the window start.Read the "what this is not" section before the numbers. This is a robustness fix. It does not buy accuracy, and I am not claiming it does.
The change
Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift, Kotlin twin atandroid/app/src/main/java/com/noop/analytics/SleepStagerV2.kt:So:
K · clamp01(1 − m / M0)withK = 3.0,M0 = 60 min, wheremis minutes since a measured sleep onset.The
1.0 * cramp and the whole deep term are untouched, deliberately. Both are correctly fractions of the session — they describe where in the night you are, which is inherently proportional. Only the latency guard changes units, because only it describes an absolute physiological interval. Ablating the ramp entirely collapses REM to 1.4 % of night and kappa to 0.143 (sleep-accel, LOSO), so it is load-bearing and I did not touch it.What this is NOT
There is no accuracy win here, and reviewers should not accept one. On sleep-accel the
-3.0step is measurably inert:All 210 tested grid cells — {cliff, graded} x {fraction, minutes} x K in 1..8 x threshold — tie, because the recipe's own first-REM latency already sits beyond every guard width tested. The step is not currently doing anything to accuracy on that cohort.
The justification is that the term is parameterised in the wrong units, which is a correctness property, not a score. A term that is inert today on one cohort is not thereby correct; it is untested. Anchoring it in minutes means it stays inert for the right reason instead of by arithmetic coincidence.
Why minutes, and why that part is settled (CONFIRMED)
Measured on PhysioNet
sleep-accel(Walch et al., SLEEP 2019; ODC-By v1.0), n = 31 subjects / 26,773 PSG-scored 30 s epochs, leave-one-subject-out:c < 0.12adds +0.009 kappa over physiology alone; continuous time adds +0.051. A threshold captures ~18 % of what continuous time provides.The shipped guard works on that cohort only by coincidence — 12 % of a near-uniform 476-min recording is 57 min, just under the 88.5-min median true latency. That cohort spans only 2.4x in session length, so it understates the problem. On my own device database (n = 36 recorded sessions, one WHOOP 5 wearer) sessions span 11.5x and the guard width therefore ranges 7.4 - 84.5 min for the same wearer and the same physiology, exactly as reported in #930.
Provenance of the sleep-accel figures: the harness compiles
SleepStagerV2.swiftverbatim and reproduces 11,460 / 11,460 epoch labels identically across 48 randomised nights against the in-app stager, so these measure this code, not a re-implementation of it.Choosing K and M0
K = 3.0 — the incumbent step's own magnitude, kept on purpose. e^-3 ~ 0.05 on the REM emission: strong suppression that sufficient evidence still overcomes, never a veto (#930 established the step was never a hard mask — REM still occurred at 6.8 % during hour 0 as shipped). Since all 210 grid cells tie, nothing in the data discriminates K. The smallest correct change fixes the units and the shape without also retuning a magnitude no measurement can justify. Note the graded form is strictly gentler in aggregate: mean penalty over [0, M0] is K/2 = 1.5 against the incumbent's flat 3.0.
M0 = 60 min. Cost against PSG truth of a guard extending to a given width:
60 min sits inside the defensible band. Because the penalty is graded rather than a cliff it is already down to <= 0.75 log-odds (x0.47, against x0.05 at onset) across 45-60 min, so a genuine sleep-onset REM period late in the window is held back rather than suppressed. 60 min also stops clear of the ~70-100 min population first-REM latency, so the ramp is spent before real REM is expected.
This matters for the population #930 flagged as INFERRED, not measured: REM latency shortens with age, and a minority of people show sleep-onset REM periods. A graded, bounded, soft ramp degrades for them instead of cliff-edging, but I have no data on that group and am not claiming otherwise.
Before/after on real nights — kappa and stage fractions
Replayed with
Tools/SleepBench's reader over a copy of a real device database: 36 sessions, of which 15 carry astagelockcursor (human-authored hypnograms via the cloud edit path — not merelyuserEdited, which re-derives stages machine-side over a human-chosen window). 9,163 reference epochs.The reference set is the stage-locked rows precisely because that set comes from
cursorsand therefore does not move when the recipe changes. (SleepBench's "byte-exact replay of the current V2" exclusion is version-dependent by construction — a night can leave the exclusion just because the recipe changed, silently swapping the denominator underneath a before/after. That happened to me on the first pass: 11 nights before, 12 after.)kappa — essentially flat, as predicted
Per-stage fraction calibration — the guard kappa does not provide
#437 reverted #348 with the words "kappa doesn't guard stage-fraction calibration — a systematic wake-fraction bias sails through it." A cliff term is exactly the shape that moves stage fractions without moving kappa, so:
Per-night bias vs the human reference (predicted - truth, percentage points; n = 15):
Total absolute bias 29.60 -> 26.45 pp. MAE improves on light (8.52 -> 7.41) and rem (9.01 -> 8.33), is unchanged on deep, and worsens 0.11 pp on wake (8.01 -> 8.12).
Healthy-sleeper stratum (in-bed >= 5 h, n = 20) — the cohort #348 broke:
For scale, the #348 field symptom was a healthy night moving wake 6 % -> 23 % (~17 pp). Nothing here moves more than 0.42 pp, and wake moves 0.03 pp.
Deep is byte-identical on all 36 nights (max |delta deep| = 0.00 pp), which is the direct check that the deep term was not disturbed.
Where the change actually lands
12 of 36 nights change at all; 24 are byte-identical. The effect is monotone in session length — i.e. it lands on precisely the sessions the fractional guard was mis-scaling:
First-REM latency (the quantity this term controls), all 36 nights: median 72.2 -> 83.2 min, p10 37.0 -> 58.0, min 10.0 -> 43.0 min. The sub-15-minute sleep-onset-REM calls are gone.
I want to be explicit about the largest behavioural change, because it is the one worth arguing with: four sessions of 61-81 min drop from 11.7-35.5 % REM to 3.7-5.7 %. Under a 60-minute guard, a nap shorter than an hour is suppressed for most of its length. I believe that is the physiologically right prior — REM in a sub-hour nap is uncommon outside REM rebound — and the guard remains soft, which is why those nights still call 3.7-5.7 % REM rather than zero. But it is a real change in what a nap looks like in the app, it is by design, and if anyone thinks a shorter M0 is warranted for naps specifically, 45 min is equally defensible on the measured cost table above.
Plumbing
EpochgainsminutesSinceOnset, andstageEpochsbecomes two passes: stage once with the guard disabled (cyclePrior(c, .infinity)), take the first sustained non-wake run as onset, then apply the guard and re-run Viterbi. The onset rule is 5 minutes sustained sleep (10 epochs on the 30 s grid), measured against PSG onset on sleep-accel at bias -3.8 min, MAE 7.4 min (n = 31).Only the guard differs between the passes — every emission term, z-score and percentile is computed once and reused — so the cost is one extra Viterbi over an already-built lattice, not a second featurisation, and
stageSessionmemoizes the result anyway.Two deliberate degradation choices:
c-based guard used.[0, K], so a pre-onset epoch cannot be penalised harder than onset itself. SleepStagerV2.cyclePrior: the REM step is a fraction of session length, so the REM-latency guard varies 7-85 min across one user's nights #930's follow-up comment noted that the origin inherits detection error and that [sleep] WHOOP 4.0 over-stages the low-motion pre-onset stretch as sleep (detected onset lands too early) #271 can place a window start ~4 h early on WHOOP 4.0; without the clamp such an epoch would take an unbounded penalty.Tests
The regression test the previous shape could not express — a 10-hour night and a 3-hour fragment must receive the same guard at the same minute after onset. Under
c < 0.12they disagree by construction: 30 min in isc = 0.05(fully suppressed) on the long night andc = 0.167(not suppressed at all) on the fragment. The test isolates the guard from the1.0 * cramp (which legitimately differs) by subtracting the ramp back out, and there is an end-to-end twin that stages the same streams under both window lengths and asserts the first 60 minutes agree epoch-for-epoch.Also added, in both languages: guard shape / clamp / monotonicity with the constants pinned across platforms, and the sustained-onset rule (9 epochs must not qualify, 10 must, a brief run before a sustained one must not be mistaken for onset).
swift test(Packages/StrandAnalytics, on this branch)./gradlew testFullDebugUnitTestAndroid counts read from
app/build/test-results/testFullDebugUnitTest/*.xml, not the wrapper exit code.android.ymlis disabled so CI does not run Kotlin tests on PRs — local verification is the only gate, and I ran it against JDK 17.Both frozen goldens are unchanged, in Swift and Kotlin — the pinned end-to-end recipe shape and the Swift/Kotlin parity it proves both survive this change, which is the cheapest evidence that the two implementations still agree epoch-for-epoch.
Per the parity contract the Kotlin twin changes in this same PR, including the constants;
remLatencyPenalty/remLatencyMinutes/cyclePrior/sustainedSleepOnsetareinternalon Android so the parity test can pin them, matching the Swift visibility.Known, and NOT addressed here
Two real defects this work surfaced that are not caused by this term and are tracked separately:
Neither is in scope; fixing them inside a units correction would make both un-reviewable.
Caveats
< 2 hstratum is 4 nights. The mean -20.9 pp REM shift there is the direction the design intends, but the magnitude rests on very little data.Refs #930. Prior art on changing V2's staging constants: #348 and its #437 revert — the "kappa doesn't guard stage-fraction calibration" line from #437 is why the stage-fraction and healthy-stratum tables above exist at all.