Skip to content

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

Merged
ryanbr merged 2 commits into
mainfrom
diag-319-sleep-motion-trace
Jul 12, 2026
Merged

ryanbr merged 2 commits into
mainfrom
diag-319-sleep-motion-trace

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

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):

sleep-motion day=2026-07-12 grav=118 hr=590 sparse=true stager=V1 family=whoop4

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 by SleepMotionLineTest (JVM) and RestSubScoreTraceTests.testSleepMotionLine (swift-packages) with identical expected strings. No em-dashes. Full testFullDebugUnitTest green; Swift via swift-packages CI (StrandAnalytics hits the GRDB/Linux wall locally).

ryanbr added 2 commits July 12, 2026 21:22
…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
ryanbr merged commit 7ee89f8 into main Jul 12, 2026
8 checks passed
@ryanbr
ryanbr deleted the diag-319-sleep-motion-trace branch July 12, 2026 09:40
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant