Skip to content

fix(sleep): scope userEdited/manual sleep corrections to their own day so one edit can't pin every night (#299) - #318

Merged
ryanbr merged 1 commit into
mainfrom
fix-299-sleep-edit-day-scope
Jul 12, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix-299-sleep-edit-day-scope

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Fixes #299 (WHOOP 5.0: Rest/sleep score stuck at ~85% every live night; export shows totalSleepMin=453 identical on every night, incl. matched=0 nights).

Root cause (both platforms)

analyzeRecent built the editsByStart/editOnsetByStart maps window-wide (every userEdited/hand-logged-nap sleep row across the whole recompute window) and passed them to sleepEditedDaily for every day. sleepEditedDaily folds any edited row that isn't a twin of that day's detected sessions in as a "manual" block — so a single bed/wake edit or nap (startTs=T, total 453) was "twinless" for every other day and had its total folded into all of them, including matched=0 nights whose totalSleepMin was otherwise null. One correction silently overrode the whole window → the same number every night.

  • Kotlin: IntelligenceEngine.kt:745/1425
  • Swift twin: IntelligenceEngine.swift:848/1600 (same bug, byte-parity)

Fix

Scope the edits to the day each belongs to — the day its night ends on (dayString(endTs)), matching AnalyticsEngine's end-day session bucket. endTs is stable under a bedtime edit (only the onset/startTsAdjusted moves), so end-day is the right key. A single-night edit still overrides its own night; it just stops leaking. Extracted into a pure IntelligenceEngine.editedRowsForDay on both platforms.

Byte-parity & scope

  • Identical logic Kotlin + Swift.
  • The window-wide editedWindows skip set is deliberately left window-wide (it must span the scan to skip re-detecting all edited nights) — only the fold is day-scoped.
  • Single-night-edit + nap behaviour preserved (existing SleepEditDurabilityTest/ManualNapTest/MainNightConsistencyTest/Issue547 all pass).

Tests

  • Kotlin: Issue299EditDayScopeTest pins the day-scoping (an edit folds only into the day its night ends on; a no-edit day gets none). Full testFullDebugUnitTest green.
  • Swift: compile via app-build. The full analyzeRecent integration needs a live store the unit harness can't build (matching the existing pure-function test pattern), so the pure scoping is pinned + the twin logic is byte-identical.

…y so one edit can't pin every night (#299)

Reported: on WHOOP 5.0 the Rest/sleep score sits at a constant (85%) every live
night despite different underlying data; the export shows totalSleepMin=453 IDENTICAL
on every night, INCLUDING nights that detected no sleep (matched=0), while in-bed
sample counts genuinely differ.

Root cause (both platforms): analyzeRecent built the editsByStart / editOnsetByStart
maps WINDOW-WIDE (every userEdited or hand-logged-nap sleep row across the whole
recompute window) and passed them to sleepEditedDaily for EVERY day. sleepEditedDaily
folds any edited row that isn't a twin of THAT day's detected sessions in as a "manual"
block — so a single bed/wake edit or nap (startTs=T, total 453) was "twinless" for
every OTHER day and had its total folded into all of them, including matched=0 nights
whose totalSleepMin was otherwise null. One correction silently overrode the whole
window.

Fix: scope the edits to the day each one belongs to — the day its night ENDS on
(dayString(endTs)), matching AnalyticsEngine's end-day session bucket. endTs is stable
under a bedtime edit (only the onset/startTsAdjusted moves), so end-day is the right
key. A single-night edit still overrides its OWN night; it just stops leaking. Extracted
into a pure IntelligenceEngine.editedRowsForDay on both platforms.

Byte-parity: identical logic in Kotlin + Swift (Strand/Data/IntelligenceEngine.swift).
The window-wide editedWindows skip set is deliberately left window-wide (it must span the
scan to skip re-detecting all edited nights). Kotlin: Issue299EditDayScopeTest pins the
day-scoping + full testFullDebugUnitTest green; Swift compile: app-build (the analyzeRecent
integration needs a live store the JVM/unit harness can't build, so the pure scoping is pinned).
@ryanbr
ryanbr merged commit b4c9ed2 into main Jul 12, 2026
4 checks passed
@ryanbr
ryanbr deleted the fix-299-sleep-edit-day-scope branch July 12, 2026 07:48
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…y so one edit can't pin every night (ryanbr#299) (ryanbr#318)

Reported: on WHOOP 5.0 the Rest/sleep score sits at a constant (85%) every live
night despite different underlying data; the export shows totalSleepMin=453 IDENTICAL
on every night, INCLUDING nights that detected no sleep (matched=0), while in-bed
sample counts genuinely differ.

Root cause (both platforms): analyzeRecent built the editsByStart / editOnsetByStart
maps WINDOW-WIDE (every userEdited or hand-logged-nap sleep row across the whole
recompute window) and passed them to sleepEditedDaily for EVERY day. sleepEditedDaily
folds any edited row that isn't a twin of THAT day's detected sessions in as a "manual"
block — so a single bed/wake edit or nap (startTs=T, total 453) was "twinless" for
every OTHER day and had its total folded into all of them, including matched=0 nights
whose totalSleepMin was otherwise null. One correction silently overrode the whole
window.

Fix: scope the edits to the day each one belongs to — the day its night ENDS on
(dayString(endTs)), matching AnalyticsEngine's end-day session bucket. endTs is stable
under a bedtime edit (only the onset/startTsAdjusted moves), so end-day is the right
key. A single-night edit still overrides its OWN night; it just stops leaking. Extracted
into a pure IntelligenceEngine.editedRowsForDay on both platforms.

Byte-parity: identical logic in Kotlin + Swift (Strand/Data/IntelligenceEngine.swift).
The window-wide editedWindows skip set is deliberately left window-wide (it must span the
scan to skip re-detecting all edited nights). Kotlin: Issue299EditDayScopeTest pins the
day-scoping + full testFullDebugUnitTest green; Swift compile: app-build (the analyzeRecent
integration needs a live store the JVM/unit harness can't build, so the pure scoping is pinned).
hisariaraunak added a commit to hisariaraunak/noop that referenced this pull request Aug 5, 2026
Two defects, found by reading the actual sleepSession rows off the
device: both broken nights were addManualNap rows (userEdited=1,
startTsAdjusted=null) spanning only ~88 and ~72 minutes.

1. The durability guard suppressed re-detection of ANY session
   overlapping ANY userEdited row. A hand-CORRECTED night is the
   detected night's own twin, so that is right for it (ryanbr#318). A
   manually-ADDED session is not a twin -- it exists because detection
   missed that window -- so a short nap logged inside a real night
   permanently erased the whole night from the sleepSession table. The
   day still scored correctly (analyzeDay reads freshly-detected
   sessions), so the daily total and the Sleep tab's trend chart stayed
   right while the hero showed only the nap, and re-syncing could never
   heal it because SleepSessionDedup never drops a userEdited row. An
   added session now only suppresses a detected session it covers at
   least 50% of; corrected nights are unchanged.

2. Add-nap committed on the second time picker's OK, with no explicit
   save -- so tapping through the prefilled defaults banked a nap the
   user never intended (which is how these rows appeared). The pickers
   now only draft a window; an explicit "Save nap" confirmation writes
   it, mirroring the bed/wake edit's existing Save-only commit funnel.

Mirrored the guard change into the Swift twin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…y so one edit can't pin every night (ryanbr#299) (ryanbr#318)

Reported: on WHOOP 5.0 the Rest/sleep score sits at a constant (85%) every live
night despite different underlying data; the export shows totalSleepMin=453 IDENTICAL
on every night, INCLUDING nights that detected no sleep (matched=0), while in-bed
sample counts genuinely differ.

Root cause (both platforms): analyzeRecent built the editsByStart / editOnsetByStart
maps WINDOW-WIDE (every userEdited or hand-logged-nap sleep row across the whole
recompute window) and passed them to sleepEditedDaily for EVERY day. sleepEditedDaily
folds any edited row that isn't a twin of THAT day's detected sessions in as a "manual"
block — so a single bed/wake edit or nap (startTs=T, total 453) was "twinless" for
every OTHER day and had its total folded into all of them, including matched=0 nights
whose totalSleepMin was otherwise null. One correction silently overrode the whole
window.

Fix: scope the edits to the day each one belongs to — the day its night ENDS on
(dayString(endTs)), matching AnalyticsEngine's end-day session bucket. endTs is stable
under a bedtime edit (only the onset/startTsAdjusted moves), so end-day is the right
key. A single-night edit still overrides its OWN night; it just stops leaking. Extracted
into a pure IntelligenceEngine.editedRowsForDay on both platforms.

Byte-parity: identical logic in Kotlin + Swift (Strand/Data/IntelligenceEngine.swift).
The window-wide editedWindows skip set is deliberately left window-wide (it must span the
scan to skip re-detecting all edited nights). Kotlin: Issue299EditDayScopeTest pins the
day-scoping + full testFullDebugUnitTest green; Swift compile: app-build (the analyzeRecent
integration needs a live store the JVM/unit harness can't build, so the pure scoping is pinned).
bogaczcyprien-svg added a commit to bogaczcyprien-svg/noop that referenced this pull request Oct 8, 2026
…e thresholds

Third report this session that the displayed sleep-onset time reads
too early -- "when I'm lying in bed, not when I actually fall asleep."
Previous passes tightened the simple V1 Cole-Kripke detector's own
constants (onsetHRSleepMult to its floor, a dedicated longer
onsetPersistEpochsForOnset) with no real validation behind the exact
numbers chosen.

Investigated the actual architecture instead of tuning blind a fourth
time: SleepStagerV2 (the shipped default stager, on since ryanbr#277/ryanbr#351)
already computes a FAR richer per-epoch signal -- HR, HR-variability,
movement AND respiratory regularity (RSA) via a Viterbi/HMM recipe --
and already has its own sustained-non-wake onset rule
(`sustainedSleepOnset`), MEASURED against real PSG scoring (PhysioNet
sleep-accel, n=31: bias -3.8 min, MAE 7.4 min). But that function was
only ever used internally for the REM-latency guard's own reference
point -- never exposed as the onset the user actually sees, which
still comes entirely from V1's cruder window-finding regardless of
which recipe stages the night (documented: "Session DETECTION...
still comes entirely from V1").

Added `SleepStagerV2.refinedOnsetTs(_:)`: the SAME validated
sustained-non-wake rule, applied to a session's FINAL merged
[StageSegment] array (real timestamps) rather than a raw epoch-label
array, so it reads whatever recipe actually staged the night. Wired
into AnalyticsEngine's CachedSleepSession construction via the
EXISTING `startTsAdjusted` field (ryanbr#318, built for exactly this shape:
"the displayed/effective start can differ from the immutable detected
key"). Only applies when V2 staged the session (V1 already forces its
own onset to "wake", so re-deriving from its stages would just
reproduce the same number) and only ever proposes a LATER onset, never
earlier. The upsert's own `userEdited` guard means a night the user
has manually corrected keeps their edit on every future sync --
nothing here can override a real correction.

This is a genuinely different lever from the last three passes: it
reuses an already-built, already-PSG-validated signal instead of
nudging an unvalidated constant again.

Fork-only, not ported to the Android twin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mdPEFgoJhtm2kaxPp7LTK
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.

Sleep always 85% despite completely different markers behind

1 participant