Files
WerkoutAPI/generator/rules_engine.py
Trey t c80c66c2e5 Codebase hardening: 102 fixes across 35+ files
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>
2026-02-27 22:29:14 -06:00

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