Skip to content

sleep: add an HR-only Stage-0 spine for straps that bank no motion - #1805

Merged
ryanbr merged 3 commits into
mainfrom
feat/1801-hr-only-sleep-spine
Sep 2, 2026
Merged

ryanbr merged 3 commits into
mainfrom
feat/1801-hr-only-sleep-spine

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Refs #1801. Algorithm half only — pure, unwired, no behaviour change on either platform.

Why

Stage 0 is a gravity-stillness spine (Cole-Kripke) that heart rate only confirms, via
hrSleepBandAcross and confirmSleepWithHR. A 5/MG that cannot bond never banks motion at all —
SET_CLOCK rides a handshake it never completes — so grav is empty, there is no spine, and
IntelligenceEngine.kt:2907 states the consequence itself: "no quantity of HR can stage a night."

From the field report on 11.0.0-staging:

sleep-detect day=2026-09-02 NO-NIGHT hr=152999 rr=99337 resp=0 grav=0 steps=0 provided=0
             window=48h reason=no-motion

~153k HR rows for a day whose night is plainly visible as a 00:00–07:00 shelf at 59–70 bpm.

What this adds

hrOnlySleepRuns — per-epoch median bpm against baseline * hrSleepBandMult, with run
construction mirroring buildRuns line for line (close on class change, or a gap over maxGapMin).
Two deliberate choices:

  • It reuses the rule the confirm path already trusts rather than introducing a second definition of
    "in the sleep band". Median for the reason hrSleepBandAcross documents — one arousal spike must
    not flip an epoch.
  • Only the flag source differs from the motion spine; segmentation is identical. A reviewer who
    knows buildRuns already knows this function.

What it does NOT do

This will not make Rest appear. There is no caller. Two things are still needed:

  1. The detect path must use this when gravity is absent.
  2. DetectedSleep carries no confidence concept, and display-only status needs one.

Both are follow-ups, kept separate so the algorithm can be reviewed on its own merits.

The weakness, named rather than tuned around

The file already records that a long still daytime stretch is gravity-indistinguishable from a nap,
and that HR is what rescues it. Remove motion and the inverse is exposed: a quiet evening at rest can
sit in the sleep band. The doc says so, and says a caller must not let one of these runs reach a
baseline it cannot be unwound from. The scope decision on #1801 is display-only for exactly this
reason.

Verification

  • Kotlin: SleepStagerHrOnlySpineTest, 7 tests, 0 failures (result XML checked for a real count,
    not a zero-match filter). compileFullDebugKotlin clean.
  • Swift: swift build clean; SleepStagerHrOnlySpineTests asserts the identical cases.
  • The Kotlin expectations are the Swift twin's own stdout, from swiftc -O twin.swift main.swift
    over this exact case list — not read off the Kotlin. The harness exists because the package test
    binary cannot link GRDB's sqlite3 on Linux, so XCTest could not be executed here; the Swift
    assertions are written but unrun on this machine and want a macOS run.
  • doc_comment_lint and i18n_audit --ci exit 0.

Cases pinned: nil/zero baseline, empty HR, all-in-band, all-out-of-band, sleep/active/sleep
segmentation, a gap wider than maxGapMin splitting a same-class run, a 190 bpm spike failing to
flip an epoch, and the inclusive 63 = 60 × 1.05 band boundary.

Stage 0 is a gravity-stillness spine (Cole-Kripke) that heart rate only CONFIRMS, through
hrSleepBandAcross and confirmSleepWithHR. A WHOOP 5/MG that cannot bond never banks motion at all -
SET_CLOCK rides a handshake it never completes - so gravity is empty, there is no spine, and no
quantity of heart rate can stage the night. A field report on 11.0.0-staging shows ~153k HR rows and
~99k R-R rows for a day whose night is plainly visible as a 00:00-07:00 shelf, scored as NO-NIGHT
reason=no-motion.

Adds hrOnlySleepRuns: per-epoch median bpm against baseline * hrSleepBandMult, with run construction
mirroring buildRuns line for line, so the two spines segment identically once flags exist and only
the flag SOURCE differs. It reuses the rule the confirm path already trusts rather than introducing
a second definition of "in the sleep band".

PURE AND UNWIRED. No caller, so no behaviour changes on either platform. Two things are still
missing before a night reaches the Rest tile: the detect path has to use this when gravity is
absent, and DetectedSleep carries no confidence concept, which display-only status needs. Both are
follow-ups, deliberately separate from the algorithm so this half can be reviewed on its own.

The doc names the weakness rather than tuning around it. The file already records that a long still
daytime stretch is gravity-indistinguishable from a nap and that HR is what rescues it; with motion
gone the inverse is exposed, and a quiet evening at rest can sit in the sleep band. A caller must not
let one of these runs reach a baseline it cannot be unwound from.

Kotlin expectations are the Swift twin's own stdout from a standalone harness, not a reading of the
Kotlin. The XCTest twin asserts the same values on Apple; the harness exists because the package test
binary cannot link GRDB's sqlite3 on Linux.

Refs #1801.
Re-review. The doc said the run construction "mirrors buildRuns line for line". It does not, and the
difference is one a reviewer would assume away: buildRuns can forgive a gap when hrSleepBandAcross
vouches that HR stayed in band across it, rescuing a night whose GRAVITY dropped out. The HR-only
spine has no such branch.

Omitting it is correct rather than missing — the gap there IS in the heart rate, so there is nothing
left to vouch with. But that has a real consequence worth stating instead of glossing: a long HR
dropout breaks an HR-only run where it would not break a motion-backed one, and the fragments are
then dropped by the caller's minimum-duration gate rather than bridged.

Also verified this pass, and unchanged: HrvAnalyzer.median and HRVAnalyzer.median are byte-identical
including the even-count average, so the per-epoch flag cannot diverge across platforms.
Re-review, and this one is a behaviour bug rather than a doc claim.

buildRuns takes `end` from `times[i - 1]`, where `times` are the gravity SAMPLE timestamps — so its
end is the last instant actually observed. The HR-only spine reused that expression against an axis
of epoch STARTS, so every run was reported one whole epoch short of the data it covered. The old
oracle showed it plainly: three 60 s epochs of samples laid every 10 s reported 60000-60120, a 120 s
span for 170 s of samples.

Small on an eight-hour night and invisible, which is what makes it worth fixing: the understated
span is what the caller's minimum-duration gate would weigh, so a night sitting near the threshold
could be dropped for a shortfall that is an artefact of the axis.

Now two axes, and the doc says why: a gap and a run's START stay on epoch starts, so a gap is
measured between epochs rather than between whichever samples sat at their edges, while a run's END
is the last sample seen in its final epoch — buildRuns' own meaning of `end`.

Expectations regenerated from the Swift harness rather than edited by hand, and a new case on both
platforms pins the axis so it cannot silently revert. Kotlin: 8 tests, 0 failures.
@ryanbr
ryanbr merged commit d2f2c96 into main Sep 2, 2026
15 checks passed
@ryanbr
ryanbr deleted the feat/1801-hr-only-sleep-spine branch September 2, 2026 07:09
ryanbr added a commit that referenced this pull request Sep 2, 2026
… median (#1806)

Wires hrOnlySessions into the day scan for a strap that streams heart rate but banks no motion, and
fixes a real flaw in the spine merged in #1805 that writing the tests exposed.

THE ANCHOR. hrOnlySleepRuns took its band from hrBaseline, the window MEDIAN, on the reasoning that
reusing the motion path's definition was parity. That was wrong by arithmetic rather than by tuning:
a median splits the samples in half by definition, so a band of median * 1.05 admits strictly more
than half of ANY window. Measured on a realistic 24 h - 16 h awake at 74-96, 8 h night at 59-70 - it
called 14.4 hours sleep against a truth of 8. hrSleepBandMult is correct where it was designed to be
used, as a CONFIRMATION gate on a run gravity stillness already found. A confirmation threshold and a
detection threshold are not the same instrument.

Now anchored on the tenth percentile by nearest rank, which sits in the night's trough and cannot
admit half a window however the day is shaped. Chosen from a sweep against windows with known truth,
taking the conservative corner because the failure directions are not symmetric: over-detection puts
a wrong Rest on screen, under-detection leaves "No data", which is the state this improves on.

MERGE THE RUNS. A run boundary is a threshold crossing and a sleeping heart rate oscillates across
the band all night, so the spine returned the night's minutes shredded into sub-mergeMin fragments,
every one of which then failed the minimum-duration gate - 8 h of detected sleep yielding zero
sessions. mergePeriods first, exactly as the motion path does.

DISPLAY-ONLY, ENFORCED AT THE SET. An HR-only night carries null restingHR/avgHRV, but that only
protects aggregates that READ those fields: the deep-window HRV pool and the SDNN index both
re-derive from rr over each session's own stages, and would have folded one into Charge and the HRV
baseline regardless. deepHrvWindow is a user setting, so that path is reachable. Fixed by naming the
set once - physiologySessions - and routing all four aggregates through it.

A 4.0 IS UNTOUCHED. Nothing in the change keys on model or family; the gate is absent gravity plus
the device having persisted no hypnogram of its own. Every changed line outside the new functions is
a no-op when no session is hrOnly. Pinned by a source-reading test.

Two comments corrected that this change falsified, and two places where Swift's explicit init dropped
a field Kotlin's copy kept.

21 Kotlin tests, 0 failures. Swift twins run on CI. app-build green on both Apple targets - it caught
an internal entry point invisible to the app target that every default job missed.

Refs #1801.
ryanbr added a commit that referenced this pull request Sep 2, 2026
Staging bump, so all three numbers move together: MARKETING_VERSION and versionName to 11.1.0, and
the build numbers past the staging high-water — versionCode 411 -> 412 and CURRENT_PROJECT_VERSION
292 -> 293. 411 was taken by the 11.0.0 release, so 412 is the first free code a side-installed
staging build can carry without the installer refusing it as a downgrade.

fork-testing-build.yml reads MARKETING_VERSION out of project.yml for the asset names and the
release title, so the testing artefacts will read v11.1.0.

Carries the HR-only sleep spine (#1805) and its wiring (#1806): a strap that streams heart rate but
banks no motion can now stage a night from HR alone, marked lower-confidence and kept out of Charge
and the baselines. No schema change, so no migration.
ryanbr added a commit that referenced this pull request Sep 2, 2026
Perf audit of this PR, second finding. The epoch count built a set over EVERY sample -
mapTo(HashSet()) on Kotlin, Set(hrS.map{}) on Swift, the latter allocating a full intermediate array
as well - to arrive at a few thousand distinct keys. That is ~160k boxed values per scored day across
the 21-day rescore, and it is precisely the allocation shape #707 names as the heap-churn source
under the OOM.

hrS is already sorted by timestamp, so the bucket key is non-decreasing and a running comparison
counts them with no allocation at all. Pinned by a test, because the zero-allocation version trades
the set for that ordering assumption: unsorted input would silently over-count rather than fail.

The rest of the audit came back clean, and the parts that look expensive are not this PR's. The bpm
boxing in the percentile sort and the epoch HashMap inside hrOnlySleepRuns both predate it - they
arrived with the spine in #1805/#1806 - and this change does not add to either. Worth a separate look
if the spine ever runs on every day rather than only motionless ones; not worth widening a
diagnostic PR to chase.

26 HR-only tests, 0 failures; swift builds; doc_comment_lint clean.
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