Repository navigation
diag(sleep): emit motion-coverage + stager context in the Sleep & Rest trace (#319) - #328
Merged
Merged
Conversation
…t 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.
…Family 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
added a commit
that referenced
this pull request
Jul 12, 2026
* Instrument WHOOP 4.0 sleep-onset decision for #271 (Sleep & Rest test mode) #271 is the detection-side of the 4.0 sparse-motion problem: onset lands hours early because a still-but-awake pre-onset stretch classifies as sleep. NOOP has a hardened WAKE-side guard (morning re-onset requires HR to dip below 0.90×baseline + band-state confirm) but NO equivalent ONSET-side guard — and isPreOnsetAwakeStub (#736) is display-only, so the stored/scored window still opens early. Tuning that needs real 4.0 raw, so instrument first. Extend the #328 sleep-motion trace with a sleep-onset line: onsetTs, median HR in the first 10 min of the window (hrAtOnset), the day-median baseline (dayHr when supplied, else night window), and hrRatio = atOnset/baseline. A ratio near 1.0 means HR had NOT dipped when the window opened (suspected pre-onset-awake over-staging); a real onset sits well below the day median. The next 4.0 strap-log then shows how early onset fired and whether an HR-dip gate would trim it without clipping — the #688/#328 instrument-then-tune pattern. Pure sleepOnsetLine + medianBpm (byte-identical Swift/Kotlin), twin tests. Trace-only (traceSink); DayResult unchanged. Android compile + tests green; StrandAnalytics validated via swift-packages. * #271 re-review: read hrAtOnset from the SAME window detection ran over Detection runs over dayHr ?? hr (the full calendar day when supplied), so the detected onset lives there — but the first cut sampled hrAtOnset from the night-window hr alone. When dayHr is supplied and the onset falls outside that night slice, hr.filter was empty → hrAtOnset null → no onset line, dropping the measurement on exactly the current-day nights dayHr covers. Read BOTH the baseline and the at-onset window from dayHr ?? hr so the onset instant is always inside the window. Call-site only; pure functions + their twin tests unchanged.
ryanbr
pushed a commit
that referenced
this pull request
Jul 14, 2026
) (#395) A WHOOP 4.0 night synced from band history banks motion coarsely (~20% coverage) while keeping HR/R-R dense, so the gravity-based detector counts still-but-awake time as asleep -> inflated duration/efficiency -> a confident 85-100 Rest on a poor night. The H9 guard misses this: it only fires on LOW restorative, but a sparse night whose staging manufactures HIGH efficiency AND HIGH restorative reads SOLID. ScoreConfidence.forRest gains a gravitySparse param: a sparse-motion night is downgraded SOLID -> BUILDING, whatever engine staged it. Confidence-only -- never changes the Rest score or invents stages, same philosophy as H9. AnalyticsEngine passes the already-computed SleepStager.isGravitySparse (hoisted so it is computed once, shared with the #328 sleep-motion trace). Keying on the DATA (not on V1 staging flat) also catches 5.0 sparse-offload nights and is a prerequisite for a future 4.0->V2. Cross-platform (Kotlin + Swift, byte-identical). Tests: 4 unit (forRest sparse->BUILDING, dense->SOLID) + 4 end-to-end (real isGravitySparse -> real forRest on a synthetic offload night pinned to the #319 ~20% coverage). Android analytics suite green; Swift lives in StrandAnalytics (swift-packages CI).
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').
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
* Instrument WHOOP 4.0 sleep-onset decision for ryanbr#271 (Sleep & Rest test mode) ryanbr#271 is the detection-side of the 4.0 sparse-motion problem: onset lands hours early because a still-but-awake pre-onset stretch classifies as sleep. NOOP has a hardened WAKE-side guard (morning re-onset requires HR to dip below 0.90×baseline + band-state confirm) but NO equivalent ONSET-side guard — and isPreOnsetAwakeStub (ryanbr#736) is display-only, so the stored/scored window still opens early. Tuning that needs real 4.0 raw, so instrument first. Extend the ryanbr#328 sleep-motion trace with a sleep-onset line: onsetTs, median HR in the first 10 min of the window (hrAtOnset), the day-median baseline (dayHr when supplied, else night window), and hrRatio = atOnset/baseline. A ratio near 1.0 means HR had NOT dipped when the window opened (suspected pre-onset-awake over-staging); a real onset sits well below the day median. The next 4.0 strap-log then shows how early onset fired and whether an HR-dip gate would trim it without clipping — the ryanbr#688/ryanbr#328 instrument-then-tune pattern. Pure sleepOnsetLine + medianBpm (byte-identical Swift/Kotlin), twin tests. Trace-only (traceSink); DayResult unchanged. Android compile + tests green; StrandAnalytics validated via swift-packages. * ryanbr#271 re-review: read hrAtOnset from the SAME window detection ran over Detection runs over dayHr ?? hr (the full calendar day when supplied), so the detected onset lives there — but the first cut sampled hrAtOnset from the night-window hr alone. When dayHr is supplied and the onset falls outside that night slice, hr.filter was empty → hrAtOnset null → no onset line, dropping the measurement on exactly the current-day nights dayHr covers. Read BOTH the baseline and the at-onset window from dayHr ?? hr so the onset instant is always inside the window. Call-site only; pure functions + their twin tests unchanged.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
, ryanbr#319) (ryanbr#395) A WHOOP 4.0 night synced from band history banks motion coarsely (~20% coverage) while keeping HR/R-R dense, so the gravity-based detector counts still-but-awake time as asleep -> inflated duration/efficiency -> a confident 85-100 Rest on a poor night. The H9 guard misses this: it only fires on LOW restorative, but a sparse night whose staging manufactures HIGH efficiency AND HIGH restorative reads SOLID. ScoreConfidence.forRest gains a gravitySparse param: a sparse-motion night is downgraded SOLID -> BUILDING, whatever engine staged it. Confidence-only -- never changes the Rest score or invents stages, same philosophy as H9. AnalyticsEngine passes the already-computed SleepStager.isGravitySparse (hoisted so it is computed once, shared with the ryanbr#328 sleep-motion trace). Keying on the DATA (not on V1 staging flat) also catches 5.0 sparse-offload nights and is a prerequisite for a future 4.0->V2. Cross-platform (Kotlin + Swift, byte-identical). Tests: 4 unit (forRest sparse->BUILDING, dense->SOLID) + 4 end-to-end (real isGravitySparse -> real forRest on a synthetic offload night pinned to the ryanbr#319 ~20% coverage). Android analytics suite green; Swift lives in StrandAnalytics (swift-packages CI).
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
* Instrument WHOOP 4.0 sleep-onset decision for ryanbr#271 (Sleep & Rest test mode) ryanbr#271 is the detection-side of the 4.0 sparse-motion problem: onset lands hours early because a still-but-awake pre-onset stretch classifies as sleep. NOOP has a hardened WAKE-side guard (morning re-onset requires HR to dip below 0.90×baseline + band-state confirm) but NO equivalent ONSET-side guard — and isPreOnsetAwakeStub (ryanbr#736) is display-only, so the stored/scored window still opens early. Tuning that needs real 4.0 raw, so instrument first. Extend the ryanbr#328 sleep-motion trace with a sleep-onset line: onsetTs, median HR in the first 10 min of the window (hrAtOnset), the day-median baseline (dayHr when supplied, else night window), and hrRatio = atOnset/baseline. A ratio near 1.0 means HR had NOT dipped when the window opened (suspected pre-onset-awake over-staging); a real onset sits well below the day median. The next 4.0 strap-log then shows how early onset fired and whether an HR-dip gate would trim it without clipping — the ryanbr#688/ryanbr#328 instrument-then-tune pattern. Pure sleepOnsetLine + medianBpm (byte-identical Swift/Kotlin), twin tests. Trace-only (traceSink); DayResult unchanged. Android compile + tests green; StrandAnalytics validated via swift-packages. * ryanbr#271 re-review: read hrAtOnset from the SAME window detection ran over Detection runs over dayHr ?? hr (the full calendar day when supplied), so the detected onset lives there — but the first cut sampled hrAtOnset from the night-window hr alone. When dayHr is supplied and the onset falls outside that night slice, hr.filter was empty → hrAtOnset null → no onset line, dropping the measurement on exactly the current-day nights dayHr covers. Read BOTH the baseline and the at-onset window from dayHr ?? hr so the onset instant is always inside the window. Call-site only; pure functions + their twin tests unchanged.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
, ryanbr#319) (ryanbr#395) A WHOOP 4.0 night synced from band history banks motion coarsely (~20% coverage) while keeping HR/R-R dense, so the gravity-based detector counts still-but-awake time as asleep -> inflated duration/efficiency -> a confident 85-100 Rest on a poor night. The H9 guard misses this: it only fires on LOW restorative, but a sparse night whose staging manufactures HIGH efficiency AND HIGH restorative reads SOLID. ScoreConfidence.forRest gains a gravitySparse param: a sparse-motion night is downgraded SOLID -> BUILDING, whatever engine staged it. Confidence-only -- never changes the Rest score or invents stages, same philosophy as H9. AnalyticsEngine passes the already-computed SleepStager.isGravitySparse (hoisted so it is computed once, shared with the ryanbr#328 sleep-motion trace). Keying on the DATA (not on V1 staging flat) also catches 5.0 sparse-offload nights and is a prerequisite for a future 4.0->V2. Cross-platform (Kotlin + Swift, byte-identical). Tests: 4 unit (forRest sparse->BUILDING, dense->SOLID) + 4 end-to-end (real isGravitySparse -> real forRest on a synthetic offload night pinned to the ryanbr#319 ~20% coverage). Android analytics suite green; Swift lives in StrandAnalytics (swift-packages CI).
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.
Instrument-first for #319 (WHOOP 4.0 Rest scores stuck at 85-100).
Why
#319 is over-counted sleep duration — 4.0 banks motion coarsely (the reporter's log: only 118/590 records carry motion), so the detector's sparse-motion recovery counts quiet, low-HR periods as asleep. The Sleep & Rest test mode already logs the Rest sub-scores (duration/efficiency/restorative/consistency → composite) but nothing about why they're inflated, or which stager ran.
What
One diagnostic line, emitted alongside the sub-score line (same trace sink, zero-cost when the mode is off):
isGravitySparsegate (the 4.0 coarse-motion condition).So a stuck-high score can be explained straight from an export:
sparse=true+ a near-need duration pins it to the coarse-motion over-count.Parity & tests
Byte-identical wire string:
RestScorer.sleepMotionLine(Kotlin) /AnalyticsEngine.sleepMotionLine(StrandAnalytics). Pinned bySleepMotionLineTest(JVM) andRestSubScoreTraceTests.testSleepMotionLine(swift-packages) with identical expected strings. No em-dashes. FulltestFullDebugUnitTestgreen; Swift via swift-packages CI (StrandAnalytics hits the GRDB/Linux wall locally).