fix(sleep): motion-corroborated wake — elevated HR alone no longer scores WAKE on a motionless wrist (fixes #462) - #465
Merged
ryanbr merged 2 commits intoJul 14, 2026
Conversation
…ores WAKE on a motionless wrist (#462) Both stagers (SleepStager V1 and the default SleepStagerV2) and the HR-led session confirmation (confirmSleepWithHR) called WAKE primarily off HR / HR-variability with no motion or posture cross-check. On a night whose resting HR is held elevated WITHOUT the wearer getting up — a supplement protocol, a fever, a hot room, alcohol — that logic scored hot-but-motionless sleep as wake: over-called WASO, mis-placed onset, tanked efficiency and Rest. Rule: elevated HR ALONE is insufficient to call wake. An epoch or run at the night's quiescent MOTION floor with UNCHANGED posture cannot be scored WAKE on cardiac evidence alone. Corroboration comes from the gravity posture/jerk signal both stagers already consume (always present), not step ticks. - SleepStagerV2 (default): on a motion-quiescent epoch (no observed movement; peak jerk at/below the night-relative wake-gate floor) the AWAKE cardiac term is clamped to <= 0 — wake-SUPPRESSING (low, flat HR) evidence kept, the wake-PROMOTING half dropped. The clamp is applied over the restored (post-#437) 0.8*zhvv + 0.4*zhrv coefficients, so a night with any motion stages byte-identical; only a still, elevated-HR epoch is held. - confirmSleepWithHR (V1 detection): when a run is deeply motion-quiescent (>= ~90% of its dense-gravity minutes posture-stable, over >= 20 such minutes) the HR sleep band widens x1.05 -> x1.30. The band keeps a floor (genuine all-night in-bed wakefulness still dropped); with no gravity the strict band stands. runIsDeeplyQuiescent + posturVarianceG2 are pure and deterministic. - adaptiveOvernightHRBaseline + sleepHRBaseline threading: an optional personalised overnight band (median of recent overnight medians, floored), threaded through detectSleep as an argument defaulting to nil (byte-identical when unset). Live cross-night wiring in IntelligenceEngine is a follow-up. Why upstream #402 didn't cover this: #402 is a default-OFF post-pass over an already-staged hypnogram, gated on step density. This acts where the mis-scoring is produced (the stager emission + the HR confirmation), is on by default, and reads gravity posture/jerk rather than step ticks. Verification: `swift test` in Packages/StrandAnalytics is green — 1077 tests, 0 failures, including 8 new MotionCorroboratedWakeTests and every SleepStager / SleepStagerV2 / WakeMotionRefinement golden suite (the byte-identical default path with empty gravity / nil baseline is preserved). Two confirmed real nights motivating the fix are documented in issue #462 (2026-07-13: 194 min WAKE vs ~67; 2026-07-14: onset 1:41 vs ~1:29 plus a 44-min WAKE block). Kotlin twin deferred: the com.noop.analytics stagers need the same motion-quiescent clamp and widened HR band transcribed to stay byte-identical under the parity contract. Flagged as a Swift-only contribution (per PR #250); tracked as a follow-up.
…R #465) Twin the StrandAnalytics motion-corroborated-wake fix (#462) into the Kotlin analytics stagers: - SleepStager.confirmSleepWithHR gains grav + sleepHRBaseline corroboration: a deeply motion-quiescent run (runIsDeeplyQuiescent / posturVarianceG2) widens the HR sleep band x1.05 -> x1.30 (quiescentHRSleepMult), floored so a genuinely awake high-HR still run is still dropped. adaptiveOvernightHRBaseline self-calibrates the band with a floor. sleepHRBaseline is threaded through detectSleep and folded into the memo key (byte-identical when unset / no gravity). - SleepStagerV2.motionQuiescent clamps the AWAKE cardiac term to <=0 on a motion-quiescent epoch, keeping only wake-suppressing evidence; non-quiescent epochs stage byte-identical. - MotionCorroboratedWakeTest mirrors the Swift fixtures (8 tests). gradle: :app:testFullDebugUnitTest SleepStager* + MotionCorroboratedWakeTest = 85 tests, 0 failures (parity memo golden + V2 golden intact).
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…ores WAKE on a motionless wrist (fixes ryanbr#462) (ryanbr#465) * fix(sleep): motion-corroborated wake — elevated HR alone no longer scores WAKE on a motionless wrist (ryanbr#462) Both stagers (SleepStager V1 and the default SleepStagerV2) and the HR-led session confirmation (confirmSleepWithHR) called WAKE primarily off HR / HR-variability with no motion or posture cross-check. On a night whose resting HR is held elevated WITHOUT the wearer getting up — a supplement protocol, a fever, a hot room, alcohol — that logic scored hot-but-motionless sleep as wake: over-called WASO, mis-placed onset, tanked efficiency and Rest. Rule: elevated HR ALONE is insufficient to call wake. An epoch or run at the night's quiescent MOTION floor with UNCHANGED posture cannot be scored WAKE on cardiac evidence alone. Corroboration comes from the gravity posture/jerk signal both stagers already consume (always present), not step ticks. - SleepStagerV2 (default): on a motion-quiescent epoch (no observed movement; peak jerk at/below the night-relative wake-gate floor) the AWAKE cardiac term is clamped to <= 0 — wake-SUPPRESSING (low, flat HR) evidence kept, the wake-PROMOTING half dropped. The clamp is applied over the restored (post-ryanbr#437) 0.8*zhvv + 0.4*zhrv coefficients, so a night with any motion stages byte-identical; only a still, elevated-HR epoch is held. - confirmSleepWithHR (V1 detection): when a run is deeply motion-quiescent (>= ~90% of its dense-gravity minutes posture-stable, over >= 20 such minutes) the HR sleep band widens x1.05 -> x1.30. The band keeps a floor (genuine all-night in-bed wakefulness still dropped); with no gravity the strict band stands. runIsDeeplyQuiescent + posturVarianceG2 are pure and deterministic. - adaptiveOvernightHRBaseline + sleepHRBaseline threading: an optional personalised overnight band (median of recent overnight medians, floored), threaded through detectSleep as an argument defaulting to nil (byte-identical when unset). Live cross-night wiring in IntelligenceEngine is a follow-up. Why upstream ryanbr#402 didn't cover this: ryanbr#402 is a default-OFF post-pass over an already-staged hypnogram, gated on step density. This acts where the mis-scoring is produced (the stager emission + the HR confirmation), is on by default, and reads gravity posture/jerk rather than step ticks. Verification: `swift test` in Packages/StrandAnalytics is green — 1077 tests, 0 failures, including 8 new MotionCorroboratedWakeTests and every SleepStager / SleepStagerV2 / WakeMotionRefinement golden suite (the byte-identical default path with empty gravity / nil baseline is preserved). Two confirmed real nights motivating the fix are documented in issue ryanbr#462 (2026-07-13: 194 min WAKE vs ~67; 2026-07-14: onset 1:41 vs ~1:29 plus a 44-min WAKE block). Kotlin twin deferred: the com.noop.analytics stagers need the same motion-quiescent clamp and widened HR band transcribed to stay byte-identical under the parity contract. Flagged as a Swift-only contribution (per PR ryanbr#250); tracked as a follow-up. * feat(android): Kotlin twin of motion-corroborated wake (parity with PR ryanbr#465) Twin the StrandAnalytics motion-corroborated-wake fix (ryanbr#462) into the Kotlin analytics stagers: - SleepStager.confirmSleepWithHR gains grav + sleepHRBaseline corroboration: a deeply motion-quiescent run (runIsDeeplyQuiescent / posturVarianceG2) widens the HR sleep band x1.05 -> x1.30 (quiescentHRSleepMult), floored so a genuinely awake high-HR still run is still dropped. adaptiveOvernightHRBaseline self-calibrates the band with a floor. sleepHRBaseline is threaded through detectSleep and folded into the memo key (byte-identical when unset / no gravity). - SleepStagerV2.motionQuiescent clamps the AWAKE cardiac term to <=0 on a motion-quiescent epoch, keeping only wake-suppressing evidence; non-quiescent epochs stage byte-identical. - MotionCorroboratedWakeTest mirrors the Swift fixtures (8 tests). gradle: :app:testFullDebugUnitTest SleepStager* + MotionCorroboratedWakeTest = 85 tests, 0 failures (parity memo golden + V2 golden intact).
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.
Summary
Both stagers (
SleepStagerV1 and the defaultSleepStagerV2) and the HR-led session confirmation (confirmSleepWithHR) called WAKE primarily off HR / HR-variability with no motion or posture cross-check. On a night whose resting HR is held elevated without the wearer getting up — a supplement protocol, a fever, a hot room, alcohol — that logic scores hot-but-motionless sleep as wake: it over-calls WASO, mis-places onset, and tanks efficiency and Rest.The rule: elevated HR alone is insufficient to call wake. An epoch or run at the night's quiescent motion floor with unchanged posture cannot be scored WAKE on cardiac evidence alone. Corroboration comes from the gravity posture/jerk signal both stagers already consume (always present) — not step ticks.
The two evidence nights
Two confirmed real nights motivated the fix; details are in #462 rather than duplicated here (2026-07-13: 194 min WAKE vs ~67; 2026-07-14: onset 1:41 vs ~1:29 plus a 44-min WAKE block).
Why #402 didn't cover it
Upstream #402 is a default-OFF post-pass over an already-staged hypnogram, gated on step density. This change instead acts where the mis-scoring is produced — the stager emission and the HR-led session confirmation — is on by default, and reads the gravity posture/jerk signal rather than step ticks, so it applies to WHOOP nights that carry no step stream.
What changed, per layer
SleepStagerV2(default) —motionQuiescent+ awake cardiac clamp. On a motion-quiescent epoch (no observed movement; peak jerk at/below the night-relative wake-gate floor) the AWAKE cardiac term is clamped to ≤ 0 — the wake-suppressing (low, flat HR) half is kept, the wake-promoting half dropped. The clamp is applied over upstream's restored (post-fix(sleep): restore pre-#348 V2 staging defaults — DREAMT wake tune over-calls awake in the field (#431) #437)0.8·zhvv + 0.4·zhrvcoefficients, so any epoch that actually moved stages byte-identically; only a still, elevated-HR epoch is held.confirmSleepWithHR(V1 detection) — motion corroboration. When a run is deeply motion-quiescent (≥ ~90% of its dense-gravity minutes posture-stable, over ≥ 20 such minutes viarunIsDeeplyQuiescent/posturVarianceG2), the HR sleep band widens ×1.05 → ×1.30. The band keeps a floor (genuine all-night in-bed wakefulness is still dropped); with no gravity evidence the strict band stands. Both helpers are pure and deterministic.adaptiveOvernightHRBaseline+sleepHRBaselinethreading. An optional personalised overnight band (median of recent overnight medians, floored) threaded throughdetectSleepas an argument defaulting tonil(byte-identical when unset). Live cross-night wiring inIntelligenceEngineis a deliberate follow-up.Verification
swift testinPackages/StrandAnalyticsis green — 1077 tests, 0 failures, including 8 newMotionCorroboratedWakeTestsand everySleepStager/SleepStagerV2/WakeMotionRefinementgolden suite (the byte-identical default path — empty gravity,nilbaseline — is preserved).Cross-platform / Kotlin twin
Kotlin twin included + gradle-tested. The
com.noop.analyticsstagers carry the same policy transcribed byte-identically:SleepStager.confirmSleepWithHRgainsgrav+sleepHRBaselinecorroboration withrunIsDeeplyQuiescent/posturVarianceG2/adaptiveOvernightHRBaseline(and thex1.30/ floor / 90% / 20-min / 40 bpm constants),sleepHRBaselinethreaded throughdetectSleepand folded into the memo key;SleepStagerV2.motionQuiescentclamps the awake cardiac term≤ 0on quiescent epochs over the same coefficients.MotionCorroboratedWakeTestmirrors the 8 Swift fixtures../gradlew :app:testFullDebugUnitTest --tests "com.noop.analytics.SleepStager*" --tests "com.noop.analytics.MotionCorroboratedWakeTest"→ 85 tests, 0 failures, with the detect-memo fingerprint parity golden and the V2 staging golden intact (the byte-identical default path — empty gravity,nilbaseline — is preserved by construction).