feat(sleep): promote Sleep V2 to the default staging engine + tune deep boundary - #277
Merged
Merged
Conversation
…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
force-pushed
the
feat/sleep-staging
branch
from
July 11, 2026 14:38
fff4c16 to
3e4e4f9
Compare
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').
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 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.
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').
Merged
9 tasks
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
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):
deepGateThresh0.20 to 0.25 (recover the deep recall the tighter edits shed)-1.4*zhvv - 0.2*zhrv - 0.3*zmvvto-1.1*zhvv - 0.5*zmvv(drop the mean-HR term; HR-variability and movement carry deep)deep .90to.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.swiftmatch on deepGateThresh, the transition row, and the deep emission. The promote resolves a missing preference to the V2 default on both platforms (AndroidgetBoolean(KEY, true); macOS an explicit nil-key check beforebool(forKey:)), the two@AppStoragedefaults aretrue, 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 theswift testleg in CI.Scope and notes