fix(sleep): collapse a grazing pre-onset fragment; stop merging adjacent naps (#1284) - #1382
Merged
Merged
Conversation
…ent naps (#1284) Two #1284 residual-3 corrections from pipiche38's field captures. **Heal cliff (SleepSessionDedup.isDuplicate).** The near-adjacent collapse only fired when two sessions were exactly DISJOINT (`overlap <= 0`), so a short pre-onset fragment that OVERSHOT the anchored onset and grazed the night by 121 s stayed un-collapsed — while the same fragment ending 69 s SHORT collapsed. A discontinuity at the touch point: a fragment grazing in was treated as more distinct than one falling short. And the branch had no shape guard, so it also merged two GENUINE consecutive naps (20 + 33 min, 471 s apart) in the oura-import corpus. Replace the disjoint-only branch with a same-night FRAGMENT rule that is continuous across the overlap==0 seam: a session no more than `fragmentFractionOfLonger` (0.10) of the longer one collapses into it whether it grazes (small overlap) or falls just short (gap within `nearAdjacentSeconds`). The ratio — not an absolute cap — is what tells a re-decode fragment (26 min beside a 390 min night = 6.7 %; a 40 min pre-onset piece beside 8 h = 8.3 %) from two comparable adjacent naps (20 vs 33 min = 61 %, kept). It also closes the non-transitive, sort-order-dependent survivor set: every phantom is a fragment of the night, so all collapse to it. Existing #899 overlap rules unchanged; `my-whoop` has no near-adjacent pairs, so WHOOP is untouched. New tests on both platforms pin the grazing-fragment collapse, the adjacent-nap guard, and the multi-phantom order-independence. **Diagnostic annotation.** The dup-gen(#1284) line and its comment claimed `startDelta ≈ 0x49 onset jitter` and "startTs IS its anchored onset." Neither holds: `startTs = end − laidCodes·30 s`, and the 0x49 onset is a one-way pre-onset clip only (`OuraLiveSource` sleepStart), which does not bind when the backward lay never reaches it — so a row can be tagged `[0x49-onset]` yet not start at the onset (08-16: 4,206 s startDelta over 21 s of real onset jitter). It measures END-ANCHOR DRIFT; relabel it so the corpus reads correctly. Both platforms. Heal-side only; the durable fix is generation-side 0x49-onset keying (still open). No schema/migration, no version bump.
Follow-ups on the same heal, both serving residual 3. Tests: two survivor-selection cases the whole design leans on — - mode-2 partial drain: a 234 min partial nested in a 494 min decode of one night collapses to the FULLER row (longest-wins), so a partial re-drain never clobbers a complete night; - mode-1 identical re-anchors: several equal-length rows whose end chases wall-clock resolve by the latest-endTs tie-break — the row that matched WHOOP's wake. Pins that load-bearing order so it is not retuned away from ground truth. Swift + Kotlin. Heal diagnostic: the #899/#1284 store heal logged only a removal COUNT. It now logs, per dropped row, the dropped and surviving shapes (`[start->end] min= segs= json=`, the same vocabulary as the dup-gen diagnostic), so the strap log alone confirms the heal keeps the fuller / end-correct copy — the corpus the generation-side 0x49-onset keying will be designed against. Log-only, counts not content, both platforms. Deletion behaviour unchanged.
ryanbr
force-pushed
the
fix/1284-heal-cliff-and-annotation
branch
from
August 16, 2026 11:08
62c90cf to
b52f850
Compare
ryanbr
added a commit
that referenced
this pull request
Aug 16, 2026
…al 3) The durable fix for the duplicate-generation bug, behind a default-OFF Test Centre toggle for hardware validation. The heal (#1338/#1382) only collapses duplicates AFTER an analyze pass; this stops them being banked at the source, closing the window where the wrong night shows until the next pass / app restart. Mechanism (per @pipiche38's 08-16 capture: the 0x49 onset is stable to 21 s across 11 re-serves while the END chases wall-clock): - OuraLiveSource keys the session's startTs on the ROUNDED 0x49 onset (SleepSessionDedup.keyedStart, 60 s grid) instead of the end-anchored first-code time — so re-serves of one night share a PK, and the shown bedtime becomes the true onset (fixing the current end-anchor drift). - The persist closure runs a completeness guard (SleepSessionDedup.planBank) before banking: same collapse rule as the heal (longest, then latest-end wins), so a partial re-drain is suppressed and a fuller/later copy supersedes and deletes the rows it replaces. A rare onset-bucket straddle is caught here by overlap, not the PK. Failure falls back to a plain upsert — never loses the night. No schema migration (startTs is still the PK, set to the onset). Pure core (keyedStart + planBank, reusing the shared rank + isDuplicate) is unit- tested both platforms (SleepSessionDedupTest 23/23). The persist wiring is BLE-path — compile-only here; it must be validated on a real ring. Open for a hardware run: the 60 s grid, and whether to keep startTs exact + add a sessionKey column (migration) later. Off by default; no effect for a user with no Oura ring.
ryanbr
added a commit
that referenced
this pull request
Aug 16, 2026
…al 3) The durable fix for the duplicate-generation bug, behind a default-OFF Test Centre toggle for hardware validation. The heal (#1338/#1382) only collapses duplicates AFTER an analyze pass; this stops them being banked at the source, closing the window where the wrong night shows until the next pass / app restart. Mechanism (per @pipiche38's 08-16 capture: the 0x49 onset is stable to 21 s across 11 re-serves while the END chases wall-clock): - OuraLiveSource keys the session's startTs on the ROUNDED 0x49 onset (SleepSessionDedup.keyedStart, 60 s grid) instead of the end-anchored first-code time — so re-serves of one night share a PK, and the shown bedtime becomes the true onset (fixing the current end-anchor drift). - The persist closure runs a completeness guard (SleepSessionDedup.planBank) before banking: same collapse rule as the heal (longest, then latest-end wins), so a partial re-drain is suppressed and a fuller/later copy supersedes and deletes the rows it replaces. A rare onset-bucket straddle is caught here by overlap, not the PK. Failure falls back to a plain upsert — never loses the night. No schema migration (startTs is still the PK, set to the onset). Pure core (keyedStart + planBank, reusing the shared rank + isDuplicate) is unit- tested both platforms (SleepSessionDedupTest 23/23). The persist wiring is BLE-path — compile-only here; it must be validated on a real ring. Open for a hardware run: the 60 s grid, and whether to keep startTs exact + add a sessionKey column (migration) later. Off by default; no effect for a user with no Oura ring.
ryanbr
added a commit
that referenced
this pull request
Aug 16, 2026
…al 3) The durable fix for the duplicate-generation bug, behind a default-OFF Test Centre toggle for hardware validation. The heal (#1338/#1382) only collapses duplicates AFTER an analyze pass; this stops them being banked at the source, closing the window where the wrong night shows until the next pass / app restart. Mechanism (per @pipiche38's 08-16 capture: the 0x49 onset is stable to 21 s across 11 re-serves while the END chases wall-clock): - OuraLiveSource keys the session's startTs on the ROUNDED 0x49 onset (SleepSessionDedup.keyedStart, 60 s grid) instead of the end-anchored first-code time — so re-serves of one night share a PK, and the shown bedtime becomes the true onset (fixing the current end-anchor drift). - The persist closure runs a completeness guard (SleepSessionDedup.planBank) before banking: same collapse rule as the heal (longest, then latest-end wins), so a partial re-drain is suppressed and a fuller/later copy supersedes and deletes the rows it replaces. A rare onset-bucket straddle is caught here by overlap, not the PK. Failure falls back to a plain upsert — never loses the night. No schema migration (startTs is still the PK, set to the onset). Pure core (keyedStart + planBank, reusing the shared rank + isDuplicate) is unit- tested both platforms (SleepSessionDedupTest 23/23). The persist wiring is BLE-path — compile-only here; it must be validated on a real ring. Open for a hardware run: the 60 s grid, and whether to keep startTs exact + add a sessionKey column (migration) later. Off by default; no effect for a user with no Oura ring.
ryanbr
added a commit
that referenced
this pull request
Aug 16, 2026
…al 3) The durable fix for the duplicate-generation bug, behind a default-OFF Test Centre toggle for hardware validation. The heal (#1338/#1382) only collapses duplicates AFTER an analyze pass; this stops them being banked at the source, closing the window where the wrong night shows until the next pass / app restart. Mechanism (per @pipiche38's 08-16 capture: the 0x49 onset is stable to 21 s across 11 re-serves while the END chases wall-clock): - OuraLiveSource keys the session's startTs on the ROUNDED 0x49 onset (SleepSessionDedup.keyedStart, 60 s grid) instead of the end-anchored first-code time — so re-serves of one night share a PK, and the shown bedtime becomes the true onset (fixing the current end-anchor drift). - The persist closure runs a completeness guard (SleepSessionDedup.planBank) before banking: same collapse rule as the heal (longest, then latest-end wins), so a partial re-drain is suppressed and a fuller/later copy supersedes and deletes the rows it replaces. A rare onset-bucket straddle is caught here by overlap, not the PK. Failure falls back to a plain upsert — never loses the night. No schema migration (startTs is still the PK, set to the onset). Pure core (keyedStart + planBank, reusing the shared rank + isDuplicate) is unit- tested both platforms (SleepSessionDedupTest 23/23). The persist wiring is BLE-path — compile-only here; it must be validated on a real ring. Open for a hardware run: the 60 s grid, and whether to keep startTs exact + add a sessionKey column (migration) later. Off by default; no effect for a user with no Oura ring.
ryanbr
added a commit
that referenced
this pull request
Aug 16, 2026
…1381 Test Centre strings Two CI gates went red on main from recent merges that were completed via `gh api merge` (which bypasses the PR checks): 1. `test (WhoopStore)` (swift-packages) — red since #1382 (eebc732). The #1284 tests assert on the internal `overlapSeconds`/`edgeGapSeconds` helpers but the file used a plain `import WhoopStore`, so it fails to compile on macOS ("inaccessible due to 'internal'"). WhoopStore only builds on macOS and #1382 was gated on app-build.yml (no package tests); the Kotlin twin compiled (same-module internal). Fix: `@testable import` (already the convention in 44 of 47 WhoopStore test files). 2. `i18n Coverage` — red since #1381. The Test Centre "Polar debug logging" toggle (+ its iOS description) are intentionally-unlocalized dev-surface strings but were never added to the audit baseline, like their sibling "Detailed capture to file". Fix: `--update-baseline` adds exactly those 3 strings (android + ios), nothing else. Test-only + baseline. No source or public-API change.
ryanbr
added a commit
that referenced
this pull request
Aug 16, 2026
…1381 Test Centre strings (#1385) Two CI gates went red on main from merges completed via gh api merge (bypassing PR checks): test (WhoopStore) since #1382 (the #1284 tests assert on internal overlapSeconds/edgeGapSeconds but used a plain import WhoopStore → macOS compile error; fixed by @testable import, the convention in 44/47 WhoopStore test files), and i18n Coverage since #1381 (the Test Centre 'Polar debug logging' toggle strings were never baselined; --update-baseline adds exactly those 3). Test-only + baseline; no source change.
5 tasks
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.
Two #1284 residual-3 corrections from @pipiche38's field captures — both heal-side; the durable fix is still generation-side
0x49-onset keying.1. Heal cliff + adjacent-nap over-collapse (
SleepSessionDedup.isDuplicate)The
#1338near-adjacent branch only fired when two sessions were exactly disjoint (overlap <= 0):oura-importcorpus.Fix: replace the disjoint-only branch with a same-night fragment rule, continuous across the
overlap==0seam — a session ≤fragmentFractionOfLonger(0.10) of the longer one collapses into it whether it grazes (small overlap) or falls just short (gap withinnearAdjacentSeconds). The ratio (not an absolute cap) is what separates a re-decode fragment from two comparable naps:Every phantom is a fragment of the night, so all collapse to it → the non-transitivity closes. #899 overlap rules unchanged;
my-whoophas no near-adjacent pairs (0/313), so WHOOP is untouched.2. Diagnostic annotation
The
dup-gen(#1284)line + comment claimedstartDelta ≈ 0x49 onset jitter/ "startTs IS its anchored onset." Neither holds:startTs = end − laidCodes·30 s, and the0x49onset is a one-way pre-onset clip only (OuraLiveSourcesleepStart), which doesn't bind when the backward lay never reaches it — so a row can be tagged[0x49-onset]yet not start at the onset (08-16: 4,206 sstartDeltaover 21 s of real onset jitter). Relabelled to end-anchor drift so the corpus reads correctly. Both platforms.Verification
compileFullDebugKotlinclean;SleepSessionDedupTest15/15 (12 existing + 3 new: grazing-fragment collapse, adjacent-nap guard, multi-phantom order-independence); full*Sleep*/*Intelligence*/*Dedup*suites 0 failures (no heal regression).SleepSessionDedupis byte-identical logic → covered byswift-packagesCI;SourceCoordinator.swift(annotation) is app-target → app-build gate.Heal-side only. No schema/migration, no version bump.