Skip to content

Motion-gate the intraday stress timeline: mask exertion, not stress (stacked on #463)#467

Open
vishk23 wants to merge 5 commits into
ryanbr:mainfrom
vishk23:stress-motion-gate
Open

Motion-gate the intraday stress timeline: mask exertion, not stress (stacked on #463)#467
vishk23 wants to merge 5 commits into
ryanbr:mainfrom
vishk23:stress-motion-gate

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Stacked on #463. The motion gate extends DaytimeStress.analyze, whose signature #463 (baseline-relative daytime stress) also changes — so this branch includes #463's commits underneath and the diff shows them until #463 lands, at which point I'll rebase and only the motion-gate commit remains. Review target = the single motion-gate commit on top. The gate itself works identically in the pre-existing .dayRelative mode; it does not depend on #463's baseline logic, only on the same file.

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.analyze never 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 least activityMaskFraction (0.30) of its records. "Ambulatory" = a per-record activity intensity above WorkoutDetector.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 exact WorkoutDetector.activitySeries that SedentaryDetector / AutoWorkoutDetector already trust, so there's no new motion math.

  • Excluded from the calm reference. An exertion hour's elevated HR / suppressed HRV no longer pulls the day's own calm anchor up or inflates the spread the z-scores divide by.
  • Post-exercise shadow. HR stays elevated after exertion, so the single hour immediately following a directly-ambulatory hour is also masked while its mean HR is still above the calm reference — self-limiting to genuine cardiac recovery, and deliberately one hour deep so a genuinely tense afternoon that happens to follow a workout isn't masked away.
  • Honest coverage. Result.activityMaskedHours counts 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

gravity defaults 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). StressView reads 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):

  • Only 1 of 11 waking hours masked — the one with 36 % sustained wrist motion. Desk/light hours (3–17 % active) were all preserved and scored.
  • The 0.20 / 0.30 thresholds cleanly separate genuine ambulation from ambient movement, so a step-heavy day (this wearer logs 15–35 k steps) does not empty the timeline — most "steps" are ambient (low L2-delta), not sustained vigorous motion.

Notes

  • New tests pin masking, a still-HR control (identical HR but low motion still scores — proving motion, not HR, drives the mask), additive empty-gravity, and both shadow directions. Full StrandAnalytics suite green.
  • This unblocks finer 5-min stress epochs (the roadmap gates finer grain behind this — without it every active epoch reads as stress).
  • Surfacing maskedForActivity / activityMaskedHours in the Stress UI ("you were moving") is a small follow-up; this PR is the analytics correctness fix.
  • Swift-first; the Kotlin twin isn't included here — happy to add it or leave as a follow-up.

vishk23 added 5 commits July 14, 2026 12:30
… 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).
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