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).

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.