Files
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

105 lines
3.6 KiB
Python

"""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/<slug>.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",
]