""" 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