813bd586d6
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---
115 lines
4.5 KiB
Python
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",
|
|
] |