fix(sleep): flag Rest low-confidence on sparse-motion nights (#345, #319) - #395
Merged
Merged
Conversation
, ryanbr#319) 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).
imranahmed157
pushed a commit
to imranahmed157/noop
that referenced
this pull request
Jul 13, 2026
…computed stages (parity with ryanbr#241) (ryanbr#247) * Android: mergeSleep richness exception — stage-less import yields to computed stages (parity with ryanbr#241) Re-review of the ryanbr#241/ryanbr#246 merge surfaced a missed twin. Android's WhoopRepository.mergeSleep is the direct twin of Swift WhoopStore.SleepMerge.merge (its own comment says "Mirrors WhoopStore.SleepMerge"), but it still had the plain imported-wins-per-day logic — ryanbr#241 added the richness exception to the Swift side only, so the shared-logic twins diverged. Port the exception: a sparse import (no stage data on ANY of its sessions that day) no longer clobbers a computed day that HAS stage data; days where the import carries stages, or where neither side does, keep the imported-wins rule unchanged. Adds hasStages() (null/""/"[]"/blank = no stages), byte-parity with Swift's hasStages. Not covered by ryanbr#240: that's a Health-Connect-specific write-gate + heal-purge; it never touches mergeSleep and doesn't protect non-HC sparse imports (Apple/CSV). This is the mergeSleep-level twin. Mirrors Swift SleepMergeTests' 5 richness cases in MergeSleepLocalDayTest; compileFullDebugKotlin + MergeSleepLocalDayTest pass (8/0). * Android: route the Sleep screen through the richness merge too (re-review) Re-review of the first commit found it incomplete: mergeSleep feeds only CSV export + HealthConnectWriter. The Sleep SCREEN builds its night list with its OWN inline imported-wins merge (SleepScreen.kt, two sites: the main loader + the post-nap-edit reload), which had no richness exception — so a stage-less import would still blank stages on the Android sleep screen, while iOS gets richness via repo.sleeps (WhoopStore.SleepMerge). That's the exact user-visible surface the fix is meant to protect. Extract mergeSleepRichness (the imported-wins + ryanbr#241 richness rule, returned UNSORTED) and route both SleepScreen sites through it, keeping their effectiveStartTs sort (ryanbr#395) and the ryanbr#814 union inputs unchanged. mergeSleep is now that helper keyed by wake-day + sorted by startTs, so all three consumers (browse, CSV, screen) share ONE richness implementation. compileFullDebugKotlin + MergeSleepLocalDayTest pass (9/0, +1 mergeSleepRichness case).
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…computed stages (parity with ryanbr#241) (ryanbr#247) * Android: mergeSleep richness exception — stage-less import yields to computed stages (parity with ryanbr#241) Re-review of the ryanbr#241/ryanbr#246 merge surfaced a missed twin. Android's WhoopRepository.mergeSleep is the direct twin of Swift WhoopStore.SleepMerge.merge (its own comment says "Mirrors WhoopStore.SleepMerge"), but it still had the plain imported-wins-per-day logic — ryanbr#241 added the richness exception to the Swift side only, so the shared-logic twins diverged. Port the exception: a sparse import (no stage data on ANY of its sessions that day) no longer clobbers a computed day that HAS stage data; days where the import carries stages, or where neither side does, keep the imported-wins rule unchanged. Adds hasStages() (null/""/"[]"/blank = no stages), byte-parity with Swift's hasStages. Not covered by ryanbr#240: that's a Health-Connect-specific write-gate + heal-purge; it never touches mergeSleep and doesn't protect non-HC sparse imports (Apple/CSV). This is the mergeSleep-level twin. Mirrors Swift SleepMergeTests' 5 richness cases in MergeSleepLocalDayTest; compileFullDebugKotlin + MergeSleepLocalDayTest pass (8/0). * Android: route the Sleep screen through the richness merge too (re-review) Re-review of the first commit found it incomplete: mergeSleep feeds only CSV export + HealthConnectWriter. The Sleep SCREEN builds its night list with its OWN inline imported-wins merge (SleepScreen.kt, two sites: the main loader + the post-nap-edit reload), which had no richness exception — so a stage-less import would still blank stages on the Android sleep screen, while iOS gets richness via repo.sleeps (WhoopStore.SleepMerge). That's the exact user-visible surface the fix is meant to protect. Extract mergeSleepRichness (the imported-wins + ryanbr#241 richness rule, returned UNSORTED) and route both SleepScreen sites through it, keeping their effectiveStartTs sort (ryanbr#395) and the ryanbr#814 union inputs unchanged. mergeSleep is now that helper keyed by wake-day + sorted by startTs, so all three consumers (browse, CSV, screen) share ONE richness implementation. compileFullDebugKotlin + MergeSleepLocalDayTest pass (9/0, +1 mergeSleepRichness case).
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).
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.
Problem (#345, #319)
A WHOOP 4.0 night synced from band history banks motion coarsely while keeping HR/R-R dense. From the #319 reporter's own strap log:
session persisted 1260 rows (252 with motion)→ ~20% motion coverage (also 628/3140, 413/2177 on other nights), with R-Rcoverage=0.90. The gravity-based sleep/wake detector then counts still-but-awake time as asleep → inflated duration/efficiency → a confident 85–100 Rest on a poor night (rest composite=92.23 eff=0.93 restor=1.0in that log).The existing H9 guard misses this: it only downgrades when restorative is low, but a sparse night whose staging manufactures high efficiency AND high restorative reads SOLID — exactly the #319 signature.
Change (confidence-only)
ScoreConfidence.rest(...)gains agravitySparseparam: a night staged on sparse gravity is downgraded SOLID → BUILDING, whatever engine staged it. It never changes the Rest score or invents stages — same philosophy as H9.AnalyticsEnginepasses the already-computedSleepStager.isGravitySparse(...), hoisted so it runs once and is shared with the #328 sleep-motion trace.Keying on the data (not on V1 happening to stage flat, as #327 relies on) means it also catches 5.0 sparse-offload nights, and it's a clean prerequisite for a future 4.0→V2.
Verification
Unit (both platforms): sparse → BUILDING; dense (default) → SOLID unchanged.
End-to-end (both platforms): real
isGravitySparse→ realrest(...)on a synthetic offload night pinned to the [sleep] Sleep & Rest #319 ~20% coverage → BUILDING; dense twin → SOLID.Grounded synthetic sweep (DREAMT PSG, model = noop's own
#28/#308characterization: dense 1 Hz bursts + >maxGapMin dropouts,med gap 1s / max gap 23–30m):The guard fires across the entire realistic 10–30% offload band and never on dense; staging accuracy roughly halves under sparsity — which is exactly what BUILDING signals.
Android
com.noop.analytics.*suite green. Swift lives inPackages/StrandAnalytics→ covered byswift-packages.yml.Scope
Confidence tier only — no score/staging/BLE/schema/migration change. Deliberately not included: de-weighting the score on sparse nights, which would benefit from a real captured offload night to tune. No real sparse-offload capture exists yet; the validation above is synthetic but pinned to the #319 report's own numbers and noop's own offload characterization.