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