Skip to content

Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456)#469

Open
vishk23 wants to merge 5 commits into
ryanbr:mainfrom
vishk23:readiness-spine-window-fold
Open

Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456)#469
vishk23 wants to merge 5 commits into
ryanbr:mainfrom
vishk23:readiness-spine-window-fold

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Stacked on #456 (Wave 0). RD2 folds through the log-domain HRV z that #456's RD1 introduced, so this branch carries #456's commits underneath and the diff shows them until #456 lands, at which point I'll rebase to just the readiness commit. Review target = the single readiness commit on top.

Readiness: fold the HRV/RHR baselines through the shared spine — in a window-fold mode that adapts

ReadinessEngine z-scored today's HRV/RHR against a naive mean + sample SD over the trailing 30 nights. Two defects: a single freak night inflates that SD (so a real drop under-flags for weeks until the outlier rolls out), and a tight baseline saturates the z. The app already has a robust baseline engine (Baselines: Winsorized-EWMA, σ-floor, recency-weighting) — but adopting it naively is wrong for readiness, and finding out why is most of this PR.

Why not just call Baselines.foldHistory

The spine's hard-outlier rejection (>5σ → discard) is correct for the RecoveryScorer's incremental nightly fold. But readiness re-folds a trailing window every call, and a recent sustained shift (a fitness change, or an Oura→WHOOP device swap, or a supplement onset) lands at the window's end — past the young-adapt grace period — so rejection discards the new normal as a run of "outliers" and readiness keeps scoring you against a stale baseline. Replayed on real HRV history, a settled 140 ms → 85 ms window keeps the baseline at 140 and flags every new-normal night as a catastrophic drop.

The fix: a window-fold mode

Baselines.update / foldHistory gain rejectHardOutliers: Bool = true (default preserves the RecoveryScorer exactly). Readiness folds with it off: Winsorization still damps a single freak night (clamped to ±3σ, not folded raw), but a sustained shift is followed instead of rejected — each night nudges the center and widens the spread until the new level is no longer an outlier. Single spike damped; sustained shift adapted — the distinction the naive path and the incremental spine both get wrong in opposite directions.

HRV folds in the log domain (RD1 already z-scores lnRMSSD; the new readiness_hrv_ln config carries the ln-space bounds/floor), so Winsor-clamping composes with the log transform instead of fighting it. RHR uses the raw resting_hr config. Resp keeps its special plausibility-gated path for now.

Validation (real 420-night HRV history)

The design was chosen by replaying all the candidates — naive, naive+σ-floor, full spine (reject on), Winsor-EWMA (reject off), median/MAD — over two years of real nightly HRV:

  • Outlier damping: the history has 7 genuine single-night outliers (28–46 ms freak nights, some matching the known Oura import-bug signature). In a window containing one, naive's SD is 0.291 vs the Winsor-clamp's 0.220 — naive is 32 % wider, so it under-flags a real drop for ~2–4 weeks. Reject-off + Winsor keeps it tight and adapts.
  • Adaptation: on a sustained step the window-fold's center follows within ~2–3 weeks; the reject-on spine stalls. Both correctly still flag a genuine one-night drop.

New RD2SpineTests pin the reject-on-vs-off capability, single-freak damping, sustained-shift adaptation, and that a genuine low still flags; the RD1 geometric-baseline test is updated for the Winsor-EWMA center. Full StrandAnalytics suite green; macOS build green.

Notes

  • Swift-first; Kotlin twin (Baselines.update/foldHistory reject flag + the ReadinessEngine fold) not included here.
  • Additive: every existing foldHistory/update caller is byte-identical (rejectHardOutliers defaults true). Only readiness opts into the window-fold mode.

vishk23 added 5 commits July 14, 2026 10:36
…into Rest (Wave 0 · SL1/T1)

Rest was scored on a flat neutral-0.5 consistency and a fixed 8h need: despite the
"caller supplies it from history" contract, IntelligenceEngine never threaded either
into AnalyticsEngine.analyzeDay (it only threaded habitualMidsleepSec).

- T1: new AnalyticsEngine.Rest.personalizedNeedHours(nightlyHours:age:) — upper-quartile of
  the user's unrestricted nights, floored at the age-appropriate population TARGET (8h adult /
  9h <18) so a chronic under-sleeper's need can only adjust UP for genuine long sleepers, never
  collapse toward their own deficit. Cold-start returns the population default.
- SL1: thread real VitalityEngine.sleepConsistency + that personal need into the per-day
  analyzeDay call, computed once from the trailing per-night durations (recent 28 nights for
  regularity, full history for the need quantile). Extends computeHabitualMidsleep's existing
  read to also yield nightly durations — one read, midsleep result byte-identical.
- The nightly durations are TST (span x efficiency), NOT in-bed span: the need/regularity
  estimate must match the tstSeconds Rest scores against. Real-data validation on VK's noop-cloud
  history showed in-bed span over-counts need ~0.85h (his correct need ~8.0h TST).

Both degrade honestly on thin history (nil consistency -> neutral; need -> default).

Validation: RestWiringImpactTests archetype table + real-data replay on ~90d of VK's nights
(need 8.0h TST, consistency 0.57); RestNeedTests; full StrandAnalytics suite green; macOS build green.
…narrow)

ReadinessEngine scored its HRV signal as a raw-ms z-score, but RMSSD is right-skewed, so a
symmetric z over-weights the long upper tail and misstates tail rarity. lnRMSSD is closer to
normal (Plews/Altini; the app's own HRVReadiness already works in the log domain). Add a
logDomain path to the shared zSignal builder, used ONLY by HRV — RHR/resp are ~normal and stay
linear. Evidence stays in ms, but the reported baseline is the geometric mean, not an arithmetic
mean a few big-recovery nights inflate.

Deliberately NARROW: domain-correctness only. The full HRVReadiness SWC-tier engine stays behind
its experimental flag until validated on real data (its header notes n=1; roadmap anti-#194).

Tests: RD1LogDomainTests pins the geometric-mean baseline; ReadinessEngineTests unchanged; suite green.
…Wave 0 · RD-confidence)

ReadinessEngine scored off as few as 7 baseline nights with no confidence shown, while Charge
already carries a ScoreConfidence tier. Add ScoreConfidence.readiness(hasRead:baselineNights:
fullWindow:) + a `confidence` field on Readiness, from the HRV baseline density (the primary
readiness driver): calibrating (no read), building (7-29 of the 30-night window), solid (full
window). Analytics enabler; surfacing it on the Today card is a separate design pass.

Tests: RDConfidenceTests (tiers + engine building/solid/calibrating); ReadinessEngineTests
unchanged; full suite green; macOS Strand build green.
Mirror the StrandAnalytics Wave 0 changes on Android so Swift and Kotlin
produce byte-identical numbers:

- RestScorer: personalizedNeedHours(nightlyHours, age) + populationNeedFloorHours,
  minNeedNights=7, maxNeedHours=9.5 (population-anchored, age-floored, upper-quartile
  sleep need; chronic under-sleeper's need never drifts below the population target).
- ReadinessEngine: HRV z-score in the LOG domain (lnRMSSD); baseline shown is the
  geometric mean. Readiness now carries a ScoreConfidence from HRV baseline density.
- ScoreConfidence.readiness(hasRead, baselineNights, fullWindow).

Tests: RestNeedTest, RDConfidenceTest, RD1LogDomainTest, RestWiringImpactTest
(same inputs + expected values as the Swift tests). testFullDebugUnitTest green,
13 new tests; existing analytics suites unchanged.
…-fold mode

ReadinessEngine z-scored today's HRV/RHR against a naive mean + sample SD over the
trailing 30 nights. Two defects: a single freak night inflates that SD (a real drop
then under-flags for weeks until the outlier rolls out), and a tight baseline
saturates the z. The app already has a robust engine (Baselines: Winsorized-EWMA,
sigma-floor, recency) -- but adopting it naively is WRONG for readiness.

The spine's hard-outlier rejection (>5sigma -> discard) is right for RecoveryScorer's
INCREMENTAL nightly fold, but readiness re-folds a trailing WINDOW every call: a recent
sustained shift (fitness change / device swap / supplement onset) lands past the young
grace period and gets rejected as a run of outliers, so readiness scores against a
stale baseline (replayed: a settled 140->85ms window stays anchored at 140, flagging
every new-normal night as a crash).

Add rejectHardOutliers (default true -> RecoveryScorer byte-identical) to
Baselines.update/foldHistory. Readiness folds with it OFF: Winsorization still DAMPS a
single freak night (clamp to +/-3sigma, not folded raw), but a sustained shift is
FOLLOWED not rejected. HRV folds in the log domain (new readiness_hrv_ln config, ln
bounds/floor) so Winsor-clamp composes with RD1's lnRMSSD; RHR uses resting_hr.

Design chosen by replaying 5 candidates over 2yr of real nightly HRV: naive is 32%
wider-SD'd after each of 7 real freak nights (under-flags real drops); reject-on stalls
on shifts; reject-off + Winsor damps outliers AND adapts. New RD2SpineTests pin the
reject on/off capability, single-freak damping, sustained-shift adaptation, and that a
genuine low still flags; RD1 geometric test updated for the Winsor-EWMA center. Full
suite green (1137); macOS build green.
@ryanbr

ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Reviewed. This is strong work — the reasoning for not reusing foldHistory naively is the valuable part, and it is the kind of thing that usually gets discovered in production instead of in a PR description.

Verified

  • The arithmetic. minVal: 2.079 / maxVal: 5.521 are ln(8) and ln(250) to four places. Correct.
  • Genuinely additive. rejectHardOutliers: Bool = true on both update and foldHistory, and the gate becomes rejectHardOutliers && state.nValid >= minNightsSeed && !isYoung. Every existing caller is byte-identical, as claimed.
  • CI is green for real. test (StrandAnalytics) passes, along with the other packages. swift-packages.yml is the one active workflow in this repo, so package-level changes like this get genuine coverage — worth saying, because app-target changes get none.

The concern I came in with, and why it is smaller than I thought

Turning off hard-outlier rejection means the fold cannot distinguish "a new normal" from "sustained bad data", and this codebase has form: #631 zeroed every imported journal answer, #677 stamped Oura IBIs at the wrong time. A month of plausible-looking wrong HRV would silently re-baseline readiness.

I checked the ordering before raising it, and it is largely handled: the plausibility guard (cfg.minVal <= value <= cfg.maxVal → skip-and-hold) runs before the reject gate, so with rejection off, anything outside 8–250 ms in ln space is still skipped, not folded. The residual exposure is sustained wrong-but-plausible data — which is genuinely indistinguishable from a real physiological shift, so following it is the defensible choice.

Worth one sentence in the rejectHardOutliers comment saying the range guard still applies. The comment currently explains what rejection stops doing without noting what still bounds the fold, and the next reader will arrive at the same worry I did.

Not mergeable yet — and the order matters

1. The stack is real. All fifteen of #456's files are inside this PR's seventeen; the two that are yours alone are Baselines.swift and RD2SpineTests.swift. So merging this as-is lands #456 wholesale. Your note already says you will rebase once #456 is in — flagging it here so nobody merges past it. Order: #456 → rebase → this.

2. The Kotlin twin is the blocker. You call it out honestly, and CLAUDE.md's contract does allow "explicitly call out why not" — but the consequence here is not cosmetic. The Android ReadinessEngine.kt change in this diff belongs to #456's RD1; the RD2 window-fold is Swift-only. So after this lands, iOS readiness would fold through the Winsorized-EWMA spine while Android keeps the naive 30-night mean + SD — the same wearer, the same night, two different readiness scores. That escape clause is meant for divergence that is harmless or unavoidable, and a headline score differing per platform is neither.

The Swift work is the hard part and it is done; Baselines.update/foldHistory gaining a defaulted flag and ReadinessEngine opting in should port fairly mechanically, with RD2SpineTests mirrored as RD2SpineTest.kt alongside the four Kotlin twins already here.

One caveat, not a defect

The candidate selection — naive vs σ-floor vs reject-on vs reject-off+Winsor vs median/MAD — was replayed on a single 420-night history, and floorSpread: 0.08 is calibrated to "a real wearer's own ln-HRV spread (~0.10)". That is far more evidence than most changes here arrive with, and I am not asking for more before merge. But it is one wearer, and the floor is a global constant, so if a second history is ever available it would be worth re-running the same replay against it — particularly the σ-floor, since that is the parameter most likely to be person-specific.

@ryanbr

ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Stack-level note

Reviewing #456 and #469 back to back turned up one systemic thing that neither review says on its own, since there is no tracking issue carrying the pair.

Both PRs leave Android's scoring behind, one layer apart.

Landing both as they stand means iOS and Android disagree on Rest and Readiness — two headline scores — for the same wearer on the same night. RD1 and RD-confidence are fine; those live inside engines that were ported.

Suggested order:

  1. Wave 0 scoring: wire computed-but-disconnected methodology into Rest + readiness #456 + the Kotlin IntelligenceEngine wiring, rebased onto current main, CI re-run — its green run is 236 commits old.
  2. Rebase Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456) #469 onto the landed Wave 0 scoring: wire computed-but-disconnected methodology into Rest + readiness #456, so its diff narrows to the readiness commit as intended.
  3. Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456) #469 + its Kotlin twin (Baselines.update/foldHistory reject flag, the ReadinessEngine fold, RD2SpineTest.kt).

The Swift work in both is the hard part and it is done — the analysis, the candidate replay, and the reasoning for not reusing foldHistory naively are all genuinely good. What is missing is mechanical by comparison, and it is the part the parity contract cares about.

One more thing worth flagging for whoever lands these: both change scores for existing users. #456's SL1 replaces a flat +5-for-everyone consistency term and T1 replaces a fixed 8 h need; #469 changes the readiness HRV baseline. All corrections rather than tuning, but Rest and Readiness will visibly move, and re-scored history moves with them. Worth a release-note line so it reads as an intended fix.

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