"""Praxis path schema — YAML DSL -> Pydantic (SLICE-05, D-037, REQ-PATH-02). Defines the typed model for a 6-week competency path. Each week lists the scenarios it exercises and a mastery gate (>= required_scenarios distinct scenarios passed, >= required_score mean score per D-032). Loaded from `paths/.yaml` by server/paths/engine.py. Per PRD section 6.4 (D-037): exactly 6 weeks, numbered 1..6 sequentially. """ from __future__ import annotations from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator _REQUIRED_WEEKS = 6 _MIN_WEEK = 1 _MAX_WEEK = 6 _DEFAULT_REQUIRED_SCENARIOS = 3 _DEFAULT_REQUIRED_SCORE = 3.5 class WeekGate(BaseModel): """Mastery gate config for one week (D-032). A week's gate opens when the learner has passed >= required_scenarios distinct scenarios with a mean score >= required_score across those passing scenarios. """ required_scenarios: int = Field( _DEFAULT_REQUIRED_SCENARIOS, ge=1, description="Min distinct passed scenarios to open the gate (D-032 default 3)", ) required_score: float = Field( _DEFAULT_REQUIRED_SCORE, ge=0.0, description="Min mean score across passing scenarios to open the gate (D-032 default 3.5)", ) class PathWeek(BaseModel): """One week in a 6-week competency path.""" week: int = Field(..., ge=_MIN_WEEK, le=_MAX_WEEK, description="Week number 1..6") title: str = Field(..., min_length=1, description="Human-readable week title") scenario_ids: list[str] = Field( ..., min_length=1, description="Scenario ids exercised this week (authored in SLICE-06)" ) gate: WeekGate = Field(default_factory=WeekGate, description="Mastery gate for this week") @field_validator("scenario_ids") @classmethod def _scenario_ids_unique(cls, v: list[str]) -> list[str]: if len(v) != len(set(v)): dupes = sorted({s for s in v if v.count(s) > 1}) raise ValueError(f"duplicate scenario_ids in week: {dupes}") return v class Path(BaseModel): """A 6-week competency path (D-037, PRD section 6.4).""" slug: str = Field(..., min_length=1, description="Path slug, e.g. 'customer_service'") name: str = Field(..., min_length=1, description="Human-readable path name") skill: str = Field(..., min_length=1, description="Skill this path develops (matches a rubric id)") weeks: list[PathWeek] = Field(..., description="Exactly 6 weeks, numbered 1..6 sequentially") @model_validator(mode="after") def _validate_weeks(self) -> Path: if len(self.weeks) != _REQUIRED_WEEKS: raise ValueError( f"path must have exactly {_REQUIRED_WEEKS} weeks (D-037 / PRD section 6.4), " f"got {len(self.weeks)}" ) seen = sorted(w.week for w in self.weeks) expected = list(range(_MIN_WEEK, _MAX_WEEK + 1)) if seen != expected: raise ValueError( f"week numbers must be exactly 1..{_REQUIRED_WEEKS} sequential, got {seen}" ) dupes = [w.week for w in self.weeks if [x.week for x in self.weeks].count(w.week) > 1] if dupes: raise ValueError(f"duplicate week numbers: {sorted(set(dupes))}") return self def week_by_number(self, week: int) -> PathWeek | None: for w in self.weeks: if w.week == week: return w return None def all_scenario_ids(self) -> list[str]: ids: list[str] = [] for w in self.weeks: ids.extend(w.scenario_ids) return ids __all__ = [ "Path", "PathWeek", "WeekGate", "ValidationError", ]