feat(milestone): merge phase/01 mastery-core → milestone/v0.3-mastery-scoring
Phase 1 complete. Mastery scoring + competency rubrics + VC issuer shipped. 9 slices, 5 waves, 238 tests passing, 13/13 REQ-IDs covered. 4/4 grill MUST conditions satisfied. VERIFY: APPROVE_WITH_NOTES. ---ci--- project: praxis phase: 1 milestone: v0.3 status: complete requirements: covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02, REQ-NFR-VC-01, REQ-NFR-VC-02, REQ-NFR-IRT-01] partial: [] ---/ci---
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Praxis path engine package (SLICE-05, REQ-PATH-02).
|
||||
|
||||
Defines the 6-week competency path structure with mastery gates (D-037),
|
||||
loaded from YAML into typed Pydantic models and driven by the path engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from server.paths.schema import Path, PathWeek, WeekGate, ValidationError
|
||||
from server.paths.engine import PathEngine
|
||||
|
||||
__all__ = [
|
||||
"Path",
|
||||
"PathWeek",
|
||||
"WeekGate",
|
||||
"PathEngine",
|
||||
"ValidationError",
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user