Skip to content

fix(sleep): collapse a near-adjacent pre-onset fragment in the dedup heal (#1284) - #1338

Merged
ryanbr merged 2 commits into
mainfrom
oura-1284-neardup
Aug 15, 2026
Merged

ryanbr merged 2 commits into
mainfrom
oura-1284-neardup

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Extends the #899 SleepSessionDedup heal to close the one Oura duplicate mode it misses (#1284 residual 3).

What

SleepSessionDedup.isDuplicate (Swift + Kotlin twin) required overlap > 0, so it missed the Oura SleepNet pre-onset fragment: a short session ending seconds before the 0x49-anchored night starts, with no overlap (measured 69 s on 08-10/11; the pre-onset run is ~7 min / 14 codes per OuraLiveSource). Now two disjoint sessions within a 15-min edge gap are also treated as one night. A gap that small can only be one interrupted night — a real nap is hours out (tested).

The overlap branch and the survivor sort (userEdited > fresh > longest) are unchanged, so the fuller/edited row still wins and WHOOP behaviour is untouched.

Tests

Swift + Kotlin, 3 each: fragment-collapses-despite-no-overlap, real-nap-stays-separate, gap-beyond-15-min-stays-separate. Full Android suite green (3834 tests, 0 failures); Swift via swift-packages.

Scope — honest framing

This is heal-side (post-hoc): it completes the sweep so no duplicate survives to the next analyze pass. It does not stop duplicate generation or the transient phantom-nap window before the heal runs — @pipiche38 was clear that residual 3 needs the generation side (0x49/sleep-day keying), which is separate and hardware-gated. This ships as the heal-completeness half; the generation-side fix + the logging to design it are tracked on #1284.

Relates to #1284. Measurements: @pipiche38.

…heal (#1284)

The #899 SleepSessionDedup overlap rule requires overlap > 0, so it misses the
Oura SleepNet pre-onset fragment: a short session that ends seconds BEFORE the
0x49-anchored night begins with no overlap (measured 69 s on 08-10/11; the
pre-onset run is ~7 min / 14 codes). Extend isDuplicate so two DISJOINT sessions
within a 15-min edge gap are also one night — a gap that small can only be one
interrupted night, never two real sleeps (a nap is hours out). The existing
overlap branch and survivor sort (userEdited > fresh > longest) are unchanged,
so the fuller/edited row still wins and WHOOP behaviour is untouched.

Swift + Kotlin twins; 3 new tests each (fragment collapses, real nap stays
separate, gap > 15 min stays separate). Full Android suite green (3834).

Heal-side (post-hoc): this completes the sweep so no duplicate survives to the
next analyze pass. It does NOT stop duplicate GENERATION or the transient
phantom-nap window before the heal runs — that needs the separate generation-side
0x49/sleep-day keying (#1284 residual 3, hardware-gated).
overlapSeconds is internal and WhoopStoreTests imports WhoopStore without
@testable, so the disjointness sanity assertions didn't compile in swift-packages
CI (Kotlin's internal is same-module, so it passed locally). isDuplicate/dedupe
(public) already cover the behaviour; drop the redundant overlapSeconds calls.
@ryanbr
ryanbr merged commit 7180187 into main Aug 15, 2026
14 checks passed
@ryanbr
ryanbr deleted the oura-1284-neardup branch August 15, 2026 06:47
ryanbr added a commit that referenced this pull request Aug 15, 2026
…log-only) (#1339)

* feat(oura): cross-connection duplicate-generation diagnostic (#1284, log-only)

The in-source duplicate-gen(#1284) line compares a PER-CONNECTION memory list, so
it is blind to the common case pipiche measured: an overnight with link drops mints
the duplicate across DIFFERENT connections. At persist, read the day's stored
sessions instead (cross-connection, survives an app restart) and log — via the same
SleepSessionDedup.isDuplicate rule the heal uses (#1338) — when this persist
duplicates one. Emits one compact line carrying startΔ (≈ the 0x49 onset jitter, since
a session's startTs is its anchored onset), the edge-gap/overlap, and both code counts.

Log-only: behaviour is unchanged (still upserts). This is the corpus the generation-side
0x49/sleep-day keying will be designed + validated against, and it survives the log
head-clip far better than the multi-line burst trace. Swift + Kotlin twins.

* style(oura): ASCII the #1284 diagnostic log line for grep/parity with the Kotlin twin
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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…heal (ryanbr#1284) (ryanbr#1338)

* fix(sleep): collapse a near-adjacent pre-onset fragment in the dedup heal (ryanbr#1284)

The ryanbr#899 SleepSessionDedup overlap rule requires overlap > 0, so it misses the
Oura SleepNet pre-onset fragment: a short session that ends seconds BEFORE the
0x49-anchored night begins with no overlap (measured 69 s on 08-10/11; the
pre-onset run is ~7 min / 14 codes). Extend isDuplicate so two DISJOINT sessions
within a 15-min edge gap are also one night — a gap that small can only be one
interrupted night, never two real sleeps (a nap is hours out). The existing
overlap branch and survivor sort (userEdited > fresh > longest) are unchanged,
so the fuller/edited row still wins and WHOOP behaviour is untouched.

Swift + Kotlin twins; 3 new tests each (fragment collapses, real nap stays
separate, gap > 15 min stays separate). Full Android suite green (3834).

Heal-side (post-hoc): this completes the sweep so no duplicate survives to the
next analyze pass. It does NOT stop duplicate GENERATION or the transient
phantom-nap window before the heal runs — that needs the separate generation-side
0x49/sleep-day keying (ryanbr#1284 residual 3, hardware-gated).

* test(sleep): use only public dedup API in the ryanbr#1284 near-adjacent tests

overlapSeconds is internal and WhoopStoreTests imports WhoopStore without
@testable, so the disjointness sanity assertions didn't compile in swift-packages
CI (Kotlin's internal is same-module, so it passed locally). isDuplicate/dedupe
(public) already cover the behaviour; drop the redundant overlapSeconds calls.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…1284, log-only) (ryanbr#1339)

* feat(oura): cross-connection duplicate-generation diagnostic (ryanbr#1284, log-only)

The in-source duplicate-gen(ryanbr#1284) line compares a PER-CONNECTION memory list, so
it is blind to the common case pipiche measured: an overnight with link drops mints
the duplicate across DIFFERENT connections. At persist, read the day's stored
sessions instead (cross-connection, survives an app restart) and log — via the same
SleepSessionDedup.isDuplicate rule the heal uses (ryanbr#1338) — when this persist
duplicates one. Emits one compact line carrying startΔ (≈ the 0x49 onset jitter, since
a session's startTs is its anchored onset), the edge-gap/overlap, and both code counts.

Log-only: behaviour is unchanged (still upserts). This is the corpus the generation-side
0x49/sleep-day keying will be designed + validated against, and it survives the log
head-clip far better than the multi-line burst trace. Swift + Kotlin twins.

* style(oura): ASCII the ryanbr#1284 diagnostic log line for grep/parity with the Kotlin twin
ryanbr added a commit that referenced this pull request Aug 17, 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.
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