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

29 KiB

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:
    # 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):
    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:
    # 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:
    # 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:
    # 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):
    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:

  1. In _build_working_supersets(), after exercise selection (line ~1319), add:
    # 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):

    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:

    # 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:

  1. Split recently used into two tiers (around line 577-589):
    # 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:
    # 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:

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

  1. Add injury_types to UserPreferenceSerializer and UserPreferenceUpdateSerializer fields lists.

Filtering logic (exercise_selector.py):

  1. Refactor injury filtering at lines 379-397 to use both old text field AND new structured field:
    # 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:

    # 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:

    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:

  1. 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:

    # 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:

    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:

    # 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__:

    self.used_movement_patterns = Counter()  # Track patterns used in current workout
    
  2. Add to reset():

    self.used_movement_patterns = Counter()
    
  3. In select_exercises(), after exercises are selected (line ~122), update tracking:

    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:

    # 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:

    # 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__:

    self.warnings = []
    
  2. Add warnings at each fallback point:

    • _build_working_supersets() line ~1327 (broader muscles fallback):
      self.warnings.append(f"Not enough {', '.join(superset_muscles)} exercises with your equipment. Used broader muscle group.")
      
    • _build_working_supersets() line ~1338 (unfiltered fallback):
      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):
      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:

  1. Add a warnings list and populate it:
    • In bodyweight fallback (line ~172):
      self.warnings.append("Equipment constraints too restrictive. Using bodyweight alternatives.")
      

In views.py:

  1. Include warnings in generation response (line ~148):
    response_data = serializer.data
    response_data['warnings'] = generator.warnings + generator.exercise_selector.warnings
    

In serializers.py:

  1. 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():
    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

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)