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>
This commit is contained in:
745
generator/rules_engine.py
Normal file
745
generator/rules_engine.py
Normal file
@@ -0,0 +1,745 @@
|
||||
"""
|
||||
Rules Engine for workout validation.
|
||||
|
||||
Structured registry of quantitative workout rules extracted from
|
||||
workout_research.md. Used by the quality gates in WorkoutGenerator
|
||||
and the check_rules_drift management command.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleViolation:
|
||||
"""Represents a single rule violation found during workout validation."""
|
||||
rule_id: str
|
||||
severity: str # 'error', 'warning', 'info'
|
||||
message: str
|
||||
actual_value: Any = None
|
||||
expected_range: Any = None
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Per-workout-type rules — keyed by workout type name (lowercase, underscored)
|
||||
# Values sourced from workout_research.md "DB Calibration Summary" table
|
||||
# and the detailed sections for each workout type.
|
||||
# ======================================================================
|
||||
|
||||
WORKOUT_TYPE_RULES: Dict[str, Dict[str, Any]] = {
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Traditional Strength Training
|
||||
# ------------------------------------------------------------------
|
||||
'traditional_strength_training': {
|
||||
'rep_ranges': {
|
||||
'primary': (3, 6),
|
||||
'secondary': (6, 8),
|
||||
'accessory': (8, 12),
|
||||
},
|
||||
'rest_periods': { # seconds
|
||||
'heavy': (180, 300), # 1-5 reps: 3-5 min
|
||||
'moderate': (120, 180), # 6-8 reps: 2-3 min
|
||||
'light': (60, 90), # 8-12 reps: 60-90s
|
||||
},
|
||||
'duration_bias_range': (0.0, 0.1),
|
||||
'superset_size_range': (1, 3),
|
||||
'round_range': (4, 6),
|
||||
'typical_rest': 120,
|
||||
'typical_intensity': 'high',
|
||||
'movement_pattern_order': [
|
||||
'compound_heavy', 'compound_secondary', 'isolation',
|
||||
],
|
||||
'max_exercises_per_session': 6,
|
||||
'compound_pct_min': 0.6, # 70% compounds, allow some slack
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Hypertrophy
|
||||
# ------------------------------------------------------------------
|
||||
'hypertrophy': {
|
||||
'rep_ranges': {
|
||||
'primary': (6, 10),
|
||||
'secondary': (8, 12),
|
||||
'accessory': (10, 15),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (120, 180), # compounds: 2-3 min
|
||||
'moderate': (60, 120), # moderate: 60-120s
|
||||
'light': (45, 90), # isolation: 45-90s
|
||||
},
|
||||
'duration_bias_range': (0.1, 0.2),
|
||||
'superset_size_range': (2, 4),
|
||||
'round_range': (3, 4),
|
||||
'typical_rest': 90,
|
||||
'typical_intensity': 'high',
|
||||
'movement_pattern_order': [
|
||||
'compound_heavy', 'compound_secondary',
|
||||
'lengthened_isolation', 'shortened_isolation',
|
||||
],
|
||||
'max_exercises_per_session': 8,
|
||||
'compound_pct_min': 0.4,
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. HIIT
|
||||
# ------------------------------------------------------------------
|
||||
'hiit': {
|
||||
'rep_ranges': {
|
||||
'primary': (10, 20),
|
||||
'secondary': (10, 20),
|
||||
'accessory': (10, 20),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (20, 40), # work:rest based
|
||||
'moderate': (20, 30),
|
||||
'light': (10, 20),
|
||||
},
|
||||
'duration_bias_range': (0.6, 0.8),
|
||||
'superset_size_range': (3, 6),
|
||||
'round_range': (3, 5),
|
||||
'typical_rest': 30,
|
||||
'typical_intensity': 'high',
|
||||
'movement_pattern_order': [
|
||||
'posterior_chain', 'upper_push', 'core_explosive',
|
||||
'upper_pull', 'lower_body', 'finisher',
|
||||
],
|
||||
'max_duration_minutes': 30,
|
||||
'max_exercises_per_session': 12,
|
||||
'compound_pct_min': 0.3,
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Functional Strength Training
|
||||
# ------------------------------------------------------------------
|
||||
'functional_strength_training': {
|
||||
'rep_ranges': {
|
||||
'primary': (3, 6),
|
||||
'secondary': (6, 10),
|
||||
'accessory': (8, 12),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (180, 300), # 3-5 min for heavy
|
||||
'moderate': (120, 180), # 2-3 min
|
||||
'light': (45, 90), # 45-90s for carries/circuits
|
||||
},
|
||||
'duration_bias_range': (0.1, 0.2),
|
||||
'superset_size_range': (2, 3),
|
||||
'round_range': (3, 5),
|
||||
'typical_rest': 60,
|
||||
'typical_intensity': 'medium',
|
||||
'movement_pattern_order': [
|
||||
'squat', 'hinge', 'horizontal_push', 'horizontal_pull',
|
||||
'vertical_push', 'vertical_pull', 'carry',
|
||||
],
|
||||
'max_exercises_per_session': 6,
|
||||
'compound_pct_min': 0.7, # 70% compounds, 30% accessories
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Cross Training
|
||||
# ------------------------------------------------------------------
|
||||
'cross_training': {
|
||||
'rep_ranges': {
|
||||
'primary': (1, 5),
|
||||
'secondary': (6, 15),
|
||||
'accessory': (15, 30),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (120, 300), # strength portions
|
||||
'moderate': (45, 120),
|
||||
'light': (30, 60),
|
||||
},
|
||||
'duration_bias_range': (0.3, 0.5),
|
||||
'superset_size_range': (3, 5),
|
||||
'round_range': (3, 5),
|
||||
'typical_rest': 45,
|
||||
'typical_intensity': 'high',
|
||||
'movement_pattern_order': [
|
||||
'complex_cns', 'moderate_complexity', 'simple_repetitive',
|
||||
],
|
||||
'max_exercises_per_session': 10,
|
||||
'compound_pct_min': 0.5,
|
||||
'pull_press_ratio_min': 1.5, # Cross training specific
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Core Training
|
||||
# ------------------------------------------------------------------
|
||||
'core_training': {
|
||||
'rep_ranges': {
|
||||
'primary': (10, 20),
|
||||
'secondary': (10, 20),
|
||||
'accessory': (10, 20),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (30, 90),
|
||||
'moderate': (30, 60),
|
||||
'light': (30, 45),
|
||||
},
|
||||
'duration_bias_range': (0.5, 0.6),
|
||||
'superset_size_range': (3, 5),
|
||||
'round_range': (2, 4),
|
||||
'typical_rest': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'movement_pattern_order': [
|
||||
'anti_extension', 'anti_rotation', 'anti_lateral_flexion',
|
||||
'hip_flexion', 'rotation',
|
||||
],
|
||||
'max_exercises_per_session': 8,
|
||||
'compound_pct_min': 0.0,
|
||||
'required_anti_movement_patterns': [
|
||||
'anti_extension', 'anti_rotation', 'anti_lateral_flexion',
|
||||
],
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Flexibility
|
||||
# ------------------------------------------------------------------
|
||||
'flexibility': {
|
||||
'rep_ranges': {
|
||||
'primary': (1, 3),
|
||||
'secondary': (1, 3),
|
||||
'accessory': (1, 5),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (10, 15),
|
||||
'moderate': (10, 15),
|
||||
'light': (10, 15),
|
||||
},
|
||||
'duration_bias_range': (0.9, 1.0),
|
||||
'superset_size_range': (3, 6),
|
||||
'round_range': (1, 2),
|
||||
'typical_rest': 15,
|
||||
'typical_intensity': 'low',
|
||||
'movement_pattern_order': [
|
||||
'dynamic_warmup', 'static_stretches', 'pnf', 'cooldown_flow',
|
||||
],
|
||||
'max_exercises_per_session': 12,
|
||||
'compound_pct_min': 0.0,
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Cardio
|
||||
# ------------------------------------------------------------------
|
||||
'cardio': {
|
||||
'rep_ranges': {
|
||||
'primary': (1, 1),
|
||||
'secondary': (1, 1),
|
||||
'accessory': (1, 1),
|
||||
},
|
||||
'rest_periods': {
|
||||
'heavy': (120, 180), # between hard intervals
|
||||
'moderate': (60, 120),
|
||||
'light': (30, 60),
|
||||
},
|
||||
'duration_bias_range': (0.9, 1.0),
|
||||
'superset_size_range': (1, 3),
|
||||
'round_range': (1, 3),
|
||||
'typical_rest': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'movement_pattern_order': [
|
||||
'warmup', 'steady_state', 'intervals', 'cooldown',
|
||||
],
|
||||
'max_exercises_per_session': 6,
|
||||
'compound_pct_min': 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Universal Rules — apply regardless of workout type
|
||||
# ======================================================================
|
||||
|
||||
UNIVERSAL_RULES: Dict[str, Any] = {
|
||||
'push_pull_ratio_min': 1.0, # pull:push >= 1:1
|
||||
'deload_every_weeks': (4, 6),
|
||||
'compound_before_isolation': True,
|
||||
'warmup_mandatory': True,
|
||||
'cooldown_stretch_only': True,
|
||||
'max_hiit_duration_min': 30,
|
||||
'core_anti_movement_patterns': [
|
||||
'anti_extension', 'anti_rotation', 'anti_lateral_flexion',
|
||||
],
|
||||
'max_exercises_per_workout': 30,
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# DB Calibration reference — expected values for WorkoutType model
|
||||
# Sourced from workout_research.md Section 9.
|
||||
# ======================================================================
|
||||
|
||||
DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'Functional Strength Training': {
|
||||
'duration_bias': 0.15,
|
||||
'typical_rest_between_sets': 60,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 4,
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
'Traditional Strength Training': {
|
||||
'duration_bias': 0.1,
|
||||
'typical_rest_between_sets': 120,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 4,
|
||||
'rep_range_max': 8,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'HIIT': {
|
||||
'duration_bias': 0.7,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 10,
|
||||
'rep_range_max': 20,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'Cross Training': {
|
||||
'duration_bias': 0.4,
|
||||
'typical_rest_between_sets': 45,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'Core Training': {
|
||||
'duration_bias': 0.5,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 10,
|
||||
'rep_range_max': 20,
|
||||
'round_range_min': 2,
|
||||
'round_range_max': 4,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'Flexibility': {
|
||||
'duration_bias': 0.9,
|
||||
'typical_rest_between_sets': 15,
|
||||
'typical_intensity': 'low',
|
||||
'rep_range_min': 1,
|
||||
'rep_range_max': 5,
|
||||
'round_range_min': 1,
|
||||
'round_range_max': 2,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'Cardio': {
|
||||
'duration_bias': 1.0,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 1,
|
||||
'rep_range_max': 1,
|
||||
'round_range_min': 1,
|
||||
'round_range_max': 3,
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'Hypertrophy': {
|
||||
'duration_bias': 0.2,
|
||||
'typical_rest_between_sets': 90,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 4,
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Validation helpers
|
||||
# ======================================================================
|
||||
|
||||
def _normalize_type_key(name: str) -> str:
|
||||
"""Convert a workout type name to the underscore key used in WORKOUT_TYPE_RULES."""
|
||||
return name.strip().lower().replace(' ', '_')
|
||||
|
||||
|
||||
def _classify_rep_weight(reps: int) -> str:
|
||||
"""Classify rep count into heavy/moderate/light for rest period lookup."""
|
||||
if reps <= 5:
|
||||
return 'heavy'
|
||||
elif reps <= 10:
|
||||
return 'moderate'
|
||||
return 'light'
|
||||
|
||||
|
||||
def _has_warmup(supersets: list) -> bool:
|
||||
"""Check if the workout spec contains a warm-up superset."""
|
||||
for ss in supersets:
|
||||
name = (ss.get('name') or '').lower()
|
||||
if 'warm' in name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_cooldown(supersets: list) -> bool:
|
||||
"""Check if the workout spec contains a cool-down superset."""
|
||||
for ss in supersets:
|
||||
name = (ss.get('name') or '').lower()
|
||||
if 'cool' in name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_working_supersets(supersets: list) -> list:
|
||||
"""Extract only working (non warmup/cooldown) supersets."""
|
||||
working = []
|
||||
for ss in supersets:
|
||||
name = (ss.get('name') or '').lower()
|
||||
if 'warm' not in name and 'cool' not in name:
|
||||
working.append(ss)
|
||||
return working
|
||||
|
||||
|
||||
def _count_push_pull(supersets: list) -> Tuple[int, int]:
|
||||
"""Count push and pull exercises across working supersets.
|
||||
|
||||
Returns (push_count, pull_count).
|
||||
"""
|
||||
push_count = 0
|
||||
pull_count = 0
|
||||
for ss in _get_working_supersets(supersets):
|
||||
for entry in ss.get('exercises', []):
|
||||
ex = entry.get('exercise')
|
||||
if ex is None:
|
||||
continue
|
||||
patterns = getattr(ex, 'movement_patterns', '') or ''
|
||||
patterns_lower = patterns.lower()
|
||||
if 'push' in patterns_lower:
|
||||
push_count += 1
|
||||
if 'pull' in patterns_lower:
|
||||
pull_count += 1
|
||||
return push_count, pull_count
|
||||
|
||||
|
||||
def _check_compound_before_isolation(supersets: list) -> bool:
|
||||
"""Check that compound exercises appear before isolation in working supersets.
|
||||
|
||||
Returns True if ordering is correct (or no mix), False if isolation
|
||||
appears before compound.
|
||||
"""
|
||||
working = _get_working_supersets(supersets)
|
||||
seen_isolation = False
|
||||
compound_after_isolation = False
|
||||
for ss in working:
|
||||
for entry in ss.get('exercises', []):
|
||||
ex = entry.get('exercise')
|
||||
if ex is None:
|
||||
continue
|
||||
is_compound = getattr(ex, 'is_compound', False)
|
||||
tier = getattr(ex, 'exercise_tier', None)
|
||||
if tier == 'accessory' or (not is_compound and tier != 'primary'):
|
||||
seen_isolation = True
|
||||
elif is_compound and tier in ('primary', 'secondary'):
|
||||
if seen_isolation:
|
||||
compound_after_isolation = True
|
||||
return not compound_after_isolation
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Main validation function
|
||||
# ======================================================================
|
||||
|
||||
def validate_workout(
|
||||
workout_spec: dict,
|
||||
workout_type_name: str,
|
||||
goal: str = 'general_fitness',
|
||||
) -> List[RuleViolation]:
|
||||
"""Validate a workout spec against all applicable rules.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
workout_spec : dict
|
||||
Must contain 'supersets' key with list of superset dicts.
|
||||
Each superset dict has 'name', 'exercises' (list of entry dicts
|
||||
with 'exercise', 'reps'/'duration', 'order'), 'rounds'.
|
||||
workout_type_name : str
|
||||
e.g. 'Traditional Strength Training' or 'hiit'
|
||||
goal : str
|
||||
User's primary goal.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[RuleViolation]
|
||||
"""
|
||||
violations: List[RuleViolation] = []
|
||||
supersets = workout_spec.get('supersets', [])
|
||||
if not supersets:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='empty_workout',
|
||||
severity='error',
|
||||
message='Workout has no supersets.',
|
||||
))
|
||||
return violations
|
||||
|
||||
wt_key = _normalize_type_key(workout_type_name)
|
||||
wt_rules = WORKOUT_TYPE_RULES.get(wt_key, {})
|
||||
|
||||
working = _get_working_supersets(supersets)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Rep range checks per exercise tier
|
||||
# ------------------------------------------------------------------
|
||||
rep_ranges = wt_rules.get('rep_ranges', {})
|
||||
if rep_ranges:
|
||||
for ss in working:
|
||||
for entry in ss.get('exercises', []):
|
||||
ex = entry.get('exercise')
|
||||
reps = entry.get('reps')
|
||||
if ex is None or reps is None:
|
||||
continue
|
||||
# Only check rep-based exercises
|
||||
is_reps = getattr(ex, 'is_reps', True)
|
||||
if not is_reps:
|
||||
continue
|
||||
tier = getattr(ex, 'exercise_tier', 'accessory') or 'accessory'
|
||||
expected = rep_ranges.get(tier)
|
||||
if expected is None:
|
||||
continue
|
||||
low, high = expected
|
||||
# Allow a small tolerance for fitness scaling
|
||||
tolerance = 2
|
||||
if reps < low - tolerance or reps > high + tolerance:
|
||||
violations.append(RuleViolation(
|
||||
rule_id=f'rep_range_{tier}',
|
||||
severity='error',
|
||||
message=(
|
||||
f'{tier.title()} exercise has {reps} reps, '
|
||||
f'expected {low}-{high} for {workout_type_name}.'
|
||||
),
|
||||
actual_value=reps,
|
||||
expected_range=(low, high),
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Duration bias check
|
||||
# ------------------------------------------------------------------
|
||||
duration_bias_range = wt_rules.get('duration_bias_range')
|
||||
if duration_bias_range and working:
|
||||
total_exercises = 0
|
||||
duration_exercises = 0
|
||||
for ss in working:
|
||||
for entry in ss.get('exercises', []):
|
||||
total_exercises += 1
|
||||
if entry.get('duration') and not entry.get('reps'):
|
||||
duration_exercises += 1
|
||||
if total_exercises > 0:
|
||||
actual_bias = duration_exercises / total_exercises
|
||||
low, high = duration_bias_range
|
||||
# Allow generous tolerance for bias (it's a guideline)
|
||||
if actual_bias > high + 0.3:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='duration_bias_high',
|
||||
severity='warning',
|
||||
message=(
|
||||
f'Duration bias {actual_bias:.1%} exceeds expected '
|
||||
f'range {low:.0%}-{high:.0%} for {workout_type_name}.'
|
||||
),
|
||||
actual_value=actual_bias,
|
||||
expected_range=duration_bias_range,
|
||||
))
|
||||
elif actual_bias < low - 0.3 and low > 0:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='duration_bias_low',
|
||||
severity='warning',
|
||||
message=(
|
||||
f'Duration bias {actual_bias:.1%} below expected '
|
||||
f'range {low:.0%}-{high:.0%} for {workout_type_name}.'
|
||||
),
|
||||
actual_value=actual_bias,
|
||||
expected_range=duration_bias_range,
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Superset size check
|
||||
# ------------------------------------------------------------------
|
||||
ss_range = wt_rules.get('superset_size_range')
|
||||
if ss_range and working:
|
||||
low, high = ss_range
|
||||
for ss in working:
|
||||
ex_count = len(ss.get('exercises', []))
|
||||
# Allow 1 extra for sided pairs
|
||||
if ex_count > high + 2:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='superset_size',
|
||||
severity='warning',
|
||||
message=(
|
||||
f"Superset '{ss.get('name')}' has {ex_count} exercises, "
|
||||
f"expected {low}-{high} for {workout_type_name}."
|
||||
),
|
||||
actual_value=ex_count,
|
||||
expected_range=ss_range,
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Push:Pull ratio (universal rule)
|
||||
# ------------------------------------------------------------------
|
||||
push_count, pull_count = _count_push_pull(supersets)
|
||||
if push_count > 0 and pull_count > 0:
|
||||
ratio = pull_count / push_count
|
||||
min_ratio = UNIVERSAL_RULES['push_pull_ratio_min']
|
||||
if ratio < min_ratio - 0.2: # Allow slight slack
|
||||
violations.append(RuleViolation(
|
||||
rule_id='push_pull_ratio',
|
||||
severity='warning',
|
||||
message=(
|
||||
f'Pull:push ratio {ratio:.2f} below minimum {min_ratio}. '
|
||||
f'({pull_count} pull, {push_count} push exercises)'
|
||||
),
|
||||
actual_value=ratio,
|
||||
expected_range=(min_ratio, None),
|
||||
))
|
||||
elif push_count > 2 and pull_count == 0:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='push_pull_ratio',
|
||||
severity='warning',
|
||||
message=(
|
||||
f'Workout has {push_count} push exercises and 0 pull exercises. '
|
||||
f'Consider adding pull movements for balance.'
|
||||
),
|
||||
actual_value=0,
|
||||
expected_range=(UNIVERSAL_RULES['push_pull_ratio_min'], None),
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Compound before isolation ordering
|
||||
# ------------------------------------------------------------------
|
||||
if UNIVERSAL_RULES['compound_before_isolation']:
|
||||
if not _check_compound_before_isolation(supersets):
|
||||
violations.append(RuleViolation(
|
||||
rule_id='compound_before_isolation',
|
||||
severity='info',
|
||||
message='Compound exercises should generally appear before isolation.',
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Warmup check
|
||||
# ------------------------------------------------------------------
|
||||
if UNIVERSAL_RULES['warmup_mandatory']:
|
||||
if not _has_warmup(supersets):
|
||||
violations.append(RuleViolation(
|
||||
rule_id='warmup_missing',
|
||||
severity='error',
|
||||
message='Workout is missing a warm-up section.',
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Cooldown check
|
||||
# ------------------------------------------------------------------
|
||||
if not _has_cooldown(supersets):
|
||||
violations.append(RuleViolation(
|
||||
rule_id='cooldown_missing',
|
||||
severity='warning',
|
||||
message='Workout is missing a cool-down section.',
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. HIIT duration cap
|
||||
# ------------------------------------------------------------------
|
||||
if wt_key == 'hiit':
|
||||
max_hiit_min = UNIVERSAL_RULES.get('max_hiit_duration_min', 30)
|
||||
# Estimate total working time from working supersets
|
||||
total_working_exercises = sum(
|
||||
len(ss.get('exercises', []))
|
||||
for ss in working
|
||||
)
|
||||
total_working_rounds = sum(
|
||||
ss.get('rounds', 1)
|
||||
for ss in working
|
||||
)
|
||||
# Rough estimate: each exercise ~30-45s of work per round
|
||||
est_working_min = (total_working_exercises * total_working_rounds * 37.5) / 60
|
||||
if est_working_min > max_hiit_min * 1.5:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='hiit_duration_cap',
|
||||
severity='warning',
|
||||
message=(
|
||||
f'HIIT workout estimated at ~{est_working_min:.0f} min working time, '
|
||||
f'exceeding recommended {max_hiit_min} min cap.'
|
||||
),
|
||||
actual_value=est_working_min,
|
||||
expected_range=(0, max_hiit_min),
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Total exercise count cap
|
||||
# ------------------------------------------------------------------
|
||||
max_exercises = wt_rules.get(
|
||||
'max_exercises_per_session',
|
||||
UNIVERSAL_RULES.get('max_exercises_per_workout', 30),
|
||||
)
|
||||
total_working_ex = sum(
|
||||
len(ss.get('exercises', []))
|
||||
for ss in working
|
||||
)
|
||||
if total_working_ex > max_exercises + 4:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='exercise_count_cap',
|
||||
severity='warning',
|
||||
message=(
|
||||
f'Workout has {total_working_ex} working exercises, '
|
||||
f'recommended max is {max_exercises} for {workout_type_name}.'
|
||||
),
|
||||
actual_value=total_working_ex,
|
||||
expected_range=(0, max_exercises),
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. Workout type match percentage (refactored from _validate_workout_type_match)
|
||||
# ------------------------------------------------------------------
|
||||
_STRENGTH_TYPES = {
|
||||
'traditional_strength_training', 'functional_strength_training',
|
||||
'hypertrophy',
|
||||
}
|
||||
is_strength = wt_key in _STRENGTH_TYPES
|
||||
if working:
|
||||
total_ex = 0
|
||||
matching_ex = 0
|
||||
for ss in working:
|
||||
for entry in ss.get('exercises', []):
|
||||
total_ex += 1
|
||||
ex = entry.get('exercise')
|
||||
if ex is None:
|
||||
continue
|
||||
if is_strength:
|
||||
if getattr(ex, 'is_weight', False) or getattr(ex, 'is_compound', False):
|
||||
matching_ex += 1
|
||||
else:
|
||||
matching_ex += 1
|
||||
if total_ex > 0:
|
||||
match_pct = matching_ex / total_ex
|
||||
threshold = 0.6
|
||||
if match_pct < threshold:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='workout_type_match',
|
||||
severity='error',
|
||||
message=(
|
||||
f'Only {match_pct:.0%} of exercises match '
|
||||
f'{workout_type_name} character (threshold: {threshold:.0%}).'
|
||||
),
|
||||
actual_value=match_pct,
|
||||
expected_range=(threshold, 1.0),
|
||||
))
|
||||
|
||||
return violations
|
||||
Reference in New Issue
Block a user