Repository navigation
sleep: a partially-received hypnogram is not a complete night - #1717
Conversation
A session's stage timeline is supposed to TILE its span — SleepStageTotals says so
explicitly, and every consumer is written as though it does. One producer breaks that:
a device-PROVIDED hypnogram assembled from records that arrived INCOMPLETE.
OuraSleepSessionMapping merges only CONTIGUOUS codes, so a sleep-phase page that never
arrived leaves a hole in stagesJSON while startTs/endTs still span the whole night.
Measured over 31 consecutive ring nights against a WHOOP strap worn the same nights,
8 came in under 95% coverage and one covered 23% of its own span — 601 minutes claimed,
140 minutes of segments. That night was stored as 70 minutes of sleep at 50% efficiency
with 3 minutes of REM; the strap recorded 494 minutes at 85%. Mean stored-TST error on
the affected nights was -328 min, against -18..-54 on the healthy ones (the ordinary
two-device offset).
Nothing caught it. The merge's richness rule tests only that stages are PRESENT, and the
holed row has 21 segments, so it passed and won its day. Rest's two confidence guards
both structurally miss this shape: gravitySparse is inert for Oura outright (the ring
banks no gravity at all, so isGravitySparse returns false for every ring night), and the
H9 restorative floor needs efficiency >= 0.85 against this night's 0.50. It reported
SOLID.
The missing quantity is not a new measurement — it is a ratio of two numbers already
stored. HypnogramCoverage derives it on read, so nothing is persisted, there is no
migration, and it applies retroactively to nights already in the database.
* SleepMerge richness becomes a RANK: 2 = stages covering the span they claim,
1 = present but holed, 0 = none; the computed day wins only when it OUT-RANKS the
import. Collapsing 1 and 0 gives back the original presence rule exactly, so this is
a strict generalisation — every case ryanbr#240/ryanbr#241 decided it still decides the same way.
What changes is the pair the old rule could not express: a holed import against a
COMPLETE computed night. A holed import still beats no stages at all.
* ScoreConfidence.rest gains a third downgrade clause on coverage, beside gravitySparse
and the H9 floor. A new clause in the existing mechanism, not a new mechanism.
* AnalyticsEngine sums coverage straight off the already-decoded segments and passes it,
accumulated separately from inBedS because that one later absorbs the inter-fragment
gap (ryanbr#777/ryanbr#705) — a bridged out-of-bed gap is known-awake time, whereas a hole INSIDE
a fragment is time we never observed.
Both guards fail OPEN: coverage is nil for a payload it cannot measure, and nil is "not
measured", never "measured badly". That is what keeps this away from WHOOP/Apple/
Health-Connect imports, whose minute-dict shape carries no timestamps — they are judged
on presence exactly as before.
This never fills a hole in, and deliberately does NOT count uncovered time as awake: we
do not know what happened there, and asserting wake would be the same overreach in the
other direction. It reports only how much of the night was observed.
Confidence-only — no score changes, no invented stages, no recovered sleep. The missing
pages never arrived; that is the overnight link problem, tracked separately.
Verification (no strap needed — all pure, both platforms):
swift test WhoopStore 441 pass, StrandAnalytics 1636 pass
./gradlew testFullDebugUnitTest 4739 pass, 2 pre-existing failures
(RecoveryDriversTest float rounding, -0.5 vs -0.4999999999999929) confirmed
failing identically on a clean tree at this base before these changes
macOS app target builds (app-build.yml is disabled, so built locally)
doc_comment_lint OK, i18n_audit --ci OK
Byte-parity is pinned BY ORACLE, not by eye: HypnogramCoverageTest embeds the verbatim
stdout of the Swift twin compiled standalone over the whole input space that matters —
tiling, an interior hole, the measured night, the gate boundary from both sides and
exactly on it, overlap/overhang, every unmeasurable shape, degenerate spans. The
gate-exact row (950/1000 prints 0.94999999999999996, the same double the 0.95 literal
denotes, so it is NOT holed) is pinned precisely because reading the two sides could
not settle it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwDCKs1RPprtsc97nZFTaC
…eal scope Follow-up to #1717, from reviewing it. The coverage gate is pinned by an oracle across 18 payload shapes, and the two readers still disagreed on shapes the oracle did not enumerate. Swift decodes the stage array as a WHOLE (`as? [[String: Any]]`), so one non-object element makes the entire payload unmeasurable; Kotlin skipped per element and measured the remainder. Kotlin's `optDouble` also parsed a string bound that Swift's `as? NSNumber` rejects. Compiled both and ran them: [{seg},5] Swift null/not-holed Kotlin 0.1/HOLED [{seg},null] Swift null/not-holed Kotlin 0.1/HOLED [{"start":"0","end":"100"}] Swift null/not-holed Kotlin 0.1/HOLED Unreachable from any current producer - every writer emits homogeneous segments or a timestamp-free shape - so this is latent, not a live defect. It is still a divergence in the one module whose contract is byte-parity, and the oracle would never have caught it. Kotlin now bails on a non-object element and reads bounds the way NSNumber does. Both directions were checked against the real Swift, not against my reading of it: a bool bound CONVERTS on both sides, because JSONSerialization bridges `true` to NSNumber 1, and the tidy-looking alignment of rejecting non-numbers would have been wrong - under Foundation `{"start":0}` reports `is Bool == true`, so a bool exclusion would have discarded every timeline whose first segment starts at zero. Five rows added to the oracle. Two scope corrections, both documentation: - The module said the gate stays away from imports because their minute-dict shape has no timestamps. True for the WHOOP CSV, Apple and Health Connect, but not a blanket exemption: the Xiaomi Band importer emits real {start,end,stage} segments and takes its span from bedtime/wake_up_time fields independent of the items array it builds them from, so a Xiaomi night IS judged here. Arguably right - such a night genuinely is only partly described - but it is a real behaviour change for that importer, unvalidated against an export, and now pinned by test rather than left to be found. - At the ENGINE call site coverage is summed off DECODED segments, not off stagesJSON, so the payload-shape rule never runs there. What actually keeps timestamp-free imports safe is `coveredSeconds > 0` returning nil for a group with no timestamped stages at all. Same outcome, different mechanism, and it holds only while a group is single-sourced - which the day merge ensures. Re-review added two more rows, both shapes previously argued rather than run. Health Connect's REAL payload is an ARRAY of {stage,min} objects, not the throwing {light,deep,rem,awake} dict the oracle already had: it parses, reaches the loop, and exits with zero cover. That is the live producer shape this gate claims exemption from, so it is now pinned along its actual path rather than by assertion. A JSON null bound inside a well-formed object is pinned beside it. The inline comment naming only the throwing dict was reconciled with the doc. No behaviour change for any payload a producer can emit. Android 4752 pass. Swift package tests cannot build on this box (GRDB CSQLite), so the Swift half is syntax-checked locally and rests on CI; the new Swift expectations were generated by running the twin standalone rather than by hand.
…eal scope (#1721) Follow-up to #1717, from reviewing it. No behaviour change for any payload a producer can emit. The coverage gate was pinned by an oracle over 18 payload shapes, and the two readers still disagreed on shapes it did not enumerate: Swift decodes the stage array as a WHOLE, so one non-object element makes the payload unmeasurable, while Kotlin skipped per element and measured the remainder; optDouble also parsed string bounds that as? NSNumber rejects. Compiled both and ran them - [{seg},5], [{seg},null] and string bounds each read nil/not-holed on Swift and 0.1/HOLED on Kotlin. Unreachable from any current producer, but a divergence in the one module whose contract is byte-parity. Kotlin now bails on a non-object element and reads bounds the way NSNumber does. The alignment direction was measured rather than assumed, and the obvious version was wrong: a bool bound CONVERTS on both sides because JSONSerialization bridges true to NSNumber 1, and under Foundation {"start":0} reports is Bool == true - so expressing the fix as a bool exclusion would have discarded every timeline whose first segment starts at zero, including the oracle's own tiling case, while all 18 existing rows stayed green. Re-review added two more rows for shapes argued rather than run. Health Connect's real payload is an ARRAY of {stage,min} objects, not the throwing minute dict the oracle already had: it parses, reaches the loop and exits with zero cover, so the live producer shape this gate claims exemption from was reaching null by an untested path. Two documentation corrections: the Xiaomi Band importer emits real {start,end,stage} segments and takes its span from bedtime/wake_up_time fields independent of the items array, so Xiaomi nights ARE judged here - the module said imports were exempt as a class, and they are not. And at the engine call site coverage is summed off decoded segments rather than stagesJSON, so what protects timestamp-free imports there is the coveredSeconds > 0 guard, not the payload-shape rule. Oracle now 25 rows. Android 4752 pass, CI 15/15 green including both macOS Swift jobs, which is what verifies the new Swift expectations on Darwin rather than only on the Linux box they were generated on.
|
@pipiche38 — merged, and the follow-up in #1721 landed the parity and documentation edges. One design question came out of the review that is yours to settle rather than mine, so I pinned the current behaviour by test instead of changing it. The gate reaches the Xiaomi Band importer. The PR describes imports as out of scope because their minute-dict shape carries no timestamps to compare against a span. That holds for the WHOOP CSV, Apple and Health Connect. That may well be correct — such a night genuinely is only partly described, which is the argument the PR makes — but it is a behaviour change for that importer that neither of us has validated against a real export, and it lands at both call sites: rank 1 at the merge, and a Two things I could not answer from the code:
If you have a Mi Fitness export to hand, the coverage distribution across its nights would answer both at once — the same way the ring data settled the threshold. If Xiaomi turns out to need different treatment, the options are a per-source threshold or exempting it, and either is a small change on top of what is there now. No action needed if you are happy for Xiaomi to be judged on the same rule. The behaviour is pinned by an oracle row and the module doc now states it outright, so whichever way it goes it will not be discovered by surprise. |
Fixes #1716.
A sleep session's stage timeline is supposed to TILE its span.
SleepStageTotalssays so in as manywords — "the segment stages noop stores (which TILE the window, last segment clamped to the wake), Σ
stage minutes equals the clock span … would diverge only for malformed non-tiling stages" — and every
consumer downstream is written as though it holds.
One producer breaks it. A device-PROVIDED hypnogram is assembled from records that can arrive
INCOMPLETE, and
OuraSleepSessionMappingmerges only CONTIGUOUS codes, so a sleep-phase page thatnever arrived leaves a hole in
stagesJSONwhilestartTs/endTsstill span the whole night. Therow that comes out looks well-formed: non-empty, many segments, a plausible efficiency. It just
describes a fraction of the night it claims.
What it costs
31 consecutive nights on one ring, 22 of them with a WHOOP strap worn the same night as ground truth.
Minutes throughout. 9 of 31 nights affected; mean stored-TST error −328 min across the seven with
strap truth. Healthy nights sit at −18…−54 min, which is just the onset/offset difference between two
devices on one wrist.
2026-08-18 is the clean example: 70 minutes of sleep at 50% efficiency with 3 minutes of REM,
against a strap that recorded 494 minutes at 85%. Those numbers feed the sleep-debt ledger, the
Rest composite (hence Charge) and the baselines, at full weight.
Why nothing caught it
SleepMerge.hasStagesasks only whether thepayload is non-empty and not
"[]". The holed row carries 21 segments, so it passes and wins itsday. The rule guards against a stage-less import; it has no concept of a stage-holed one.
gravitySparseis inert here.SleepStager.isGravitySparsereturnsfalsewhengrav.count < 2, and the Oura path banks no gravity at all — so the Sleep: Whoop vs Noop #345 guard never fires on anyring night, not just this one.
efficiency >= 0.85. This night reads 0.50, so it is skipped.H9 also could not have caught it in principle: the part that did arrive has an ordinary
restorative share.
So the night was reported
.solid.The change
The missing quantity turns out not to be a new measurement at all — it is a ratio of two numbers
already stored (Σ segment durations ÷
endTs − startTs).HypnogramCoveragederives it on read.Nothing is persisted and there is no migration, which also means it applies retroactively to nights
already in the database rather than only to future ones.
1. Richness becomes a RANK.
SleepMerge/WhoopRepository.mergeSleepRichness:and the computed day wins only when it out-ranks the import. Collapsing 1 and 0 together gives
back the original presence rule exactly, so this is a strict generalisation — every case #240/#241
decided, it still decides the same way. The only pair whose outcome changes is the one the old rule
could not express: a holed import against a complete computed night.
This shape was not the first attempt. Swapping
hasStagesfor ahasUsableStagesthat also requirescoverage, applied symmetrically, regresses #240 — a holed computed night must still rescue the
day from a stage-less import, but under a symmetric test both sides read "not usable" and the
stage-less import wins, blanking the night. The existing suite caught it.
2.
ScoreConfidence.restgains a third downgrade clause on coverage, besidegravitySparseandthe H9 floor — a new clause in the existing mechanism, not a new mechanism.
3.
AnalyticsEnginesums coverage off the already-decoded segments (no JSON re-parse) and passesit. It is accumulated separately from
inBedS, because that one later absorbs the inter-fragment gap(#777/#705) and those are different quantities: a bridged out-of-bed gap is known-awake time between
two fragments, whereas a hole INSIDE a fragment is time we never observed.
Threshold
minCoverage = 0.95. The observed nights separate cleanly — healthy 99–100%, broken 23–93% —so the gate sits in empty space rather than balanced on the edge of the data.
What it deliberately does not do
would be the same overreach as the current behaviour, pointed the other way. The guard reports only
how much of the night was observed.
nilfor a payload it cannot measure, andnilmeans "notmeasured", never "measured badly". That is what keeps this away from WHOOP / Apple / Health-Connect
imports: their minute-dict shape carries no timestamps, so it is never judged holed and they are
scored on presence exactly as before.
separately. This stops NOOP asserting a night it did not observe.
Not in this PR
The same investigation found a second defect at
AnalyticsEngine's main-group loop: in-bed comes fromthe clock span while TST comes from the segments, so
Rest.compositereceives an inconsistentpair (
tst 70 minagainstin-bed 601 min) on exactly these nights. Fixing it means taking in-bed fromthe segments — which changes stored daily numbers, so per the one-concern rule it belongs in its own PR
with its own recompute thinking. Not here.
Two of the nine affected nights (2026-08-01, 2026-08-11) tile 100% but call most of the night "wake".
That is a different and unattributed failure mode —
0xFFdecodes to four codes of0b11= awakeand
MIN_TRAILING_UNWRITTEN = 6only suppresses a trailing run of ≥ 6 bytes, which is a candidate,not a cause. Both nights predate the raw sidecars, so the wire bytes are gone and it cannot be
confirmed. Out of scope here; noted so it is not mistaken for something this PR handles.
Verification
Pure and cross-platform throughout — no strap, no CoreBluetooth, no BLE path touched.
swift testWhoopStoreswift testStrandAnalytics./gradlew testFullDebugUnitTestStrandTestsunderxcodebuild … testNOOPiOStarget buildTools/doc_comment_lint.pyTools/i18n_audit.py --ciThe two Android failures are
RecoveryDriversTestfloat rounding (expected:<-0.5> but was:<-0.4999999999999929>), confirmed failing identically on a clean tree at this same base beforeany of these changes — unrelated to sleep, and not the known locale-sensitive set (those pass under
-Duser.language=en; these still fail).App targets were built locally because
app-build.ymlis disabled by default, so nothing in CI coversthem.
Parity is pinned by oracle, not by eye.
HypnogramCoverageTestembeds the verbatim stdout of theSwift twin compiled standalone (
swiftc -O) across the input space that matters: tiling, an interiorhole, the measured night, the gate boundary from both sides and exactly on it, overlap/overhang,
every unmeasurable payload shape, and degenerate spans. The boundary row earns its place — 950/1000
prints as
0.94999999999999996, which is the same double the0.95literal denotes, so it is notholed. Reading the two implementations side by side could not have settled that.
New tests:
HypnogramCoverageTests/HypnogramCoverageTest(both platforms), plus coverage-rankcases in
SleepMergeTests/MergeSleepLocalDayTestand coverage cases inAnalyticsEngineTests/ChargeEffortRestScoringTest. The confidence tests pin the real night twice: once asserting it reads.solidwithout coverage (the bug), once.buildingwith it.