Skip to content

Analytics (W3): an HR-only night is judged by the 24 h around it, and blocked only by a stored night of its own - #49

Open
ayiskakov wants to merge 3 commits into
mainfrom
review/w03-hr-only
Open

ayiskakov wants to merge 3 commits into
mainfrom
review/w03-hr-only

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

Phase 2 fix for W3's heart-rate-only sleep path, used when a strap banks no motion (ryanbr#1801): W03-043 and W03-052 (S3), the follow-up W03-024's V2 asked for. One commit per fix, plus a docs commit from the independent review. Each row in docs/review/workstreams/W03-analytics.md holds its V0, fix, V1 and V2.

This can change stored HR-only nights on re-score. Nights detected from gravity are untouched.

The defect

  • W03-043: the HR-only path anchored its sleep band on the 10th percentile of heart rate over the whole read. Since W03-024 that read is 66 h and holds the next night.
    • Lowering only the next night's heart rate removed the scored night.
    • A read ending 12 h after the night found a night the full read did not.
  • W03-052: the no-motion gate skipped a day's HR-only night whenever the owner had any stored session that started anywhere in the read. analyzeDay keeps only sessions that end on the day.
    • A stored night on the day before or the day after (an imported WHOOP export, a ring's hypnogram) therefore blocked a day that had none of its own.
    • That day then scored no night at all.

The fix

  • W03-043: SleepStager.hrOnlyLocalAnchors. Each epoch is held to the 10th percentile of the heart rate in the 24 h around it (radius localBaselineRadiusS, as on the gravity path).
    • At either end of the data the window slides inward rather than being cut, so every anchor stays on 24 h of heart rate, the span hrOnlyBandMult was measured on.
    • A cut window failed the field-shaped day tests, whose read ends with the night, as this morning's read does.
    • The trace prints the anchor and band as lo..hi.
  • W03-052: the gate is IntelligenceEngine.noMotionNight. Only a stored session whose end falls on the day, under the pass's offset, decides it. That is analyzeDay's rule, and only those sessions are counted in the gate line's stored=.

Evidence

  • V0/V1:
    • SleepHrOnlyWindowInvarianceTests fail on main and pass here.
    • NoMotionNightGateTests fail on the verbatim extraction of the old gate and pass here.
    • With the read-wide anchor restored, both invariance tests fail.
  • Real data (the 2026-09-30 backup with gravity withheld, 12 stored nights of 3 h or more):
    • The night's span is the same whether the read is cut 12 h after it or not on 12 of 12 nights (old anchor: 3).
    • Nights found: 12 (old 11).
    • 2,578 of 7,269 minutes fall inside the gravity-path nights (old 2,639), and 2 minutes fall outside them (old 0).
    • The same at UTC+3 and UTC+5.
  • Independent V2: holds, no S1 to S3 findings.
    • hrOnlyLocalAnchors matches an independent brute force on 43,944 cases with 0 mismatches. The cases cover repeated timestamps, gaps up to a day, out-of-range bpm and every window edge.
    • On reads under 24 h the result equals the old anchor on 27 of 27.
    • Stages and resting HR, not just bounds, are identical across the next night's heart rate and the read's reach.
    • The gate's day membership agrees with analyzeDay on 3,910 cases over ten offsets from −12 h to +14 h.
    • Cost: 0.07 s for the anchors and 0.88 s for the whole spine on 66 h at 1 Hz (debug).
    • The three doc claims it flagged are corrected in the last commit. W03-053 … W03-055 (S4) are recorded.
  • Builds and tests, run locally on this branch rebased on main:
    • swift test in StrandAnalytics: 2,222 tests, 0 failures.
    • xcodebuild test, scheme Strand (macOS): 2,151 tests, 0 failures, 2 skipped.
    • NOOPiOS builds. That build was done without the watch app, which needs a watchOS runtime this machine lacks.

Not changed

  • W03-054: a stored nap ending on the day still blocks that day's HR-only night, as before. Recorded.
  • W03-053: the out-of-range side list can go quadratic, but only on a read made almost entirely of out-of-range bpm. Recorded.

Refs ryanbr#1801

…h around it (W03-043)

The HR-only spine (a strap that banks no motion, ryanbr#1801) anchored its sleep
band on the tenth percentile of the whole read. Since W03-024 that read is
66 h and holds the next night, so a lower heart rate 16 h after a night
could remove it, and a read ending 12 h after the night disagreed with the
full one.

Each epoch is now held to the p10 of the heart rate in a 24 h window
around it (radius localBaselineRadiusS, as on the gravity path), slid
inward to stay inside the data at either end. Sliding rather than cutting
keeps every anchor on 24 h, the span hrOnlyBandMult was measured on: a cut
window failed the field-shaped day tests, whose read ends with the night
as this morning's does. The trace prints the anchor and band as lo..hi.

Over the 2026-09-30 backup with gravity withheld, the spine of all 12
nights no longer depends on the read's reach (3 before), with detection
about equal: 12 nights found (11), 2,578 of 7,269 minutes inside the
gravity-path nights (2,639), 2 minutes outside (0).
…red night of its own (W03-052)

The no-motion gate read the owner's stored sessions over the whole sleep
read and skipped the HR-only night (ryanbr#1801) whenever any were there. The
read runs from 30 h before the day to 12 h past it (W03-024), so a stored
night of the day before or the day after (an imported WHOOP export, a
ring's hypnogram) blocked a day that had none of its own, and that day
scored no night at all.

The gate is now IntelligenceEngine.noMotionNight: only a stored session
whose end falls on the day under the pass's offset, the rule analyzeDay
keeps a night by, decides it and is counted in the gate line's stored=.
Its NoMotionNight raw values are the line's reason=, so the branch and
the log share one name. NoMotionNightGateTests failed on the verbatim
extraction of the old gate and pass on this one.
…oes (W03-043, W03-052 V2)

The independent review of W03-043 and W03-052 held both fixes and flagged
three doc claims. The anchor is the heart rate of the 24 h around an
epoch, slid inward at the data's ends, not the heart rate within the
radius, and the gravity path's baseline cuts instead. Yesterday's read
reaches 12 h past the day only after local noon. The HR-only trace line
is no longer the Kotlin line's byte twin, and the gate line's stored=
now counts nights ending on the day.

W03-053, W03-054 and W03-055 recorded.
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