Repository navigation
sleep: say when a night's hypnogram only covers part of its window - #1764
Merged
ryanbr merged 1 commit intoAug 31, 2026
Merged
Conversation
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
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.
The problem
#1716's coverage guard measures the right thing and nothing shows it.
analyzeDaydowngrades Rest to.buildingwhen a night's stage timeline covers less thanHypnogramCoverage.minCoverageof the span it claims. That verdict lands inDayResult.restConfidence— which, likechargeConfidenceandeffortConfidence, has no readeroutside the analytics package on either platform. Only
AnalyticsEnginewrites it; only tests readit. 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:
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:
hypnogram trimmed 24 code(s) - unwritten (0xFF) and/or pre-onsetagainst 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).
times as history drained; every hole survived every pass.
startTscomes from the ring's 0x49 sleep-onsetevent 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.isStagingLowConfidencecallsScoreConfidence.rest(...)itselfand then re-tests
restorativeShare < 0.10 && efficiency >= 0.85on top, so a coverage-only downgradewould 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.restStageLowConfidenceis the same shape off a coverage-lessDailyMetric.What's underneath
One shared accumulation on both platforms:
HypnogramCoverage.coveredSeconds/groupFraction/isHoledGroup.covered are one night at neither figure, and the group total is what
analyzeDaygates on. A per-rowreading in the UI would have been a quietly different rule.
analyzeDay's decoded-segment accumulation term for term. Every fragment contributesits 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.
coveredSecondsreturns 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 segmentloop. Behaviour-preserving — a 0 total fails
fraction'scoveredSeconds > 0guard and still yieldsnil, 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).
(payload, span)fragments socom.noop.analyticsneed notreach into
com.noop.datafor a row type. The SwiftCachedSleepSessionoverload is a thin adapterover 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 hostStagesCardtwin, and ComposeSleepPartialNote. No stored number changes.Verification
xcodebuild … test)Strandapp targetcompileFullDebugKotlin-Duser.language=enHypnogramCoverageTestdoc_comment_lint/i18n_audit --ciThe two Android failures are the pre-existing
RecoveryDriversTestfloat-rounding pair(
expected:<-0.5> but was:<-0.4999999999999929>), confirmed failing identically on a stashed cleantree 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/2000prints as0.94999999999999996— the same double the thresholdliteral 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.xcstringsentry.What this does not do
DayResult.restConfidence,chargeConfidenceandeffortConfidencestill have no reader. Since none of the three is persisted (no column onDailyMetric), any future consumer faces the same choice this one did: a migration, or recomputingfrom the pure function at the render site. This takes the second, which is the existing idiom for both
neighbouring notes.
startTsis the ring's0x49 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.
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). Thatchanges 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 %.
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.