- 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>
746 lines
26 KiB
Python
746 lines
26 KiB
Python
"""
|
|
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
|