Repository navigation
sleep: add an HR-only Stage-0 spine for straps that bank no motion - #1805
Merged
Merged
Conversation
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
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.
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.
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
hrSleepBandAcrossandconfirmSleepWithHR. A 5/MG that cannot bond never banks motion at all —SET_CLOCKrides a handshake it never completes — sogravis empty, there is no spine, andIntelligenceEngine.kt:2907states the consequence itself: "no quantity of HR can stage a night."From the field report on 11.0.0-staging:
~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 againstbaseline * hrSleepBandMult, with runconstruction mirroring
buildRunsline for line (close on class change, or a gap overmaxGapMin).Two deliberate choices:
"in the sleep band". Median for the reason
hrSleepBandAcrossdocuments — one arousal spike mustnot flip an epoch.
knows
buildRunsalready knows this function.What it does NOT do
This will not make Rest appear. There is no caller. Two things are still needed:
DetectedSleepcarries 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
SleepStagerHrOnlySpineTest, 7 tests, 0 failures (result XML checked for a real count,not a zero-match filter).
compileFullDebugKotlinclean.swift buildclean;SleepStagerHrOnlySpineTestsasserts the identical cases.swiftc -O twin.swift main.swiftover this exact case list — not read off the Kotlin. The harness exists because the package test
binary cannot link GRDB's
sqlite3on Linux, so XCTest could not be executed here; the Swiftassertions are written but unrun on this machine and want a macOS run.
doc_comment_lintandi18n_audit --ciexit 0.Cases pinned: nil/zero baseline, empty HR, all-in-band, all-out-of-band, sleep/active/sleep
segmentation, a gap wider than
maxGapMinsplitting a same-class run, a 190 bpm spike failing toflip an epoch, and the inclusive
63 = 60 × 1.05band boundary.