Score an Oura night by feeding the ring's own hypnogram into the scorer (#804 Fix A) - #877
Score an Oura night by feeding the ring's own hypnogram into the scorer (#804 Fix A)#877pipiche38 wants to merge 1 commit into
Conversation
ee7ffa0 to
f737d3e
Compare
|
Sequencing note between this PR and #883 — not an objection, and both are yours, so this is really just a "these two want an order" flag. They touch disjoint files (this one is Short version: #883 wants to land first. The reason is a side effect of this PR that isn't obvious from its diff. Verified on
— and today a ring night contributes nothing to This PR makes Two qualifiers so this doesn't read as scarier than it is: One scoping observation on this PR specifically, unrelated to the ordering: the gate in (On the respiration axis specifically, the Oura cloud path is harmless — it writes no IBI rows, so Everything above is code-reading on |
|
Sequencing note (h/t @vishk23 on #883): merge #883 before this PR. This PR is what first reaches #883 adds the beat-accuracy precondition inside |
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
f737d3e to
01ae58e
Compare
|
I'm sorry , but this is not going as my expected speed, and this is an handicap for my development. I don't want to spend too much time in just rebasing all my PRs. So I'm closing all those PR . Feel free to take over what I was doing ... |

Closes #804.
An Oura-only night decoded and persisted a complete SleepNet hypnogram (#773) and showed on the Sleep timeline, yet the nightly summary reported it as blank:
sleep day=2026-07-27 totalSleepMin=nil stages=nil eff=nil matched=0 source=computed
hrv day=2026-07-27 window=whole avgHrv=nil
Root cause: the nightly scorer (AnalyticsEngine.analyzeDay) stages sleep from the motion/gravity vector, and a ring streams no gravity at all. So detectSleep returns nothing → the sleep window is empty → avgHrv (computed only over that window) stays nil despite tens of thousands of R‑R intervals present. The ring's persisted hypnogram only ever fed the display‑path SleepMerge, never analyzeDay.
Confirmed on device (app 9.1.1, night 2026‑07‑26→27): the ring produced deep 88 / light 316 / rem 88 / awake 182 min, eff 73%, persisted and won the merge — and the summary was still totalSleepMin=nil / avgHrv=nil. A fully‑formed hypnogram, still scoring blank
— proof the blocker is scorer wiring, not decode.
The fix (general, not Oura‑hardcoded)
analyzeDay gains an optional providedSleep: [SleepSession] = []:
IntelligenceEngine supplies it: when the day owner sends no usable gravity vector (grav.count < 2) and isn't the canonical WHOOP import namespace, it reads the owner's persisted hypnogram and reconstructs pure SleepSessions from the stored CachedSleepSession.
New shared helpers both platforms: decodeStages + sleepSession(fromProvided:).
Scope / safety
Verification
Cross‑platform
Swift + Kotlin changed byte‑identically; no schema/migration.