Files
praxis/server/mastery/rubric_schema.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

115 lines
4.5 KiB
Python

"""Praxis competency rubric schema — YAML → Pydantic (SLICE-01, D-039).
Defines the typed model for a competency rubric: 4+ criteria, each with 5
behavioral anchor levels (Dreyfus + Miller "Does" + EPA entrustment per
RESEARCH §2). Loaded from `rubrics/<skill>.yaml` by rubric_loader.py and
referenced by the scoring engine (SLICE-03).
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
_LEVEL_FLOOR = 1
_LEVEL_CEIL = 5
_REQUIRED_LEVELS = 5
_WEIGHT_TOLERANCE = 1e-6
class RubricLevel(BaseModel):
"""One anchor level (1=fail … 5=mastery/entrustable)."""
level: int = Field(..., ge=_LEVEL_FLOOR, le=_LEVEL_CEIL, description="1-5 level")
label: str = Field(..., description="Short human label, e.g. 'Fail', 'Mastery / Entrustable'")
anchor: str = Field(..., description="Observable-behavior anchor text (transcript-grounded)")
signals: list[str] = Field(
..., min_length=1, description="Observable behavior tags that map evidence to this level"
)
class RubricCriterion(BaseModel):
"""One scoring criterion (e.g. empathy) with weight + 5 anchor levels."""
id: str = Field(..., description="Criterion id, e.g. 'empathy'")
name: str = Field(..., description="Human-readable criterion name")
weight: float = Field(..., ge=0.0, le=1.0, description="Criterion weight (sums to 1.0 across criteria)")
conjunctive_floor: int | None = Field(
None,
ge=_LEVEL_FLOOR,
le=_LEVEL_CEIL,
description="If set, scenario cannot pass unless this criterion ≥ floor (professionalism ≥2)",
)
levels: list[RubricLevel] = Field(..., min_length=_REQUIRED_LEVELS, max_length=_REQUIRED_LEVELS)
@field_validator("levels")
@classmethod
def _levels_are_sequential(cls, v: list[RubricLevel]) -> list[RubricLevel]:
seen = sorted(lvl.level for lvl in v)
expected = list(range(_LEVEL_FLOOR, _LEVEL_CEIL + 1))
if seen != expected:
raise ValueError(
f"criterion levels must be exactly 1..{_REQUIRED_LEVELS}, got {seen}"
)
return v
def level_by_value(self, level: int) -> RubricLevel | None:
for lvl in self.levels:
if lvl.level == level:
return lvl
return None
class Rubric(BaseModel):
"""A competency rubric for a skill (e.g. customer_service)."""
id: str = Field(..., description="Rubric id, e.g. 'customer_service'")
skill: str = Field(..., description="Skill path this rubric scores, e.g. 'customer_service'")
description: str | None = Field(None, description="Optional human description")
criteria: list[RubricCriterion] = Field(..., min_length=1)
archetype_weights: dict[str, dict[str, float]] | None = Field(
None, description="Per-archetype weight overrides (D-039 amendment)"
)
escalated_weights: dict[str, float] | None = Field(
None, description="Optional re-weight set when the escalate branch triggers (RESEARCH §6.3)"
)
@model_validator(mode="after")
def _validate_weights_and_ids(self) -> Rubric:
total = sum(c.weight for c in self.criteria)
if abs(total - 1.0) > _WEIGHT_TOLERANCE:
raise ValueError(
f"criterion weights must sum to 1.0 (±{_WEIGHT_TOLERANCE}), got {total}"
)
ids = [c.id for c in self.criteria]
if len(ids) != len(set(ids)):
dupes = sorted({i for i in ids if ids.count(i) > 1})
raise ValueError(f"duplicate criterion ids: {dupes}")
if self.skill != self.id and not self.id.startswith(self.skill):
pass
return self
def criterion_by_id(self, criterion_id: str) -> RubricCriterion | None:
for c in self.criteria:
if c.id == criterion_id:
return c
return None
def weights_for_archetype(self, archetype: str | None) -> dict[str, float]:
"""Return {criterion_id: weight} for an archetype, falling back to the base weights."""
if archetype and self.archetype_weights and archetype in self.archetype_weights:
override = self.archetype_weights[archetype]
return {c.id: override.get(c.id, c.weight) for c in self.criteria}
return {c.id: c.weight for c in self.criteria}
def criterion_ids(self) -> list[str]:
return [c.id for c in self.criteria]
__all__ = [
"Rubric",
"RubricCriterion",
"RubricLevel",
"ValidationError",
]