Readiness score: algorithm, baselines, and storage (1/5) - #104
Open
ak710 wants to merge 1 commit into
Open
Conversation
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>
ak710
force-pushed
the
feat/readiness-score
branch
from
August 1, 2026 02:16
6e75acb to
f9eed52
Compare
This was referenced Aug 2, 2026
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.
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
SleepScorealready encodes population-normal ranges.UserProfile.hrRestingBaselineSleepScore.calculate().scoreFull 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 ofSleepInsights.swift.testMissingContributorIsNeverScoredAsZerois 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:
baselineLearningReuse rather than a parallel pipeline
BaselineStats.computesupplies the HRV and temperature baselines, and its existingisEstablishedgate (≥7 days, ≥20 samples) decides trustworthiness. No new baseline type.UserProfile.hrRestingBaseline, whichRestingHRBaselineServicealready learns, persists and throttles.ReadinessServiceis shaped after that service — same throttle/bounded-fetch/write-only-on-change structure.isEstablishedinto a 20-night gate, where raw samples clear it in about a week of wear.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.
testDaytimeSamplesAreExcludedFromTheOvernightWindowplants 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
readinessDailiesisOptional.PulseArchiveuses 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-writteninit(from:)listing all 30 fields, which would also have to live in an extension to preserve the memberwise initDataArchiveServicedepends on, and couldn't move to another file because the synthesizedCodingKeysis private toDataArchive.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.testV1ArchiveWithoutReadinessStillImportsproves it by exporting a real archive, stripping the key, settingformatVersionto 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.
masterEnableddefaults totrue— unlikeNutritionPrefs.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
ReadinessDailywith their contributor breakdown rather than being recomputed on demand, because the trend chart wants 30–90 days (incompatible with theTodayStoresignature 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.testAlgorithmVersionMismatchForcesRecomputeInsideTheThrottlecovers that path;testAlgorithmVersionIsPinnedwill 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, noNaN, 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, andmax-not-sum training load.DataArchiveTests— round-trip plus the v1 compatibility case above.Reviewable without a ring:
SeedData.seedDemonow backfills, producing 10 scored days spanning 54–93 across multiple bands, each with a stored breakdown. Locked bytestDemoSeedProducesReadinessHistory, 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
get_readiness(returns the contributor array, so the coach cites the real reason rather than inventing one)Closes nothing yet — #103 stays open until the UI lands.