"""Unit tests for the rubric schema + loader (SLICE-01: TASK-01-04).""" from __future__ import annotations import copy from pathlib import Path import pytest import yaml from server.mastery.rubric_loader import clear_cache, load_rubric from server.mastery.rubric_schema import Rubric, RubricCriterion, RubricLevel, ValidationError _RUBRICS_DIR = Path(__file__).resolve().parent.parent / "rubrics" def _valid_rubric_dict() -> dict: return { "id": "customer_service", "skill": "customer_service", "description": "CS rubric for refund/complaint", "criteria": [ { "id": "empathy", "name": "Empathy", "weight": 0.35, "conjunctive_floor": None, "levels": [ {"level": i, "label": f"L{i}", "anchor": f"anchor {i}", "signals": [f"s{i}"]} for i in range(1, 6) ], }, { "id": "resolution", "name": "Resolution", "weight": 0.30, "levels": [ {"level": i, "label": f"L{i}", "anchor": f"anchor {i}", "signals": [f"s{i}"]} for i in range(1, 6) ], }, { "id": "de_escalation", "name": "De-escalation", "weight": 0.20, "levels": [ {"level": i, "label": f"L{i}", "anchor": f"anchor {i}", "signals": [f"s{i}"]} for i in range(1, 6) ], }, { "id": "professionalism", "name": "Professionalism", "weight": 0.15, "conjunctive_floor": 2, "levels": [ {"level": i, "label": f"L{i}", "anchor": f"anchor {i}", "signals": [f"s{i}"]} for i in range(1, 6) ], }, ], } def test_valid_rubric_parses(): r = Rubric.model_validate(_valid_rubric_dict()) assert r.id == "customer_service" assert r.skill == "customer_service" assert len(r.criteria) == 4 assert r.criterion_ids() == ["empathy", "resolution", "de_escalation", "professionalism"] def test_weights_sum_to_one(): r = Rubric.model_validate(_valid_rubric_dict()) total = sum(c.weight for c in r.criteria) assert abs(total - 1.0) < 1e-6 def test_reject_invalid_weights(): bad = _valid_rubric_dict() bad["criteria"][0]["weight"] = 0.50 # now sums to 1.15 with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_reject_weights_not_summing_to_one_low(): bad = _valid_rubric_dict() bad["criteria"][0]["weight"] = 0.10 # now sums to 0.75 with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_reject_missing_levels(): bad = _valid_rubric_dict() bad["criteria"][0]["levels"] = bad["criteria"][0]["levels"][:4] # only 4 levels with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_reject_too_many_levels(): bad = copy.deepcopy(_valid_rubric_dict()) bad["criteria"][0]["levels"].append( {"level": 6, "label": "L6", "anchor": "anchor 6", "signals": ["s6"]} ) with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_reject_non_sequential_levels(): bad = copy.deepcopy(_valid_rubric_dict()) bad["criteria"][0]["levels"] = [ {"level": i, "label": f"L{i}", "anchor": f"anchor {i}", "signals": [f"s{i}"]} for i in [1, 2, 3, 4, 6] # skips 5, includes 6 ] with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_reject_duplicate_criterion_ids(): bad = copy.deepcopy(_valid_rubric_dict()) bad["criteria"][1]["id"] = "empathy" # duplicate with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_reject_empty_signals(): bad = copy.deepcopy(_valid_rubric_dict()) bad["criteria"][0]["levels"][0]["signals"] = [] with pytest.raises(ValidationError): Rubric.model_validate(bad) def test_criterion_lookup_by_id(): r = Rubric.model_validate(_valid_rubric_dict()) c = r.criterion_by_id("empathy") assert c is not None assert c.id == "empathy" assert c.weight == 0.35 assert r.criterion_by_id("nonexistent") is None def test_level_lookup_by_value(): c = RubricCriterion.model_validate(_valid_rubric_dict()["criteria"][0]) lvl3 = c.level_by_value(3) assert lvl3 is not None assert lvl3.level == 3 assert c.level_by_value(99) is None def test_conjunctive_floor_field(): r = Rubric.model_validate(_valid_rubric_dict()) assert r.criterion_by_id("professionalism").conjunctive_floor == 2 assert r.criterion_by_id("empathy").conjunctive_floor is None def test_archetype_weights_override(): d = _valid_rubric_dict() d["archetype_weights"] = { "complaint": { "empathy": 0.40, "resolution": 0.25, "de_escalation": 0.20, "professionalism": 0.15, } } r = Rubric.model_validate(d) base = r.weights_for_archetype(None) assert base["empathy"] == 0.35 complaint = r.weights_for_archetype("complaint") assert complaint["empathy"] == 0.40 assert complaint["resolution"] == 0.25 def test_load_customer_service_rubric_yaml(): clear_cache() r = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) assert r.id == "customer_service" assert r.skill == "customer_service" assert len(r.criteria) == 4 assert {c.id for c in r.criteria} == {"empathy", "resolution", "de_escalation", "professionalism"} assert r.criterion_by_id("professionalism").conjunctive_floor == 2 assert r.archetype_weights is not None assert "refund" in r.archetype_weights assert "complaint" in r.archetype_weights def test_load_rubric_caches(): clear_cache() r1 = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) r2 = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) assert r1 is r2 def test_load_rubric_missing_file_raises(): clear_cache() with pytest.raises(FileNotFoundError): load_rubric("does_not_exist", rubrics_dir=_RUBRICS_DIR) def test_loaded_rubric_yaml_weights_sum_to_one(): clear_cache() r = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) total = sum(c.weight for c in r.criteria) assert abs(total - 1.0) < 1e-6 def test_loaded_rubric_has_five_levels_per_criterion(): clear_cache() r = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) for c in r.criteria: assert len(c.levels) == 5 assert sorted(lvl.level for lvl in c.levels) == [1, 2, 3, 4, 5] def test_loaded_rubric_levels_have_signals(): clear_cache() r = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) for c in r.criteria: for lvl in c.levels: assert len(lvl.signals) >= 1 assert all(isinstance(s, str) and s for s in lvl.signals) def test_rubric_level_model_validation(): lvl = RubricLevel(level=3, label="Competent", anchor="...", signals=["a", "b"]) assert lvl.level == 3 with pytest.raises(ValidationError): RubricLevel(level=0, label="x", anchor="x", signals=["a"]) with pytest.raises(ValidationError): RubricLevel(level=6, label="x", anchor="x", signals=["a"]) def test_loaded_rubric_escalated_weights_present(): clear_cache() r = load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR) assert r.escalated_weights is not None assert abs(sum(r.escalated_weights.values()) - 1.0) < 1e-6 assert r.escalated_weights["de_escalation"] == 0.40