Skip to content

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 via workout_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 RecommendationFactor rows on the result):
    • phase_match: baseWeight × confidenceScale where baseWeight is 8 (same phase), 4 (adjacent), 1 (distant), and confidenceScale is 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 ​

RuleStatus
§9.1 No deterministic phase prescriptionCompliant (v0.2.0) — phase is a weighted preference, not a filter.
§9.3 ProvenancePartial — 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 UncertaintyCompliant — PhaseConfidence scales the phase weight.
§9.5 ExplainabilityCompliant — factors persisted per session; Today card surfaces the top 5.
§9.7 Algorithm versioningCompliant.

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_version stores the exact scorer version used. When we ship a new scorer, old sessions retain their original attribution.

Example persisted output ​

json
{
  "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).

Readiness engine (Phase 6f — shipped) ​

A rules-based readiness evaluator that runs on Today after the recommender picks a workout. It looks at:

  1. Sleep last night — <5h is a strong nudge; 5–6h is a soft nudge.
  2. Steps today — 12k+ combined with a hard planned session (RPE ≥7) is a nudge.
  3. HRV vs 30-day baseline — 10%+ drop is a soft nudge, 20%+ is stronger.
  4. 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.
  5. Today's symptoms — fatigue-family symptoms (fatigue, low_energy, insomnia, anxiety, irritability).
  6. Cycle phase × planned intensity — late luteal + RPE ≥8 is a nudge.
  7. Recent felt-scores — 2+ poor ratings in the last 5 sessions.
  8. Consecutive hard days — 3+ hard sessions in a row.
  9. 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.
  10. 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:

  1. Cycle phase per day — CyclePhaseCalculator.phaseForDay for each of the 14 days, scaled to the user's own cycle length.
  2. Training history summary — TrainingHistoryAnalyzer looks at the last 8 weeks of imported_activities and produces:
    • sessionsPerWeek — average
    • minutesPerWeek — average total moving-time
    • restProbabilityByWeekday — 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=true when fewer than 3 activities in the window; caller falls back to a default cadence.
  3. 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_doc intervals 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 ​

VersionDateChange
0.1.0 (implicit)2026-08-07Initial weighted scorer with hard phase filter.
0.2.02026-08-08Phase 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.

UNDA is a fitness and training support product. It is not a medical device.