Skip to content

feat(sleep): promote Sleep V2 to the default staging engine + tune deep boundary - #277

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
tanarchytan:feat/sleep-staging
Jul 12, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
tanarchytan:feat/sleep-staging

Conversation

@tanarchytan

@tanarchytan tanarchytan commented Jul 11, 2026 •

Copy link
Copy Markdown

Promotes the transparent Sleep V2 recipe (from #600) from opt-in to the default staging engine, and tunes its deep boundary. V1 stays available behind the flag. This is the staging half of the sleep-tracking work; the companion detection fix (recovering nights that were dropped whole on an HR spike) already merged in #268, so both V1 and V2 now detect every night.

Before / after

Per-epoch, cross-subject leave-one-subject-out, run through the real shipped stager (not a reimplementation) on two public wrist datasets with PSG ground truth: AAUWSS (13 subjects, E4 wrist + gold PSG-ECG R-R + AASM labels) and Walch 2019 sleep-accel (31 subjects). BEFORE is the current default (V1 staging). AFTER is what this PR makes the default (V2 staging).

metric BEFORE (V1 default) AFTER (V2 default)
Cohen kappa, AAUWSS 0.039 0.415
Cohen kappa, sleep-accel 0.089 0.352
sleep/wake, AAUWSS 86.4 % 88.7 %
sleep/wake, sleep-accel 88.8 % 91.3 %
4-class acc, AAUWSS 44.3 % 59.4 %
4-class acc, sleep-accel 50.6 % 58.1 %
deep recall, AAUWSS 1 % 62 %
deep recall, sleep-accel 9 % 60 %

V1's near-zero kappa and 1 % deep recall are the flat, deep-poor hypnograms users see today. V2 recovers deep and REM. V1 stays available behind the same flag for anyone who prefers it.

V2 shipped opt-in because it had only n=1 validation. The 44-subject benchmark above is the multi-subject validation it lacked, and it shows V2 strictly dominates V1 on every metric on both datasets.

The deep-boundary tune

On the same benchmark, V2's deep boundary is tuned with parity-safe constant edits (set a-priori from physiology and population base rates, none fit to labels):

  • deepGateThresh 0.20 to 0.25 (recover the deep recall the tighter edits shed)
  • deep emission -1.4*zhvv - 0.2*zhrv - 0.3*zmvv to -1.1*zhvv - 0.5*zmvv (drop the mean-HR term; HR-variability and movement carry deep)
  • deep transition row deep .90 to .86 (renormalises to rem .007 / light .126 / awake .007)

Isolated impact (untuned to tuned V2, same detection): kappa +0.018 (AAUWSS) / +0.027 (sleep-accel), 4-class +1.9 / +2.6, sleep/wake flat, deep precision up. Nothing slid.

Parity and tests

Byte-identical Kotlin and Swift: SleepStagerV2.kt / SleepStagerV2.swift match on deepGateThresh, the transition row, and the deep emission. The promote resolves a missing preference to the V2 default on both platforms (Android getBoolean(KEY, true); macOS an explicit nil-key check before bool(forKey:)), the two @AppStorage defaults are true, and the Settings and Test Centre copy is updated so it no longer describes V2 as opt-in / off by default. The V2 tests are property-based (segments tile the window, canonical stage labels, deep and REM present, cycle-prior monotonicity) and pass unchanged. Kotlin compiles and tests pass; the Swift package build needs macOS, so please confirm the swift test leg in CI.

Scope and notes

  • Pure analysis switch. Sleep DETECTION, scoring, and the V1 code path are untouched; the same detected window is simply staged by V2. Model-agnostic (WHOOP 4 and 5).
  • The per-epoch V2 coefficients remain a fixed, transparent recipe. This is not a trained model.
  • With iOS: write sleep stages, 1-min heart rate, and workouts back to Apple Health #249's Apple-Health sleep-stage writeback on iOS, making V2 the default means the richer V2 deep/REM stages now flow to Apple Health.

…ep boundary

Sleep V2 (the transparent per-night cardiorespiratory recipe from PR ryanbr#600) shipped
opt-in/experimental because it had only n=1 validation. A 44-subject cross-subject
benchmark (AAUWSS 13 + Walch sleep-accel 31, leave-one-subject-out, run through the
real shipped stager) shows V2 strictly dominates the default V1 on both datasets:
kappa 0.356 vs 0.034 (AAUWSS) / 0.321 vs 0.072 (sleep-accel), deep recall 53% vs 1%
/ 57% vs 9%, with 4-class and sleep/wake also higher. So promote V2 to the default
and keep V1 available behind the flag.

Also tune V2's deep boundary on the same benchmark (parity-safe constant edits, all
a-priori, none fit to labels):
- deepGateThresh 0.20 -> 0.25 (recover the deep recall the tighter edits shed)
- deep emission -1.4*zhvv -0.2*zhrv -0.3*zmvv -> -1.1*zhvv -0.5*zmvv (drop the
  mean-HR term; HR-variability + movement carry deep)
- deep transition row deep .90 -> .86 (renormalises to rem .007 / light .126 /
  awake .007)
Isolated impact (untuned -> tuned V2, same detection): kappa +0.018 / +0.027,
4-class +1.9 / +2.6, sleep/wake flat, deep precision up, nothing slid.

Promote is the PuffinExperiment default flip (Android getBoolean default true;
macOS resolves a nil key to true, matching) plus the two @AppStorage defaults. Pure
analysis switch: detection, scoring and the V1 path are untouched; the same detected
window is simply staged by V2. Byte-identical Kotlin + Swift.
@tanarchytan tanarchytan changed the title feat(sleep): V2 staging as default + recover dropped nights (kappa 0.03 to 0.42) feat(sleep): promote Sleep V2 to the default staging engine + tune deep boundary Jul 11, 2026
@ryanbr
ryanbr merged commit 3ecd649 into ryanbr:main Jul 12, 2026
6 checks passed
ryanbr added a commit that referenced this pull request Jul 12, 2026
…onstants on both platforms (#310)

#277 promoted SleepStagerV2 to the default and retuned the deep boundary
(deepGateThresh 0.20->0.25, deep emission -1.1*zhvv-0.5*zmvv, deep transition row
0.86/0.007/0.126/0.007), validated only by an OFFLINE 44-subject benchmark. The
existing V2 tests are property-based (tiling, canonical labels, deep+REM present),
so a constant typo or a one-sided Swift/Kotlin edit could shift real stages without
failing CI.

Adds a frozen-golden test on BOTH platforms: a crafted 4-phase night
(deep-favorable -> high-RSA -> mild -> restless) must reproduce one exact hypnogram
(deep/light/rem/light/rem/wake). Input is integer-only / fixed-literal so Swift and
Kotlin build byte-identical samples (Kotlin roundToInt half-up vs Swift .rounded()
half-away-from-zero would otherwise diverge on negatives). The golden was generated
from the Kotlin stager and asserted identically in the Swift twin, so a change to
any tuned constant on either platform fails the golden.

Kotlin: testFullDebugUnitTest green locally. Swift: SleepStagerV2Tests runs in
swift-packages CI (StrandAnalytics needs macOS).
ryanbr added a commit that referenced this pull request Jul 12, 2026
…lden alone didn't lock them) (#311)

Re-review of #310 found its frozen golden does NOT actually guard the #277 tuned
constants: reverting deepGateThresh 0.25->0.20, the deep emission weights, OR the
deep transition row all still reproduce the exact golden hypnogram (only a gross
change, e.g. forcing deep out, fails it). The crafted night's phases are stark
enough that the fine tune moves no boundary — a golden only discriminates where the
input sits near a decision boundary. So the golden's real value is locking the
recipe's end-to-end SHAPE + full-path Swift/Kotlin parity, not the exact values.

Adds a direct, input-independent guard on both platforms
(tunedDeepBoundaryConstantsArePinned / testTunedDeepBoundaryConstantsArePinned):
asserts deepGateThresh == 0.25 and the exact deep transition row, plus a row-sum==1.0
invariant over the whole matrix (catches a renormalisation typo in the hand-edited
row). Widens the two Kotlin constants private->internal to match Swift's already-
internal statics (byte-parity-neutral). Verified sensitive: reverting deepGateThresh
or breaking the transition row now fails the direct test. The golden's doc comment is
corrected to say what it locks (shape + parity + gross regressions), and its Kotlin
name changed to frozenGoldenHypnogramPinsTheRecipeShapeAndParity.

The inline deep emission weights aren't named constants, so they remain guarded only
at the gross level by the golden (noted in-test).

Kotlin: testFullDebugUnitTest green. Swift: swift-packages CI (StrandAnalytics needs macOS).
ryanbr added a commit that referenced this pull request Jul 12, 2026
…319) (#327)

#319 (WHOOP 4.0, 8.6.2-staging): Rest/sleep scores sit at 85-100 on poor nights. The
strap log shows the offload is healthy but only 118 of 590 records carry motion (~20%
coverage) — WHOOP 4.0 banks motion at a coarse cadence. The sleep detector's #28
sparse-motion recovery (HR-vouched bridge) then counts quiet, low-HR, motion-less
periods as asleep, inflating duration + efficiency.

#277 made V2 staging the default for EVERYONE. On 4.0 that makes it worse, two ways:
1. V2 recovers more deep/REM → raises the Rest RESTORATIVE term on the already-inflated
   duration, nudging the score higher.
2. V2 manufacturing deep/REM DEFEATS the existing H9 low-confidence guard (which
   downgrades a high-efficiency / near-zero-restorative night to BUILDING). V1 stages
   flat on a sparse night, so H9 fires and the number reads honestly as low-confidence;
   V2 fills the stages in, so it reads as a confident 85-100. V2 was also benchmarked on
   NON-WHOOP wrist sensors, so its accuracy on 4.0 sparse motion was never validated.

Fix: gate the V2 default by the night's device family — V2 stays the default on 5.0/MG
(where #277 promoted it), WHOOP 4.0 always uses V1. Pure IntelligenceEngine.
sleepStagerV2ForFamily(enabled, family) = enabled && family != WHOOP4, applied at the
normal detected-night staging call (skinFamily already resolved there). 5.0/MG behaviour
is byte-identical (enabled && WHOOP5 != WHOOP4 == enabled).

Byte-parity: Kotlin + Swift (Strand/Data/IntelligenceEngine.swift) identical gate.
Kotlin SleepV2FamilyGateTest pins it; full testFullDebugUnitTest green; Swift: app-build.
Note: the userEdited self-heal restage still reads the raw flag (edited 4.0 nights) — a
small follow-up; #319 is about normal detected nights.
ryanbr added a commit that referenced this pull request Jul 12, 2026
…t trace (#319) (#328)

* diag(sleep): emit motion-coverage + stager context in the Sleep & Rest trace (#319)

#319 (WHOOP 4.0 Rest scores 85-100 on poor nights) is over-counted sleep DURATION: 4.0
banks motion coarsely (the reporter's log showed 118/590 records with motion), so the
detector's sparse-motion recovery counts quiet, low-HR periods as asleep. The existing
Sleep & Rest test mode already emits the Rest SUB-SCORES (duration/efficiency/restorative
/consistency -> composite) but nothing about WHY the inputs are inflated.

Adds one diagnostic line alongside the sub-score line (same trace sink, zero-cost when the
mode is off): `sleep-motion day=… grav=… hr=… sparse=… stager=V1|V2 family=whoop4|whoop5`.
It exposes the night-window motion-vs-HR sample counts, the gravity-sparse gate, which
staging engine ran, and the owner family — so a stuck-high score can be explained straight
from an export: sparse=true + a near-need duration pins it to the 4.0 coarse-motion
over-count, and stager= answers whether V1 or V2 staged it (relevant to #277/#319's gate).

Byte-parity: RestScorer.sleepMotionLine (Kotlin) / AnalyticsEngine.sleepMotionLine
(StrandAnalytics) emit the byte-identical wire string, pinned by SleepMotionLineTest (JVM)
and RestSubScoreTraceTests.testSleepMotionLine (swift-packages) with identical expected
strings. No em-dashes. Full testFullDebugUnitTest green; Swift via swift-packages CI.

* StrandAnalytics: import WhoopProtocol in RestSubScoreTrace for DeviceFamily

sleepMotionLine references DeviceFamily; Swift imports are per-file, so
the file needs its own import even though AnalyticsEngine.swift has it.
Fixes the StrandAnalytics build (emit-module: cannot find type 'DeviceFamily').
@tanarchytan
tanarchytan deleted the feat/sleep-staging branch July 14, 2026 12:23
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ep boundary (ryanbr#277)

Sleep V2 (the transparent per-night cardiorespiratory recipe from PR ryanbr#600) shipped
opt-in/experimental because it had only n=1 validation. A 44-subject cross-subject
benchmark (AAUWSS 13 + Walch sleep-accel 31, leave-one-subject-out, run through the
real shipped stager) shows V2 strictly dominates the default V1 on both datasets:
kappa 0.356 vs 0.034 (AAUWSS) / 0.321 vs 0.072 (sleep-accel), deep recall 53% vs 1%
/ 57% vs 9%, with 4-class and sleep/wake also higher. So promote V2 to the default
and keep V1 available behind the flag.

Also tune V2's deep boundary on the same benchmark (parity-safe constant edits, all
a-priori, none fit to labels):
- deepGateThresh 0.20 -> 0.25 (recover the deep recall the tighter edits shed)
- deep emission -1.4*zhvv -0.2*zhrv -0.3*zmvv -> -1.1*zhvv -0.5*zmvv (drop the
  mean-HR term; HR-variability + movement carry deep)
- deep transition row deep .90 -> .86 (renormalises to rem .007 / light .126 /
  awake .007)
Isolated impact (untuned -> tuned V2, same detection): kappa +0.018 / +0.027,
4-class +1.9 / +2.6, sleep/wake flat, deep precision up, nothing slid.

Promote is the PuffinExperiment default flip (Android getBoolean default true;
macOS resolves a nil key to true, matching) plus the two @AppStorage defaults. Pure
analysis switch: detection, scoring and the V1 path are untouched; the same detected
window is simply staged by V2. Byte-identical Kotlin + Swift.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…dary constants on both platforms (ryanbr#310)

ryanbr#277 promoted SleepStagerV2 to the default and retuned the deep boundary
(deepGateThresh 0.20->0.25, deep emission -1.1*zhvv-0.5*zmvv, deep transition row
0.86/0.007/0.126/0.007), validated only by an OFFLINE 44-subject benchmark. The
existing V2 tests are property-based (tiling, canonical labels, deep+REM present),
so a constant typo or a one-sided Swift/Kotlin edit could shift real stages without
failing CI.

Adds a frozen-golden test on BOTH platforms: a crafted 4-phase night
(deep-favorable -> high-RSA -> mild -> restless) must reproduce one exact hypnogram
(deep/light/rem/light/rem/wake). Input is integer-only / fixed-literal so Swift and
Kotlin build byte-identical samples (Kotlin roundToInt half-up vs Swift .rounded()
half-away-from-zero would otherwise diverge on negatives). The golden was generated
from the Kotlin stager and asserted identically in the Swift twin, so a change to
any tuned constant on either platform fails the golden.

Kotlin: testFullDebugUnitTest green locally. Swift: SleepStagerV2Tests runs in
swift-packages CI (StrandAnalytics needs macOS).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…the golden alone didn't lock them) (ryanbr#311)

Re-review of ryanbr#310 found its frozen golden does NOT actually guard the ryanbr#277 tuned
constants: reverting deepGateThresh 0.25->0.20, the deep emission weights, OR the
deep transition row all still reproduce the exact golden hypnogram (only a gross
change, e.g. forcing deep out, fails it). The crafted night's phases are stark
enough that the fine tune moves no boundary — a golden only discriminates where the
input sits near a decision boundary. So the golden's real value is locking the
recipe's end-to-end SHAPE + full-path Swift/Kotlin parity, not the exact values.

Adds a direct, input-independent guard on both platforms
(tunedDeepBoundaryConstantsArePinned / testTunedDeepBoundaryConstantsArePinned):
asserts deepGateThresh == 0.25 and the exact deep transition row, plus a row-sum==1.0
invariant over the whole matrix (catches a renormalisation typo in the hand-edited
row). Widens the two Kotlin constants private->internal to match Swift's already-
internal statics (byte-parity-neutral). Verified sensitive: reverting deepGateThresh
or breaking the transition row now fails the direct test. The golden's doc comment is
corrected to say what it locks (shape + parity + gross regressions), and its Kotlin
name changed to frozenGoldenHypnogramPinsTheRecipeShapeAndParity.

The inline deep emission weights aren't named constants, so they remain guarded only
at the gross level by the golden (noted in-test).

Kotlin: testFullDebugUnitTest green. Swift: swift-packages CI (StrandAnalytics needs macOS).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…s V1 (ryanbr#319) (ryanbr#327)

ryanbr#319 (WHOOP 4.0, 8.6.2-staging): Rest/sleep scores sit at 85-100 on poor nights. The
strap log shows the offload is healthy but only 118 of 590 records carry motion (~20%
coverage) — WHOOP 4.0 banks motion at a coarse cadence. The sleep detector's #28
sparse-motion recovery (HR-vouched bridge) then counts quiet, low-HR, motion-less
periods as asleep, inflating duration + efficiency.

ryanbr#277 made V2 staging the default for EVERYONE. On 4.0 that makes it worse, two ways:
1. V2 recovers more deep/REM → raises the Rest RESTORATIVE term on the already-inflated
   duration, nudging the score higher.
2. V2 manufacturing deep/REM DEFEATS the existing H9 low-confidence guard (which
   downgrades a high-efficiency / near-zero-restorative night to BUILDING). V1 stages
   flat on a sparse night, so H9 fires and the number reads honestly as low-confidence;
   V2 fills the stages in, so it reads as a confident 85-100. V2 was also benchmarked on
   NON-WHOOP wrist sensors, so its accuracy on 4.0 sparse motion was never validated.

Fix: gate the V2 default by the night's device family — V2 stays the default on 5.0/MG
(where ryanbr#277 promoted it), WHOOP 4.0 always uses V1. Pure IntelligenceEngine.
sleepStagerV2ForFamily(enabled, family) = enabled && family != WHOOP4, applied at the
normal detected-night staging call (skinFamily already resolved there). 5.0/MG behaviour
is byte-identical (enabled && WHOOP5 != WHOOP4 == enabled).

Byte-parity: Kotlin + Swift (Strand/Data/IntelligenceEngine.swift) identical gate.
Kotlin SleepV2FamilyGateTest pins it; full testFullDebugUnitTest green; Swift: app-build.
Note: the userEdited self-heal restage still reads the raw flag (edited 4.0 nights) — a
small follow-up; ryanbr#319 is about normal detected nights.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…t trace (ryanbr#319) (ryanbr#328)

* diag(sleep): emit motion-coverage + stager context in the Sleep & Rest trace (ryanbr#319)

ryanbr#319 (WHOOP 4.0 Rest scores 85-100 on poor nights) is over-counted sleep DURATION: 4.0
banks motion coarsely (the reporter's log showed 118/590 records with motion), so the
detector's sparse-motion recovery counts quiet, low-HR periods as asleep. The existing
Sleep & Rest test mode already emits the Rest SUB-SCORES (duration/efficiency/restorative
/consistency -> composite) but nothing about WHY the inputs are inflated.

Adds one diagnostic line alongside the sub-score line (same trace sink, zero-cost when the
mode is off): `sleep-motion day=… grav=… hr=… sparse=… stager=V1|V2 family=whoop4|whoop5`.
It exposes the night-window motion-vs-HR sample counts, the gravity-sparse gate, which
staging engine ran, and the owner family — so a stuck-high score can be explained straight
from an export: sparse=true + a near-need duration pins it to the 4.0 coarse-motion
over-count, and stager= answers whether V1 or V2 staged it (relevant to ryanbr#277/ryanbr#319's gate).

Byte-parity: RestScorer.sleepMotionLine (Kotlin) / AnalyticsEngine.sleepMotionLine
(StrandAnalytics) emit the byte-identical wire string, pinned by SleepMotionLineTest (JVM)
and RestSubScoreTraceTests.testSleepMotionLine (swift-packages) with identical expected
strings. No em-dashes. Full testFullDebugUnitTest green; Swift via swift-packages CI.

* StrandAnalytics: import WhoopProtocol in RestSubScoreTrace for DeviceFamily

sleepMotionLine references DeviceFamily; Swift imports are per-file, so
the file needs its own import even though AnalyticsEngine.swift has it.
Fixes the StrandAnalytics build (emit-module: cannot find type 'DeviceFamily').
ryanbr pushed a commit that referenced this pull request Jul 28, 2026
… the claim is measurable (#925)

* docs(sleep): say which stager actually ships — V2, not V1

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)

* tools(sleep): add SleepBench, an offline scoring harness for the stagers

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 added a commit that referenced this pull request Jul 29, 2026
Two comment inaccuracies, no behaviour.

The sleep files carried #600 and #690 from the project's pre-fork repo. Neither
resolves here, and worse, both collide: this repo's #600 is an iOS Target Strain
notification and its #690 is the body-location probe, which BodyLocationProbe
cites correctly. So the same number meant two things depending on the file.

Rewritten to references that resolve in THIS repo:
  - the V2 recipe keeps its credit as @sunny-noop, matching how the same
    contributor is credited in HealthExportPlan and SpotHrvReading, and drops the
    dead repo path
  - the V2-flag sites now cite 7.0.0, the release that introduced SleepStagerV2
    here (CHANGELOG.md:240; the file arrived in commit 4da3cfb), plus #277/#351
    for the default-ON claim, which those files already cite a line above
  - HrBroadcaster's credit keeps ryanbr, loses the dead path

github.com/NoopApp/noop 404s and this repo is a fork whose parent no longer
resolves, so every one of those links was dead. Nothing live pointed there:
UpdateChecker and UpdateCheck both read api.github.com/repos/ryanbr/noop, and
both About links go to github.com/ryanbr/noop.

The two AppChangelog entries that mention NoopApp are left alone deliberately —
they are the shipped release notes for the version that made that change, and
rewriting them would falsify the record rather than correct it.

Also: ReportReviewGate described the Oura Tier-B sidecars as capped at 20 MB.
Both dumps enforce 25 MB (OuraActivityDump.maxBytes / OuraMotionDump.maxBytes).
ryanbr added a commit that referenced this pull request Jul 29, 2026
…#939)

Two comment inaccuracies, no behaviour.

The sleep files carried #600 and #690 from the project's pre-fork repo. Neither
resolves here, and worse, both collide: this repo's #600 is an iOS Target Strain
notification and its #690 is the body-location probe, which BodyLocationProbe
cites correctly. So the same number meant two things depending on the file.

Rewritten to references that resolve in THIS repo:
  - the V2 recipe keeps its credit as @sunny-noop, matching how the same
    contributor is credited in HealthExportPlan and SpotHrvReading, and drops the
    dead repo path
  - the V2-flag sites now cite 7.0.0, the release that introduced SleepStagerV2
    here (CHANGELOG.md:240; the file arrived in commit 4da3cfb), plus #277/#351
    for the default-ON claim, which those files already cite a line above
  - HrBroadcaster's credit keeps ryanbr, loses the dead path

github.com/NoopApp/noop 404s and this repo is a fork whose parent no longer
resolves, so every one of those links was dead. Nothing live pointed there:
UpdateChecker and UpdateCheck both read api.github.com/repos/ryanbr/noop, and
both About links go to github.com/ryanbr/noop.

The two AppChangelog entries that mention NoopApp are left alone deliberately —
they are the shipped release notes for the version that made that change, and
rewriting them would falsify the record rather than correct it.

Also: ReportReviewGate described the Oura Tier-B sidecars as capped at 20 MB.
Both dumps enforce 25 MB (OuraActivityDump.maxBytes / OuraMotionDump.maxBytes).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ep boundary (ryanbr#277)

Sleep V2 (the transparent per-night cardiorespiratory recipe from PR ryanbr#600) shipped
opt-in/experimental because it had only n=1 validation. A 44-subject cross-subject
benchmark (AAUWSS 13 + Walch sleep-accel 31, leave-one-subject-out, run through the
real shipped stager) shows V2 strictly dominates the default V1 on both datasets:
kappa 0.356 vs 0.034 (AAUWSS) / 0.321 vs 0.072 (sleep-accel), deep recall 53% vs 1%
/ 57% vs 9%, with 4-class and sleep/wake also higher. So promote V2 to the default
and keep V1 available behind the flag.

Also tune V2's deep boundary on the same benchmark (parity-safe constant edits, all
a-priori, none fit to labels):
- deepGateThresh 0.20 -> 0.25 (recover the deep recall the tighter edits shed)
- deep emission -1.4*zhvv -0.2*zhrv -0.3*zmvv -> -1.1*zhvv -0.5*zmvv (drop the
  mean-HR term; HR-variability + movement carry deep)
- deep transition row deep .90 -> .86 (renormalises to rem .007 / light .126 /
  awake .007)
Isolated impact (untuned -> tuned V2, same detection): kappa +0.018 / +0.027,
4-class +1.9 / +2.6, sleep/wake flat, deep precision up, nothing slid.

Promote is the PuffinExperiment default flip (Android getBoolean default true;
macOS resolves a nil key to true, matching) plus the two @AppStorage defaults. Pure
analysis switch: detection, scoring and the V1 path are untouched; the same detected
window is simply staged by V2. Byte-identical Kotlin + Swift.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…dary constants on both platforms (ryanbr#310)

ryanbr#277 promoted SleepStagerV2 to the default and retuned the deep boundary
(deepGateThresh 0.20->0.25, deep emission -1.1*zhvv-0.5*zmvv, deep transition row
0.86/0.007/0.126/0.007), validated only by an OFFLINE 44-subject benchmark. The
existing V2 tests are property-based (tiling, canonical labels, deep+REM present),
so a constant typo or a one-sided Swift/Kotlin edit could shift real stages without
failing CI.

Adds a frozen-golden test on BOTH platforms: a crafted 4-phase night
(deep-favorable -> high-RSA -> mild -> restless) must reproduce one exact hypnogram
(deep/light/rem/light/rem/wake). Input is integer-only / fixed-literal so Swift and
Kotlin build byte-identical samples (Kotlin roundToInt half-up vs Swift .rounded()
half-away-from-zero would otherwise diverge on negatives). The golden was generated
from the Kotlin stager and asserted identically in the Swift twin, so a change to
any tuned constant on either platform fails the golden.

Kotlin: testFullDebugUnitTest green locally. Swift: SleepStagerV2Tests runs in
swift-packages CI (StrandAnalytics needs macOS).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…the golden alone didn't lock them) (ryanbr#311)

Re-review of ryanbr#310 found its frozen golden does NOT actually guard the ryanbr#277 tuned
constants: reverting deepGateThresh 0.25->0.20, the deep emission weights, OR the
deep transition row all still reproduce the exact golden hypnogram (only a gross
change, e.g. forcing deep out, fails it). The crafted night's phases are stark
enough that the fine tune moves no boundary — a golden only discriminates where the
input sits near a decision boundary. So the golden's real value is locking the
recipe's end-to-end SHAPE + full-path Swift/Kotlin parity, not the exact values.

Adds a direct, input-independent guard on both platforms
(tunedDeepBoundaryConstantsArePinned / testTunedDeepBoundaryConstantsArePinned):
asserts deepGateThresh == 0.25 and the exact deep transition row, plus a row-sum==1.0
invariant over the whole matrix (catches a renormalisation typo in the hand-edited
row). Widens the two Kotlin constants private->internal to match Swift's already-
internal statics (byte-parity-neutral). Verified sensitive: reverting deepGateThresh
or breaking the transition row now fails the direct test. The golden's doc comment is
corrected to say what it locks (shape + parity + gross regressions), and its Kotlin
name changed to frozenGoldenHypnogramPinsTheRecipeShapeAndParity.

The inline deep emission weights aren't named constants, so they remain guarded only
at the gross level by the golden (noted in-test).

Kotlin: testFullDebugUnitTest green. Swift: swift-packages CI (StrandAnalytics needs macOS).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…s V1 (ryanbr#319) (ryanbr#327)

ryanbr#319 (WHOOP 4.0, 8.6.2-staging): Rest/sleep scores sit at 85-100 on poor nights. The
strap log shows the offload is healthy but only 118 of 590 records carry motion (~20%
coverage) — WHOOP 4.0 banks motion at a coarse cadence. The sleep detector's ryanbr#28
sparse-motion recovery (HR-vouched bridge) then counts quiet, low-HR, motion-less
periods as asleep, inflating duration + efficiency.

ryanbr#277 made V2 staging the default for EVERYONE. On 4.0 that makes it worse, two ways:
1. V2 recovers more deep/REM → raises the Rest RESTORATIVE term on the already-inflated
   duration, nudging the score higher.
2. V2 manufacturing deep/REM DEFEATS the existing H9 low-confidence guard (which
   downgrades a high-efficiency / near-zero-restorative night to BUILDING). V1 stages
   flat on a sparse night, so H9 fires and the number reads honestly as low-confidence;
   V2 fills the stages in, so it reads as a confident 85-100. V2 was also benchmarked on
   NON-WHOOP wrist sensors, so its accuracy on 4.0 sparse motion was never validated.

Fix: gate the V2 default by the night's device family — V2 stays the default on 5.0/MG
(where ryanbr#277 promoted it), WHOOP 4.0 always uses V1. Pure IntelligenceEngine.
sleepStagerV2ForFamily(enabled, family) = enabled && family != WHOOP4, applied at the
normal detected-night staging call (skinFamily already resolved there). 5.0/MG behaviour
is byte-identical (enabled && WHOOP5 != WHOOP4 == enabled).

Byte-parity: Kotlin + Swift (Strand/Data/IntelligenceEngine.swift) identical gate.
Kotlin SleepV2FamilyGateTest pins it; full testFullDebugUnitTest green; Swift: app-build.
Note: the userEdited self-heal restage still reads the raw flag (edited 4.0 nights) — a
small follow-up; ryanbr#319 is about normal detected nights.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…t trace (ryanbr#319) (ryanbr#328)

* diag(sleep): emit motion-coverage + stager context in the Sleep & Rest trace (ryanbr#319)

ryanbr#319 (WHOOP 4.0 Rest scores 85-100 on poor nights) is over-counted sleep DURATION: 4.0
banks motion coarsely (the reporter's log showed 118/590 records with motion), so the
detector's sparse-motion recovery counts quiet, low-HR periods as asleep. The existing
Sleep & Rest test mode already emits the Rest SUB-SCORES (duration/efficiency/restorative
/consistency -> composite) but nothing about WHY the inputs are inflated.

Adds one diagnostic line alongside the sub-score line (same trace sink, zero-cost when the
mode is off): `sleep-motion day=… grav=… hr=… sparse=… stager=V1|V2 family=whoop4|whoop5`.
It exposes the night-window motion-vs-HR sample counts, the gravity-sparse gate, which
staging engine ran, and the owner family — so a stuck-high score can be explained straight
from an export: sparse=true + a near-need duration pins it to the 4.0 coarse-motion
over-count, and stager= answers whether V1 or V2 staged it (relevant to ryanbr#277/ryanbr#319's gate).

Byte-parity: RestScorer.sleepMotionLine (Kotlin) / AnalyticsEngine.sleepMotionLine
(StrandAnalytics) emit the byte-identical wire string, pinned by SleepMotionLineTest (JVM)
and RestSubScoreTraceTests.testSleepMotionLine (swift-packages) with identical expected
strings. No em-dashes. Full testFullDebugUnitTest green; Swift via swift-packages CI.

* StrandAnalytics: import WhoopProtocol in RestSubScoreTrace for DeviceFamily

sleepMotionLine references DeviceFamily; Swift imports are per-file, so
the file needs its own import even though AnalyticsEngine.swift has it.
Fixes the StrandAnalytics build (emit-module: cannot find type 'DeviceFamily').
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… the claim is measurable (ryanbr#925)

* docs(sleep): say which stager actually ships — V2, not V1

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 ryanbr#277 and extended to every strap family in ryanbr#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)

* tools(sleep): add SleepBench, an offline scoring harness for the stagers

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 ryanbr#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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ryanbr#939)

Two comment inaccuracies, no behaviour.

The sleep files carried ryanbr#600 and ryanbr#690 from the project's pre-fork repo. Neither
resolves here, and worse, both collide: this repo's ryanbr#600 is an iOS Target Strain
notification and its ryanbr#690 is the body-location probe, which BodyLocationProbe
cites correctly. So the same number meant two things depending on the file.

Rewritten to references that resolve in THIS repo:
  - the V2 recipe keeps its credit as @sunny-noop, matching how the same
    contributor is credited in HealthExportPlan and SpotHrvReading, and drops the
    dead repo path
  - the V2-flag sites now cite 7.0.0, the release that introduced SleepStagerV2
    here (CHANGELOG.md:240; the file arrived in commit 4da3cfb), plus ryanbr#277/ryanbr#351
    for the default-ON claim, which those files already cite a line above
  - HrBroadcaster's credit keeps ryanbr, loses the dead path

github.com/NoopApp/noop 404s and this repo is a fork whose parent no longer
resolves, so every one of those links was dead. Nothing live pointed there:
UpdateChecker and UpdateCheck both read api.github.com/repos/ryanbr/noop, and
both About links go to github.com/ryanbr/noop.

The two AppChangelog entries that mention NoopApp are left alone deliberately —
they are the shipped release notes for the version that made that change, and
rewriting them would falsify the record rather than correct it.

Also: ReportReviewGate described the Oura Tier-B sidecars as capped at 20 MB.
Both dumps enforce 25 MB (OuraActivityDump.maxBytes / OuraMotionDump.maxBytes).
ryanbr added a commit that referenced this pull request Sep 3, 2026
…mperature

The worst kind of bug for this feature: it would have written numbers that are
WRONG, not merely absent, into a column the user cannot regenerate.

The night window is 54 h wide — 30 h before local midnight through the next one —
because a night belonging to day D starts on the evening of D-1. At that width it
also contains the night belonging to D-1. I handed the funnel everything the
window returned, so a day's "temperature" could be the mean of two nights, stored
as that day's own.

The engine does not do this; it attributes by END (#277):

    // Sessions attributed to `day` = those whose end falls on `day` (LOCAL day)
    val matched = allSessions.filter { tsInDay(it.end) }

`sessionsEndingOnDay` applies exactly that rule, pinned by a test with a previous
night, the day's own night, and the next night in one window — asserting only the
middle one survives. A daytime nap ending the same day is kept, as the engine
keeps it, and the day bound is half-open so no instant lands in two days.

Reads are now taken over the MATCHED sessions' span rather than the whole window,
which is both the correct set and a much smaller one — the HR read, the expensive
one, drops from ~54 h to the night itself.

Also split `noSessions` out of `noSamples` in the report: "no sleep session ending
on this night" and "no raw samples banked" are different answers to the user, and
reporting them as one would have sent someone looking for missing sample data
when the night simply has no session.

Full suite 5140/0 (4 new). Gates clean.
ryanbr added a commit that referenced this pull request Sep 3, 2026
…aches (#1852)

skinTempC is written by the scoring pass, which walks a rolling 21-night window, so nights already outside it when the column landed (2026-08-27, #1663) were never revisited. An install can hold weeks of deviations and a handful of temperatures -- which is what the Temperature setting then has to work with, and on the reporting install left five nights spanning four days against twenty-three deviations spanning three weeks.

The raw material is still there. skinTempSample has NO age-based retention; the only DELETE is the #547 plausibility quarantine, which removes rows outside a valid timestamp window rather than old ones.

RE-DERIVES, NEVER RECONSTRUCTS. absolute = baseline + deviation is available arithmetic and is wrong: the baseline drifts over weeks, so applying one night's baseline to another night's delta manufactures numbers that read as measurements. This re-runs AnalyticsEngine.skinTempFunnel, the same function over the same night window, so a backfilled night and a scored night come from one code path and cannot disagree.

CANNOT DESTROY DATA, which matters because these rows cannot be regenerated: a single-column UPDATE, never an entity upsert that would blank columns it did not carry, and the statement carries AND skinTempC IS NULL so it can only fill a hole. Nothing nulls anything; a night yielding no mean is left alone.

Runs after a successful scoring pass and independently of the display preference, so the data is there whichever way the setting is left and switching between Temperature and vs baseline is instant rather than starting work. One page per pass, so a deep history drains over successive passes rather than becoming the #836/#841 battery shape; nights with no skin samples are skipped before their expensive HR read.

Three re-review passes each found a real defect in the first cut, and all three are the same mistake at different levels -- reusing a piece of the engine while missing the rule around it:

Attribution. The night window is 54 h wide because a night belonging to day D starts on the evening of D-1, so it also contains D-1's night. Handing the funnel everything the window returned averaged TWO nights into one temperature and stored it as D's. The engine attributes by END (#277); sessionsEndingOnDay now does the same, and the reads narrowed to the matched sessions' span as a result.

Paging. The candidate query was a plain "oldest N", returning the same nights every pass -- and the oldest are the ones most likely to have lost their raw samples, so the sweep latched on page one and never reached the newer nights that could fill. It now pages on a cursor, a short page signals the sweep saw everything, and the fruitless watermark is recorded only when a WHOLE sweep filled nothing. That watermark keys on the uncapped outstanding count, since the capped one was constant at the page size and could never notice a newly imported night.

The 4.0 anchor. It is learned window-wide, and the engine's note says that is safe because the offset cancels in the deviation -- true for a delta, not for an absolute. Re-learning from the old window would put backfilled nights on a different offset from the stored ones and plot two scales as one line. The anchor is passed in from the CURRENT scan window, and a 4.0 without one is declined rather than written on the global default, which maps a second real strap's worn band to 47-72 C.

Full suite 5143/0, 16 new cases. Doc-comment and i18n gates clean. Android only; the Apple twin is tracked with its ordering (#1848 first, then #1853).
bogaczcyprien-svg added a commit to bogaczcyprien-svg/noop that referenced this pull request Oct 8, 2026
…e thresholds

Third report this session that the displayed sleep-onset time reads
too early -- "when I'm lying in bed, not when I actually fall asleep."
Previous passes tightened the simple V1 Cole-Kripke detector's own
constants (onsetHRSleepMult to its floor, a dedicated longer
onsetPersistEpochsForOnset) with no real validation behind the exact
numbers chosen.

Investigated the actual architecture instead of tuning blind a fourth
time: SleepStagerV2 (the shipped default stager, on since ryanbr#277/ryanbr#351)
already computes a FAR richer per-epoch signal -- HR, HR-variability,
movement AND respiratory regularity (RSA) via a Viterbi/HMM recipe --
and already has its own sustained-non-wake onset rule
(`sustainedSleepOnset`), MEASURED against real PSG scoring (PhysioNet
sleep-accel, n=31: bias -3.8 min, MAE 7.4 min). But that function was
only ever used internally for the REM-latency guard's own reference
point -- never exposed as the onset the user actually sees, which
still comes entirely from V1's cruder window-finding regardless of
which recipe stages the night (documented: "Session DETECTION...
still comes entirely from V1").

Added `SleepStagerV2.refinedOnsetTs(_:)`: the SAME validated
sustained-non-wake rule, applied to a session's FINAL merged
[StageSegment] array (real timestamps) rather than a raw epoch-label
array, so it reads whatever recipe actually staged the night. Wired
into AnalyticsEngine's CachedSleepSession construction via the
EXISTING `startTsAdjusted` field (ryanbr#318, built for exactly this shape:
"the displayed/effective start can differ from the immutable detected
key"). Only applies when V2 staged the session (V1 already forces its
own onset to "wake", so re-deriving from its stages would just
reproduce the same number) and only ever proposes a LATER onset, never
earlier. The upsert's own `userEdited` guard means a night the user
has manually corrected keeps their edit on every future sync --
nothing here can override a real correction.

This is a genuinely different lever from the last three passes: it
reuses an already-built, already-PSG-validated signal instead of
nudging an unvalidated constant again.

Fork-only, not ported to the Android twin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mdPEFgoJhtm2kaxPp7LTK
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