Skip to content

stress: baseline-relative daytime mode + Oura-comparable high-stress minutes - #413

Closed
vishk23 wants to merge 3 commits into
ryanbr:mainfrom
vishk23:stress-baseline-relative
Closed

stress: baseline-relative daytime mode + Oura-comparable high-stress minutes#413
vishk23 wants to merge 3 commits into
ryanbr:mainfrom
vishk23:stress-baseline-relative

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Summary

  • Adds an additive, opt-in DaytimeStress.ScoringMode alongside the existing day-relative default: .baselineRelative(hr:rmssd:) z-scores each waking hour against a PERSONAL rolling baseline (Oura-style) instead of the day's own calm-hour reference. Existing callers (StressView's call site is untouched) keep the exact prior day-relative behavior — the default parameter value is .dayRelative.
  • Adds two new Baselines.metricCfg entries (daytime_hr, daytime_rmssd) so the baseline-relative mode reuses the SAME Winsorized-EWMA machinery (Baselines.update/foldHistory) that backs the nightly HRV/resting-HR baselines elsewhere in the app, rather than a one-off implementation. Distinct from the nightly configs because daytime HR runs warmer than nocturnal resting HR (posture, thermic effect) — reusing the nightly configs would systematically over-read stress.
  • 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 — mirrors the existing per-hour graceful-nil in rawScore, just at the whole-baseline grain.
  • Adds Result.highStressMinutes: an Oura-comparable "time in high stress" total (scored waking hours at/above highBandFloor, converted to minutes), computed uniformly in both modes — maps onto Oura's stress_high_s for comparison (coarser, hourly grain vs Oura's ~5-minute grain).
  • Extracted Baselines.sigma(_:) (the abs-dev-to-Gaussian-σ conversion) out of deviation() so it has one definition, shared by the new baseline-relative RMSSD term.

Validated tuning (mid-implementation correction)

A 26-day Oura-reference correlation found a pooled/rolling personal daytime-HR baseline (10th-percentile daytime 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) — clearly better than a per-day baseline (r 0.43–0.53, because an all-day-elevated day pulls its own floor up and masks the stress). This confirms the cross-day rolling-EWMA design over a day-local one, and gave a concrete number to calibrate against: .baselineRelative's HR term now uses DaytimeStress.marginToSigma(marginBPM: baselineRelativeHighMarginBPM, atBand: highBandFloor) for its spread — solved so that exactly "baseline + 15 bpm" lands on highBandFloor on the shared squash curve — rather than scaling by this person's own day-to-day HR spread. The RMSSD term is unchanged (still spread-scaled via Baselines.sigma) since RMSSD wasn't part of the validated comparison; that's flagged as an open tuning seam, expected to beat the HR-only r≈0.6 ceiling once an HR+HRV comparison exists. Both constants are named, documented, and cite the validation inline (DaytimeStress.baselineRelativeHighMarginBPM, the daytime_hr/daytime_rmssd config comments).

Relationship to the existing baseline-relative surface on the Stress screen

Read StressView.swift end to end before building this, since the Stress screen already has baseline-relative content:

  • StressModel — the DAILY 0–3 score already compares last night's NIGHTLY resting-HR/HRV to a plain trailing 30-day mean/SD, computed locally in StressView (its own private StressMath, not routed through Baselines.swift at all). Backs the "Stress" tile, the "Resting HR"/"HRV vs 30-day baseline" marker tiles.
  • Advanced HRV card — Baevsky StressIndex + HRVFreqDomain, a today-only descriptive lens computed from today's R-R, no baseline at all.
  • This PR's .baselineRelative — an HOURLY breakdown of TODAY from DAYTIME/waking-hours HR+RMSSD against a PERSONAL cross-day rolling baseline. Different grain (hourly vs daily), different signal (daytime waking-hours vs nightly sleep vitals), different underlying baseline (new daytime_hr/daytime_rmssd vs the existing local ad-hoc trailing mean/SD). Complementary lens on the same underlying autonomic signal, not a duplicate/competing implementation — documented inline in the ScoringMode doc comment for future readers.

Test plan

  • cd Packages/StrandAnalytics && swift test1047/1047 green (1038 pre-existing + 9 new: 7 in DaytimeStressTests.swift, 2 in BaselinesTests.swift).
  • Day-relative additivity: testDayRelativeDefaultIsByteIdenticalToExplicitMode proves the implicit (omitted mode) and explicit .dayRelative calls produce a byte-identical Result (every field), and all pre-existing DaytimeStressTests/BaselinesTests pass completely unmodified.
  • Baseline-relative, multiple injected levels (per CLAUDE.md's derived-signal rule — "recover multiple injected values, not one"): testBaselineRelativeModeRecoversMultipleInjectedElevations injects 4 distinct HR elevations (at-baseline, +7, +15 validated margin, +30) and asserts strictly increasing scores, with the +15 bpm hour landing exactly on highBandFloor.
  • Calm day at baseline reads ~1.5 (not elevated); elevated day produces highStressMinutes > 0; highStressMinutes counts ALL high-band scored hours (not just the trailing sustained-high run, proven with a fixture where they diverge).
  • HRV-missing day: testBaselineRelativeNilRMSSDFallsBackToHROnlyAndFlagsDegraded — HR-only fallback fires, produces a non-empty timeline, never crashes.
  • Direct unit test of the margin-to-sigma identity (testMarginToSigmaLandsExactlyOnBand) and of the new Baselines.sigma/daytime_hr/daytime_rmssd additions.
  • xcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO buildBUILD SUCCEEDED, 0 errors. StressView.swift's call site is unchanged (relies on the .dayRelative default), so nothing the Stress screen currently shows changes.

Android

Kotlin twin included + gradle-tested (10 new tests green). The Kotlin twin (android/app/src/main/java/com/noop/analytics/{DaytimeStress,Baselines}.kt) mirrors the Swift structure identically: the same two metricCfg entries (daytime_hr/daytime_rmssd), a ScoringMode sealed interface (DayRelative / BaselineRelative(hr, rmssd)) matching the Swift enum, the same baselineRelativeHighMarginBPM = 15.0 constant, the marginToSigma identity, the shared Baselines.sigma() extraction, and the new highStressMinutes / hrOnlyFallback result fields — so Swift and Kotlin yield byte-identical scores. The 9 Swift tests are mirrored as Kotlin tests with identical inputs and expected values (8 in DaytimeStressTest, 2 in BaselinesSigmaDaytimeTest), all green under ./gradlew :app:testFullDebugUnitTest.

vishk23 added 3 commits July 13, 2026 20:01
… 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.
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

Triage note from the author's side: this PR is fully superseded by #463 and should be closed. I have deliberately not closed it myself — flagging so a maintainer can.

The evidence:

Nothing here is lost by closing it — every line lands via #463.

(For context on #463's old red check: its test (NoopLocalAccess) leg failed after 45 minutes on a branch that does not touch NoopLocalAccess. That was a stalled runner; the leg passes in 53s on the refreshed branch.)

@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

Closing as subsumed by #463.

The evidence is unambiguous: this PR's three commits are #463's first three (two of them patch-id identical), and its eight files are a strict subset of #463's thirteen. Everything here lands with #463, so keeping both open just splits review attention across the same change.

#463 is now green and mergeable — the CI failure it carried turned out to be a stalled runner rather than a real failure; that leg passes in 53s after a merge with main.

@vishk23 vishk23 closed this Jul 27, 2026
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