Repository navigation
fix(sleep): scope userEdited/manual sleep corrections to their own day so one edit can't pin every night (#299) - #318
Merged
Conversation
…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).
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
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.
Fixes #299 (WHOOP 5.0: Rest/sleep score stuck at ~85% every live night; export shows
totalSleepMin=453identical on every night, incl.matched=0nights).Root cause (both platforms)
analyzeRecentbuilt theeditsByStart/editOnsetByStartmaps window-wide (everyuserEdited/hand-logged-nap sleep row across the whole recompute window) and passed them tosleepEditedDailyfor every day.sleepEditedDailyfolds 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, includingmatched=0nights whosetotalSleepMinwas otherwisenull. One correction silently overrode the whole window → the same number every night.IntelligenceEngine.kt:745/1425IntelligenceEngine.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.endTsis stable under a bedtime edit (only the onset/startTsAdjustedmoves), so end-day is the right key. A single-night edit still overrides its own night; it just stops leaking. Extracted into a pureIntelligenceEngine.editedRowsForDayon both platforms.Byte-parity & scope
editedWindowsskip set is deliberately left window-wide (it must span the scan to skip re-detecting all edited nights) — only the fold is day-scoped.SleepEditDurabilityTest/ManualNapTest/MainNightConsistencyTest/Issue547all pass).Tests
Issue299EditDayScopeTestpins the day-scoping (an edit folds only into the day its night ends on; a no-edit day gets none). FulltestFullDebugUnitTestgreen.analyzeRecentintegration 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.