mirror of https://github.com/razor-ai/soup.git
472 lines
16 KiB
Python
472 lines
16 KiB
Python
"""v0.65.0 Part A — Judge calibration tests (TDD-first).
|
|
|
|
Covers SCOPE/CJE-style bidirectional pairwise judging, position-bias offset
|
|
fitting, and conformal abstention thresholds. Refusal to use uncalibrated
|
|
judges in production scoring is wired through a runtime gate.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from soup_cli.eval.calibrate import (
|
|
JudgeCalibrationReport,
|
|
PairwiseJudgement,
|
|
classify_kl_delta,
|
|
conformal_threshold,
|
|
ensure_judge_calibrated,
|
|
fit_position_bias,
|
|
kl_divergence,
|
|
load_judge_calibration,
|
|
run_pairwise_calibration,
|
|
write_judge_calibration,
|
|
)
|
|
|
|
# ─── PairwiseJudgement frozen dataclass ───
|
|
|
|
|
|
class TestPairwiseJudgement:
|
|
def test_frozen(self):
|
|
j = PairwiseJudgement(
|
|
prompt_id="p1", first_winner="a", second_winner="a", oracle="a",
|
|
)
|
|
with pytest.raises(Exception):
|
|
j.first_winner = "b" # type: ignore[misc]
|
|
|
|
def test_invalid_winner_first(self):
|
|
with pytest.raises(ValueError, match="first_winner"):
|
|
PairwiseJudgement(
|
|
prompt_id="p", first_winner="X", second_winner="a", oracle="a",
|
|
)
|
|
|
|
def test_invalid_winner_second(self):
|
|
with pytest.raises(ValueError, match="second_winner"):
|
|
PairwiseJudgement(
|
|
prompt_id="p", first_winner="a", second_winner="Z", oracle="a",
|
|
)
|
|
|
|
def test_invalid_oracle(self):
|
|
with pytest.raises(ValueError, match="oracle"):
|
|
PairwiseJudgement(
|
|
prompt_id="p", first_winner="a", second_winner="b", oracle="x",
|
|
)
|
|
|
|
def test_null_byte_prompt_id(self):
|
|
with pytest.raises(ValueError, match="null"):
|
|
PairwiseJudgement(
|
|
prompt_id="p\x00", first_winner="a", second_winner="b", oracle="a",
|
|
)
|
|
|
|
def test_empty_prompt_id(self):
|
|
with pytest.raises(ValueError, match="prompt_id"):
|
|
PairwiseJudgement(
|
|
prompt_id="", first_winner="a", second_winner="a", oracle="a",
|
|
)
|
|
|
|
def test_accepts_tie(self):
|
|
j = PairwiseJudgement(
|
|
prompt_id="p", first_winner="tie", second_winner="tie", oracle="tie",
|
|
)
|
|
assert j.first_winner == "tie"
|
|
|
|
|
|
# ─── fit_position_bias ───
|
|
|
|
|
|
class TestFitPositionBias:
|
|
def test_no_bias_when_consistent(self):
|
|
judgements = [
|
|
PairwiseJudgement(prompt_id=f"p{i}", first_winner="a",
|
|
second_winner="a", oracle="a")
|
|
for i in range(10)
|
|
]
|
|
bias = fit_position_bias(judgements)
|
|
assert bias == 0.0
|
|
|
|
def test_position_bias_when_first_always_wins(self):
|
|
# Judge always picks the FIRST slot regardless of swap:
|
|
# arrangement 1: a, b -> judge picks "a" (first slot)
|
|
# arrangement 2 (swapped): b, a -> judge picks "b" (first slot)
|
|
judgements = []
|
|
for i in range(10):
|
|
oracle = "a" if i % 2 == 0 else "b"
|
|
judgements.append(PairwiseJudgement(
|
|
prompt_id=f"p{i}",
|
|
first_winner="a",
|
|
second_winner="b",
|
|
oracle=oracle,
|
|
))
|
|
bias = fit_position_bias(judgements)
|
|
# judge always picks the first slot → strong positive position bias
|
|
assert bias > 0.5
|
|
|
|
def test_empty_judgements(self):
|
|
with pytest.raises(ValueError, match="judgements"):
|
|
fit_position_bias([])
|
|
|
|
def test_non_iterable(self):
|
|
with pytest.raises(TypeError):
|
|
fit_position_bias(42) # type: ignore[arg-type]
|
|
|
|
def test_returns_finite(self):
|
|
judgements = [
|
|
PairwiseJudgement(prompt_id=f"p{i}",
|
|
first_winner="a", second_winner="b", oracle="a")
|
|
for i in range(5)
|
|
]
|
|
bias = fit_position_bias(judgements)
|
|
assert math.isfinite(bias)
|
|
|
|
def test_bias_in_range(self):
|
|
# Position-bias coefficient should be in [-1, 1].
|
|
judgements = [
|
|
PairwiseJudgement(prompt_id=f"p{i}",
|
|
first_winner="a", second_winner="b", oracle="a")
|
|
for i in range(10)
|
|
]
|
|
bias = fit_position_bias(judgements)
|
|
assert -1.0 <= bias <= 1.0
|
|
|
|
|
|
# ─── conformal_threshold ───
|
|
|
|
|
|
class TestConformalThreshold:
|
|
def test_basic(self):
|
|
# 100 scores 0.0..0.99; alpha=0.1 means we keep top 90%, so threshold
|
|
# is the 10th percentile.
|
|
scores = [i / 100.0 for i in range(100)]
|
|
t = conformal_threshold(scores, alpha=0.1)
|
|
# Should be roughly 0.10 (10th percentile).
|
|
assert 0.05 <= t <= 0.15
|
|
|
|
def test_alpha_zero_keeps_all(self):
|
|
scores = [0.5, 0.6, 0.7]
|
|
t = conformal_threshold(scores, alpha=0.0)
|
|
assert t == min(scores)
|
|
|
|
def test_alpha_one_keeps_none(self):
|
|
scores = [0.5, 0.6, 0.7]
|
|
t = conformal_threshold(scores, alpha=1.0)
|
|
assert t == max(scores)
|
|
|
|
def test_invalid_alpha_negative(self):
|
|
with pytest.raises(ValueError, match="alpha"):
|
|
conformal_threshold([0.5], alpha=-0.1)
|
|
|
|
def test_invalid_alpha_above_one(self):
|
|
with pytest.raises(ValueError, match="alpha"):
|
|
conformal_threshold([0.5], alpha=1.1)
|
|
|
|
def test_invalid_alpha_nan(self):
|
|
with pytest.raises(ValueError, match="alpha"):
|
|
conformal_threshold([0.5], alpha=float("nan"))
|
|
|
|
def test_invalid_alpha_bool(self):
|
|
with pytest.raises(ValueError, match="alpha"):
|
|
conformal_threshold([0.5], alpha=True)
|
|
|
|
def test_empty_scores(self):
|
|
with pytest.raises(ValueError, match="scores"):
|
|
conformal_threshold([], alpha=0.1)
|
|
|
|
def test_non_finite_score(self):
|
|
with pytest.raises(ValueError, match="finite"):
|
|
conformal_threshold([0.5, float("inf")], alpha=0.1)
|
|
|
|
def test_score_out_of_range_high(self):
|
|
with pytest.raises(ValueError, match="range"):
|
|
conformal_threshold([0.5, 1.5], alpha=0.1)
|
|
|
|
def test_score_out_of_range_low(self):
|
|
with pytest.raises(ValueError, match="range"):
|
|
conformal_threshold([-0.1, 0.5], alpha=0.1)
|
|
|
|
|
|
# ─── JudgeCalibrationReport ───
|
|
|
|
|
|
class TestJudgeCalibrationReport:
|
|
def test_frozen(self):
|
|
r = JudgeCalibrationReport(
|
|
position_bias=0.05,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=0.8,
|
|
num_pairs=10,
|
|
calibrated=True,
|
|
)
|
|
with pytest.raises(Exception):
|
|
r.position_bias = 0.0 # type: ignore[misc]
|
|
|
|
def test_invalid_bias(self):
|
|
with pytest.raises(ValueError, match="position_bias"):
|
|
JudgeCalibrationReport(
|
|
position_bias=2.0,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=0.8,
|
|
num_pairs=10,
|
|
calibrated=True,
|
|
)
|
|
|
|
def test_invalid_threshold(self):
|
|
with pytest.raises(ValueError, match="conformal_threshold"):
|
|
JudgeCalibrationReport(
|
|
position_bias=0.0,
|
|
conformal_threshold=2.0,
|
|
agreement_rate=0.8,
|
|
num_pairs=10,
|
|
calibrated=True,
|
|
)
|
|
|
|
def test_invalid_agreement(self):
|
|
with pytest.raises(ValueError, match="agreement_rate"):
|
|
JudgeCalibrationReport(
|
|
position_bias=0.0,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=1.5,
|
|
num_pairs=10,
|
|
calibrated=True,
|
|
)
|
|
|
|
def test_invalid_num_pairs(self):
|
|
with pytest.raises(ValueError, match="num_pairs"):
|
|
JudgeCalibrationReport(
|
|
position_bias=0.0,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=0.8,
|
|
num_pairs=-1,
|
|
calibrated=True,
|
|
)
|
|
|
|
def test_bool_num_pairs(self):
|
|
with pytest.raises(ValueError, match="num_pairs"):
|
|
JudgeCalibrationReport(
|
|
position_bias=0.0,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=0.8,
|
|
num_pairs=True, # type: ignore[arg-type]
|
|
calibrated=True,
|
|
)
|
|
|
|
def test_non_bool_calibrated(self):
|
|
with pytest.raises(ValueError, match="calibrated"):
|
|
JudgeCalibrationReport(
|
|
position_bias=0.0,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=0.8,
|
|
num_pairs=10,
|
|
calibrated="yes", # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
# ─── run_pairwise_calibration ───
|
|
|
|
|
|
class TestRunPairwiseCalibration:
|
|
def test_perfect_calibration(self):
|
|
judgements = [
|
|
PairwiseJudgement(
|
|
prompt_id=f"p{i}", first_winner="a",
|
|
second_winner="a", oracle="a",
|
|
) for i in range(10)
|
|
]
|
|
scores = [0.9] * 10
|
|
report = run_pairwise_calibration(judgements, scores=scores, alpha=0.1)
|
|
assert report.agreement_rate == 1.0
|
|
assert report.calibrated is True
|
|
assert report.num_pairs == 10
|
|
|
|
def test_returns_report(self):
|
|
judgements = [
|
|
PairwiseJudgement(
|
|
prompt_id=f"p{i}", first_winner="a",
|
|
second_winner="a", oracle="a",
|
|
) for i in range(20)
|
|
]
|
|
scores = [0.5 + i * 0.02 for i in range(20)]
|
|
r = run_pairwise_calibration(judgements, scores=scores, alpha=0.1)
|
|
assert isinstance(r, JudgeCalibrationReport)
|
|
|
|
def test_length_mismatch(self):
|
|
judgements = [
|
|
PairwiseJudgement(
|
|
prompt_id="p", first_winner="a",
|
|
second_winner="a", oracle="a",
|
|
)
|
|
]
|
|
with pytest.raises(ValueError, match="length"):
|
|
run_pairwise_calibration(judgements, scores=[0.5, 0.6], alpha=0.1)
|
|
|
|
def test_too_few_pairs(self):
|
|
# Need a minimum sample to compute conformal threshold meaningfully.
|
|
with pytest.raises(ValueError, match="pairs"):
|
|
run_pairwise_calibration([], scores=[], alpha=0.1)
|
|
|
|
def test_too_many_pairs(self):
|
|
judgements = [
|
|
PairwiseJudgement(
|
|
prompt_id=f"p{i}", first_winner="a",
|
|
second_winner="a", oracle="a",
|
|
) for i in range(50_001)
|
|
]
|
|
scores = [0.5] * 50_001
|
|
with pytest.raises(ValueError, match="cap"):
|
|
run_pairwise_calibration(judgements, scores=scores, alpha=0.1)
|
|
|
|
|
|
# ─── ensure_judge_calibrated production gate ───
|
|
|
|
|
|
class TestEnsureJudgeCalibrated:
|
|
def test_passes_calibrated(self):
|
|
r = JudgeCalibrationReport(
|
|
position_bias=0.05, conformal_threshold=0.3,
|
|
agreement_rate=0.85, num_pairs=20, calibrated=True,
|
|
)
|
|
# Should not raise.
|
|
ensure_judge_calibrated(r)
|
|
|
|
def test_refuses_uncalibrated(self):
|
|
r = JudgeCalibrationReport(
|
|
position_bias=0.05, conformal_threshold=0.3,
|
|
agreement_rate=0.85, num_pairs=20, calibrated=False,
|
|
)
|
|
with pytest.raises(RuntimeError, match="calibrat"):
|
|
ensure_judge_calibrated(r)
|
|
|
|
def test_refuses_none(self):
|
|
with pytest.raises(RuntimeError, match="calibrat"):
|
|
ensure_judge_calibrated(None)
|
|
|
|
def test_refuses_low_agreement(self):
|
|
r = JudgeCalibrationReport(
|
|
position_bias=0.0, conformal_threshold=0.3,
|
|
agreement_rate=0.5, num_pairs=20, calibrated=True,
|
|
)
|
|
with pytest.raises(RuntimeError, match="agreement"):
|
|
ensure_judge_calibrated(r, min_agreement=0.7)
|
|
|
|
def test_refuses_high_bias(self):
|
|
r = JudgeCalibrationReport(
|
|
position_bias=0.4, conformal_threshold=0.3,
|
|
agreement_rate=0.9, num_pairs=20, calibrated=True,
|
|
)
|
|
with pytest.raises(RuntimeError, match="bias"):
|
|
ensure_judge_calibrated(r, max_bias=0.2)
|
|
|
|
def test_non_report_type(self):
|
|
with pytest.raises(TypeError):
|
|
ensure_judge_calibrated("calibrated") # type: ignore[arg-type]
|
|
|
|
|
|
# ─── back-compat: existing kl_divergence + classify_kl_delta still work ───
|
|
|
|
|
|
class TestBackCompat:
|
|
def test_kl_divergence_still_works(self):
|
|
assert kl_divergence([1.0, 0.0], [1.0, 0.0]) == 0.0
|
|
|
|
def test_classify_kl_delta_still_works(self):
|
|
assert classify_kl_delta(0.0) == "OK"
|
|
assert classify_kl_delta(0.1) == "MINOR"
|
|
assert classify_kl_delta(0.3) == "MAJOR"
|
|
|
|
|
|
# ─── No heavy top-level imports ───
|
|
|
|
|
|
class TestSourceWiring:
|
|
def test_no_heavy_imports(self):
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "src" / "soup_cli" / "eval" / "calibrate.py"
|
|
text = src.read_text(encoding="utf-8")
|
|
# Should not import torch, transformers, peft at module scope.
|
|
forbidden_imports = (
|
|
"import torch\n",
|
|
"import transformers\n",
|
|
"from torch",
|
|
"from transformers",
|
|
)
|
|
for forbidden in forbidden_imports:
|
|
assert forbidden not in text, f"Found heavy top-level import: {forbidden!r}"
|
|
|
|
|
|
# ─── v0.71.1 #214 — judge_calibration registry artifact persistence ───
|
|
|
|
|
|
class TestJudgeCalibrationPersistence:
|
|
def _report(self, calibrated: bool = True) -> JudgeCalibrationReport:
|
|
return JudgeCalibrationReport(
|
|
position_bias=0.05,
|
|
conformal_threshold=0.3,
|
|
agreement_rate=0.8,
|
|
num_pairs=12,
|
|
calibrated=calibrated,
|
|
)
|
|
|
|
def test_to_dict_round_trips_fields(self):
|
|
r = self._report()
|
|
d = r.to_dict()
|
|
assert d == {
|
|
"position_bias": 0.05,
|
|
"conformal_threshold": 0.3,
|
|
"agreement_rate": 0.8,
|
|
"num_pairs": 12,
|
|
"calibrated": True,
|
|
}
|
|
|
|
def test_write_then_load_round_trip(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
r = self._report()
|
|
out = write_judge_calibration(r, "calib.json")
|
|
assert out.exists()
|
|
loaded = load_judge_calibration(str(out))
|
|
assert isinstance(loaded, JudgeCalibrationReport)
|
|
assert loaded == r
|
|
|
|
def test_write_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
monkeypatch.chdir(sub)
|
|
with pytest.raises(ValueError, match="cwd"):
|
|
write_judge_calibration(self._report(), str(outside / "calib.json"))
|
|
|
|
def test_write_rejects_non_report(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(TypeError):
|
|
write_judge_calibration({"calibrated": True}, "calib.json") # type: ignore[arg-type]
|
|
|
|
def test_load_re_validates_corrupt_report(self, tmp_path, monkeypatch):
|
|
# An out-of-range field on disk must be rejected on load (the
|
|
# frozen dataclass __post_init__ is the production-gate safety net).
|
|
import json
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "bad.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"position_bias": 2.0, # out of [-1, 1]
|
|
"conformal_threshold": 0.3,
|
|
"agreement_rate": 0.8,
|
|
"num_pairs": 12,
|
|
"calibrated": True,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(ValueError, match="position_bias"):
|
|
load_judge_calibration("bad.json")
|
|
|
|
def test_load_missing_file_raises(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(FileNotFoundError):
|
|
load_judge_calibration("nope.json")
|
|
|
|
def test_registry_kind_registered(self):
|
|
from soup_cli.registry.store import _VALID_KINDS
|
|
|
|
assert "judge_calibration" in _VALID_KINDS
|