Skip to content

sleep: say when a night's hypnogram only covers part of its window - #1764

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/hypnogram-coverage-note-on-sleep-card
Aug 31, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/hypnogram-coverage-note-on-sleep-card

Conversation

@pipiche38

Copy link
Copy Markdown

The problem

#1716's coverage guard measures the right thing and nothing shows it.

analyzeDay downgrades Rest to .building when a night's stage timeline covers less than
HypnogramCoverage.minCoverage of the span it claims. That verdict lands in
DayResult.restConfidence — which, like chargeConfidence and effortConfidence, has no reader
outside the analytics package on either platform
. Only AnalyticsEngine writes it; only tests read
it. Verified by grep over the whole tree (build dirs excluded) on Swift and Kotlin.

So on the 2026-08-28/29 ring night — 470.5 min claimed, 440.0 min of segments, 93.5 % covered —
the guard fired correctly and the Sleep screen was indistinguishable from a complete night.

This is pre-existing and was not introduced by #1717. The guard is correct and inert.

The fix

A third caveat under the stage breakdown, beside the existing #H9 and #345 notes:

Partly recorded — Only 93 % of this night's window has stage data. The stage totals cover only
that part of the night.

The copy names no cause and offers no remedy, deliberately. Two earlier drafts of this sentence
said "the rest never reached NOOP" and "syncing again can fill in a night that arrived incomplete",
and the captures this note was built from falsify both:

  • The missing epochs did reach the phone. The final persist on 2026-08-30/31 logs hypnogram trimmed 24 code(s) - unwritten (0xFF) and/or pre-onset against a hole exactly 24 codes wide —
    the ring reported those epochs as unwritten. Across the corpus of holed nights, on 7 of 10 the
    ring's HR record is essentially complete through the very window whose stages are missing (the two
    blackout nights are the exception, not the rule — this is a count, not a mechanism).
  • Re-syncing does not help. 2026-08-29/30 was re-persisted five times and 2026-08-30/31 eight
    times as history drained; every hole survived every pass.
  • Part of a shortfall is not a hole at all. startTs comes from the ring's 0x49 sleep-onset
    event while the hypnogram's first written code can land later — 13.6 min later on 08-29/30, and
    366.8 min on 08-17. No sync can fill an interval that never existed as stages.

So the note reports the measurement and its consequence and stops there. It also does not point at
the totals by direction: both hosts render it below the stage-breakdown card that carries them.

Why a separate note rather than threading coverage into the existing "Low confidence" badge. That
badge is a different gate: SleepView.isStagingLowConfidence calls ScoreConfidence.rest(...) itself
and then re-tests restorativeShare < 0.10 && efficiency >= 0.85 on top, so a coverage-only downgrade
would be filtered straight back out. And its wording claims the deep/REM split is doubted, when the
claim here is that part of the night is missing. Different fact, different sentence. Android's
TodayScoring.restStageLowConfidence is the same shape off a coverage-less DailyMetric.

What's underneath

One shared accumulation on both platforms: HypnogramCoverage.coveredSeconds / groupFraction /
isHoledGroup.

  • Asked of the bridged main-night GROUP, never of one row. Two fragments individually 90 % and 99 %
    covered are one night at neither figure, and the group total is what analyzeDay gates on. A per-row
    reading in the UI would have been a quietly different rule.
  • Reproduces analyzeDay's decoded-segment accumulation term for term. Every fragment contributes
    its full span to the denominator whether or not its payload is measurable, and contributes cover only
    from timestamped segments — so the mixed-source case still reads as holed, and an all-timestamp-free
    group still comes back nil (unknown, not holed). The inter-fragment gap is NOT in the denominator:
    it is known-awake out-of-bed time (Android auto-detect: read HR over the active-strap union, not hardcoded my-whoop (#767) #777/Oura activity tracking — direction? (MET 0x50 vs HR-derived strain) #705), not time we failed to observe. Folding it in would
    report a correctly-recorded biphasic night as partly missing.
  • coveredSeconds returns 0, not nil, for an unmeasurable payload, because it is the summable half;
    "is this measurable at all" stays in fraction, which turns a zero total back into nil.
    fraction(stagesJSON:spanSeconds:) now delegates to it on both sides, removing a duplicated segment
    loop. Behaviour-preserving — a 0 total fails fraction's coveredSeconds > 0 guard and still yields
    nil, and the whole-payload bail on a non-object element is still a bail (returning a partial sum there
    would resurrect exactly the Swift/Kotlin divergence Make the two hypnogram-coverage readers agree, and state the gate's real scope #1721 closed).
  • The Kotlin twin takes entity-free (payload, span) fragments so com.noop.analytics need not
    reach into com.noop.data for a row type. The Swift CachedSleepSession overload is a thin adapter
    over the same arithmetic, so both platforms run identical maths and only the packaging differs.

The printed percentage is floored, never rounded, so 94.8 % cannot render as "95 %" beside a badge
raised for falling below 95 %. Pinned by test, because that is a property of the gate rather than of one
screen and both hosts print it.

Rendered by SleepView.stagePartialNote, the Today host StagesCard twin, and Compose
SleepPartialNote. No stored number changes.

Verification

WhoopStore 455 ✅
StrandAnalytics 1647 ✅
StrandTests (app target, xcodebuild … test) 1403 ✅ (1 skipped)
macOS Strand app target BUILD SUCCEEDED
Android compileFullDebugKotlin SUCCESSFUL
Android unit tests 4778, 2 failures under -Duser.language=en
Kotlin HypnogramCoverageTest 10/10
doc_comment_lint / i18n_audit --ci clean

The two Android failures are the pre-existing RecoveryDriversTest float-rounding pair
(expected:<-0.5> but was:<-0.4999999999999929>), confirmed failing identically on a stashed clean
tree at this exact base
— not a caveat carried over from another branch. Under a fr_FR default locale
six further tests go red (StandardHrSensorFormatTest ×2, TodayExplainabilityTest ×3,
AiCoachContextTest); that set is locale-dependent, pre-existing, and green under -Duser.language=en.

Parity by oracle, not by eye. The three new Kotlin cases are pinned against stdout from the
shipped Swift implementation, linked as a package dependency and run — not a retyped standalone twin.
16 group rows cover mixed-source, all-unmeasurable, the gap, overlap/clamp and degenerate spans, plus
the knife-edge row where 1900/2000 prints as 0.94999999999999996 — the same double the threshold
literal denotes — and is therefore NOT holed. Reading the two accumulations side by side could not
have settled that row.

Hardware data, both ways. Three captured ring nights were loaded into the macOS build. 2026-08-28/29
(93.5 %) and 2026-08-29/30 (92.7 %) render the amber "Partly recorded" badge and the floored percentage
above the raw-stages note; 2026-08-30/31 (96.8 %) renders no badge at all. This is a UI/analytics
change with no BLE surface, so there is nothing to test on a strap beyond the captured nights it was
built against.

i18n. New strings ship in en/de/es/fr/pt-PT plus pl/zh so the non-focus ratchet is not worsened, and
the interpolated literal has a Localizable.xcstrings entry.

What this does not do

  • It wires the guard, not the field. DayResult.restConfidence, chargeConfidence and
    effortConfidence still have no reader. Since none of the three is persisted (no column on
    DailyMetric), any future consumer faces the same choice this one did: a migration, or recomputing
    from the pure function at the render site. This takes the second, which is the existing idiom for both
    neighbouring notes.
  • It does not exclude the pre-first-stage head from the gate's denominator. startTs is the ring's
    0x49 sleep-onset event, and the first written stage code can land well after it — 13.6 min later on
    2026-08-29/30, 366.8 min on 2026-08-17. That interval is an anchor artifact rather than data loss,
    and counting it can flip the verdict: 08-29/30 is 92.7 % with it and 95.1 % without, i.e. above the
    gate. Correcting the denominator changes which nights the gate flags, so it belongs in its own PR
    alongside part 2.
  • It does not touch the in-bed denominator (AnalyticsEngine.swift:551, part 2 of A partially-received Oura hypnogram is stored and scored as a COMPLETE night (TST off by -328 min on 9 of 31 nights) #1716). That
    changes stored daily numbers and is its own PR per one-concern. On the 08-28/29 night the screen still
    reads "7h 20m in bed · 76 % efficiency" — the 440-min segment total, not the 470.5-min span — while
    NOOP's TST (332.5) matches the Oura app's 332 to the half-minute; the corrected denominator would give
    70.7 % against Oura's own 70.5 %.
  • It does not correct A partially-received Oura hypnogram is stored and scored as a COMPLETE night (TST off by -328 min on 9 of 31 nights) #1716's stated mechanism. The issue says a sleep-phase page never arrived.
    Across 08-29/30 and 08-30/31 the persist log trims the missing epochs as unwritten (0xFF) — 24
    trimmed for a 24-code hole on the latter — and on 7 of 10 holed nights the ring's other channels stream
    through the hole intact. The dominant mode is stages the ring never wrote, not a page lost in transit.
    Worth a comment on A partially-received Oura hypnogram is stored and scored as a COMPLETE night (TST off by -328 min on 9 of 31 nights) #1716 rather than a code change here.

ryanbr#1716's coverage guard measures the right thing and nothing shows it. The
gate downgrades Rest to `.building` on a holed night, but that verdict
lands in `DayResult.restConfidence`, which — like `chargeConfidence` and
`effortConfidence` — has no reader outside the analytics package on either
platform. On the 2026-08-28/29 ring night (470.5 min claimed, 440.0 min of
segments, 93.5% covered) the guard fired correctly and the Sleep screen was
indistinguishable from a complete night.

Give it a consumer: a third caveat under the stage breakdown, beside the
existing #H9 and ryanbr#345 notes.

It is a separate note rather than a coverage argument threaded into the
"Low confidence" badge. That badge is a different gate — it calls
`ScoreConfidence.rest(...)` itself and then re-tests `restorativeShare <
0.10 && efficiency >= 0.85`, so a coverage-only downgrade would be filtered
straight back out — and its wording claims the deep/REM SPLIT is doubted,
when the claim here is that part of the night is MISSING. Different fact,
different sentence.

The copy names NO cause and offers NO remedy, and that is the whole of what
it can attribute. Three captures say so. On 08-29/30 and 08-30/31 the final
persist logged `hypnogram trimmed 136 code(s)` and `24 code(s) - unwritten
(0xFF) and/or pre-onset`, the second matching the 12.0 min hole code for
code, so the epochs DID reach the phone and the ring marked them unwritten;
re-persisting those nights five and eight times left every hole intact; and
part of a shortfall is not a hole at all but the 0x49 sleep-onset anchor
landing before the first written code (13.6 min on 08-29/30, 366.8 min on
08-17), which nothing can ever fill. Nor does the note point at the totals
by direction — both hosts render it BELOW the stage-breakdown card that
carries them. It says only that this share of the window has stage data and
that the totals cover only that part of the night.

Underneath, one shared accumulation on both platforms:
`HypnogramCoverage.coveredSeconds` / `groupFraction` / `isHoledGroup`.

  - It is asked of the bridged main-night GROUP, never of one row. Two
    fragments individually 90% and 99% covered are one night at neither
    figure, and the group total is what `analyzeDay` gates on; a per-row
    reading in the UI would have been a quietly different rule.
  - It reproduces `analyzeDay`'s decoded-segment accumulation term for
    term: every fragment contributes its full span to the denominator
    whether or not its payload is measurable, and contributes cover only
    from timestamped segments — so the mixed-source case still reads as
    holed and an all-timestamp-free group still comes back nil (unknown,
    not holed). The inter-fragment gap is NOT in the denominator: it is
    known-awake out-of-bed time (ryanbr#777/ryanbr#705), not time we failed to observe.
  - `coveredSeconds` returns 0, not nil, for an unmeasurable payload,
    because it is the summable half; "is this measurable at all" stays in
    `fraction`, which turns a zero total back into nil. `fraction(stagesJSON:
    spanSeconds:)` now delegates to it on both sides, which removes a
    duplicated segment loop and is behaviour-preserving.
  - The Kotlin twin takes entity-free `(payload, span)` fragments so
    `com.noop.analytics` need not reach into `com.noop.data` for a row
    type; the Swift `CachedSleepSession` overload is a thin adapter over
    the same arithmetic.

The printed percentage is floored, never rounded, so 94.8% cannot render as
"95%" beside a badge raised for falling below 95%. Pinned by test, since
that is a property of the gate rather than of one screen, and both hosts
print it.

Verification. Swift WhoopStore 455, StrandAnalytics 1647, StrandTests 1403,
macOS app target builds; Android 4778 with the two pre-existing
`RecoveryDriversTest` float-rounding failures, confirmed failing identically
on a clean tree at this base (the six further reds under a fr_FR default
locale are the known locale-dependent set and are green under
`-Duser.language=en`, on this tree and without it). Kotlin
`HypnogramCoverageTest` 10/10, including three new cases pinned against
stdout from the shipped Swift implementation linked as a package dependency
— 16 group rows covering the mixed-source, all-unmeasurable, gap, overlap
and degenerate-span cases, and the knife-edge row where 1900/2000 prints as
0.94999999999999996, the same double the threshold literal denotes, and is
therefore NOT holed. Reading the two accumulations side by side could not
have settled that row. doc-lint and the i18n gate pass; the new strings ship
in en/de/es/fr/pt-PT plus pl/zh so the non-focus ratchet is not worsened.

Confirmed on hardware data, both ways: the 2026-08-28/29 and 2026-08-29/30
databases loaded into the macOS build render the amber "Partly recorded"
badge and a floored percentage above the raw-stages note, and the clean
2026-08-30/31 night (96.8%) shows no badge at all. No stored number changes.

This wires the guard, not the field: `DayResult.restConfidence` and its two
siblings still have no reader, and since none of the three is persisted, any
future consumer faces the same choice between a migration and recomputing at
the render site as this does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153E6spximZQJUii9gn6Kwc
@ryanbr
ryanbr merged commit bd24995 into ryanbr:main Aug 31, 2026
15 checks passed
@pipiche38
pipiche38 deleted the fix/hypnogram-coverage-note-on-sleep-card branch September 12, 2026 17:41
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