"""Praxis path engine — 6-week progression + mastery gates (SLICE-05, REQ-PATH-02). Loads a competency path YAML, reads learner progress, checks week gates, and advances the learner week-by-week per D-048. Gate evaluation delegates to `server.mastery.mastery_score.check_gate` when available (SLICE-03); until then, a local deterministic gate check implements the same D-032 contract (>= required_scenarios distinct passed AND >= required_score mean). The loader does NOT fail when referenced scenario YAMLs are missing — the scenarios are authored in SLICE-06. Use `validate_scenarios_exist(library)` once the library is populated to enforce referential integrity. """ from __future__ import annotations from copy import deepcopy from pathlib import Path as FsPath from threading import Lock from typing import Any, Dict import yaml from server.paths.schema import Path, PathWeek, ValidationError _DEFAULT_PATHS_DIR = FsPath(__file__).resolve().parent.parent.parent / "paths" _MAX_WEEK = 6 _cache: Dict[str, Path] = {} _cache_lock = Lock() def _local_check_gate(distinct_passed: int, mean_score: float, gate: Any) -> bool: return distinct_passed >= gate.required_scenarios and mean_score >= gate.required_score def _resolve_mastery_check_gate(): try: from server.mastery.mastery_score import check_gate as _ms_check_gate # type: ignore[import] except Exception: return None return _ms_check_gate def _eval_gate(progress: dict, week: int, path: Path, gate: Any) -> bool: distinct_passed = int(progress.get("distinct_passed", 0)) mean_score = float(progress.get("mastery_score", 0.0)) ms_check_gate = _resolve_mastery_check_gate() if ms_check_gate is not None: try: return bool(ms_check_gate(mean_score, distinct_passed, gate)) except TypeError: try: return bool(ms_check_gate(path_score=mean_score, distinct_passed_count=distinct_passed, gate=gate)) except TypeError: pass return _local_check_gate(distinct_passed, mean_score, gate) class PathEngine: """Loads paths and drives 6-week progression + mastery gate evaluation.""" def __init__(self, paths_dir: FsPath | None = None) -> None: self.paths_dir = paths_dir or _DEFAULT_PATHS_DIR def load_path(self, slug: str) -> Path: """Load and validate a path by slug. Cached in-memory per slug. Does NOT validate that referenced scenarios exist (SLICE-06 authors them); call `validate_scenarios_exist(library)` for that. """ with _cache_lock: cached = _cache.get(slug) if cached is not None: return cached path = self.paths_dir / f"{slug}.yaml" if not path.exists(): raise FileNotFoundError(f"Path YAML not found: {slug} in {self.paths_dir}") with path.open("r", encoding="utf-8") as f: raw = yaml.safe_load(f) parsed = Path.model_validate(raw) with _cache_lock: _cache[slug] = parsed return parsed def validate_scenarios_exist(self, path: Path, library: Any) -> list[str]: """Verify every scenario_id referenced by the path exists in the library. Returns the list of all referenced scenario ids on success. Raises ValueError listing the missing ids. Call only after SLICE-06 has authored the scenarios. """ referenced = path.all_scenario_ids() missing: list[str] = [] for sid in referenced: try: library.get(sid) except Exception: missing.append(sid) if missing: raise ValueError( f"path {path.slug!r} references {len(missing)} missing scenario(s): {missing}" ) return referenced def current_week(self, progress: dict) -> int: """Read the learner's current week from mastery_progress.current_week. Defaults to 1 (cold start) when absent or out of range. """ w = int(progress.get("current_week", 1)) if w < 1: return 1 if w > _MAX_WEEK: return _MAX_WEEK return w def check_gate(self, progress: dict, week: int, path: Path) -> bool: """Evaluate whether the mastery gate for `week` is open. Reads `distinct_passed` and `mastery_score` from `progress` and compares against the week's gate config (D-032). Delegates to `mastery_score.check_gate` when the SLICE-03 module is importable. """ week_obj = path.week_by_number(week) if week_obj is None: raise ValueError(f"week {week} not in path {path.slug!r} (weeks 1..{_MAX_WEEK})") return _eval_gate(progress, week, path, week_obj.gate) def advance_week(self, progress: dict) -> dict: """Increment current_week (D-048). Returns a new progress dict. Does NOT mutate the input. Caps at week 6. The caller is expected to have verified the current week's gate is open before calling. """ out = deepcopy(progress) w = self.current_week(out) if w < _MAX_WEEK: out["current_week"] = w + 1 else: out["current_week"] = _MAX_WEEK return out def is_path_complete(self, progress: dict, path: Path) -> bool: """True when the week-6 mastery gate is open (path fully complete).""" return self.check_gate(progress, _MAX_WEEK, path) def clear_cache() -> None: """Clear the in-memory path cache (test helper).""" with _cache_lock: _cache.clear() __all__ = ["PathEngine", "Path", "PathWeek", "ValidationError", "clear_cache"]