Skip to content

Readiness score: algorithm, baselines, and storage (1/5) - #104

Open
ak710 wants to merge 1 commit into
saksham2001:mainfrom
ak710:feat/readiness-score
Open

Readiness score: algorithm, baselines, and storage (1/5)#104
ak710 wants to merge 1 commit into
saksham2001:mainfrom
ak710:feat/readiness-score

Conversation

@ak710

@ak710 ak710 commented Aug 1, 2026

Copy link
Copy Markdown

Implements the scoring engine and storage for the readiness score proposed in #103 — the roadmap's "Performance & recovery: readiness, training/cardio load, HRV and resting-HR trends" item.

This PR ships no UI. It's the reviewable core: pure maths, baselines, persistence, docs, and tests. The Today tile, detail screen, coach tool, and widget follow as separate PRs so none of them lands as one huge diff.

Weights and thresholds are exactly as proposed in #103 — happy to adjust before the UI PRs build on them.

The score

Five contributors, 100 points. Four are one-sided deviations from the user's own baseline; sleep is absolute because SleepScore already encodes population-normal ranges.

Contributor Points Measured Compared against
HRV 30 overnight mean (ms) 30-day overnight median
Resting HR 25 overnight p10 (bpm) UserProfile.hrRestingBaseline
Sleep 30 SleepScore.calculate().score absolute
Skin temperature 10 overnight mean (°C) 30-day overnight median
Training load 5 yesterday's minutes trailing 7-day mean

Full knots, band edges, and reasoning are in the new docs/project/readiness.md.

Three design rules

Missing signals leave the denominator — they are never scored as zero. A night where the ring dropped its temperature reading is scored out of 90 points, not penalised 10, and the result reports its coverage. This mirrors the doctrine already stated at the top of SleepInsights.swift. testMissingContributorIsNeverScoredAsZero is the test I'd point a reviewer at first.

An unestablished baseline counts as missing, not as "at baseline." Scoring a deviation against three days of data would read as authoritative while being noise. A ring in its first week returns .unavailable(.baselineLearning) — deliberately distinct from .noSignals, so the tile can say "still learning" rather than looking broken.

Every contributor carries its own explanation ("HRV 12% below your baseline"), so a score is never surfaced as a bare number. That's the "documented metrics, no black boxes" principle made structural rather than aspirational.

Per-device outcomes fall out of the 50-point coverage gate:

Situation Available Result
Colmi, baselines established 100 full-fidelity score
Colmi, no temperature that night 90 scored, coverage 0.90
jring (sleep + HR, no HRV) 55 scored
Any ring, first week < 50 baselineLearning
Neither HRV nor sleep no tile at all

Reuse rather than a parallel pipeline

  • BaselineStats.compute supplies the HRV and temperature baselines, and its existing isEstablished gate (≥7 days, ≥20 samples) decides trustworthiness. No new baseline type.
  • Resting HR reuses UserProfile.hrRestingBaseline, which RestingHRBaselineService already learns, persists and throttles. ReadinessService is shaped after that service — same throttle/bounded-fetch/write-only-on-change structure.
  • Baselines feed on raw overnight samples, not nightly aggregates: one value per night would turn isEstablished into a 20-night gate, where raw samples clear it in about a week of wear.
  • Every baseline window excludes the day it's judging, so a night can actually deviate from its own baseline.

Overnight signals are read from the sleep session's own span (already resolved to the day's longest session, so a nap is never mistaken for the night), falling back to 22:00–08:00 when sleep wasn't decoded. testDaytimeSamplesAreExcludedFromTheOvernightWindow plants a 40 bpm afternoon reading and asserts it can't masquerade as a good resting HR.

Two things worth a closer look in review

1. Archive format version → 2, and readinessDailies is Optional.

PulseArchive uses the synthesized decoder, which has no notion of property defaults — so a non-optional array would make every existing v1 backup fail to import. The alternative was a hand-written init(from:) listing all 30 fields, which would also have to live in an extension to preserve the memberwise init DataArchiveService depends on, and couldn't move to another file because the synthesized CodingKeys is private to DataArchive.swift (already ~1000 lines). The Optional gets identical tolerance in one line, and is the pattern the next person adding a table will copy correctly.

testV1ArchiveWithoutReadinessStillImports proves it by exporting a real archive, stripping the key, setting formatVersion to 1, and importing that.

Import also calls ReadinessService.backfill(days: 90) afterwards, so a v1 archive self-heals its readiness history from the measurements it did restore.

2. masterEnabled defaults to true — unlike NutritionPrefs.

Nutrition defaults off because it's manual entry that can ship meal photos to a third-party LLM: a real new privacy surface. Readiness is derived entirely from data the ring already collects locally — no new permission, no network egress, nothing stored the user didn't already have — and it's self-gating on capability plus baseline establishment. Easy to flip if you'd rather it be opt-in; it's one line plus a Settings row becoming the discovery point.

Storage

Scores persist as ReadinessDaily with their contributor breakdown rather than being recomputed on demand, because the trend chart wants 30–90 days (incompatible with the TodayStore signature architecture that exists to keep work off the render path), and because recomputing an old morning against today's baseline would give a different, wrong answer.

Rows carry an algorithmVersion. Changing any weight invalidates them so they recompute, instead of old scores being silently reinterpreted under new rules. testAlgorithmVersionMismatchForcesRecomputeInsideTheThrottle covers that path; testAlgorithmVersionIsPinned will fail loudly if weights change without a bump.

Migration is additive-only, so SwiftData lightweight migration handles it.

Testing

911 tests pass, 0 failures (871 pre-existing + 40 new).

  • ReadinessScoreTests (22) — every band knot pinned to an exact value, monotonicity swept across the whole HRV domain, symmetry of the temperature curve, exact wording of the explanation strings, and a hostile-input suite (NaN, , zero baselines, negative values) asserting no crash, no NaN, no out-of-range score.
  • ReadinessServiceTests (17) — overnight windowing and daytime exclusion, baseline windows excluding the scored day, upsert/throttle/version behaviour, row deletion when an outcome becomes unavailable, backfill idempotency, and max-not-sum training load.
  • DataArchiveTests — round-trip plus the v1 compatibility case above.

Reviewable without a ring: SeedData.seedDemo now backfills, producing 10 scored days spanning 54–93 across multiple bands, each with a stored breakdown. Locked by testDemoSeedProducesReadinessHistory, so the upcoming tile and trend chart will have real data under -seedDemo YES.

Note: I ran the suite on an iOS 26.5 simulator — the CI comment says the MainActor concurrency double-free is fixed there, and it runs clean. CI will still prefer 18.x on its side.

Follow-ups

  1. Today tile + Settings
  2. Detail screen with contributor breakdown and trend chart
  3. Coach tool get_readiness (returns the contributor array, so the coach cites the real reason rather than inventing one)
  4. Widget metric
  5. (optional) check-in mention + a sharp-drop anomaly rule

Closes nothing yet — #103 stays open until the UI lands.

@ak710
ak710 requested a review from saksham2001 as a code owner August 1, 2026 00:31
First of several PRs implementing the readiness/recovery score from the
roadmap's "Metrics you can trust" section (saksham2001#103). This one lands the engine
and its storage; the Today tile, detail screen, coach tool, and widget
follow separately.

A daily 0-100 score from five contributors, weighted 30/25/30/10/5:
overnight HRV, resting heart rate, sleep, skin temperature, and yesterday's
training load. Four are judged against the user's own baseline; sleep is
absolute because SleepScore already encodes population-normal ranges.

Three rules shape the design:

- Missing signals leave the denominator rather than scoring zero. A night
  without a temperature reading is scored out of 90 points, not penalised
  10, and the result reports its coverage. This mirrors the doctrine at the
  top of SleepInsights.swift.
- An unestablished baseline counts as missing, not as "at baseline".
  Scoring a deviation against three days of data would look authoritative
  while being noise.
- Every contributor carries its own explanation ("HRV 12% below your
  baseline"), so the score is never surfaced as a bare number. The full
  algorithm - every weight and threshold - is documented in
  docs/project/readiness.md.

Reuses the existing baseline machinery rather than building a parallel one:
BaselineStats for HRV and temperature, and UserProfile.hrRestingBaseline,
which RestingHRBaselineService already learns and throttles. ReadinessService
is shaped after that service. Overnight signals are read from the sleep
session's own span, falling back to 22:00-08:00 when sleep wasn't decoded,
so daytime readings can't masquerade as recovery data.

Scores persist as ReadinessDaily with their breakdown, since recomputing an
old morning against today's baseline would give a different and wrong
answer. Rows carry an algorithmVersion that invalidates them on a weight
change instead of silently reinterpreting them.

Archive format version goes to 2. readinessDailies is Optional because
PulseArchive uses the synthesized decoder, which has no notion of property
defaults - a non-optional array would make every existing v1 backup
unimportable. Covered by a test that strips the key from a real export.

39 new tests. Demo seed data produces 10 scored days across multiple bands,
so the feature is reviewable without a ring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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