Skip to content

Sleep: correct which stager ships (V2, not V1), and add SleepBench so the claim is measurable - #925

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:upstream-pr/sleep-stager-truth
Jul 28, 2026
Merged

Sleep: correct which stager ships (V2, not V1), and add SleepBench so the claim is measurable#925
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:upstream-pr/sleep-stager-truth

Conversation

@vishk23

@vishk23 vishk23 commented Jul 28, 2026

Copy link
Copy Markdown

What this PR does

Two commits on the sleep-staging subsystem: a documentation correction, and the measuring tool that made the correction provable.

1. docs(sleep) — say which stager actually ships

Comments on both platforms described SleepStagerV2 as an "opt-in experimental" stager and V1 as "the default". That is backwards. One comment in Repository.restageFromRaw said it outright: "V1 stays the default and is untouched".

Both preferences are default ON:

Strand/BLE/PuffinExperiment.swift experimentalSleepV2Enabledobject(forKey:) == nil ? true : …
android/.../ble/PuffinExperiment.kt experimentalSleepV2prefs.getBoolean(KEY, true)
Strand/Screens/SettingsView.swift @AppStorage(...) private var experimentalSleepV2Enabled = true

V2 was promoted over V1 in #277 and extended to every strap family in #351, so V2 is what stages a normal user's nights.

The confusion has a single source: useSleepStagerV2: Bool = false in the detectSleep / analyzeDay signatures (SleepStager.swift:851, AnalyticsEngine.swift:328). That is the library default, there so pure-function callers and the frozen-golden tests stay byte-identical. The app never takes it — the live call site threads the preference. Reading the signature alone gets the product's behaviour exactly wrong, so each of these comments now names both defaults and says which is which.

Comments only. No behaviour change on either platform.

2. tools(sleep) — add Tools/SleepBench

An offline harness that replays SleepStager (V1) and SleepStagerV2 over every recorded night in a NOOP database and scores each hypnogram against whichever independent references that database carries:

  • (a) hand-authored hypnograms — sleepSession.userEdited rows that also carry a stagelock:<device>:<startTs> cursor;
  • (b) the strap's own band sleep_state (the v18 @81 high nibble) — an independent hardware verdict NOOP does not compute, and one that needs no human labelling, so it scales to a whole history for free;
  • (c) the stored hypnogram itself, which identifies the recipe that produced it.

Reported per stager: wake-minute error, sleep-onset and final-wake offsets, per-stage sensitivity/specificity, Cohen's kappa on both the 4-class and the sleep/wake problem, and per-night variance. #738's band veto is mirrored so its effect can be scored on the isolated staging path too.

Two correctness points the harness makes rather than assumes:

  • userEdited alone does not mean human stage labels. The local editor corrects bed/wake bounds and re-derives the hypnogram from raw, so its stages are machine output over a human-chosen window. Only a stagelock row had its stages authored directly. Section 0 reports the split so a caller cannot silently score a stager against its own output. On the database I ran it against, this excluded 4 of 15 edited nights as byte-exact replays of the current V2 — i.e. self-comparison.
  • Session rows and stream rows can live under different deviceIds in a real database, so --device and --stream-device are separate arguments.

Why

The subsystem has been changing without a shared yardstick. #348 (@tanarchytan) re-tuned the V2 deep boundary on DREAMT (n=100 gold) and merged 2026-07-13; #437 restored the pre-#348 defaults 36 hours later, because the tune "over-calls awake in the field" (#431). Both were reasonable calls on the evidence available. Neither side could put a number on the disagreement, because there was no way to score a candidate against a wearer's own recorded nights.

Right now 12 of the 60 open PRs touch a Sleep/Stager-named file, from 6 contributors (@Newbbsss, @DX23876, @tigercraft4, @digitalerdude, @pipiche38, and me). None of them shares a metric. This is the missing piece — not a tuning change, just the ability to measure one.

The measurement that motivated commit 1

Replaying both stagers over one user's 36 recorded sessions, epoch-for-epoch against the stored hypnogram. On the 21 unedited nights — where the stored stages are whatever the live stager emitted — V2 reproduces the stored hypnogram byte-exactly on 21 of 21; V1 on 0 of 21, agreeing only 30.6–65.8%.

That is what settles which stager ships, and it is reproducible by anyone with a NOOP database.

Scope of that evidence, stated plainly: one user, one strap family, 36 sessions. It establishes which code path runs — which is a fact about this repository, not about sleep physiology — and nothing more. It is not a validation of V2's accuracy.

Safety of the tool

  • The database is opened SQLITE_OPEN_READONLY with immutable=1 (DB.swift:15-16), so it cannot write, and cannot trigger a WAL/journal recovery write on a pulled device file.
  • Its path is always an argument; there is no default and no hardcoded path. Missing --db exits 2.
  • Nothing is written back to the database. The only file it can create is the optional --csv <path> report you explicitly ask for.
  • No health data is added to this repository. The harness is code only; you point it at your own database.

Type of change

  • Documentation
  • CI / tooling

How it was tested

No BLE path is touched by either commit — commit 1 is comments only, commit 2 is a standalone offline tool that reads a SQLite file.

swift test (Packages/StrandAnalytics)                1167 tests, 0 failures
./gradlew assembleFullDebug testFullDebugUnitTest    3201 tests, 0 failures, 5 skipped
swift build -c release (Tools/SleepBench)            clean

Android counts were read from app/build/test-results/testFullDebugUnitTest/TEST-*.xml rather than the wrapper's exit status, since the Gradle wrapper can report success on a failed build. Built with JDK 17, matching the project's jvmTarget.

sleepbench was additionally run end-to-end against a real 36-session database to produce the numbers above.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/StrandAnalytics)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing (n/a — no UI change)
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Context for the documentation fix: #277, #351, #690.
Context for why a shared harness is worth having: #348, #431, #437.

vishk23 added 2 commits July 28, 2026 01:44
Several comments on both platforms described `SleepStagerV2` as an "opt-in experimental"
stager and V1 as "the default". That is backwards, and one Swift comment in
`Repository.restageFromRaw` stated it outright: "V1 stays the default and is untouched".

Both preferences are default ON:

  Strand/BLE/PuffinExperiment.swift  experimentalSleepV2Enabled — `object(forKey:) == nil ? true : …`
  android/.../ble/PuffinExperiment.kt experimentalSleepV2       — `prefs.getBoolean(KEY, true)`
  Strand/Screens/SettingsView.swift   @AppStorage(...) = true

V2 was promoted over V1 in #277 and extended to every strap family in #351, so V2 is what
stages a normal user's nights.

The confusion has a single source: `useSleepStagerV2: Bool = false` in the `detectSleep` /
`analyzeDay` signatures (SleepStager.swift:851, AnalyticsEngine.swift:328). That is the
LIBRARY default, there so pure-function callers and the frozen-golden tests stay
byte-identical. The app never takes it; the live call site threads the preference. Reading
the signature alone gets the product's behaviour exactly wrong, so every one of these
comments now names both defaults and says which is which.

Verified empirically before changing anything, by replaying both stagers over one user's
36 recorded sessions and comparing epoch-for-epoch against the stored hypnogram. On the 21
UNEDITED nights (where the stored stages are whatever the live stager emitted), V2
reproduces the stored hypnogram byte-exactly on 21 of 21; V1 on 0 of 21, agreeing only
30.6–65.8%. The harness is `Tools/SleepBench`, added in the following commit, so anyone can
re-run this against their own database.

Comments only — no behaviour change on either platform.

  swift test (Packages/StrandAnalytics):        1167 tests, 0 failures
  ./gradlew assembleFullDebug testFullDebugUnitTest: 3201 tests, 0 failures, 5 skipped
    (JDK 17; counts read from app/build/test-results/testFullDebugUnitTest/TEST-*.xml,
     not from the wrapper's exit code)
Eleven open PRs claim sleep-accuracy improvements and none of them is scored
against a shared reference, so nobody can say which ones help or by how much.
This adds the missing yardstick.

`sleepbench --db <copy-of-a-noop-sqlite>` replays `SleepStager` (V1) and
`SleepStagerV2` over every recorded night in a database and scores each
hypnogram against whichever independent references that database carries:

  (a) hand-authored hypnograms — `sleepSession.userEdited` rows that ALSO carry a
      `stagelock:<device>:<startTs>` cursor;
  (b) the strap's own band sleep_state (the v18 @81 high nibble), which is an
      independent hardware verdict NOOP does not compute — and needs no human
      labelling, so it scales to a whole history for free;
  (c) the stored hypnogram itself, which identifies the recipe that produced it.

Reported per stager: wake-minute error, sleep-onset and final-wake offsets,
per-stage sensitivity/specificity, Cohen's kappa on both the 4-class and the
sleep/wake problem, and per-night variance. PR #738's band veto is mirrored so
its effect can be scored on the isolated staging path too.

Two correctness points the harness makes rather than assumes:

  - `userEdited` alone does NOT mean human stage labels. The local editor
    corrects bed/wake BOUNDS and re-derives the hypnogram from raw, so its stages
    are machine output over a human-chosen window. Only a `stagelock` row had its
    stages authored directly. Section 0 reports the split so a caller cannot
    silently score a stager against its own output.
  - Session rows and stream rows can live under DIFFERENT deviceIds in a real
    database, so `--device` and `--stream-device` are separate arguments.

Read-only by construction: the database is opened `SQLITE_OPEN_READONLY` with
`immutable=1`, its path is always an argument, and nothing is ever written back.
No health data lives in this repository.

Built and run against a real 36-session database; `swift build -c release` clean.

@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 the load-bearing claim independently rather than from the table in the description, since a
"documentation correction" is worth exactly whether the new text is true.

It is. experimentalSleepV2Enabled is object(forKey:) == nil ? true : …;
experimentalSleepV2 is prefs.getBoolean(KEY_EXPERIMENTAL_SLEEP_V2, true). There is one live
staging call site per platform and both thread the preference — IntelligenceEngine.swift:672, and
Repository.restageFromRaw, which gates on the flag directly via stageSession rather than the
useSleepStagerV2 parameter (so a census on the parameter name alone misses it — worth knowing, since
that is the function whose comment said "V1 stays the default and is untouched"). The library
= false really is only the library's contract.

Comments-only holds. Every changed line outside Tools/ is a comment or blank — checked
mechanically, not by eye, across all 13 files. Parity is kept: each Swift edit has its Kotlin twin.

#277 and #351 resolve correctly — "promote Sleep V2 to the default staging engine" and "V2 default
for every strap". Good citations to add.

Two things.

1. Nothing builds Tools/, and the tool path-depends on two packages that change constantly.
Package.swift points at ../../Packages/StrandAnalytics and ../../Packages/WhoopProtocol, and no
workflow compiles anything under Tools/. So the first API change in either package breaks
sleepbench and nothing reports it. A harness meant to be the shared yardstick for the sleep PRs is
worth little if it stops compiling in three weeks and we find out when someone tries to use it. One
swift build step in swift-packages.yml fixes it; worth doing here rather than as a follow-up.

2. #600 and #690 point at unrelated work in this repo — a Target Strain notification and a
body-location probe. They are pre-existing and not yours, but this PR repeats them in the comments it
is making trustworthy, so they are worth correcting in the same pass. The V2 lineage that resolves is
#347 ("remove the V2 toggle") → #351 ("reworks #347") → #277. If #600/#690 are upstream
numbering, saying so inline would stop the next reader chasing them.

On the tool: immutable=1 + SQLITE_OPEN_READONLY is the right call and the reasoning about
suppressing WAL recovery writes on a pulled device file is correct. Cohen's kappa is implemented
correctly, including returning .nan at p_e >= 1 instead of dividing by zero. Excluding the
4 userEdited-without-stagelock nights as self-comparison is the distinction that makes the
21-of-21 number mean anything, and scoping it to "which code path runs, not a physiological
validation" is the right claim to make from one user's 36 sessions.

Approving on the substance. Please add the CI build step before merge.

@ryanbr

ryanbr commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Correcting my own note above, since it would have sent you the wrong way.

I said #600 / #690 "point at unrelated work in this repo" and suggested #347 → #351 → #277 as the
lineage. The first half is right but for the wrong reason, and the suggestion was wrong: those are
upstream numbers, not stale ones. SleepStagerV2.swift:8 says so two lines into the file —
"Reimplemented clean from the contributor recipe in NoopApp/noop PR #600 (sunny-noop)" — which I had
not read when I wrote that. Swapping in this repo's numbers would have destroyed the provenance of a
contributor's recipe.

The real problem is narrower: the tree mixes two trackers without saying which is which, so #690 means
the body-location probe in one file and the V2 staging flag in another. #939 qualifies the sleep-side
ones as upstream #690 / NoopApp/noop#600, matching the upstream #287 convention already in the Oura
decoders. #277 and #351 are this repo's and were correct as you wrote them.

ryanbr pushed a commit that referenced this pull request Jul 29, 2026
… 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.
@vishk23
vishk23 deleted the upstream-pr/sleep-stager-truth branch July 30, 2026 19:59
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