Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456)#469
Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456)#469vishk23 wants to merge 5 commits into
Conversation
…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.
|
Reviewed. This is strong work — the reasoning for not reusing Verified
The concern I came in with, and why it is smaller than I thoughtTurning 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 ( Worth one sentence in the Not mergeable yet — and the order matters1. The stack is real. All fifteen of #456's files are inside this PR's seventeen; the two that are yours alone are 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 The Swift work is the hard part and it is done; One caveat, not a defectThe candidate selection — naive vs σ-floor vs reject-on vs reject-off+Winsor vs median/MAD — was replayed on a single 420-night history, and |
Stack-level noteReviewing #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:
The Swift work in both is the hard part and it is done — the analysis, the candidate replay, and the reasoning for not reusing 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. |
Readiness: fold the HRV/RHR baselines through the shared spine — in a window-fold mode that adapts
ReadinessEnginez-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.foldHistoryThe 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/foldHistorygainrejectHardOutliers: 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_lnconfig carries the ln-space bounds/floor), so Winsor-clamping composes with the log transform instead of fighting it. RHR uses the rawresting_hrconfig. 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:
New
RD2SpineTestspin 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. FullStrandAnalyticssuite green; macOS build green.Notes
Baselines.update/foldHistoryreject flag + the ReadinessEngine fold) not included here.foldHistory/updatecaller is byte-identical (rejectHardOutliersdefaults true). Only readiness opts into the window-fold mode.