Files
WerkoutAPI/IMPLEMENTATION_PLAN.md
Trey t 1c61b80731 workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types
- Add quality gate retry loop in generate_single_workout()
- Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections)
- Wire WeeklySplitPattern DB records into _pick_weekly_split()
- Enforce movement patterns from WorkoutStructureRule in exercise selection
- Add straight-set strength support (single main lift, 4-6 rounds)
- Add modality consistency check for duration-dominant workout types
- Add InjuryStep component to onboarding and preferences
- Add sibling exercise exclusion in regenerate and preview_day endpoints
- Display generator warnings on dashboard
- Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo
- Add audit_exercise_data and check_rules_drift management commands
- Add Next.js frontend with dashboard, onboarding, preferences, history pages
- Add generator app with ML-powered workout generation pipeline
- 96 new tests across 7 test modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:07:40 -06:00

727 lines
29 KiB
Markdown

# Werkout Generator - Complete Fix Plan
## Scope
Fix every issue found in the end-to-end audit: safety bugs, unused data, preference gaps, data consistency, calibration conflicts, and variety enforcement. 14 phases, ordered by dependency and impact.
---
## Phase 1: Consolidate WorkoutType Defaults (Single Source of Truth)
**Problem:** `calibrate_workout_types.py` and `DEFAULT_WORKOUT_TYPES` in `workout_analyzer.py` conflict. Running `analyze_workouts` after calibration overwrites calibrated values.
**Files:**
- `generator/services/workout_analyzer.py` (~lines 58-195) - Update `DEFAULT_WORKOUT_TYPES` to use calibrated (research-backed) values directly
- `generator/management/commands/calibrate_workout_types.py` - Delete this file (no longer needed)
- `generator/management/commands/analyze_workouts.py` - Remove any reference to calibration
**Changes:**
1. In `workout_analyzer.py`, update `DEFAULT_WORKOUT_TYPES` dict to merge in all calibrated values from `CALIBRATIONS` in `calibrate_workout_types.py`:
- `functional_strength_training`: rep_min=6, rep_max=15, rest=90, intensity=high, duration_bias=0.15, superset_size_min=2, superset_size_max=4
- `traditional_strength_training`: rep_min=3, rep_max=8, rest=150, intensity=high, duration_bias=0.0, round_min=4, round_max=6
- `hypertrophy`: rep_min=6, rep_max=15, rest=90, intensity=high, duration_bias=0.1
- `cross_training`: duration_bias=0.5
- `core_training`: duration_bias=0.6
- `flexibility`: duration_bias=1.0
2. Populate `display_name` for all 8 types:
- `functional_strength_training` -> "Functional Strength"
- `traditional_strength_training` -> "Traditional Strength"
- `high_intensity_interval_training` -> "HIIT"
- `cross_training` -> "Cross Training"
- `core_training` -> "Core Training"
- `flexibility` -> "Flexibility"
- `cardio` -> "Cardio"
- `hypertrophy` -> "Hypertrophy"
3. Change `get_or_create` in analyzer to use `update_or_create` so re-running always applies the latest defaults without needing a separate calibration step.
4. Delete `calibrate_workout_types.py`.
**Test:** Run `python manage.py analyze_workouts --dry-run` and verify all 8 types show correct calibrated values and display_names.
---
## Phase 2: Fix Bodyweight Fallback Safety Gap
**Problem:** `_get_bodyweight_queryset()` (exercise_selector.py:400-446) skips fitness level and injury filters. A beginner with a knee injury could get advanced plyometric bodyweight exercises.
**Files:**
- `generator/services/exercise_selector.py` - lines 400-446
**Changes:**
1. Add `fitness_level` parameter to `_get_bodyweight_queryset()` signature (currently only takes `muscle_groups` and `is_duration_based`).
2. After the muscle group filtering (line ~440), add the same fitness level filtering that exists in `_get_filtered_queryset`:
```python
# Fitness level safety (same as main queryset lines 367-376)
if fitness_level is not None and fitness_level <= 1:
for pattern in self.ADVANCED_PATTERNS:
qs = qs.exclude(movement_patterns__icontains=pattern)
qs = qs.exclude(difficulty_level='advanced')
```
3. Add injury filtering (same logic as lines 379-397):
```python
injuries = (self.user_preference.injuries_limitations or '').lower()
if injuries:
# Apply same keyword-based filtering as _get_filtered_queryset
...
```
4. Update the call site at line ~172 to pass `fitness_level=self.user_preference.fitness_level`.
**Test:** Create a test with a beginner user who has knee injury + limited equipment. Verify bodyweight fallback never returns advanced or high-impact exercises.
---
## Phase 3: Use `hr_elevation_rating` for Warmup & Cooldown Quality
**Problem:** `hr_elevation_rating` (1-10) is populated on every exercise but never used. Warmup could select high-HR exercises, cooldown could select activation exercises.
**Files:**
- `generator/services/exercise_selector.py` - `select_warmup_exercises()` (lines 193-230) and `select_cooldown_exercises()` (lines 231-279)
**Changes:**
### Warmup (lines 193-230):
1. After the existing filtering and before `_weighted_pick` (line ~210), add HR-based preference:
```python
# Prefer moderate HR for warmup (gradual elevation)
warmup_hr_preferred = preferred_qs.filter(hr_elevation_rating__gte=2, hr_elevation_rating__lte=5)
warmup_hr_other = preferred_qs.filter(
Q(hr_elevation_rating__isnull=True) | Q(hr_elevation_rating__lt=2) | Q(hr_elevation_rating__gt=5)
)
```
2. Pass `warmup_hr_preferred` as the preferred queryset to `_weighted_pick`, with `warmup_hr_other` as other.
3. In the fallback path (lines 214-224), also apply the HR preference.
### Cooldown (lines 231-279):
1. After the R11 weight filtering (line ~252), add HR ceiling:
```python
# Hard filter: cooldown exercises should have low HR elevation
qs = qs.filter(Q(hr_elevation_rating__lte=3) | Q(hr_elevation_rating__isnull=True))
```
2. Apply the same filter in the fallback path (lines 260-273).
**Test:** Generate workouts and verify warmup exercises have HR ratings 2-5, cooldown exercises have HR ratings <= 3.
---
## Phase 4: Use `complexity_rating` for Beginner Safety
**Problem:** `complexity_rating` (1-5) is populated but never used. Beginners can get complexity-5 exercises (Olympic variations) that passed the pattern filter.
**Files:**
- `generator/services/exercise_selector.py` - `_get_filtered_queryset()` (lines 367-376)
**Changes:**
1. After the existing fitness level filtering (line 376), add complexity cap:
```python
# Complexity cap by fitness level
if fitness_level is not None:
complexity_caps = {1: 3, 2: 4, 3: 5, 4: 5} # Beginner max 3, Intermediate max 4
max_complexity = complexity_caps.get(fitness_level, 5)
qs = qs.filter(
Q(complexity_rating__lte=max_complexity) | Q(complexity_rating__isnull=True)
)
```
2. Also add this to `_get_bodyweight_queryset()` (from Phase 2 changes).
**Test:** Generate workouts for a beginner. Verify no exercises with complexity_rating > 3 appear.
---
## Phase 5: Use `stretch_position` for Hypertrophy Workouts
**Problem:** `stretch_position` (lengthened/mid/shortened) is populated but unused. Hypertrophy workouts should balance stretch positions per muscle group for optimal stimulus.
**Files:**
- `generator/services/exercise_selector.py` - `select_exercises()` (lines 55-192)
- `generator/services/workout_generator.py` - `_build_working_supersets()` (lines 1176-1398)
**Changes:**
### In exercise_selector.py:
1. Add a new method `_balance_stretch_positions(self, selected, muscle_groups)`:
```python
def _balance_stretch_positions(self, selected, muscle_groups, base_qs):
"""For hypertrophy, ensure we don't have all exercises in the same stretch position for a muscle."""
if len(selected) < 3:
return selected
positions = [ex.stretch_position for ex in selected if ex.stretch_position]
if not positions:
return selected
# If >66% of exercises have the same stretch position, try to swap one
from collections import Counter
counts = Counter(positions)
most_common_pos, most_common_count = counts.most_common(1)[0]
if most_common_count / len(positions) <= 0.66:
return selected
# Find underrepresented position
all_positions = {'lengthened', 'mid', 'shortened'}
missing = all_positions - set(positions)
target_position = missing.pop() if missing else None
if not target_position:
return selected
# Try to swap last accessory with an exercise of the missing position
replacement_qs = base_qs.filter(stretch_position=target_position).exclude(
pk__in=self.used_exercise_ids
)
replacement = replacement_qs.first()
if replacement:
# Swap last non-primary exercise
for i in range(len(selected) - 1, -1, -1):
if selected[i].exercise_tier != 'primary':
selected[i] = replacement
break
return selected
```
### In workout_generator.py:
2. In `_build_working_supersets()`, after exercise selection (line ~1319), add:
```python
# Balance stretch positions for hypertrophy goals
if self.preference.primary_goal == 'hypertrophy':
exercises = self.exercise_selector._balance_stretch_positions(
exercises, superset_muscles, base_qs
)
```
**Test:** Generate hypertrophy workouts. Verify exercises for the same muscle don't all share the same stretch position.
---
## Phase 6: Strengthen Recently-Used Exclusion
**Problem:** Recently used exercises are only down-weighted (3x to 1x in `_weighted_pick`), not excluded. Users can get the same exercises repeatedly.
**Files:**
- `generator/services/exercise_selector.py` - `_weighted_pick()` (lines 447-496) and `_get_filtered_queryset()` (lines 284-399)
- `generator/services/workout_generator.py` - lines 577-589
**Changes:**
### In exercise_selector.py:
1. Add a `hard_exclude_ids` set to `__init__` (line 42-46):
```python
def __init__(self, user_preference, recently_used_ids=None, hard_exclude_ids=None):
self.user_preference = user_preference
self.used_exercise_ids = set()
self.recently_used_ids = recently_used_ids or set()
self.hard_exclude_ids = hard_exclude_ids or set() # Exercises from last 3 workouts
```
2. In `_get_filtered_queryset()`, after excluding `used_exercise_ids` (line 304-305), add:
```python
# Hard exclude exercises from very recent workouts (last 3)
if self.hard_exclude_ids:
qs = qs.exclude(pk__in=self.hard_exclude_ids)
```
3. Keep the existing soft penalty in `_weighted_pick` (lines 467-468) for exercises from workouts 4-7.
### In workout_generator.py:
4. Split recently used into two tiers (around line 577-589):
```python
# Last 3 workouts: hard exclude
very_recent_workout_ids = list(
GeneratedWorkout.objects.filter(...)
.order_by('-scheduled_date')[:3]
.values_list('workout_id', flat=True)
)
hard_exclude_ids = set(
SupersetExercise.objects.filter(superset__workout_id__in=very_recent_workout_ids)
.values_list('exercise_id', flat=True)
) if very_recent_workout_ids else set()
# Workouts 4-7: soft penalty (existing behavior)
older_recent_ids = list(
GeneratedWorkout.objects.filter(...)
.order_by('-scheduled_date')[3:7]
.values_list('workout_id', flat=True)
)
soft_penalty_ids = set(
SupersetExercise.objects.filter(superset__workout_id__in=older_recent_ids)
.values_list('exercise_id', flat=True)
) if older_recent_ids else set()
self.exercise_selector = ExerciseSelector(
self.preference,
recently_used_ids=soft_penalty_ids,
hard_exclude_ids=hard_exclude_ids
)
```
**Test:** Generate 4 weekly plans in sequence. Verify exercises from the most recent 3 workouts never appear in the next plan (unless the pool is too small and fallback kicks in).
---
## Phase 7: Apply Target Muscles to ALL Split Types
**Problem:** User's `target_muscle_groups` are only injected on `full_body` days (workout_generator.py:681-694). On push/pull/legs days, they're completely ignored.
**Files:**
- `generator/services/workout_generator.py` - `generate_single_workout()` (lines 681-694)
**Changes:**
1. Replace the full_body-only logic with universal target muscle integration:
```python
# Get user's target muscle groups
user_target_muscles = list(
self.preference.target_muscle_groups.values_list('name', flat=True)
)
if user_target_muscles:
normalized_targets = [normalize_muscle_name(m) for m in user_target_muscles]
if split_type == 'full_body':
# Full body: inject all target muscles
for m in normalized_targets:
if m not in target_muscles:
target_muscles.append(m)
else:
# Other splits: inject target muscles that are RELEVANT to this split type
split_relevant_muscles = set()
categories = MUSCLE_GROUP_CATEGORIES # from muscle_normalizer
# Map split_type to relevant muscle categories
split_muscle_map = {
'push': categories.get('upper_push', []),
'pull': categories.get('upper_pull', []),
'upper': categories.get('upper_push', []) + categories.get('upper_pull', []),
'lower': categories.get('lower_push', []) + categories.get('lower_pull', []),
'legs': categories.get('lower_push', []) + categories.get('lower_pull', []),
'core': categories.get('core', []),
}
relevant = set(split_muscle_map.get(split_type, []))
# Add user targets that overlap with this split's muscle domain
for m in normalized_targets:
if m in relevant and m not in target_muscles:
target_muscles.append(m)
```
**Test:** Set target muscles to ["biceps", "glutes"]. Generate a PPL plan. Verify "biceps" appears in pull day targets and "glutes" appears in legs day targets. Neither should appear in push day targets.
---
## Phase 8: Expand Injury Filtering
**Problem:** Only 3 injury types (knee/back/shoulder) with freeform text matching. No support for wrist, ankle, hip, elbow, or neck.
**Files:**
- `generator/services/exercise_selector.py` - `_get_filtered_queryset()` (lines 379-397)
- `generator/models.py` - `UserPreference` model (line 112)
**Changes:**
### Model change (generator/models.py):
1. Add structured injury field alongside the existing freeform field:
```python
INJURY_CHOICES = [
('knee', 'Knee'),
('back', 'Back'),
('shoulder', 'Shoulder'),
('wrist', 'Wrist'),
('ankle', 'Ankle'),
('hip', 'Hip'),
('elbow', 'Elbow'),
('neck', 'Neck'),
]
injury_types = JSONField(default=list, blank=True, help_text='List of injury type strings')
```
2. Create migration for the new field.
### Serializer change (generator/serializers.py):
3. Add `injury_types` to `UserPreferenceSerializer` and `UserPreferenceUpdateSerializer` fields lists.
### Filtering logic (exercise_selector.py):
4. Refactor injury filtering at lines 379-397 to use both old text field AND new structured field:
```python
# Structured injury types (new)
injury_types = set(self.user_preference.injury_types or [])
# Also parse freeform text for backward compatibility
injuries_text = (self.user_preference.injuries_limitations or '').lower()
keyword_map = {
'knee': ['knee', 'acl', 'mcl', 'meniscus', 'patella'],
'back': ['back', 'spine', 'spinal', 'disc', 'herniat'],
'shoulder': ['shoulder', 'rotator', 'labrum', 'impingement'],
'wrist': ['wrist', 'carpal'],
'ankle': ['ankle', 'achilles', 'plantar'],
'hip': ['hip', 'labral', 'hip flexor'],
'elbow': ['elbow', 'tennis elbow', 'golfer'],
'neck': ['neck', 'cervical'],
}
for injury_type, keywords in keyword_map.items():
if any(kw in injuries_text for kw in keywords):
injury_types.add(injury_type)
# Apply filters per injury type
if 'knee' in injury_types:
qs = qs.exclude(impact_level='high')
if 'back' in injury_types:
qs = qs.exclude(impact_level='high')
qs = qs.exclude(
Q(movement_patterns__icontains='hip hinge') &
Q(is_weight=True) &
Q(difficulty_level='advanced')
)
if 'shoulder' in injury_types:
qs = qs.exclude(movement_patterns__icontains='upper push - vertical')
if 'wrist' in injury_types:
qs = qs.exclude(
Q(movement_patterns__icontains='olympic') |
Q(name__icontains='wrist curl') |
Q(name__icontains='handstand')
)
if 'ankle' in injury_types:
qs = qs.exclude(impact_level__in=['high', 'medium'])
if 'hip' in injury_types:
qs = qs.exclude(
Q(movement_patterns__icontains='hip hinge') &
Q(difficulty_level='advanced')
)
qs = qs.exclude(impact_level='high')
if 'elbow' in injury_types:
qs = qs.exclude(
Q(movement_patterns__icontains='arms') &
Q(is_weight=True) &
Q(difficulty_level='advanced')
)
if 'neck' in injury_types:
qs = qs.exclude(name__icontains='neck')
qs = qs.exclude(
Q(movement_patterns__icontains='olympic') &
Q(difficulty_level='advanced')
)
```
**Test:** Set injury_types=["knee", "wrist"]. Verify no high-impact or Olympic/handstand exercises appear.
---
## Phase 9: Fix Cardio Data & Ensure Full Rule Coverage
**Problem:** ML extraction can produce broken cardio rules (23-25 rounds). Some workout types may have no rules at all after analysis.
**Files:**
- `generator/services/workout_analyzer.py` - `_step5_extract_workout_structure_rules()` (~lines 818-1129)
- `generator/management/commands/calibrate_structure_rules.py` - Merge into analyzer
**Changes:**
### In workout_analyzer.py:
1. In `_step5_extract_workout_structure_rules()`, add sanity bounds for per-superset rounds:
```python
# Clamp rounds to per-superset range (not total workout rounds)
typical_rounds = max(1, min(8, typical_rounds)) # Was min(50)
```
2. Add a validation pass after rule extraction:
```python
def _ensure_full_rule_coverage(self):
"""Ensure every WorkoutType has at least one rule per (section, goal) combo."""
for wt in WorkoutType.objects.all():
for section in ['warm_up', 'working', 'cool_down']:
for goal, _ in GOAL_CHOICES:
exists = WorkoutStructureRule.objects.filter(
workout_type=wt, section_type=section, goal_type=goal
).exists()
if not exists:
self._create_default_rule(wt, section, goal)
```
3. Merge the essential fixes from `calibrate_structure_rules.py` into the analyzer's default rule creation so they're always applied.
### In calibrate_structure_rules.py:
4. Keep this command but change it to only fix known data issues (rep floor clamping, cardio round clamping) rather than creating rules from scratch. Add a check: if rules already have sane values, skip.
**Test:** Run `analyze_workouts`. Verify all 8 workout types x 3 sections x 5 goals = 120 rules exist, and no rule has rounds > 8 or rep_min < 1.
---
## Phase 10: Fix WeeklySplitPattern PK Stability
**Problem:** `WeeklySplitPattern.pattern` stores raw `MuscleGroupSplit` PKs. Re-running the analyzer creates new splits with new PKs, making old pattern references stale.
**Files:**
- `generator/models.py` - `WeeklySplitPattern` (lines 196-204)
- `generator/services/workout_analyzer.py` - `_step4_extract_weekly_split_patterns()` (~lines 650-812)
- `generator/services/workout_generator.py` - `_pick_weekly_split()` (lines 739-796)
**Changes:**
### Option: Store labels instead of PKs
1. In `WeeklySplitPattern`, the `pattern` field currently stores `[5, 12, 5, 8]` (MuscleGroupSplit PKs). Change the analyzer to store split_type strings instead: `["push", "pull", "push", "lower"]`.
2. In the analyzer's `_step4`, when building patterns:
```python
# Instead of storing PKs
pattern_entry = {
'split_types': [split.split_type for split in matched_splits],
'labels': [split.label for split in matched_splits],
'muscle_sets': [split.muscle_names for split in matched_splits],
}
```
3. Update `WeeklySplitPattern` model:
```python
pattern = JSONField(default=list) # Now stores split_type strings
pattern_muscles = JSONField(default=list) # Stores muscle name lists per day
```
Create migration.
4. In `_pick_weekly_split()` (workout_generator.py:739-796), resolve patterns using `split_type` + `muscle_names` instead of PK lookups:
```python
# Instead of MuscleGroupSplit.objects.get(pk=split_id)
# Use the pattern's stored muscle lists directly
for i, split_type in enumerate(pattern.pattern):
muscles = pattern.pattern_muscles[i] if pattern.pattern_muscles else []
split_days.append({
'label': pattern.pattern_labels[i],
'muscles': muscles,
'split_type': split_type,
})
```
**Test:** Run `analyze_workouts` twice. Verify the second run doesn't break weekly patterns from the first.
---
## Phase 11: Add Movement Pattern Variety Tracking
**Problem:** No tracking of movement patterns within a workout. Could get 3 horizontal presses in one session.
**Files:**
- `generator/services/exercise_selector.py` - Add tracking
- `generator/services/workout_generator.py` - `_build_working_supersets()` (lines 1176-1398)
**Changes:**
### In exercise_selector.py:
1. Add pattern tracking to `__init__`:
```python
self.used_movement_patterns = Counter() # Track patterns used in current workout
```
2. Add to `reset()`:
```python
self.used_movement_patterns = Counter()
```
3. In `select_exercises()`, after exercises are selected (line ~122), update tracking:
```python
for ex in selected:
patterns = get_movement_patterns_for_exercise(ex)
for p in patterns:
self.used_movement_patterns[p] += 1
```
4. Add a new method to penalize overused patterns in `_weighted_pick`:
```python
# In _weighted_pick, when building the pool (lines 461-474):
for ex in preferred_list:
patterns = get_movement_patterns_for_exercise(ex)
# If any pattern already used 2+ times, downweight
if any(self.used_movement_patterns.get(p, 0) >= 2 for p in patterns):
pool.extend([ex] * 1) # Reduced weight
else:
pool.extend([ex] * weight_preferred)
```
**Test:** Generate a full-body workout. Verify no single movement pattern (e.g., "upper push - horizontal") appears more than twice in working supersets.
---
## Phase 12: Add Minimum Working Superset Validation After Trimming
**Problem:** Aggressive trimming could remove all working supersets, leaving only warmup + cooldown.
**Files:**
- `generator/services/workout_generator.py` - `_trim_to_fit()` (lines 1537-1575)
**Changes:**
1. After the trimming loop, add a minimum check:
```python
# Ensure at least 1 working superset remains
working_supersets = [
ss for ss in workout_spec['supersets']
if ss['name'] not in ('Warm Up', 'Cool Down')
]
if not working_supersets:
# Re-add the last removed working superset with minimal config
# (2 exercises, 2 rounds - absolute minimum)
if removed_supersets:
minimal = removed_supersets[-1]
minimal['exercises'] = minimal['exercises'][:2]
minimal['rounds'] = 2
# Insert before cooldown
cooldown_idx = next(
(i for i, ss in enumerate(workout_spec['supersets']) if ss['name'] == 'Cool Down'),
len(workout_spec['supersets'])
)
workout_spec['supersets'].insert(cooldown_idx, minimal)
```
2. Track removed supersets during trimming by adding a `removed_supersets` list.
**Test:** Set `preferred_workout_duration=15` (minimum). Generate a workout. Verify it has at least 1 working superset.
---
## Phase 13: Add Generation Warnings to API Response
**Problem:** Users never know when their preferences can't be honored (equipment fallback, target muscle ignored, etc.).
**Files:**
- `generator/services/workout_generator.py` - Multiple methods
- `generator/services/exercise_selector.py` - Fallback paths
- `generator/views.py` - Response construction
- `generator/serializers.py` - Add warnings field
**Changes:**
### In workout_generator.py:
1. Add a warnings list to `__init__`:
```python
self.warnings = []
```
2. Add warnings at each fallback point:
- `_build_working_supersets()` line ~1327 (broader muscles fallback):
```python
self.warnings.append(f"Not enough {', '.join(superset_muscles)} exercises with your equipment. Used broader muscle group.")
```
- `_build_working_supersets()` line ~1338 (unfiltered fallback):
```python
self.warnings.append(f"Very few exercises available for {', '.join(superset_muscles)}. Some exercises may not match your muscle targets.")
```
- `generate_single_workout()` line ~681 (target muscles full_body only - with Phase 7 this becomes the "no relevant overlap" case):
```python
if user_targets_not_in_split:
self.warnings.append(f"Target muscles {', '.join(user_targets_not_in_split)} don't apply to {split_type} day.")
```
### In exercise_selector.py:
3. Add a warnings list and populate it:
- In bodyweight fallback (line ~172):
```python
self.warnings.append("Equipment constraints too restrictive. Using bodyweight alternatives.")
```
### In views.py:
4. Include warnings in generation response (line ~148):
```python
response_data = serializer.data
response_data['warnings'] = generator.warnings + generator.exercise_selector.warnings
```
### In serializers.py:
5. No model change needed - warnings are transient, added to the response dict only.
**Test:** Set equipment to only "resistance band" and target muscles to "chest". Generate a plan. Verify response includes warnings about equipment fallback.
---
## Phase 14: Validate Preference Consistency
**Problem:** Users can set contradictory preferences (e.g., 4 days/week but only 2 preferred days).
**Files:**
- `generator/serializers.py` - `UserPreferenceUpdateSerializer` (lines 79-144)
**Changes:**
1. Add validation to `UserPreferenceUpdateSerializer.validate()`:
```python
def validate(self, attrs):
errors = {}
# Preferred days vs days_per_week
preferred_days = attrs.get('preferred_days')
days_per_week = attrs.get('days_per_week')
if preferred_days and days_per_week:
if len(preferred_days) > 0 and len(preferred_days) < days_per_week:
errors['preferred_days'] = (
f"You selected {len(preferred_days)} preferred days "
f"but plan to train {days_per_week} days/week. "
f"Select at least {days_per_week} days or clear preferred days for auto-scheduling."
)
# Validate preferred_days are valid weekday indices
if preferred_days:
invalid = [d for d in preferred_days if d < 0 or d > 6]
if invalid:
errors['preferred_days'] = f"Invalid day indices: {invalid}. Must be 0 (Mon) - 6 (Sun)."
# Duration sanity
duration = attrs.get('preferred_workout_duration')
if duration is not None and (duration < 15 or duration > 120):
errors['preferred_workout_duration'] = "Duration must be between 15 and 120 minutes."
if errors:
raise serializers.ValidationError(errors)
return attrs
```
**Test:** Try to update preferences with `days_per_week=5, preferred_days=[0, 1]`. Verify it returns a validation error.
---
## Execution Order & Dependencies
```
Phase 1 (Consolidate defaults) - Independent, do first
Phase 2 (Bodyweight safety) - Independent
Phase 3 (HR warmup/cooldown) - Independent
Phase 4 (Complexity cap) - Depends on Phase 2 (same file area)
Phase 5 (Stretch position) - Independent
Phase 6 (Recently-used exclusion) - Independent
Phase 7 (Target muscles all splits)- Independent
Phase 8 (Injury filtering) - Requires migration, do after Phase 2
Phase 9 (Cardio rules/coverage) - Depends on Phase 1
Phase 10 (Pattern PK stability) - Requires migration
Phase 11 (Movement pattern variety) - Depends on Phase 6 (same tracking area)
Phase 12 (Min working superset) - Independent
Phase 13 (Generation warnings) - Do last (touches all files modified above)
Phase 14 (Preference validation) - Independent
```
## Recommended Batch Order
**Batch 1** (Foundation - no migrations): Phases 1, 2, 3, 4, 12
**Batch 2** (Selection quality): Phases 5, 6, 7, 11
**Batch 3** (Schema changes - requires migrations): Phases 8, 10
**Batch 4** (Integration): Phases 9, 13, 14
## Files Modified Summary
| File | Phases |
|------|--------|
| `generator/services/exercise_selector.py` | 2, 3, 4, 5, 6, 11, 13 |
| `generator/services/workout_generator.py` | 5, 6, 7, 9, 12, 13 |
| `generator/services/workout_analyzer.py` | 1, 9 |
| `generator/services/muscle_normalizer.py` | (read-only, imported) |
| `generator/services/plan_builder.py` | (no changes) |
| `generator/models.py` | 8, 10 |
| `generator/serializers.py` | 8, 13, 14 |
| `generator/views.py` | 13 |
| `generator/management/commands/calibrate_workout_types.py` | 1 (delete) |
| `generator/management/commands/calibrate_structure_rules.py` | 9 |
| `generator/management/commands/analyze_workouts.py` | 1 |
## New Migrations Required
1. Phase 8: Add `injury_types` JSONField to UserPreference
2. Phase 10: Add `pattern_muscles` JSONField to WeeklySplitPattern, change `pattern` semantics
## Management Commands to Run After
1. `python manage.py migrate`
2. `python manage.py analyze_workouts` (picks up Phase 1 + 9 changes)
3. `python manage.py populate_exercise_fields` (ensure all exercise fields populated)
4. `python manage.py calibrate_structure_rules` (Phase 9 rep floor + cardio fixes)