Skip to content

sleep: a partially-received hypnogram is not a complete night - #1717

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-holed-hypnogram-coverage-guard
Aug 29, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-holed-hypnogram-coverage-guard

Conversation

@pipiche38

Copy link
Copy Markdown

Fixes #1716.

A sleep session's stage timeline is supposed to TILE its span. SleepStageTotals says so in as many
words — "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 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. The
row 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.

night span covered cov% stored TST eff WHOOP TST TST err
2026-07-30 52 52 100% 44 86% 492 −448
2026-08-01 510 510 100% 81 16% 458 −377
2026-08-11 520 520 100% 186 36% 506 −320
2026-08-12 520 442 85% 328 74% 444 −116
2026-08-13 130 62 48% 28 46% 474 −446
2026-08-15 390 156 40% 64 41% 483 −418
2026-08-16 368 286 78% 100 35% 445 −344
2026-08-17 485 405 83% 346 85% 448 −102
2026-08-18 601 140 23% 70 50% 494 −424
2026-08-21 534 498 93% 416 84% — —
2026-08-24 550 506 92% 411 81% — —

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

  • The merge's richness rule is a presence test. SleepMerge.hasStages asks only whether the
    payload is non-empty and not "[]". The holed row carries 21 segments, so it passes and wins its
    day. The rule guards against a stage-less import; it has no concept of a stage-holed one.
  • gravitySparse is inert here. SleepStager.isGravitySparse returns false when
    grav.count < 2, and the Oura path banks no gravity at all — so the Sleep: Whoop vs Noop #345 guard never fires on any
    ring night, not just this one.
  • The #H9 restorative floor needs 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). HypnogramCoverage derives 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:

rank meaning
2 stages covering the span they claim
1 stages present but holed
0 no stages

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 hasStages for a hasUsableStages that also requires
coverage, 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.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.

3. AnalyticsEngine sums coverage off the already-decoded segments (no JSON re-parse) and passes
it. 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

  • It does not count uncovered time as awake. We do not know what happened there. Asserting wake
    would be the same overreach as the current behaviour, pointed the other way. The guard reports only
    how much of the night was observed.
  • Both guards fail OPEN. Coverage is nil for a payload it cannot measure, and nil means "not
    measured", 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.
  • It recovers no sleep. The pages never arrived — that is the overnight link/drain problem, tracked
    separately. This stops NOOP asserting a night it did not observe.
  • It changes no score. Confidence-only, and no stages are invented.

Not in this PR

The same investigation found a second defect at AnalyticsEngine's main-group loop: in-bed comes from
the clock span while TST comes from the segments, so Rest.composite receives an inconsistent
pair (tst 70 min against in-bed 601 min) on exactly these nights. Fixing it means taking in-bed from
the 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 — 0xFF decodes to four codes of 0b11 = awake
and MIN_TRAILING_UNWRITTEN = 6 only 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.

gate result
swift test WhoopStore 441 pass
swift test StrandAnalytics 1636 pass
./gradlew testFullDebugUnitTest 4739 pass, 2 failures (below)
macOS app target build succeeds
StrandTests under xcodebuild … test passes
iOS NOOPiOS target build succeeds
Tools/doc_comment_lint.py OK
Tools/i18n_audit.py --ci OK

The two Android failures are RecoveryDriversTest float rounding (expected:<-0.5> but was:<-0.4999999999999929>), confirmed failing identically on a clean tree at this same base before
any 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.yml is disabled by default, so nothing in CI covers
them.

Parity is pinned by oracle, not by eye. HypnogramCoverageTest embeds the verbatim stdout of the
Swift twin compiled standalone (swiftc -O) across the 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 payload shape, and degenerate spans. The boundary row earns its place — 950/1000
prints as 0.94999999999999996, which is the same double the 0.95 literal denotes, so it is not
holed. Reading the two implementations side by side could not have settled that.

New tests: HypnogramCoverageTests / HypnogramCoverageTest (both platforms), plus coverage-rank
cases in SleepMergeTests / MergeSleepLocalDayTest and coverage cases in AnalyticsEngineTests /
ChargeEffortRestScoringTest. The confidence tests pin the real night twice: once asserting it reads
.solid without coverage (the bug), once .building with it.

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
@ryanbr
ryanbr marked this pull request as ready for review August 29, 2026 00:12
@ryanbr
ryanbr merged commit def4627 into ryanbr:main Aug 29, 2026
15 checks passed
ryanbr added a commit that referenced this pull request Aug 29, 2026
…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.
ryanbr added a commit that referenced this pull request Aug 29, 2026
…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.
@ryanbr

ryanbr commented Aug 29, 2026

Copy link
Copy Markdown
Owner

@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. XiaomiBandImporter is the exception: it emits real {start,end,stage} segments from the vendor's items array, and takes startTs/endTs from bedtime/wake_up_time (falling back to device_bedtime/out_bed_timestamp) — fields that are independent of the items it builds those segments from. So a Xiaomi night is measured like any device hypnogram, and one whose items do not reach its own bed/wake bounds reads as holed.

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 .building Rest.

Two things I could not answer from the code:

  1. Do Xiaomi's items tile bedtime → wake_up_time by construction? stageName maps state 1 and 5 to wake, so awake spans are represented, which suggests they might. But if the vendor's bed/wake fields bracket the items even slightly — bed recorded at "got into bed" while the first item starts at sleep detection — every Xiaomi night sits just under 1.0, and some fraction lands under 0.95 with nothing distinguishing them from a genuinely holed night.

  2. If they routinely do not tile, is 0.95 the right threshold for that source? Your 0.95 sits in empty space for Oura, where the split is 99–100% against 23–93%. There is no reason that separation transfers to a different vendor's segment semantics.

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.

@pipiche38
pipiche38 deleted the fix/oura-holed-hypnogram-coverage-guard 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.

A partially-received Oura hypnogram is stored and scored as a COMPLETE night (TST off by -328 min on 9 of 31 nights)

2 participants