What problem does this solve?
The roadmap lists readiness under Metrics you can trust — "Performance & recovery: readiness, training/cardio load, HRV and resting-HR trends, VO₂max" and "Personal baselines: learn what's normal for you and flag meaningful changes" — but there's nothing in the codebase yet.
Today PulseLoop shows me the inputs to recovery (HRV, resting HR, sleep score, skin temperature, yesterday's activity) on separate cards, and leaves me to integrate them in my head every morning. Every ring I could buy this for — Colmi, jring, the YCBT family — already reports enough to answer "should I train hard today?" directly. This is also the single biggest thing paid competitors (Oura, Whoop) put behind a subscription, and PulseLoop already has all the data locally.
Opening this per CONTRIBUTING before writing much code, so the algorithm can be agreed first. I'd like to implement it. I have a Colmi ring to validate against.
Proposed solution
A daily Readiness score (0–100) with a band label, shown as a Today tile and a tap-through detail screen that always shows which contributors drove it.
The framing I care about most: it must not be a black box. Per the project's principles the tile shows a number and its biggest drag ("HRV 12% below your baseline"), the detail screen breaks out every contributor with its earned/possible points, and the weights live in a committed docs/project/readiness.md rather than only in code.
Contributors — 5 signals, 100 points. Each is a one-sided deviation from the user's own baseline, except sleep (absolute, since SleepScore already encodes population ranges).
| Contributor |
Pts |
Measured |
Compared against |
ideal |
soft |
hard |
| HRV |
30 |
overnight mean (ms) |
30d BaselineStats.median |
≥0% |
−15% |
−40% |
| Resting HR |
25 |
overnight p10 (bpm) |
UserProfile.hrRestingBaseline |
≤0 |
+5 |
+12 |
| Sleep |
30 |
SleepScore.calculate().score |
absolute |
≥88 |
65 |
30 |
| Skin temperature |
10 |
overnight mean (°C) |
30d BaselineStats.median |
≤0.2 |
0.6 |
1.2 |
| Training load |
5 |
yesterday's active/workout minutes |
trailing 7d mean |
≤1.2× |
1.8× |
3.0× |
Band-scored between the knots (same shape as SleepScore.bandScore, but one-sided and with a harsher 0.55 knee — a recovery score that never drops below 65 isn't telling you anything). HRV above baseline and RHR below baseline are never penalized; parasympathetic-overshoot logic isn't falsifiable from a ring.
Honest degradation is the core design rule. Following the doctrine already stated at the top of SleepInsights.swift — "missing nights are never treated as zero" — a signal that wasn't captured is excluded from the denominator, never scored as zero:
score = 100 × earned / available // `available` sums only computable contributors
Gate: available ≥ 50 and at least one of {HRV, sleep} present. That falls out correctly across the supported hardware:
- Colmi, baselines established → all 100 pts
- Colmi, no temperature that night → 90 pts, coverage 0.90
- jring (sleep + HR, no HRV) → 55 pts, still scored
- Any ring in its first week →
unavailable(.baselineLearning), tile shows "Day N of 14" using the existing calibration state
- Ring with neither HRV nor sleep → tile never appears
Reuse, not new machinery. BaselineStats.compute (VitalsModelBridge.swift) supplies the HRV/temperature baselines and its existing isEstablished gate (≥7 days, ≥20 samples) decides when a contributor is trustworthy. Resting HR reuses UserProfile.hrRestingBaseline, which RestingHRBaselineService already learns, persists and throttles — no second baseline pipeline. ReadinessService is modeled directly on that service's shape.
Scores persist as a ReadinessDaily model (with the contributor breakdown), so history keeps its why and the trend chart doesn't recompute 90 days on the render path. Rows carry an algorithmVersion, so old scores are never silently reinterpreted under new weights.
Suggested PR sequence, so nothing lands as one huge diff:
- Algorithm + baselines + storage + docs page + unit tests — no UI
- Today tile (+ Settings)
- Detail screen with contributor breakdown and trend chart
- Coach tool
get_readiness (returns the contributor array, so the coach cites the real reason instead of inventing one)
- Widget metric
- (optional) check-in mention + an anomaly rule for a sharp drop
Questions I'd like your call on before I start
- The weights above — 30/25/30/10/5. Happy to adjust; this is the main thing I want agreed.
- Default on or off? I'm proposing on by default: unlike nutrition it adds no new permission, no network egress, and stores nothing the user didn't already have, and it self-gates on capability + baseline establishment. Easy to flip if you'd rather it be opt-in.
- Resting HR source. Reusing
hrRestingBaseline is an all-day p10 over 30 days rather than an overnight-only baseline. It's dominated by sleep values anyway (your lowest HR of the day is during sleep), and reusing it avoids a parallel pipeline — but an overnight-only variant is a clean follow-up if you'd prefer it from the start.
- Naming — "Readiness" vs "Recovery"? The roadmap says readiness; happy either way.
- Anything here that should land on Android too, so I can keep the docs cross-platform.
Area
Data / decoding / sync (with UI and Coach follow-ups)
Alternatives considered
- Compute on demand instead of persisting. Rejected: the trend chart wants 30–90 days, which is incompatible with the
TodayStore signature architecture that exists to keep work off the render path — and recomputing an old day against today's baseline would give a different, wrong answer.
- Score missing signals at a neutral 50% instead of excluding them. Rejected: it silently invents data and would drag a perfectly good night down just because the ring dropped a temperature reading.
- Nightly aggregates as the baseline unit rather than raw samples. Rejected: it would turn
BaselineStats.isEstablished (≥20 samples) into a 20-night gate. A Colmi samples HRV every ~30 min overnight, so raw samples clear it in about a week of wear.
- A single composite "recovery" number with no breakdown. Rejected on principle — "documented metrics, no black boxes."
Would you be willing to work on this?
Yes, I'd like to implement it.
What problem does this solve?
The roadmap lists readiness under Metrics you can trust — "Performance & recovery: readiness, training/cardio load, HRV and resting-HR trends, VO₂max" and "Personal baselines: learn what's normal for you and flag meaningful changes" — but there's nothing in the codebase yet.
Today PulseLoop shows me the inputs to recovery (HRV, resting HR, sleep score, skin temperature, yesterday's activity) on separate cards, and leaves me to integrate them in my head every morning. Every ring I could buy this for — Colmi, jring, the YCBT family — already reports enough to answer "should I train hard today?" directly. This is also the single biggest thing paid competitors (Oura, Whoop) put behind a subscription, and PulseLoop already has all the data locally.
Opening this per CONTRIBUTING before writing much code, so the algorithm can be agreed first. I'd like to implement it. I have a Colmi ring to validate against.
Proposed solution
A daily Readiness score (0–100) with a band label, shown as a Today tile and a tap-through detail screen that always shows which contributors drove it.
The framing I care about most: it must not be a black box. Per the project's principles the tile shows a number and its biggest drag ("HRV 12% below your baseline"), the detail screen breaks out every contributor with its earned/possible points, and the weights live in a committed
docs/project/readiness.mdrather than only in code.Contributors — 5 signals, 100 points. Each is a one-sided deviation from the user's own baseline, except sleep (absolute, since
SleepScorealready encodes population ranges).BaselineStats.medianUserProfile.hrRestingBaselineSleepScore.calculate().scoreBaselineStats.medianBand-scored between the knots (same shape as
SleepScore.bandScore, but one-sided and with a harsher0.55knee — a recovery score that never drops below 65 isn't telling you anything). HRV above baseline and RHR below baseline are never penalized; parasympathetic-overshoot logic isn't falsifiable from a ring.Honest degradation is the core design rule. Following the doctrine already stated at the top of
SleepInsights.swift— "missing nights are never treated as zero" — a signal that wasn't captured is excluded from the denominator, never scored as zero:Gate:
available ≥ 50and at least one of {HRV, sleep} present. That falls out correctly across the supported hardware:unavailable(.baselineLearning), tile shows "Day N of 14" using the existing calibration stateReuse, not new machinery.
BaselineStats.compute(VitalsModelBridge.swift) supplies the HRV/temperature baselines and its existingisEstablishedgate (≥7 days, ≥20 samples) decides when a contributor is trustworthy. Resting HR reusesUserProfile.hrRestingBaseline, whichRestingHRBaselineServicealready learns, persists and throttles — no second baseline pipeline.ReadinessServiceis modeled directly on that service's shape.Scores persist as a
ReadinessDailymodel (with the contributor breakdown), so history keeps its why and the trend chart doesn't recompute 90 days on the render path. Rows carry analgorithmVersion, so old scores are never silently reinterpreted under new weights.Suggested PR sequence, so nothing lands as one huge diff:
get_readiness(returns the contributor array, so the coach cites the real reason instead of inventing one)Questions I'd like your call on before I start
hrRestingBaselineis an all-day p10 over 30 days rather than an overnight-only baseline. It's dominated by sleep values anyway (your lowest HR of the day is during sleep), and reusing it avoids a parallel pipeline — but an overnight-only variant is a clean follow-up if you'd prefer it from the start.Area
Data / decoding / sync (with UI and Coach follow-ups)
Alternatives considered
TodayStoresignature architecture that exists to keep work off the render path — and recomputing an old day against today's baseline would give a different, wrong answer.BaselineStats.isEstablished(≥20 samples) into a 20-night gate. A Colmi samples HRV every ~30 min overnight, so raw samples clear it in about a week of wear.Would you be willing to work on this?
Yes, I'd like to implement it.