Skip to content

fix(sleep): motion-corroborated wake — elevated HR alone no longer scores WAKE on a motionless wrist (fixes #462) - #465

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:upstream-pr/motion-corroborated-wake
Jul 14, 2026
Merged

fix(sleep): motion-corroborated wake — elevated HR alone no longer scores WAKE on a motionless wrist (fixes #462)#465
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:upstream-pr/motion-corroborated-wake

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Summary

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 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·zhrv coefficients, 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 via runIsDeeplyQuiescent / 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 + 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 deliberate follow-up.

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 — empty gravity, nil baseline — is preserved).

Cross-platform / Kotlin twin

Kotlin twin included + gradle-tested. The com.noop.analytics stagers carry the same policy transcribed byte-identically: SleepStager.confirmSleepWithHR gains grav + sleepHRBaseline corroboration with runIsDeeplyQuiescent / posturVarianceG2 / adaptiveOvernightHRBaseline (and the x1.30 / floor / 90% / 20-min / 40 bpm constants), sleepHRBaseline threaded through detectSleep and folded into the memo key; SleepStagerV2.motionQuiescent clamps the awake cardiac term ≤ 0 on quiescent epochs over the same coefficients. MotionCorroboratedWakeTest mirrors 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, nil baseline — is preserved by construction).

vishk23 added 2 commits July 14, 2026 12:58
…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).
@ryanbr
ryanbr merged commit afdbdad into ryanbr:main Jul 14, 2026
8 checks passed
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).
@vishk23
vishk23 deleted the upstream-pr/motion-corroborated-wake branch July 30, 2026 19:59
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.

2 participants