Files
praxis/server/paths/engine.py
T
Praxis CI 813bd586d6 docs(milestone): merge v0.3-mastery-scoring → main
v0.3 milestone merged to main. Mastery scoring + competency rubrics +
verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered.
Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres).

---ci---
project: praxis
phase: 2
milestone: v0.3
status: complete
milestone_complete: true
milestone_merged_to_main: true
---/ci---
2026-08-04 00:14:59 +00:00

158 lines
5.6 KiB
Python

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