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