Skip to content

Sleep staging: onset from the band inside V2, a deep-latency guard, and an Asleep/Woke row that matches the hypnogram - #20

Merged
ayiskakov merged 20 commits into
mainfrom
fix/sleep-band-onset-staging
Sep 24, 2026
Merged

ayiskakov merged 20 commits into
mainfrom
fix/sleep-band-onset-staging

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

Follow-up to #9. That PR fixed the two large problems on this WHOOP 5/MG: REM staged at 36–49 % of sleep, and total sleep 14–65 min above the strap's own band state. Build 11.9.6 re-scored the nights. In a new backup, stored REM is 22–27 % and TST sits within −14 … +25 min of the band's asleep time on all six nights.

A replay harness runs the backup through the real detectSleep, using the engine's read windows. It reproduces all six stored hypnograms byte for byte, so the figures below are the app's own.

Three things were still wrong, and all three come from where sleep onset sits.

Problem Seen on the 5/MG nights
1 The band trim ran after SleepStagerV2, so the recipe's pass 1 took onset from its own hypnogram, which slept through the lead-in. The REM-latency guard started up to two hours early and was spent before sleep began. REM 31 min after the band's onset on the longest lead-in
2 Deep at the first sleep epoch. Descent into N3 runs through N1/N2, but the deep terms (HR-flatness gate, early-night prior) are strongest at onset. First deep at minute 0 on all six nights; about 5 min a night of deep while the band still read "still"
3 The Sleep tab's Asleep / Woke row printed the in-bed bounds, while the trim draws the lead-in and tail as wake right under it. One row printed an in-bed start 2 h 7 min before the first sleep epoch as "Asleep"; another printed "Woke" 29 min after the last sleep epoch

Commits

  1. fix(sleep): stage V2 inside the band's sleep window instead of trimming after.
    • SleepStager.bandSleepWindow returns the span the trim keeps. SleepStagerV2.stageSession(sleepWindow:) forces wake outside that span inside the Viterbi lattice.
    • Pass 1's onset, the REM guard and the wake → light entry all follow the band as a result. Emissions and normalisation are unchanged.
    • The trim still runs afterwards and finds nothing left to relabel; V1 still relies on it. The window is part of the V2 cache key.
  2. fix(sleep): hold deep back for the first 20 minutes after sleep onset.
    • A deep-latency guard, the twin of the REM guard: 3.0 log-odds at onset, decaying linearly to 0 at 20 min, from the same onset and in the same second pass.
    • The magnitude is the REM guard's. The 20 min decay is where staged first-deep latency is unbiased against PSG. Kappa keeps rising up to 25 min, so the value was not picked for kappa.
    • Tools/SleepPSG's port mirrors the guard and gains a guard-off variant.
  3. fix(sleep-ui): the Asleep/Woke row reads the hypnogram, not the in-bed bounds.
    • Night.sleepOnsetTs / finalWakeTs are the first and last non-wake interval of the real timeline.
    • The edit sheet still edits the in-bed bounds, so its fields are now Bedtime (an existing key) and Got up (new, nine locales).

Results

PhysioNet sleep-accel, 31 PSG-scored subjects. The "band window" rows use each PSG hypnogram as a stand-in band, run through the production bandSleepWindow.

kappa deep F1 (all scored epochs) first-deep latency bias subjects with deep < 5 min after onset (truth: 1) first-REM latency MAE
No band, before 0.363 0.498 −12.5 min 6 49.9 min
No band, after (shipped baseline) 0.373 0.523 −3.3 min 0 49.9 min
Band window, 11.9.6 (relabel after) 0.413 0.505 −18.6 min 13 47.4 min
Band window, after 0.434 0.541 +0.4 min 0 44.0 min

The pooled deep bias falls from +5.17 to +3.57 pp; REM and wake F1 are unchanged.

On the six 5/MG nights:

  • TST moves by at most 2 min.
  • First deep moves from minute 0 to 10–28 min after onset.
  • Stage split: deep 15–27 % of sleep (was 18–29 %), REM 22–26 %, light 47–60 %.
  • The Asleep/Woke row now matches the hypnogram.

These are n = 1 wearer figures. The PSG set carries no R-R and no band, which is why the band rows use the PSG hypnogram as a stand-in.

Verification

  • swift test in Packages/StrandAnalytics: 2177 pass, with 2 skips that were already there.
  • New tests:
    • the window/grid mapping, cache-key separation, and that the trim is a no-op after the lattice;
    • REM re-anchoring and the deep guard, each pinned verbatim from runs of the recipe.
  • The frozen golden gains 8 min of light before its first deep run; every later boundary is unchanged.
  • Tools/SleepPSG: 33 tests pass, including port validation. Tools/SleepBench: 13 tests pass. Tools/Backfill builds.
  • App-target Swift changed (SleepModel, SleepView):
    • macOS Strand builds and all 1957 StrandTests pass, including 5 new ones for the row.
    • NOOPiOS builds for the iOS Simulator. The Watch app was left out of a local, uncommitted copy of project.yml, because no watchOS runtime is installed on this machine.
  • Tools/doc_comment_lint.py is clean. Tools/i18n_audit.py --ci main is clean.
  • Not yet opened on a device after the change.

Not in this PR

  • Session start still comes from gravity stillness. A long evening lead-in therefore counts as time in bed, and efficiency drops (0.77 on the longest lead-in night). Moving the start changes the session key, so it needs its own design.
  • The Today and full-day chart sleep bands are labelled with the in-bed span.
  • On one night the recipe finds no REM in the first three cycles, so first REM lands at 246 min. There is no stage-level truth for these nights to tune against.

…ng after

The band latency trim relabelled the lead-in and tail as wake after
SleepStagerV2 had already staged the whole gravity window. V2's pass 1
reads sleep onset from its own hypnogram and measures the REM-latency
guard from it, so on a night with a long lead-in the guard was anchored
up to two hours before the band's onset and spent before sleep began.
On a banked 5/MG night whose session opened 2 h 13 min before the band's
onset, REM appeared 31 min after the real onset. The relabelling also cut straight from wake
into whatever V2 had staged there, which the awake row forbids.

SleepStager.bandSleepWindow now returns the span the trim keeps, and
detectSleep passes it to SleepStagerV2.stageSession as sleepWindow.
Epochs outside it are forced to wake inside the Viterbi lattice, so
pass 1's onset, the REM guard and the wake -> light entry all follow
the band. Emissions and normalisation are unchanged. The trim still
runs afterwards (V1 relies on it) and finds nothing left to relabel.
The window is part of the V2 cache key.

PhysioNet sleep-accel, PSG hypnogram standing in for the band (31
subjects): kappa 0.413 -> 0.414, first-REM latency MAE 47.4 -> 44.0 min
against relabelling afterwards. Inert without a band. On six 5/MG nights
TST moves by at most 2 min; REM latency on the two long-lead-in nights
moves from 31 and 51 min to 137 and 246 min.
SleepStagerV2 staged deep from the first sleep epoch. Descent into N3
runs through N1 and N2, but the recipe's deep terms (the HR-flatness
gate and the early-night cycle prior) are strongest at onset itself.
On PhysioNet sleep-accel it staged deep within 5 min of onset for 6 of
30 subjects (1 of 30 in the PSG truth), 12 of 30 once a band window
fixes the onset, and at minute 0 on every one of six banked 5/MG
nights.

The deep emission now carries a latency guard: 3.0 log-odds at onset,
decaying linearly to 0 at 20 min, measured from the same onset and
applied in the same second pass as the REM-latency guard. The magnitude
is the REM guard's. Twenty minutes sits inside the classic first-cycle
descent and is where staged first-deep latency is unbiased against PSG
on sleep-accel (-3.3 min, from -12.5). Kappa rises across the whole
10-25 min grid, so the value was chosen for the unbiased latency, not
the kappa.

Shipped sleep-accel baseline (31 subjects): kappa 0.363 -> 0.373, deep
F1 0.498 -> 0.523, pooled deep bias +5.17 -> +3.57 pp; REM and wake
unchanged. With the band window: kappa 0.414 -> 0.434. On the six 5/MG
nights first deep moves from minute 0 to 10-28 min, deep falls 1-3
points to 15-27 % of sleep, TST is unchanged.

The frozen golden hypnogram gains 8 min of light before its first deep
run; every later boundary is unchanged. Tools/SleepPSG's port carries
the guard (port validation passes) and adds a guard-off variant.
…d bounds

The Sleep tab's Asleep/Woke row printed the session's in-bed start and
end. Since the band latency trim, a banked 5/MG night scores the
lying-awake lead-in and tail as wake inside those bounds, so the row and
the hypnogram under it disagreed: one night's row printed an in-bed
start 2 h 7 min before the hypnogram's first sleep epoch as "Asleep",
and another printed a "Woke" time 29 min after its last sleep epoch.

Night.sleepOnsetTs / finalWakeTs are the start of the first and the end
of the last non-wake interval of the night's real timeline, and the row
reads them. A night with no real timeline (imported totals) keeps its
bounds.

The edit sheet the row opens still edits the in-bed bounds, so its
fields are now labelled Bedtime (existing, translated) and Got up (new,
nine locales) instead of Asleep and Woke, keeping one name per time.
Every published wrist stager that reaches a four-class kappa of 0.5 fits
its per-epoch emissions to human-scored PSG. NOOP's V1 and V2 set every
coefficient by hand. Tools/SleepTrain fits one on open data only and
reports it.

- prepare_*.py turn Wearanize+ OA (88 nights, Empatica E4, CC BY 4.0),
  PhysioNet sleep-accel (31 nights, Apple Watch, ODC-By) and DREAMT
  (sleep-clinic patients, restricted licence) into one night format:
  whole-second gravity, integer bpm and whole-second-stamped integer-ms
  beat intervals, which is what the app hands a stager.
- sleeptrain/features.py is the feature definition in plain numpy with
  every sum taken left to right, so a Swift port can reproduce it.
  Everything is relative to the night: motion over the night's own jerk
  floor, posture change over its median change, heart rate as within-night
  z-scores and ranks, 5-minute HRV scaled to the night's percentiles, and
  elapsed time as minutes plus three decays so a linear head can learn
  the sleep-onset descent.
- train.py uses every night twice (whole scored span, and cropped to the
  window SleepStager.bandSleepWindow would allow), fits a base head
  (motion and heart rate) and an HRV head, counts the stage transitions,
  reports 10-fold grouped cross-validation and scores DREAMT as an
  untouched test set, then writes the generated Swift model.
- make_oracle.py writes the fixture that pins the Swift port.

Datasets are never committed. DREAMT's licence limits it to research,
so it only tests the model and never enters a fit. The fit is
deterministic: two runs write byte-identical coefficients.
SleepStagerV3 is a drop-in for SleepStagerV2.stageSession: same
signature, same [StageSegment] tiling, memoized the same way. It ports
Tools/SleepTrain's feature definition line for line and runs the two
generated heads. When the night has beat intervals on at least half its
epochs both heads run and their log-posteriors are averaged with equal
weight; otherwise the base head (motion and heart rate) stages it. A
Viterbi pass over the counted stage transitions decodes the result.
With the band's sleep window it stages only the epochs inside it, and
everything else is wake.

Cross-validated agreement with PSG (four-class kappa over every scored
epoch, wake outside the window), V2 -> V3:
- Wearanize+, band-style window: 0.363 -> 0.547 (second scorer 0.742)
- Wearanize+, whole span: 0.301 -> 0.533
- sleep-accel, band-style window: 0.434 -> 0.504
- DREAMT sleep-clinic patients, never trained on: 0.259 -> 0.313

On Wearanize+ every stage share is within 1 pp of PSG (V2: light +13.5,
deep -5.4, REM -7.0 pp), and no night starts deep within 5 min of onset.
First REM comes 47 min late, as V2's does (48).

The equal blend is untuned. The HRV head alone scored 0.554 and 0.320
but called REM high outside its training cohort (+7.9 pp on DREAMT,
+5.9 blended).

SleepStagerV3OracleTests rebuilds three SplitMix64 synthetic nights and
requires every feature and both heads' posteriors within 1e-9 of the
Python reference, and every label and segment equal. A night stages in
about 0.09 s in a release build (V2: 0.3 s).
`sleeppsg --section v3 --nights DIR ...` replays the shipped V2 and V3
over prepared night-format cohorts. Each night is staged inside a
band-style window emulated from its PSG hypnogram, and over its whole
scored span. The report gives kappa, per-stage F1, stage-share bias,
per-night minute bias with limits of agreement, and first deep / REM
latency.

On DREAMT, which V3 never trained on, it reproduces train.py's Python
figures exactly (0.313 in the window, 0.256 over the span). That is the
end-to-end check that the Swift port stages what the reference does. On
the training cohorts V3's rows are in-sample, and the report says so.
SleepStagerVersion (.v1 / .v2 / .v3) replaces the useSleepStagerV2 Bool
through detectSleep, AnalyticsEngine and the sleep-motion trace line,
and dispatches to its recipe. V2 and V3 stage inside the band's sleep
window; V1 still relies on the latency trim. The HR-only path keeps V2,
as before, because V3 was trained with motion present.

PuffinExperiment.sleepStager is the one resolver that the engine, the
self-heal restage, the REM-funnel diagnostic line and Settings all read.
An explicit choice wins. Otherwise a legacy explicit V2-off stays V1,
and everyone else gets V3. The key is not in the backup whitelist, so
backups are unchanged. The day-cache config signature carries the
choice, so switching restages the scoring window.

Settings > Experimental > Sleep staging becomes a V3 / V2 / V1 segmented
picker. It reads through that resolver, so an upgraded user whose V2
switch was off sees V1 there, as staged. Five new strings in nine
locales; the old toggle's two strings are removed.

Tests: SleepStagerSettingTests pins the upgrade rules. detectSleep with
.v3 keeps the same detected window and stages it with V3. Every version
dispatches to its own recipe. V3 detects no sleep without gravity, like
V1 and V2.
Adds the V3 section: features, the two heads and their blend, the
decoder, the band window, the training data, and the cross-validated
agreement next to V2 and a second human scorer, with its known limits
(first REM late, deep over-counted on sleep-clinic nights, and no WHOOP
strap in any PSG set). The honest-hedging note no longer says "not
PSG-validated". Three lines that named V2 the default are corrected.
ayiskakov and others added 12 commits September 24, 2026 19:13
…n uses

`Repository.restageFromRaw` (edit, add-nap and the post-sync self-heal) called the recipe
alone: no band sleep window and no band latency trim. `detectSleep` stages inside the
window and trims outside it. Re-staging a detected night over its own bounds therefore
staged the lying-awake lead-in as sleep again. On the owner's six banked 5/MG nights, V2's
first sleep moved up to 2 h 04 min earlier and TST rose by up to 60 min; V3's first sleep
and TST each moved by about 20 min.

`SleepStager.stageWindow` is now the one staging funnel. It applies the recipe inside the
window (V2/V3), then the trim and the wake-veto. `detectSleep` stages every accepted night
through it, and the re-stage reads the band's state and calls it too. The window is
resolved once per night and handed to both the recipe and the trim, so the band stream is
no longer scanned twice.
…ature records

`analyzeRecent` read the staging choice (and the motion-aware wake toggle) once for
the day-cache config signature and again inside the day loop. A picker change made while
a pass was running could therefore stage the later days of that pass with the new recipe
and cache them under a signature that names the old one. The loop now uses the single
pass-level read.
…sleeppsg does

`train.py`'s summary averaged every night's kappa, so a night with one class on both
sides (pe = 1) made the "mean night kappa" column NaN. `sleeppsg --section v3` leaves such
a night out, so the two reports that ANALYTICS.md says reproduce each other could print
different figures for the same cohort. `kappa` now returns NaN explicitly where it is
undefined, as `Confusion.kappa` does, and the summary skips it. No published figure
changes: no cohort night has an undefined kappa.
`dayCacheConfigSig` builds 17 values, but the separate `dayCacheConfigFields` list held
16 names: the Healthspan zone set joined the signature without a name. `changedConfigField`
refuses to name anything when the counts differ, so every cache drop since has been logged
as "unknown", including a switch of the new sleep-stager picker. The pinned test built its
signature from the 16-name list, not from the real construction, so it could not notice.

The signature is now built from (name, value) pairs, and the names travel to
`changedConfigField` with it, so adding or reordering a field cannot leave its name
behind. The Kotlin-twin list test goes: this fork ships no Android target.
…the night's recipe

"screen staged by V3" printed the current picker value as the recipe that staged the
diagnosed night. A stored night keeps no record of its recipe. It may have been staged
before the picker moved, and a night the merge takes from an import was never staged here.
The line now says the screen shows the stored hypnogram and names the current setting, and
it always says the totals can differ, including when the setting is V1.
…and Got up

The night editor's default labels became Bedtime / Got up when the Asleep/Woke row moved
to the hypnogram's bounds. The nap row opens the same sheet with those defaults, so editing
a 14:00 nap showed Bedtime and Got up. A nap edit now uses the nap wording the Add-a-nap
sheet and the nap row's pencil already use: "Edit nap times", "Nap started", "Nap ended".
All three strings are already translated.
…h it lies in

`epochInSleepWindow` judges each 30 s recipe epoch (wall-clock grid) by the one
session-grid instant it contains. The session's last recipe epoch is cut short at `end`,
and when the session starts δ seconds off the wall clock and ends within δ of a
wall-clock boundary, that epoch holds no session-grid instant before `end`. Judged by
the instant past `end`, it fell outside a window that runs to the end. V2 and V3 then
closed a night the band slept through on 1-29 s of wake, which the trim never samples
and so never removed. With the window clamped at `end`, which is what the trim needs,
leaving `to` unclamped does not reach that instant either.

The epoch now falls back to the session's last grid instant. `end` is threaded through
V2's and V3's `stageEpochs` and mirrored in `sleeptrain/model.py`'s `in_window`. The
committed V3 oracle regenerates byte-identical: its one windowed case closes 15 min
before its end.

On the owner's six banked 5/MG nights it applies to two. One gains its last 9 s of
sleep. On the other, V3 stages one more epoch. V3 normalises its features over the
staged span and decodes it as a whole, so that night's borderline tail re-decodes: its
last sleep moves 44 min earlier and TST falls by 6 min. V2's nights move by the sliver
alone.
… not 0

A V3 feature missing for the whole staged span was imputed as 0. That is neutral for a
z-scored column but an extreme for a percentile rank (typical 0.5) or a ratio. A span
with gravity but no heart rate yet, which the re-stage's density gate allows, read as the
lowest, flattest heart rate of the night: hr_pct and the three hrsd ranks at z = -1.74,
hr_over_low at -1.03. With the heart rate removed, the owner's six banked 5/MG nights
staged 32-48 % deep, against 8-18 % with it. A whole-night gap now takes the head's
training mean for that column, so it standardises to 0 and moves no logit (those nights
come out 84-95 % light).

The fit and every PSG figure are unchanged. No record the heads were trained on
(Wearanize+ 176, sleep-accel 62) or tested on (DREAMT 20) has such a gap, and
`sleeppsg --section v3` scores all three cohorts identically either way.
`sleeptrain/features.py`'s `impute` takes the same fallback from `Head.posteriors`. In the
regenerated oracle, only the no-beats case's HRV-head posteriors move; that head does not
stage the case, and every label is the same.
…say which ran

`SleepStager.hrOnlySessions`, the engine's fallback for a day that banked no gravity,
always staged with V2. The Rest trace's `stager=` still named the picker's choice
(V3 by default), and the picker had no effect on those nights.

The choice now reaches them: `hrOnlySessions(stager:)` stages with `stager.hrOnlyRecipe`,
which is the choice itself for V2 and V3. V1 builds its epoch grid from gravity and
returns one flat "light" block without it, so V1 maps to V2. `analyzeDay` names that
recipe in the trace for an HR-only night.

This makes V3 reachable with no motion, so it now reads such a span properly. No jerk
anywhere in the span (no gravity, or none in two consecutive seconds) leaves the motion
features missing, and no measurable block-to-block posture change leaves the posture
features missing. With the previous fix, a missing feature takes the head's training
mean. Read as present, zero activity and the time-since-move cap made this the stillest
night V3 ever saw, and deep came out 19-30 points too high.

With motion stripped from the PSG nights, four-class kappa inside the band window:

  cohort        V2 (was)  V3
  Wearanize+    0.303     0.477
  sleep-accel   0.363     0.446
  DREAMT        0.224     0.275   (the only out-of-sample cohort)

V3's first REM lands 40-74 min late on these nights. With gravity present, every figure
is unchanged: every cohort night carries gravity.

`sleeptrain/features.py` mirrors the rule. The oracle gains a fourth, gravity-free
night that pins it in both languages; the first three cases are unchanged.
…indow, measured

With a band sleep window, V2's REM and deep onset guards run from the band's onset, but
its cycle prior (deep early, REM rising toward morning) still reads `c`, the fraction of
the whole session. On a long lying-awake lead-in, part of the early deep prior is spent
before sleep begins: on the owner's six banked 5/MG nights, `c` at the band's onset was
0.03-0.19, a deep prior of 0.79-1.13 instead of 1.20.

Measuring `c` over the window instead scored no better against PSG in the window.
Four-class kappa went 0.363 -> 0.360 on Wearanize+, 0.434 -> 0.427 on sleep-accel and
0.259 -> 0.261 on DREAMT, and first REM came later on all three. So the recipe is
unchanged, and the claims around it now say exactly what moves with the window: the
detection comment said "the recipe starts from the band's onset". `cyclePrior` and
ANALYTICS.md record the measurement.
V3 re-implemented V2's `stageSession` veneer: the stable timestamp sort (V2's
`stableOrder` closure, V3's `sortedByTs`), the half-open clip (`clipToWindow` / `clip`),
the memo key (`V2Key` / `V3Key`, field for field) and the segment tiling. A future fix to
the clip bounds or the key would have had to find both copies.

`StagerInput` now holds the sort, the clip, one `Key` built from the clipped streams and
the sleep window, and `tile`. Both stagers call it and keep their own caches and read
windows (V2's pads, V3's epoch span). Output is byte-identical: V2's frozen golden and
shuffled-input tests, the V3 oracle and `sleeppsg`'s port validation all pass unchanged.
V2's extra "nothing clipped" early return was unreachable once the first bounds check
had failed, so it went.
Sleep staging V3: a stager fitted to polysomnography, default, with V2/V1 selectable
@ayiskakov
ayiskakov merged commit 8e65f6c into main Sep 24, 2026
15 checks passed
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.

1 participant