Skip to content

fix(sleep): collapse a grazing pre-onset fragment; stop merging adjacent naps (#1284) - #1382

Merged
ryanbr merged 2 commits into
mainfrom
fix/1284-heal-cliff-and-annotation
Aug 16, 2026
Merged

fix(sleep): collapse a grazing pre-onset fragment; stop merging adjacent naps (#1284)#1382
ryanbr merged 2 commits into
mainfrom
fix/1284-heal-cliff-and-annotation

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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 #1338 near-adjacent branch only fired when two sessions were exactly disjoint (overlap <= 0):

  • 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 treated as more distinct than one falling short), and the source of the non-transitive, sort-order-dependent survivor set on 08-14/15.
  • 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.

Fix: replace the disjoint-only branch with a same-night fragment rule, continuous across the overlap==0 seam — a session ≤ 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 separates a re-decode fragment from two comparable naps:

case shorter / longer result
26 min fragment grazing 390 min night 6.7 % collapse ✅
40 min pre-onset piece beside 8 h night 8.3 % collapse ✅
20 min + 33 min adjacent naps 61 % kept
1 h nap overlapping night by 15 min (existing test) 12.5 % kept

Every phantom is a fragment of the night, so all collapse to it → the non-transitivity closes. #899 overlap rules unchanged; my-whoop has no near-adjacent pairs (0/313), so WHOOP is untouched.

2. Diagnostic annotation

The dup-gen(#1284) line + comment claimed startDelta ≈ 0x49 onset jitter / "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 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 s startDelta over 21 s of real onset jitter). Relabelled to end-anchor drift so the corpus reads correctly. Both platforms.

Verification

  • Kotlin: compileFullDebugKotlin clean; SleepSessionDedupTest 15/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).
  • Swift SleepSessionDedup is byte-identical logic → covered by swift-packages CI; SourceCoordinator.swift (annotation) is app-target → app-build gate.

Heal-side only. No schema/migration, no version bump.

ryanbr added 2 commits August 16, 2026 22:44
…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
ryanbr force-pushed the fix/1284-heal-cliff-and-annotation branch from 62c90cf to b52f850 Compare August 16, 2026 11:08
@ryanbr
ryanbr merged commit eebc732 into main Aug 16, 2026
16 of 18 checks passed
@ryanbr
ryanbr deleted the fix/1284-heal-cliff-and-annotation branch August 16, 2026 11:19
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.
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.

1 participant