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---
94 lines
2.9 KiB
Python
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",
|
|
] |