Motion-gate the intraday stress timeline: mask exertion, not stress (stacked on #463)#467
Open
vishk23 wants to merge 5 commits into
Open
Motion-gate the intraday stress timeline: mask exertion, not stress (stacked on #463)#467vishk23 wants to merge 5 commits into
vishk23 wants to merge 5 commits into
Conversation
… minutes DaytimeStress.analyze gains an additive `mode` parameter (default .dayRelative, byte-identical to before) alongside a new .baselineRelative case that z-scores each waking hour against a PERSONAL rolling baseline (Oura-style) instead of the day's own calm-hour reference. Reuses Baselines' existing Winsorized-EWMA machinery via two new MetricCfg entries (daytime_hr, daytime_rmssd). A missing personal RMSSD baseline (e.g. an imported Oura-era day with no R-R history) gracefully falls back to HR-only scoring, honestly flagged via Result.hrOnlyFallback. Also adds Result.highStressMinutes: an Oura-comparable "time in high stress" total (scored waking hours at/above highBandFloor, in minutes), computed uniformly in both modes. Extracted Baselines.sigma(_:) (the abs-dev-to-Gaussian-sigma conversion) out of deviation() so DaytimeStress's baseline-relative path shares the exact same conversion rather than duplicating the 1.253 constant. StressView's call site is unchanged (mode defaults to .dayRelative), so nothing the Stress screen currently shows changes.
…Oura tuning A 26-day Oura-reference correlation found a pooled/rolling personal daytime-HR baseline (10th-percentile HR pooled across days, ~65 bpm) with a fixed ~15 bpm margin correlates best with Oura's own stress signal (r~0.6, HR-only) — better than scaling the threshold by this person's own day-to-day spread, and much better than a per-day baseline (r 0.43-0.53), confirming the cross-day rolling-EWMA design over a day-local one. Replaces .baselineRelative's HR sdHR (previously Baselines.sigma(hrBaseline), i.e. derived from personal variability) with a value solved so that exactly "baseline + baselineRelativeHighMarginBPM" lands on highBandFloor on the shared squash curve (DaytimeStress.marginToSigma). The RMSSD term is unchanged (still Baselines.sigma-scaled) since it wasn't part of the validated comparison — flagged as an open tuning seam pending an HR+HRV validation pass, which is expected to beat the HR-only r~0.6 ceiling. Also documents how this hourly, daytime-baseline mode relates to the two other baseline-relative surfaces already on the Stress screen: the daily StressModel score (nightly resting-HR/HRV vs a local trailing-30-day mean/SD, not routed through Baselines.swift) and the today-only Advanced HRV card (StressIndex/HRVFreqDomain, no baseline at all) — different grain and different signal, so this is a complementary lens, not a parallel reimplementation of either. Test fixtures updated to the new margin-calibrated sd (a 65 bpm baseline, elevations expressed in terms of the validated 15 bpm margin so the expected band crossings are exact), plus a direct test of the margin-to-sigma identity.
…meline baseline-relative The .baselineRelative daytime-stress mode existed but was never fed real history, so StressView still scored every intraday hour against the day's OWN calm hours (.dayRelative). This adds the missing caller-side folding path and wires StressView to use it. DaytimeBaselines.swift (new): - dayDaytimeAggregate(hr:rr:tzOffsetSeconds:): one day's daytime aggregates, computed with the SCORER's own hourly bucketing / waking window / minHourHRSamples gate so the folded value is exactly the quantity the mode later z-scores against. HR = P10 of waking-hour mean HRs (the validated ~65 bpm calm floor); RMSSD = P50 (median/typical), deliberately NOT the symmetric calm ceiling: the RMSSD term is spread-scaled (Baselines.sigma), so a median anchor keeps a typical hour neutral instead of stacking a false stress term on top of the HR term. - foldDaytimeBaselines(days:): replays the trailing per-day aggregates through Baselines.foldHistory (the same Winsorized-EWMA path as the nightly baselines) with daytimeHRCfg/daytimeRMSSDCfg. The RMSSD baseline is withheld (nil -> HR-only) until >= minNightsSeed daytime-RR days exist. - scoringMode(history:): the single graceful-degradation gate — .baselineRelative once the personal HR baseline is usable, else .dayRelative (cold start / all-sparse history stays byte-identical to before). StressView: builds the baselines from the trailing 30 local days (today excluded, unworn days skipped without an R-R read) after the existing HR-count guard, then passes the resolved mode to DaytimeStress.analyze. The timeline's explanatory copy is now mode-aware (personal baseline vs. today's own calm hours). Purely additive — a user without enough worn history keeps exactly today's behaviour. Tests: DaytimeBaselinesTests (11) cover the P10/P50 aggregate definitions, the gate + waking mirroring, the fold, the degradation gate, and the fold->score round trip including the anti-stacking property. swift test: 1054 pass; the macOS app target compiles.
Real 1Hz wrist data shows daytime RMSSD is artifact-dominated (hourly values swing ~40->430 ms as posture/motion break the R-R stream), an order of magnitude noisier than overnight recumbent HRV. rawScore sums the HRV z equal-weight with the HR z, so an artifact hour can move the combined score by the full band on noise alone. The r~0.6 baseline-relative margin was validated HR-only; adding an unvalidated noisy term risks pushing the score below that ceiling. Hold RMSSD out of the LIVE score behind daytimeRMSSDScoringEnabled (false). The whole RMSSD half -- aggregate, fold, daytime_rmssd config, rawScore term -- stays built and unit-tested; only its arrival at production scoring is gated. Flipping the flag is one line with a tripwire test that inverts with it.
…t score it as stress Cardiac signals can't tell psychological stress from EXERTION: a brisk walk and an anxious meeting both raise HR and drop HRV. DaytimeStress.analyze never saw motion, so an ambulatory hour was scored as 'stress' — the roadmap's central correctness bug. When the caller supplies the day's gravity (wrist accelerometer), an hour that was ambulatory for >= activityMaskFraction (0.30) of its records — 'ambulatory' = a per-record activity intensity above WorkoutDetector.motionThreshold (0.20 L2-g, the codebase's calibrated walk floor) — is MASKED (level=nil, HourPoint.maskedForActivity) rather than scored, and excluded from the day's calm reference and coverage totals. A one-hour post-exercise shadow masks the following hour while its HR is still above the calm reference (self-limiting to genuine cardiac recovery). Reuses the exact WorkoutDetector.activitySeries that SedentaryDetector/AutoWorkoutDetector already trust. Purely additive: no gravity -> nothing masked -> byte-identical to before (honest — we only gate when we can see motion). StressView reads gravity via the same store handle as R-R. Result gains activityMaskedHours for honest coverage. Validated on real 1Hz wrist data (2026-07-12): only 1 of 11 waking hours masked (the one with 36% sustained wrist motion); desk/light hours (3-17% active) are all preserved. The 0.20/0.30 thresholds cleanly separate genuine ambulation from ambient movement, so a step-heavy day does NOT empty the timeline. New tests pin masking, a still-HR control (motion drives the mask, not HR), additive empty-gravity, and both shadow directions; full StrandAnalytics suite green (1133).
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.
Motion-gate the intraday stress timeline: mask exertion, don't score it as stress
Cardiac signals cannot tell psychological stress from exertion — a brisk walk and an anxious meeting both raise HR and drop HRV.
DaytimeStress.analyzenever saw motion, so an ambulatory hour was scored as "stress." This is the intraday timeline's central correctness gap.The gate
When the caller supplies the day's
gravity(wrist accelerometer), an hour is masked (level == nil,HourPoint.maskedForActivity == true) instead of scored when it was ambulatory for at leastactivityMaskFraction(0.30) of its records. "Ambulatory" = a per-record activity intensity aboveWorkoutDetector.motionThreshold(0.20 L2-g — the codebase's already-calibrated walk floor: desk ≈ 0.05–0.10 g, walking ≈ 0.2–0.4 g). It reuses the exactWorkoutDetector.activitySeriesthatSedentaryDetector/AutoWorkoutDetectoralready trust, so there's no new motion math.Result.activityMaskedHourscounts the masked hours so a caller can say "N hours excluded — you were moving" rather than showing a silently short timeline.Additive and honest by construction
gravitydefaults empty: with no motion signal nothing is masked and the read is byte-identical to before (we only gate when we can actually see motion; hardware/imports without accelerometer simply keep the prior behaviour).StressViewreads gravity through the same store handle it already uses for R-R.Validation (real 1 Hz wrist data)
Replayed the gate's exact math over a real waking day's raw gravity (61k samples):
Notes
StrandAnalyticssuite green.maskedForActivity/activityMaskedHoursin the Stress UI ("you were moving") is a small follow-up; this PR is the analytics correctness fix.