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

94 lines
2.9 KiB
Python

"""Mastery score + gate logic — deterministic (SLICE-03 TASK-03-03).
Weighted mean of per-criterion levels with a conjunctive floor (every criterion
>= 2 AND scenario mean >= 3.0 to pass). Path score is the mean over passing
scenarios only. Gate opens at >=3 distinct passed scenarios AND path score
>= 3.5 (D-032).
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from server.mastery.rubric_scorer import CriterionScore
from server.mastery.rubric_schema import Rubric
_SCENARIO_PASS_MEAN = 3.0
_CONJUNCTIVE_FLOOR = 2
_GATE_REQUIRED_DISTINCT = 3
_GATE_REQUIRED_SCORE = 3.5
class ScenarioScore(BaseModel):
criterion_scores: list[CriterionScore]
weighted_mean: float
passed: bool
fail_reason: str | None = None
@property
def scenario_id(self) -> str | None:
return None
def compute_scenario_score(
criterion_scores: list[CriterionScore], rubric: Rubric
) -> ScenarioScore:
"""Compute a deterministic scenario score with conjunctive-floor enforcement.
Pass requires: weighted mean >= 3.0 AND every criterion >= 2 AND any
criterion with `conjunctive_floor` set must be >= that floor.
"""
weights = {c.id: c.weight for c in rubric.criteria}
total = 0.0
for cs in criterion_scores:
w = weights.get(cs.criterion_id, cs.weight)
total += cs.level * w
mean = round(total, 6)
floor_violations: list[str] = []
for cs in criterion_scores:
c = rubric.criterion_by_id(cs.criterion_id)
floor = c.conjunctive_floor if c else None
required = max(floor or _CONJUNCTIVE_FLOOR, _CONJUNCTIVE_FLOOR)
if cs.level < required:
floor_violations.append(cs.criterion_id)
fail_reason: str | None = None
if floor_violations:
fail_reason = f"conjunctive_floor_violation:{','.join(floor_violations)}"
elif mean < _SCENARIO_PASS_MEAN:
fail_reason = f"mean_below_threshold:{mean}<{_SCENARIO_PASS_MEAN}"
passed = fail_reason is None
return ScenarioScore(
criterion_scores=criterion_scores,
weighted_mean=mean,
passed=passed,
fail_reason=fail_reason,
)
def compute_path_score(passing_scenario_scores: list[ScenarioScore]) -> float:
"""Mean weighted-mean over passing scenarios only. Empty → 0.0."""
if not passing_scenario_scores:
return 0.0
return round(sum(s.weighted_mean for s in passing_scenario_scores) / len(passing_scenario_scores), 6)
def check_gate(
path_score: float,
distinct_passed_count: int,
*,
required: int = _GATE_REQUIRED_DISTINCT,
threshold: float = _GATE_REQUIRED_SCORE,
) -> bool:
"""Gate opens at >= `required` distinct passed scenarios AND path_score >= `threshold` (D-032)."""
return distinct_passed_count >= required and path_score >= threshold
__all__ = [
"ScenarioScore",
"compute_scenario_score",
"compute_path_score",
"check_gate",
]