Skip to content

sleep: score stages instead of AND-ing gates, drop RMSSD - #34

Merged
abdulsaheel merged 2 commits into
mainfrom
feat/stager-decision-seam
Aug 2, 2026
Merged

sleep: score stages instead of AND-ing gates, drop RMSSD#34
abdulsaheel merged 2 commits into
mainfrom
feat/stager-decision-seam

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Built a harness that runs the real stager against DREAMT (99 PSG-labelled wrist nights). Our rules scored kappa 0.036. Deep PPV 5.7% against a 4.5% base rate, REM PPV 12.1% against 14.0% — chance, basically.

Checked each axis per-subject to see why:

axis Deep vs Light REM vs Light what we did with it
Rk d -0.53 d +0.43 throttled to z>4, ~never fired
hrSd d -0.43 d +0.27 deep only
sdnn d -0.41 d +0.32 didn't compute it
lfhf d -0.33 d +0.12 throttled to z>4
hr d +0.31 d +0.18 deep gate, wrong direction
rmssd d -0.13 d -0.02 REM primary

So the deep rule AND-ed one good gate, one null gate and one inverted gate. Those only line up by luck — which is why deep sleep came out as 30-second specks that the 3-min bout rule then deleted. The "needs smoothing" theory was treating the symptom.

what changed

Weighted sums of robust-z axes instead of boolean conjunctions. Weights are the measured d values, not tuned. Dropped rmssd and mean HR from both scores, added sdnn. Atonia + HR floor stay as gates on REM (preconditions, not evidence).

Also split cardioStager into feature extraction + classifyCardioEpochs so the rules can be scored on their own. Split itself is behaviour-neutral.

numbers

kappa Deep sens/PPV REM sens/PPV
before 0.036 10.3 / 5.7 30.6 / 12.1
after 0.128 53.0 / 12.9 52.6 / 20.7
after, 49 held-out subjects 0.132 56.0 / 10.9 51.9 / 21.5

Base rates are 4.5% Deep and 14% REM, so both minority classes now land well above chance where they used to sit at or below it.

Still miles off the 0.60-0.66 literature ceiling. Most of what's left is the wake rule (11% sens), which I didn't touch.

two things worth arguing about

Cutoffs are not the kappa optimum, on purpose. kappa keeps climbing as you suppress deep — deepCut 1.0 gives 0.3% deep and kappa 0.151 on the dev split. That's arithmetic on a clinic cohort where deep is only 4.5% of epochs, not physiology. Shipping it means users see ~0 deep sleep again, which is the bug we're fixing. Picked for normative proportions (REM 20-25%, Deep 13-23% of TST) and reported the kappa that falls out.

Cutoffs are calibrated on our own captures, not DREAMT. The DREAMT-optimal remCut 0.8 gives ~23% REM there and ~10% on WHOOP, and blew up the real-night regression test (62 min REM vs ~162 expected). Weights transfer, cutoffs don't — different sensor, different feature spread. Swept on 11 real nights instead (tool/whoop_proportions.dart): 0.5/0.3 gives REM 23.6% / Deep 14.9%.

caveats

One cohort, one sensor, DREAMT's feature extraction rather than ours, deep resting on 19-29 subjects, and no PSG on our own device at all. Proportion matching says the hypnogram has a believable shape, not that any epoch is right. All written into the source comments too.

Needs a kAlgoVersion bump in edge when it repins.

386 tests green, analyze clean.

🤖 Generated with Claude Code

Scored the stager against DREAMT (99 PSG-labelled wrist nights) via a new
harness and the old rules came out at kappa 0.036 — deep PPV 5.7% against a
4.5% base rate, REM PPV 12.1% against 14.0%, so basically chance on both.

Per-subject effect sizes say why:

  axis    Deep vs Light   REM vs Light    what we did with it
  Rk        d -0.53         d +0.43       throttled to z>4, ~never fired
  hrSd      d -0.43         d +0.27       deep only
  sdnn      d -0.41         d +0.32       didn't compute it
  lfhf      d -0.33         d +0.12       throttled to z>4
  hr        d +0.31         d +0.18       deep gate, wrong direction
  rmssd     d -0.13         d -0.02       REM PRIMARY

So the deep rule was AND-ing one good gate, one null gate and one inverted
gate. Those only line up by luck, which is why deep came out as 30-second
specks that the 3-min bout rule then deleted.

Now: weighted sums of robust-z axes, weights = the measured d values. Dropped
rmssd and mean HR from both scores. Added sdnn. Atonia + HR floor stay as
gates on REM since they're preconditions, not evidence.

kappa 0.036 -> 0.121 (0.101 on 49 held-out subjects). Deep 10.3/5.7 ->
55.0/12.5 sens/PPV, REM 30.6/12.1 -> 59.2/19.7.

Cutoffs are NOT the kappa optimum. kappa keeps climbing as you suppress deep
(deepCut 1.0 -> 0.2% deep, kappa 0.158) because DREAMT is a clinic cohort with
only 4.5% deep. Shipping that gives users ~0 deep sleep again. Picked for
normative proportions instead.

Also had to calibrate cutoffs on our own captures, not DREAMT — the
DREAMT-optimal remCut 0.8 gives 23% REM there but 10.3% on WHOOP, and failed
the real-night regression test (62 min REM vs ~162 expected). Weights travel,
cutoffs don't.

Split cardioStager into feature extraction + classifyCardioEpochs so the rules
can be scored on their own. No behaviour change from the split itself.

Caveats: one cohort, one sensor, DREAMT's feature extraction not ours, deep
rests on 19-29 subjects, no PSG on our own device. Proportion matching says the
hypnogram shape is believable, not that any epoch is right.

386 tests green.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The cardio stager now uses weighted robust-z scores over SDNN, HR dispersion, LF/HF, and R(k). It exposes feature extraction and classification APIs. Two command-line tools validate cutoffs and calculate sleep-stage proportions from fixture and night data.

Changes

Cardio staging and validation

Layer / File(s) Summary
Feature extraction and classifier contract
lib/src/onehz/sleep/cardio_stager.dart
The stager adds CardioEpochFeatures, classifyCardioEpochs, SDNN extraction, shared RR cleaning, and configurable REM and deep score cutoffs.
Weighted REM and deep classification
lib/src/onehz/sleep/cardio_stager.dart
REM and deep classification uses weighted robust-z scores. Missing axes are omitted. Atonia and local HR-floor gates remain active.
Fixture evaluation and cutoff sweeps
tool/stager_harness.dart
The harness parses fixtures, trims sleep periods, partitions subjects, sweeps cutoffs, compares baselines, and reports accuracy, kappa, sensitivity, PPV, and call rates.
Night proportion analysis
tool/whoop_proportions.dart
The utility reconstructs night streams, runs cardio-stage cutoff sweeps, and reports median REM, deep, and total-sleep proportions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant cardioStager
  participant CardioEpochFeatures
  participant classifyCardioEpochs
  Caller->>cardioStager: Provide HR, accelerometer, RR data, and score cutoffs
  cardioStager->>CardioEpochFeatures: Extract epoch features
  cardioStager->>classifyCardioEpochs: Pass feature arrays and cutoffs
  classifyCardioEpochs->>Caller: Return sleep-stage classifications
Loading

Possibly related PRs

  • OpenStrap/analytics#25: Refactors and extends the same cardio staging logic and personalized baseline features.
  • OpenStrap/analytics#30: Also changes the cardioStager pipeline, staging behavior, and abstention behavior.
  • OpenStrap/analytics#31: Also modifies robust-z scoring and sleep-reference feature scaling in cardio_stager.dart.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main staging changes: replacing boolean gates with scores and removing RMSSD.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/src/onehz/sleep/cardio_stager.dart`:
- Around line 604-611: The hrSd baseline and scoring paths need the same
validity gates as the other axes. In lib/src/onehz/sleep/cardio_stager.dart
lines 604-611, update the hrSdScale sample to include only still(e) && hrSd[e] >
0 values; in lines 665-670, make hrSdZ return null when the gated sample has
fewer than four entries or hrSd[e] is NaN or zero. Re-run the cutoff sweep in
tool/whoop_proportions.dart after both changes.
- Around line 604-611: The hrSd baseline currently includes non-sleep epochs
unlike the other stage-score axes. Update the hrSdScale sample construction in
the surrounding staging logic to include only values where still(e) is true and
hrSd[e] is positive, while leaving the existing robust scaling and downstream
hrSdZ usage unchanged.
- Around line 429-464: Update classifyCardioEpochs to validate that motion, hr,
hrSd, rmssd, lfhf, rk, and sdnn all have the same length before deriving nEpoch
or indexing any list. Report the contract violation explicitly in a release-safe
check rather than relying on assertions, while preserving normal classification
when all lengths match.

In `@tool/stager_harness.dart`:
- Around line 320-337: Extract the shared subject label mapping into a helper
such as _labelSubject, returning aligned truth and prediction index lists using
the existing SleepStage/deepFlag mapping. Replace the duplicated builders in
_scoreSet, _deepMetrics, and the main loop with calls to this helper, preserving
the main loop’s per-subject 30-epoch limit where required.
- Around line 227-238: After the grid search in the sweep logic, check whether
best remains below zero and report the failed sweep to stderr instead of
printing the seeded bestR, bestD, and kappa values. Preserve the existing
optimization behavior when at least one grid point produces a valid score.
- Around line 202-210: The _featuresOf function must validate that every feature
key, including sdnn, exists, contains a list, and has the same length as motion
before constructing CardioEpochFeatures; preserve clear fixture errors instead
of allowing _nums to throw. In tool/stager_harness.dart lines 27-32, update the
documented fixture schema to include "sdnn":[..] and correct the flag list on
line 28.
- Around line 129-142: Update the stage-processing loop around kClasses.indexOf
so unrecognised labels are counted rather than silently skipped, while
preserving the existing handling of recognised stages. After processing the run,
report the accumulated unknown-label counts to stderr once, including each label
and its count, before the tool exits or prints the final scoring summary.
- Around line 88-89: Expose the library’s _remScoreCut and _deepScoreCut
defaults from classifyCardioEpochs as public constants, then update the harness
argOf defaults and shipped-cutoff reporting to reference those constants instead
of hardcoded 0.3 values. Preserve the existing override behavior for --rem-cut
and --deep-cut.
- Around line 166-185: Guard the summary block when perSubjectKappa is empty:
print the subject-count line indicating that zero subjects were scored, then
return before sorting, percentile calculation, reduce, or percentage division.
Also update _report to avoid computing correct / t.length for an empty report
and emit the established no-scores outcome instead of NaN.
- Around line 27-32: Update the usage block to list the implemented
flags—--sleep-window, --sweep, --deep-curve, --dev, --holdout, --rem-cut <v>,
and --deep-cut <v>—and remove the unsupported --profile flag. Expand the fixture
schema description to include the sdnn field, preserving the existing
null-means-not-measurable guidance.
- Around line 90-101: Apply the existing split filter before the --deep-curve
branch so _deepMetrics receives only the development subjects during the deepCut
sweep. Remove the duplicated split-filter block that follows the branch, while
preserving the curve output and early return behavior.
- Around line 194-199: Update _splitOf to select the split using a high bit of
the completed FNV-1a hash instead of h.isEven, preserving the existing
_Split.dev and _Split.holdout outcomes while avoiding correlation with
sequential subject identifiers. Recalculate any documented dev/holdout counts
affected by the repartition.

In `@tool/whoop_proportions.dart`:
- Around line 28-32: Update the file selection flow in the `files` declaration
to remove the hard-coded `2026-` path condition, allowing all JSON files in the
configured directory to be included. Keep the existing `File`-type and `.json`
extension filters unchanged.
- Around line 41-66: Update the accelerometer initialization in the
stream-building loop around `AccelSample` so every missing sample starts with
its own timestamp (`ts * 1000.0`) and `valid: false`. Preserve observed samples
as valid and only mark gap-filled samples valid when they are within the
existing 120-second carry-forward window, ensuring pre-observation and long-gap
slots remain invalid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4e29e913-d80b-4a92-b6b1-84bb01c79bfc

📥 Commits

Reviewing files that changed from the base of the PR and between 656b33f and 98f8a33.

📒 Files selected for processing (3)
  • lib/src/onehz/sleep/cardio_stager.dart
  • tool/stager_harness.dart
  • tool/whoop_proportions.dart

Comment thread lib/src/onehz/sleep/cardio_stager.dart
Comment thread lib/src/onehz/sleep/cardio_stager.dart Outdated
Comment thread tool/stager_harness.dart Outdated
Comment thread tool/stager_harness.dart Outdated
Comment thread tool/stager_harness.dart
Comment thread tool/stager_harness.dart
Comment thread tool/stager_harness.dart Outdated
Comment on lines +227 to +238
var best = -1.0, bestR = 0.3, bestD = 0.3;
final cuts = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.3, 1.6, 2.0];
for (final r in cuts) {
for (final d in cuts) {
final k = _scoreSet(dev, epochSec, r, d);
if (k > best) {
best = k;
bestR = r;
bestD = d;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report a failed sweep instead of printing kappa=-1.000.

_scoreSet returns _kappa, which returns NaN when the epoch list is empty or the agreement is degenerate. NaN > best is always false, so a NaN grid point never updates best. If every grid point returns NaN, best stays at -1.0 and bestR/bestD stay at the seed values 0.3. Lines 250-253 then print dev optimum remCut=0.3 deepCut=0.3 DEV kappa=-1.000, which reads as a real but very poor optimum rather than as a sweep that scored nothing.

Detect best < 0 after the grid and write a diagnostic to stderr.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tool/stager_harness.dart` around lines 227 - 238, After the grid search in
the sweep logic, check whether best remains below zero and report the failed
sweep to stderr instead of printing the seeded bestR, bestD, and kappa values.
Preserve the existing optimization behavior when at least one grid point
produces a valid score.

Comment thread tool/stager_harness.dart
Comment thread tool/whoop_proportions.dart
Comment thread tool/whoop_proportions.dart
Critical one was real. The new hrSd axis skipped the sample-selection every
other axis gets:

- hrSdScale pooled ALL epochs, not just still(e), so wake epochs raised the
  median and widened the MAD. hrSd carries the second-largest deep weight
  (0.43) so that alone shifts stage proportions.
- no >=4 sample floor, and no rejection of hrSd == 0. That's the "fewer than 2
  valid HR samples" sentinel, not a real zero, and it z-scores very negative,
  i.e. scores as evidence FOR deep sleep. One real capture had 226 of 1120
  epochs (20%) with no HR, every one of them scoring toward deep. Gappy-wear
  users, which is exactly who we're trying to help.

Fixed both. Proportions actually improved: 0.5/0.3 now gives REM 23.6% /
Deep 14.9%, both centred in the normative bands. Cutoffs unchanged.

Also the harness was hardcoding 0.3/0.3 as its defaults while the library
shipped 0.5/0.3, so every number I reported was measured at the wrong cutoffs.
Now reads kDefaultRemScoreCut/kDefaultDeepScoreCut from the library and prints
which it used. Corrected numbers: kappa 0.128 all / 0.132 holdout (was
reporting 0.121 / 0.101).

Other tool fixes:
- --deep-curve was sweeping over holdout subjects, now dev-only
- crash when no subject scored
- split hashed on the low bit, which for S001/S002/... degenerates to
  alternating by last character. Uses a middle bit now.
- fixture schema validated up front instead of producing a confident wrong
  kappa
- extracted the truth/prediction builder, it existed three times
- whoop_proportions matched files by a hardcoded '2026-', now matches by record
  shape
- whoop_proportions filled gaps with a fake (0,0,1) sample at a stale
  timestamp; now tracks usable runs and stages each separately like production

Length invariant on CardioEpochFeatures asserted in dev, and length() returns
the shortest list so a bad caller truncates instead of RangeError.

386 tests green.
@abdulsaheel

Copy link
Copy Markdown
Contributor Author

Went through all 14. Verified each against the code before touching anything — most were right, and one was a genuinely important catch.

the critical one — real, and worse than described

The hrSd axis skipping still(e) gating: confirmed, fixed. But the second half of that finding is the serious bit. hrSd is 0-initialised and only assigned when an epoch had >=2 valid HR samples, so 0 means "no HR here", not "perfectly steady". It z-scores very negative, and since deep uses -hrSdZ * 0.43 that made a data-gap epoch score as evidence for deep sleep.

Measured it: one of our real captures has 226 of 1120 epochs (20%) with no HR, every one of them scoring toward deep. The other ten nights have ~0%. So this specifically hit users with gappy wear — precisely the bad-tail population this whole PR is about. Good catch.

Both parts fixed. Proportions got better: at 0.5/0.3 we now get REM 23.6% / Deep 14.9%, both centred in the normative bands rather than at the edge. Cutoffs unchanged.

the harness default drift — this invalidated my reported numbers

--rem-cut defaulted to 0.3 while the library shipped 0.5, so everything I reported in the PR description was measured at the wrong cutoffs. Now reads kDefaultRemScoreCut / kDefaultDeepScoreCut from the library and prints which values it used, with an (OVERRIDDEN — not what ships) marker.

Corrected numbers:

kappa Deep sens/PPV REM sens/PPV
before this PR 0.036 10.3 / 5.7 30.6 / 12.1
as reported 0.121
actual, all 99 0.128 53.0 / 12.9 52.6 / 20.7
actual, holdout 49 0.132 56.0 / 10.9 51.9 / 21.5

Better than I claimed, but I claimed it wrong. Description and the source doc comment are both corrected.

rest

All valid, all fixed:

  • --deep-curve was sweeping over holdout subjects — dev-only now
  • crash when no subject scores
  • split hashed on the low bit. FNV-1a's last step multiplies by an odd constant so the low bit is just prevHash ^ lastByte — for S001/S002/S003 that's an alternating split, not a hashed one. Uses bit 16 now.
  • fixture schema validated up front rather than producing a confident wrong kappa
  • extracted the truth/prediction builder (existed 3x)
  • kappa=-1.000 on a failed sweep
  • usage block, unrecognised-label reporting, sdnn documented
  • whoop_proportions matched files by a hardcoded '2026-' — now matches by record shape (dropping the filter outright broke it, the dir holds other json)
  • whoop_proportions filled gaps with a fake (0,0,1) at a stale timestamp. Now tracks usable runs and stages each separately, like _stageSessionCardio does. This one affected the calibration numbers too.

one I did differently

For the CardioEpochFeatures length invariant you suggested an assert. Added that, but also made length return the shortest list rather than motion.length — assert is stripped in release, and this file's existing idiom is to clamp (cardioStager already does min(hr1hz.length, accel.length)). Truncating beats a RangeError in a release build.

386 tests green, analyze clean.

🤖 Generated with Claude Code

@abdulsaheel
abdulsaheel merged commit f0d1153 into main Aug 2, 2026
3 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