Deep audit identified 106 findings; 102 fixed, 4 deferred. Covers 8 areas: - Settings & deploy: env-gated DEBUG/SECRET_KEY, HTTPS headers, gunicorn, celery worker - Auth (registered_user): password write_only, request.data fixes, transaction safety, proper HTTP status codes - Workout app: IDOR protection, get_object_or_404, prefetch_related N+1 fixes, transaction.atomic - Video/scripts: path traversal sanitization, HLS trigger guard, auth on cache wipe - Models (exercise/equipment/muscle/superset): null-safe __str__, stable IDs, prefetch support - Generator views: helper for registered_user lookup, logger.exception, bulk_update, transaction wrapping - Generator core (rules/selector/generator): push-pull ratio, type affinity normalization, modality checks, side-pair exact match, word-boundary regex, equipment cache clearing - Generator services (plan_builder/analyzer/normalizer): transaction.atomic, muscle cache, bulk_update, glutes classification fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
924 lines
34 KiB
Python
924 lines
34 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.
|
|
"""
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional, Dict, Any, Tuple
|
|
|
|
import logging
|
|
|
|
from generator.services.exercise_selector import extract_movement_families
|
|
|
|
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
|
|
# ------------------------------------------------------------------
|
|
'high_intensity_interval_training': {
|
|
'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,
|
|
},
|
|
'high_intensity_interval_training': {
|
|
'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,
|
|
},
|
|
}
|
|
|
|
|
|
# Canonical key aliases for workout type names. This lets callers pass
|
|
# legacy/short names while still resolving to DB-style identifiers.
|
|
WORKOUT_TYPE_KEY_ALIASES: Dict[str, str] = {
|
|
'hiit': 'high_intensity_interval_training',
|
|
}
|
|
|
|
|
|
# ======================================================================
|
|
# Validation helpers
|
|
# ======================================================================
|
|
|
|
def _normalize_type_key(name: str) -> str:
|
|
"""Convert a workout type name to the canonical key in WORKOUT_TYPE_RULES."""
|
|
if not name:
|
|
return ''
|
|
normalized = name.strip().lower().replace('-', '_').replace(' ', '_')
|
|
normalized = '_'.join(part for part in normalized.split('_') if part)
|
|
return WORKOUT_TYPE_KEY_ALIASES.get(normalized, normalized)
|
|
|
|
|
|
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.
|
|
|
|
Exercises with BOTH push AND pull patterns are counted as neutral
|
|
(neither push nor pull) to avoid double-counting.
|
|
|
|
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()
|
|
is_push = 'push' in patterns_lower
|
|
is_pull = 'pull' in patterns_lower
|
|
if is_push and is_pull:
|
|
# Dual pattern — count as neutral to avoid double-counting
|
|
pass
|
|
elif is_push:
|
|
push_count += 1
|
|
elif is_pull:
|
|
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
|
|
|
|
|
|
def _focus_key_for_entry(entry: dict) -> Optional[str]:
|
|
"""Derive a coarse focus key from an entry's exercise."""
|
|
ex = entry.get('exercise')
|
|
if ex is None:
|
|
return None
|
|
families = sorted(extract_movement_families(getattr(ex, 'name', '') or ''))
|
|
if families:
|
|
return families[0]
|
|
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
|
|
for token in ('upper pull', 'upper push', 'hip hinge', 'squat', 'lunge', 'core', 'carry'):
|
|
if token in patterns:
|
|
return token
|
|
return None
|
|
|
|
|
|
def _is_recovery_entry(entry: dict) -> bool:
|
|
"""Return True when an entry is a recovery/stretch movement."""
|
|
ex = entry.get('exercise')
|
|
if ex is None:
|
|
return False
|
|
|
|
name = (getattr(ex, 'name', '') or '').lower()
|
|
# Use word boundary check to avoid over-matching (e.g. "Stretch Band Row"
|
|
# should not be flagged as recovery).
|
|
if re.search(r'\bstretch(ing|es|ed)?\b', name):
|
|
return True
|
|
|
|
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
|
|
recovery_tokens = (
|
|
'mobility - static',
|
|
'static stretch',
|
|
'cool down',
|
|
'cooldown',
|
|
'yoga',
|
|
'breathing',
|
|
'massage',
|
|
)
|
|
return any(token in patterns for token in recovery_tokens)
|
|
|
|
|
|
# ======================================================================
|
|
# 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 + 1:
|
|
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 pull_count == 0 and push_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. Working-set guardrails (no recovery movements, non-zero rest)
|
|
# ------------------------------------------------------------------
|
|
for ss in working:
|
|
ss_name = ss.get('name') or 'Working set'
|
|
rest_between_rounds = ss.get('rest_between_rounds')
|
|
if rest_between_rounds is None or rest_between_rounds <= 0:
|
|
violations.append(RuleViolation(
|
|
rule_id='working_rest_missing',
|
|
severity='warning',
|
|
message=(
|
|
f"{ss_name} is missing rest_between_rounds "
|
|
"(expected a positive value)."
|
|
),
|
|
actual_value=rest_between_rounds,
|
|
expected_range=(15, None),
|
|
))
|
|
|
|
recovery_names = []
|
|
for entry in ss.get('exercises', []):
|
|
if _is_recovery_entry(entry):
|
|
ex = entry.get('exercise')
|
|
recovery_names.append(getattr(ex, 'name', 'Unknown Exercise'))
|
|
if recovery_names:
|
|
violations.append(RuleViolation(
|
|
rule_id='working_contains_recovery',
|
|
severity='error',
|
|
message=(
|
|
f"{ss_name} contains recovery/stretch movement(s): "
|
|
f"{', '.join(sorted(set(recovery_names)))}."
|
|
),
|
|
actual_value=sorted(set(recovery_names)),
|
|
))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 6. Focus spread across working supersets
|
|
# ------------------------------------------------------------------
|
|
if working:
|
|
for ss in working:
|
|
seen_focus = set()
|
|
duplicate_focus = set()
|
|
for entry in ss.get('exercises', []):
|
|
focus_key = _focus_key_for_entry(entry)
|
|
if not focus_key:
|
|
continue
|
|
if focus_key in seen_focus:
|
|
duplicate_focus.add(focus_key)
|
|
seen_focus.add(focus_key)
|
|
if duplicate_focus:
|
|
violations.append(RuleViolation(
|
|
rule_id='superset_focus_repetition',
|
|
severity='error',
|
|
message=(
|
|
f"Superset '{ss.get('name')}' repeats focus area(s): "
|
|
f"{', '.join(sorted(duplicate_focus))}."
|
|
),
|
|
actual_value=sorted(duplicate_focus),
|
|
))
|
|
|
|
# Advisory: same dominant focus in adjacent working supersets.
|
|
previous_focus = None
|
|
for ss in working:
|
|
focus_keys = {
|
|
_focus_key_for_entry(entry)
|
|
for entry in ss.get('exercises', [])
|
|
}
|
|
focus_keys.discard(None)
|
|
if previous_focus is not None and focus_keys and focus_keys == previous_focus:
|
|
violations.append(RuleViolation(
|
|
rule_id='adjacent_superset_focus_repetition',
|
|
severity='info',
|
|
message=(
|
|
f"Adjacent supersets reuse the same focus profile "
|
|
f"({', '.join(sorted(focus_keys))}); spread focus when possible."
|
|
),
|
|
actual_value=sorted(focus_keys),
|
|
))
|
|
if focus_keys:
|
|
previous_focus = focus_keys
|
|
|
|
# ------------------------------------------------------------------
|
|
# 7. 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.',
|
|
))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 8. 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.',
|
|
))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 9. Cooldown check
|
|
# ------------------------------------------------------------------
|
|
if not _has_cooldown(supersets):
|
|
violations.append(RuleViolation(
|
|
rule_id='cooldown_missing',
|
|
severity='warning',
|
|
message='Workout is missing a cool-down section.',
|
|
))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 10. HIIT duration cap
|
|
# ------------------------------------------------------------------
|
|
if wt_key == 'high_intensity_interval_training':
|
|
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),
|
|
))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 11. 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),
|
|
))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 12. Workout type match percentage (refactored from _validate_workout_type_match)
|
|
# ------------------------------------------------------------------
|
|
_STRENGTH_TYPES = {
|
|
'traditional_strength_training', 'functional_strength_training',
|
|
'hypertrophy',
|
|
}
|
|
_HIIT_TYPES = {'high_intensity_interval_training'}
|
|
_CARDIO_TYPES = {'cardio'}
|
|
_CORE_TYPES = {'core_training'}
|
|
_FLEXIBILITY_TYPES = {'flexibility'}
|
|
|
|
is_strength = wt_key in _STRENGTH_TYPES
|
|
is_hiit = wt_key in _HIIT_TYPES
|
|
is_cardio = wt_key in _CARDIO_TYPES
|
|
is_core = wt_key in _CORE_TYPES
|
|
is_flexibility = wt_key in _FLEXIBILITY_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
|
|
elif is_hiit:
|
|
# HIIT: favor high HR, compound, or duration-capable exercises
|
|
hr = getattr(ex, 'hr_elevation_rating', None) or 0
|
|
if hr >= 5 or getattr(ex, 'is_compound', False) or getattr(ex, 'is_duration', False):
|
|
matching_ex += 1
|
|
elif is_cardio:
|
|
# Cardio: favor duration-capable or high-HR exercises
|
|
hr = getattr(ex, 'hr_elevation_rating', None) or 0
|
|
if getattr(ex, 'is_duration', False) or hr >= 5:
|
|
matching_ex += 1
|
|
elif is_core:
|
|
# Core: check if exercise targets core muscles
|
|
muscles = (getattr(ex, 'muscle_groups', '') or '').lower()
|
|
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
|
|
if any(tok in muscles for tok in ('core', 'abs', 'oblique')):
|
|
matching_ex += 1
|
|
elif 'core' in patterns or 'anti' in patterns:
|
|
matching_ex += 1
|
|
elif is_flexibility:
|
|
# Flexibility: favor duration-based, stretch/mobility exercises
|
|
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
|
|
if getattr(ex, 'is_duration', False) or any(
|
|
tok in patterns for tok in ('stretch', 'mobility', 'yoga', 'flexibility')
|
|
):
|
|
matching_ex += 1
|
|
else:
|
|
# Unknown type — count all as matching (no false negatives)
|
|
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
|