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
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).
- Cycle inputs → Settings (
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. |