Skip to content

fix(sleep): flag Rest low-confidence on sparse-motion nights (#345, #319) - #395

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
tanarchytan:fix/sleep-rest-sparse-confidence
Jul 14, 2026
Merged

fix(sleep): flag Rest low-confidence on sparse-motion nights (#345, #319)#395
ryanbr merged 1 commit into
ryanbr:mainfrom
tanarchytan:fix/sleep-rest-sparse-confidence

Conversation

@tanarchytan

Copy link
Copy Markdown

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-R coverage=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.0 in 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 a gravitySparse param: 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. AnalyticsEngine passes the already-computed SleepStager.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 → real rest(...) 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/#308 characterization: dense 1 Hz bursts + >maxGapMin dropouts, med gap 1s / max gap 23–30m):

    coverage guard fires V2 κ vs PSG
    dense 0/30 0.330
    30% 30/30 0.199
    25% 30/30 0.196
    20% ([sleep] Sleep & Rest #319) 30/30 0.189
    15% 30/30 0.175
    10% 30/30 0.173

    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 in Packages/StrandAnalytics → covered by swift-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.

, 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).
@ryanbr
ryanbr merged commit 6fe065b into ryanbr:main Jul 14, 2026
8 checks passed
@tanarchytan
tanarchytan deleted the fix/sleep-rest-sparse-confidence branch July 14, 2026 12:18
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).
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