UNDA — Recommendation Engine
Status: Draft. The current implementation is a phase-first weighted selector; §9 requires it to be less deterministic. Migration work is tracked as blocker B7. Last reviewed: 2026-08-08
Purpose
Suggest a single workout each day (with 3 alternatives) that fits the user's estimated cycle phase, sport interests, equipment, level, goals, and recent history — as advisory guidance, not prescription.
Current implementation (v0.2.0)
- File:
app/lib/core/services/workout_recommender.dart - Version constant:
kWorkoutRecommenderVersion = 'unda-workout-recommender-0.2.0', persisted on every logged session viaworkout_sessions.algorithm_version. - Inputs:
phase,phaseConfidence,profile.sports,profile.goals,profile.equipment,profile.fitnessLevel,recentWorkoutSlugs. - Hard constraints: level and equipment. A workout the user physically cannot do is filtered out.
- Weighted scoring (all contributions surface as
RecommendationFactorrows on the result):phase_match:baseWeight × confidenceScalewherebaseWeightis 8 (same phase), 4 (adjacent), 1 (distant), andconfidenceScaleis 0.5 (low) / 0.8 (medium) / 1.0 (high).phase_confidence_penalty: informational only — surfaces how much confidence dampened the phase signal.sport_overlap:+5 · overlap count.goals_overlap:+3 · overlap count.equipment_match:+2.level_ok:+1.recency_penalty:-4 · (2 - indexInRecent)for the last two done.- Random
[0..1)jitter for tie-breaking.
Compliance status vs §9
| Rule | Status |
|---|---|
| §9.1 No deterministic phase prescription | Compliant (v0.2.0) — phase is a weighted preference, not a filter. |
| §9.3 Provenance | Partial — phase source is stored as unda_inferred on each session. Individual factor inputs are not yet provenance-tagged (nothing user-reported is fed to the scorer today; when we add subjective energy / imported HRV, those will carry a Provenance). |
| §9.4 Uncertainty | Compliant — PhaseConfidence scales the phase weight. |
| §9.5 Explainability | Compliant — factors persisted per session; Today card surfaces the top 5. |
| §9.7 Algorithm versioning | Compliant. |
Target design (v1 — blockers B2/B3/B4/B7)
┌──────────────────────────────────────────────────┐
│ Inputs (each carries a Provenance tag) │
│ │
user_reported │ · profile.goals, sports, equipment, level │
│ · recent felt_score per phase │
│ · today's subjective energy (if logged) │
│ │
unda_inferred │ · phase estimate + confidence │
│ · recent training load (from workout_sessions) │
│ │
provider_imp. │ · HRV / RHR / sleep (when integrations wired) │
└───────────────────────┬──────────────────────────┘
▼
Weighted scorer, phase weight capped
│
▼
Ranked list of candidates + factor breakdown
│
▼
Persist: {slug, factors[], algorithm_version}Change highlights
- Phase becomes a weighted preference, not a filter. A workout in the "wrong" phase can still surface if the user's recent felt-scores in the "right" phase have been poor, or if their subjective energy today contradicts the phase estimate.
- Uncertainty propagates. Low phase confidence lowers the phase weight; the recommender leans on other signals instead.
- Factor breakdown persisted.
workout_sessions.factors(jsonb) will store, per session, the numeric contributions of each factor so we can render "What influenced today's recommendation?". - Algorithm version persisted.
workout_sessions.algorithm_versionstores the exact scorer version used. When we ship a new scorer, old sessions retain their original attribution.
Example persisted output
{
"workout_slug": "F1_full_body_strength",
"algorithm_version": "unda-workout-recommender-1.0.0",
"factors": [
{"type": "phase_match", "impact": +8, "confidence": 0.6},
{"type": "sport_overlap", "impact": +5},
{"type": "recent_felt_score_this_phase", "impact": -3},
{"type": "recent_training_load", "impact": -2},
{"type": "goals_overlap", "impact": +3},
{"type": "recency_penalty", "impact": 0}
]
}User-facing explanation
Users must be able to answer "why this workout today?" (§9.5). Planned UI in the Today card:
What influenced today's recommendation?
Cycle pattern ↓ (moderate confidence)
Recent load ↓
Sports (cycling) ↑
Recent feel ↓
Sleep →Text uses relative direction indicators, not exact numbers, so users are not led to over-interpret precision.
Rectification & override (§9.6)
- Every recommendation is a suggestion. The Today card offers "Start" and "Swap". Users can always pick a different workout, and their pick logs a session with the actual chosen workout — feeding the recommender's history.
- Users can edit any input:
- Cycle inputs → Settings (
last_period_start,avg_cycle_length,avg_period_length). - Sport interests / level / goals / equipment → Personalize.
- Symptoms → Calendar tab (currently only today — historical edit planned).
- Cycle inputs → Settings (
Readiness engine (Phase 6f — shipped)
A rules-based readiness evaluator that runs on Today after the recommender picks a workout. It looks at:
- Sleep last night — <5h is a strong nudge; 5–6h is a soft nudge.
- Steps today — 12k+ combined with a hard planned session (RPE ≥7) is a nudge.
- HRV vs 30-day baseline — 10%+ drop is a soft nudge, 20%+ is stronger.
- Resting HR vs baseline — 7+ bpm above baseline is a nudge. 4b. Today's self-reported energy (from the Today check-in card) — ≤2/5 adds a strong nudge, 3/5 a mild one. Weighted higher than objective health data because the user is literally saying how they feel. 4c. Today's self-reported mood — ≤2/5 adds a moderate nudge. Softer than energy because mood-training-capacity link is real but weaker than physical fatigue.
- Today's symptoms — fatigue-family symptoms (fatigue, low_energy, insomnia, anxiety, irritability).
- Cycle phase × planned intensity — late luteal + RPE ≥8 is a nudge.
- Recent felt-scores — 2+ poor ratings in the last 5 sessions.
- Consecutive hard days — 3+ hard sessions in a row.
- Recent objective effort — from the last 48h of imported activities: TSS ≥100 (industry-standard "hard training" threshold), or average HR ≥90% of estimated max HR. One factor max — we don't stack the same ride multiple times.
- Long endurance yesterday — a 90+ minute session in the last 48h, but only fires if today's planned workout is itself long (≥60 min) or intense (RPE ≥7). Won't downshift a short yoga session just because you did a big ride yesterday.
Score thresholds: <1.5 → ready (silent), 1.5–3.5 → soft downshift (yellow card), ≥3.5 → hard recovery (red card).
Output is a ReadinessVerdict with the level, up-to-4 human-readable factors, a suggested alternative from the workout library, and a one-line rationale. UI renders it as an expandable card under the session card on Today with Keep planned / Swap actions.
Swap semantics:
- Adds the alternative slug to the recency-penalty list (recommender learns).
- If Intervals.icu is connected, deletes today's planned events and pushes the alternative onto the Intervals calendar — Garmin/Wahoo pick it up on next sync.
Design principles:
- Null-tolerant: missing signals (no HRV data, denied scope) don't punish the user.
- Bounded factors: single signals nudge, multiple signals stack — no runaway.
- Never overrides a light workout: if the planned session is already RPE ≤5, engine returns ready.
- Readiness data is never persisted — computed at render time, discarded. Only the user's Apple Health app stores the underlying values.
Block planner (Phase 6d — shipped)
Beyond per-day recommendations, UNDA can generate a 14-day training block that the user reviews and bulk-pushes to Intervals.icu. Sits under Plan tab → "Plan next 2 weeks".
Layered inputs:
- Cycle phase per day —
CyclePhaseCalculator.phaseForDayfor each of the 14 days, scaled to the user's own cycle length. - Training history summary —
TrainingHistoryAnalyzerlooks at the last 8 weeks ofimported_activitiesand produces:sessionsPerWeek— averageminutesPerWeek— average total moving-timerestProbabilityByWeekday— per-weekday probability the user rests (weekdays with ≥60% probability become rest days in the plan)topSports— most frequent activity types (biases workout picks toward what the user actually does)isSparse=truewhen fewer than 3 activities in the window; caller falls back to a default cadence.
- Workout library — filtered by phase + equipment + level, scored by closeness to per-day duration budget, sport bias, recency penalty.
Output: List<PlannedDay> where each day is either a workout with a rationale ("Base window · matches your typical volume") or a rest day ("Rest — you usually rest on Sundays").
The user can swap any workout, mark any day as rest, or regenerate the whole block. The "Push all to Intervals" action:
- Skips days without a workout.
- Skips workouts already pushed for that (workout, date) — dedup via
pushed_intervals_events(workout_slug × target_date UNIQUE). - Reports per-day success/skip/fail counts in a snackbar.
Not yet:
- Structured
workout_docintervals in the pushed events (only prose today). - Base / build / peak / taper detection from load patterns.
- Regeneration seed so "give me a different plan" is deterministic.
Version history
| Version | Date | Change |
|---|---|---|
| 0.1.0 (implicit) | 2026-08-07 | Initial weighted scorer with hard phase filter. |
| 0.2.0 | 2026-08-08 | Phase becomes a weighted preference (same=8/adjacent=4/distant=1). Confidence scales phase weight (0.5/0.8/1.0). §9.1 compliant. |
| 1.0.0 (planned) | — | Feed the scorer subjective energy + recent felt-scores + imported HRV/RHR/sleep once integrations are wired. Add a "recent felt-score in this phase" factor so historically-bad phases downshift. |