Skip to content

Healthspan v2 (2/3): six-month windows, %HRR zones, SRI, persisted per-driver years - #13

Merged
ayiskakov merged 11 commits into
feat/healthspan-engine-v2from
feat/healthspan-pipeline-v2
Sep 23, 2026
Merged

ayiskakov merged 11 commits into
feat/healthspan-engine-v2from
feat/healthspan-pipeline-v2

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

PR 2 of 3 for Healthspan v2. Stacked on #12; review and merge that one first.

What changes

  • One resolver, every window. Strand/Data/HealthspanPipeline.swift builds the 182-day Body Age window, the 30-day recent window and the six monthly windows, all from healthspanInputs(history:endDay:windowDays:…).
  • %HRR zone minutes. The day scan now banks Karvonen zone 1–3 and 4–5 minutes as zone_min_hrr_1_3 and zone_min_hrr_4_5. They use the night's resting HR and the profile's max HR, via the new HRZones.heartRateReserveZones. The %HRmax zone_min_2_3 / _4_5 rows are still written for the Metric Explorer, but no longer feed Healthspan.
  • SRI comes from every stored sleep block: onset/end plus the wake segments in stagesJSON. It is computed once over the history, off the main actor.
  • VO₂max comes from vo2max_est (tagged strap-derived) or an imported vo2max (tagged external, and preferred in its window).
  • Lean mass is imported lean_mass as a percentage of the window's weight, falling back to the profile weight.
  • Unlock. Nothing is written until the day has 21 scored days in the last 31 and the user is 18+.
  • One stored point per day. For each unlocked day the pipeline persists:
    • body_age, vitality
    • pace_of_aging, pace_of_aging_margin, body_age_projected
    • per driver: hs_years_<key>, hs_value_<key>, hs_target_<key>, hs_recent_<key>
  • Model version. A healthspan_model marker row (day 1970-01-01, which no chart shows) triggers a one-shot, idempotent re-derivation of the last 730 days under the v2 model. The re-derivation clears the Saturday-keyed v1 rows, so the trend chart never splices two models. The marker is written only after the rows land, so a failed write retries on the next pass. Ordinary passes refresh the last 7 days.
  • New store primitive. WhoopStore.replaceMetricSeries(_:deviceId:keys:from:to:) deletes the named keys in a range and upserts the new rows in one transaction. A day that loses a driver therefore cannot keep a stale row, and there is no empty window between the two writes.
  • Screens. HealthspanDrivers.latest now reads the hs_* rows for the headline's day instead of recomputing a 7-day window. The Health hub's "helping most / holding you back" line uses exactly the shares the Body Age was summed from.
  • Old code removed: the OLS pace fit, the weekly-nights gate and the old 7-day input builder.

Plan deviation (recorded in the plan doc)

The plan had a Sunday weekly headline plus daily trend points. That would put two different "current" Body Ages on one screen (the headline and the chart's last point), which AGENTS.md's two-readouts rule forbids. The headline is now the newest daily point. The 182-day window already gives the stability the weekly cadence was for.

Verification

  • HealthspanInputsTests is rewritten for the new resolver. It covers:
    • dose scaling and the ≥ 4 observed-day floor
    • watched-but-no-strength counting as zero
    • SRI windowing
    • an external VO₂max winning over the strap estimate
    • lean mass using the window's weight
    • the 21/31 and 18+ unlock gate
    • persisted driver years summing exactly to the persisted Body Age offset
    • projected = current + 0.5 · pace
    • unchanging history → 1×
    • day-key round trips
  • MetricSeriesStoreTests.testReplaceMetricSeriesClearsOnlyTheNamedKeysInRange checks that only the named keys, inside the range, for this device, are cleared.
  • HRZonesTests.testHeartRateReserveZones and SleepRegularityTests.testSessionFromStoredBlock cover the two new helpers.
  • Full StrandTests run with xcodebuild … test (macOS): 1,937 tests, 0 failures.
  • swift test passes for StrandAnalytics and WhoopStore (MetricSeriesStoreTests).
  • xcodebuild succeeds for NOOPiOS. It was built without the embedded watch app, because the watchOS runtime is not installed locally.
  • doc_comment_lint and i18n_audit --ci main are clean.
  • Not yet checked on real history: the one-shot re-derivation's runtime and output on a real store. It is idempotent and bounded to 730 output days, and runs on the first analytics pass after install.

No BLE changes and no GRDB migration: metricSeries takes any key.

HRZones.heartRateReserveZones builds the Karvonen 50/60/70/80/90 % HRR zone set,
the model the Healthspan zone targets are stated in and the one StrainScorer
already uses.
SleepRegularity.session reads the wake segments of a computed staging array; an
imported minute dictionary keeps its span with no wake stretches.
…nsaction

replaceMetricSeries deletes the named keys in [from, to] and upserts the new
rows atomically, so a recomputed window that drops a key cannot leave the old
row behind or expose an empty window between two writes.
…ears, daily

- One resolver builds the 182-day, 30-day and monthly windows from stored history.
- The day scan banks %HRR zone 1–3 / 4–5 minutes (zone_min_hrr_*).
- Sleep regularity is the SRI from stored sleep sessions; VO₂max comes from the
  strap estimate or an import (tagged external); lean mass is a percentage of
  body weight.
- Unlocks at 21 scored days in the last 31. Each unlocked day persists
  body_age, vitality, pace_of_aging (+ margin), body_age_projected and, per
  driver, hs_years/value/target/recent.
- A healthspan_model marker triggers a one-shot, idempotent re-derivation of
  two years of history under the v2 model, clearing the Saturday-keyed v1 rows.
Nothing calls it since the pipeline moved to PaceOfAgingEngine.project.
…line

HealthspanDrivers.latest reads the hs_* rows written for the same day as the
stored Body Age, replacing the 7-day recomputation, so the Healthspan rows and
the Health hub's helping-most / holding-back line use exactly the shares the
headline was summed from.
- Hero: six-month Body Age vs calendar age with its ± band, and a
  21-of-31-scored-days countdown until it unlocks.
- Pace dial: −1…3× in four bands (reversing / slowing / steady /
  accelerating), the margin, and the projected Body Age in six months;
  "holding steady" whenever the margin covers 1×.
- Biggest lever: the years reaching that driver's target would take off.
- Drivers: value, target, a signed years chip and a 30-day-vs-6-month arrow;
  tapping opens the driver's hazard-ratio curve and its source.
- Six-month Body Age and pace trend charts, HRV as unscored context, and a
  method-and-references sheet.
- Every readout comes from one snapshot of the persisted rows for the newest
  day, so the chips, lever, dial and chart cannot disagree with the headline.
Healthspan v2 (3/3): rebuilt Healthspan tab
@ayiskakov
ayiskakov merged commit 67ef9f6 into feat/healthspan-engine-v2 Sep 23, 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