Skip to content

Readiness score: coach context and get_readiness tool (4/5) - #109

Open
ak710 wants to merge 5 commits into
saksham2001:mainfrom
ak710:feat/readiness-coach
Open

Readiness score: coach context and get_readiness tool (4/5)#109
ak710 wants to merge 5 commits into
saksham2001:mainfrom
ak710:feat/readiness-coach

Conversation

@ak710

@ak710 ak710 commented Aug 3, 2026

Copy link
Copy Markdown

Fourth readiness PR (#103), building on the detail screen in #108. This is the follow-up listed there as "4. Coach integration".

Stacked on #108. This branch sits on top of feat/readiness-detail, so until the earlier PRs merge the diff here shows all four commits. Once they land, this collapses to the single coach commit. Reviewing c063557 alone is the intended unit.

Puts the score and its breakdown in front of the coach, gated on the "Share readiness with Coach" toggle from #105.

Two surfaces, both read-only

  • Context packet: this morning's score, band, coverage, contributors and what wasn't measured, so the coach opens a conversation already knowing how recovered the user is.
  • get_readiness(start_date, end_date): daily scores over a range, defaulting to the last 7 days and clamped to 31.

The contributor breakdown is the entire point

Without it the model can see a 74 and has to invent a reason for it; with it, it can say "your HRV was 12% below your baseline" because that is what the app actually computed. The tool description says so explicitly — use the contributors, never guess — and contributors arrive sorted by drag so the model leads with what mattered.

not_measured is carried for the same reason. "Your temperature was fine" and "your temperature wasn't measured" are different claims, and a model that cannot distinguish them will confidently assert the first when the second is true. An empty range likewise returns an explanatory note rather than a bare empty list, since "no scores" would otherwise read as "poor recovery".

Read-only by design

A readiness score is derived from measurements the ring took, so there is nothing for the model to write. A test guards against a write tool appearing later by accident.

A reversed date range is normalized rather than returning nothing. Models occasionally swap the bounds, and an empty result is a materially wrong answer here.

Gating

Mirrors nutrition exactly: readinessContextEnabled requires the feature on and shared, the tool is absent from the registry otherwise, and the packet omits the key entirely rather than sending null. The tool also re-checks the flag itself, so it refuses even if it were somehow reachable.

12 new tests.

ak710 and others added 5 commits July 31, 2026 22:14
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>
Second readiness PR (saksham2001#103), building on the scoring engine. Puts the score
on screen as a full-width card pinned directly under the Today hero, above
the metric grid, with its own Settings page.

Deliberately NOT a tile in the reorderable grid. Every other tile reports
one measurement; readiness is a verdict *over* those measurements - HRV,
resting HR, sleep, temperature and yesterday's load collapsed into a single
number. Placing it beside a peer tile framed it as a sibling metric, which
is the wrong mental model, and half a tile's width could not carry the
reasoning that stops it being a black box. So it carries no MetricKey at
all: it is pinned by design, and visibility is the Settings toggle's job
rather than the drag tray's.

The width earns its keep. The card shows the score, its band, the share of
signals it is based on, and the top two contributors holding it back - each
with its own explanation and what it cost, e.g. "Skin temperature 0.5 °C
above your baseline, −3.4 pts".

The empty state counts down instead of repeating an instruction. Readiness
cannot say anything until it knows what *your* normal looks like, and a flat
"wear your ring overnight" gives no sense of whether that means one more
night or two more weeks - on a device with a month of history it reads as
broken. The card now shows a filling ring with "3 of 7 nights" and "4 more
nights to go", so the feature visibly works before it can produce a score.

Progress counts nights that actually produced overnight signal, not days
since install: someone who wore the ring twice in a month is two nights
along, and saying otherwise would promise a score that isn't coming. A night
whose sleep decode failed still counts if vitals were captured. And because
nights are a proxy for BaselineStats.isEstablished - which also requires
enough individual readings - the copy never claims "0 more nights" while
still showing no score; it says it is still gathering readings. A test pins
the advertised night count against the gate itself, so the countdown cannot
drift from what actually unblocks a score.

This also fixes a real gap the previous copy had: it keyed off the pairing
calibration state, so a long-established user who simply lacked a readiness
baseline saw a bare "No score yet" with no path forward.

The card is gated on its master toggle plus "can this ring measure recovery
at all" - HRV or sleep, the two signals ReadinessScore requires. A ring with
neither could never produce a score, so the card is absent rather than
permanently empty.

Band zones live in ReadinessZones rather than on a view, because the card,
the detail hero and the trend chart all need them and none should depend on
another's type name. A test pins them against ReadinessScore.band.

TodayStore needed two changes. ReadinessService.refreshIfStale runs BEFORE
the signature is captured, not after - otherwise the write lands after the
snapshot and forces a second, wasted rebuild. And the signature gained a
readiness clause; without it a freshly scored night sits in the database
while the card keeps showing yesterday's.

Progress is computed only while there is no score, so it never runs on the
happy path.

Also adds an -openReadinessSettings launch arg, matching the existing
nutrition test tooling.

25 new tests. Layout verified in the simulator against seeded demo data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two inline contributor JSON literals ran past 200 characters, which is
SwiftLint's error threshold and was failing CI. Hoist both into named
multiline constants, matching how the other readiness tests already
carry their fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third readiness PR (saksham2001#103). The tile can only name the single biggest drag;
this screen accounts for every contributor, so a user can reconstruct the
arithmetic behind the number. That is what "documented metrics, no black
boxes" has to mean in practice.

Layout: hero ring with an explicit coverage line, the full breakdown sorted
by drag (each contributor showing its earned/possible points as a bar plus
its own explanation), what wasn't measured, a 7/30/90-day trend chart, and
an explainer pointing at docs/project/readiness.md.

Two deliberate choices about honesty:

- Coverage is stated on the hero whenever it is below 100%. A 74 from a
  partial night is not the same claim as a 74 from a complete one, and
  showing only the number would quietly equate them.
- Absent signals are named, not omitted. "Your temperature was fine" and
  "your temperature wasn't measured" are different statements; a
  "Not measured last night" section says which one applies, with a line
  explaining they are left out of the score rather than counted as zero.

The trend chart is the first chart in this app to be accessible to
VoiceOver. It ships an AXChartDescriptor plus per-bar labels, so the rotor
can step through days and hear "24 July, 91, Primed" instead of just
"chart". Charts.swift, VitalsCharts.swift and ActivityCharts.swift are all
still opaque; this is the pattern to back-port.

Hardening that path found a real crash. The axis description closure is
called by the framework with values the app doesn't control, and Int(Double)
traps on infinity and NaN - so an unguarded conversion took the whole app
down, and only ever for VoiceOver users. Now guarded and clamped, with a
regression test that feeds it infinity, NaN and out-of-range values.

missingKinds moved from the view onto ReadinessSnapshot: it is not
presentation logic, it is a fact about the score, and PR 4's coach tool
needs the same answer.

Also adds an -openReadiness launch arg, matching the existing test tooling.

12 new tests. 938 total, 0 failures. Verified rendering in the simulator
against seeded demo data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth readiness PR (saksham2001#103). Puts the score and its breakdown in front of the
coach, gated on the "Share readiness with Coach" toggle from PR 2.

Two surfaces, both read-only:

- Context packet: this morning's score, band, coverage, contributors and
  what wasn't measured, so the coach opens a conversation already knowing
  how recovered the user is.
- get_readiness(start_date, end_date): daily scores over a range, defaulting
  to the last 7 days and clamped to 31.

The contributor breakdown is the entire point. Without it the model can see
a 74 and has to invent a reason for it; with it, it can say "your HRV was
12% below your baseline" because that is what the app actually computed. The
tool description says so explicitly - use the contributors, never guess -
and contributors arrive sorted by drag so the model leads with what mattered.

not_measured is carried for the same reason. "Your temperature was fine" and
"your temperature wasn't measured" are different claims, and a model that
cannot distinguish them will confidently assert the first when the second is
true. An empty range likewise returns an explanatory note rather than a bare
empty list, since "no scores" would otherwise read as "poor recovery".

Read-only by design: a readiness score is derived from measurements the ring
took, so there is nothing for the model to write. A test guards against a
write tool appearing later by accident.

A reversed date range is normalized rather than returning nothing. Models
occasionally swap the bounds, and an empty result is a materially wrong
answer here.

Gating mirrors nutrition exactly: readinessContextEnabled requires the
feature on AND shared, the tool is absent from the registry otherwise, and
the packet omits the key entirely rather than sending null. The tool also
re-checks the flag itself, so it refuses even if it were somehow reachable.

12 new tests. 947 total, 0 failures.

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