forked from ChelseaKR/sprout
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_eval_core.py
More file actions
215 lines (175 loc) · 7.52 KB
/
Copy pathtest_eval_core.py
File metadata and controls
215 lines (175 loc) · 7.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""Unit tests for the eval core: dataset, stats, judge, calibration."""
from __future__ import annotations
from pathlib import Path
import pytest
from sprout.eval.calibration import (
JudgeProbe,
calibrate,
cohens_kappa,
is_stale,
)
from sprout.eval.dataset import (
Dataset,
DatasetError,
DatasetItem,
Provenance,
load_suite_dir,
write_sidecar,
)
from sprout.eval.judge import DeterministicJudge, build_judge
from sprout.eval.stats import is_underpowered, wilson_interval
PROV = {"source": "synthetic", "license": "CC0-1.0", "added": "2026-06-22"}
def _yaml(cases: str) -> str:
return "cases:\n" + cases
# --- dataset ---------------------------------------------------------------------
def test_dataset_from_items_hash_is_order_independent() -> None:
a = DatasetItem(id="a", question="q1", provenance=Provenance(**PROV))
b = DatasetItem(id="b", question="q2", provenance=Provenance(**PROV))
assert Dataset.from_items([a, b]).content_hash == Dataset.from_items([b, a]).content_hash
def test_dataset_rejects_duplicates_and_empty() -> None:
a = DatasetItem(id="a", question="q", provenance=Provenance(**PROV))
with pytest.raises(ValueError, match="duplicate"):
Dataset.from_items([a, a])
with pytest.raises(ValueError, match="empty"):
Dataset.from_items([])
def test_load_suite_dir_and_sidecar(tmp_path: Path) -> None:
suites = tmp_path / "suites"
suites.mkdir()
(suites / "g.yaml").write_text(
_yaml(
' - id: g1\n question: "why yellow?"\n'
' provenance: {source: synthetic, license: CC0-1.0, added: "2026-06-22"}\n'
),
encoding="utf-8",
)
ds = load_suite_dir(suites, verify_hash=False)
assert len(ds.items) == 1
sidecar = tmp_path / "suites.sha256"
write_sidecar(ds, sidecar)
# Matching sidecar loads fine.
assert load_suite_dir(suites).items[0].id == "g1"
# Tampered sidecar fails closed.
sidecar.write_text("deadbeef\n", encoding="utf-8")
with pytest.raises(DatasetError, match="hash mismatch"):
load_suite_dir(suites)
def test_load_cases_invalid_field_fails_closed(tmp_path: Path) -> None:
suites = tmp_path / "suites"
suites.mkdir()
(suites / "bad.yaml").write_text(
_yaml(
" - id: x\n question: q\n bogus_field: 1\n provenance: " + str(PROV) + "\n"
),
encoding="utf-8",
)
with pytest.raises(DatasetError, match="invalid case"):
load_suite_dir(suites, verify_hash=False)
def test_load_suite_dir_no_files(tmp_path: Path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(DatasetError, match="no suite"):
load_suite_dir(empty)
def test_coerce_top_level_list(tmp_path: Path) -> None:
suites = tmp_path / "s"
suites.mkdir()
(suites / "l.yaml").write_text(
"- id: a\n question: q\n"
' provenance: {source: s, license: CC0-1.0, added: "2026-06-22"}\n',
encoding="utf-8",
)
assert len(load_suite_dir(suites, verify_hash=False).items) == 1
# --- stats -----------------------------------------------------------------------
def test_wilson_interval_bounds() -> None:
low, high = wilson_interval(8, 10)
assert 0.0 <= low <= 0.8 <= high <= 1.0
assert wilson_interval(0, 0) == (0.0, 0.0)
full_low, full_high = wilson_interval(10, 10)
assert full_high == pytest.approx(1.0)
assert full_low < 1.0 # the lower bound stays below 1.0 even for a perfect rate
def test_is_underpowered() -> None:
assert is_underpowered(29)
assert not is_underpowered(30)
# --- judge -----------------------------------------------------------------------
def test_deterministic_judge_entails_and_negation() -> None:
j = DeterministicJudge()
assert j.entails(
"Yellowing leaves indicate overwatering", ["Yellowing leaves indicate overwatering."]
).passed
# High lexical overlap but opposite polarity is a contradiction, not entailment.
contradiction = j.entails("Pothos is not toxic to cats", ["Pothos is toxic to cats."])
assert not contradiction.passed
assert "polarity" in contradiction.detail
assert not j.entails("anything", []).passed
def test_deterministic_judge_contains_and_equivalent() -> None:
j = DeterministicJudge()
assert j.contains("You must report changes within 10 days.", "report within 10 days").passed
assert j.equivalent("water the monstera weekly", "water the monstera weekly").passed
assert not j.equivalent("toxic to cats", "bright indirect light").passed
def test_deterministic_judge_catches_antonym_contradiction_without_negation() -> None:
"""No explicit negation marker, but "safe" vs "toxic" is still a contradiction —
the failure mode the judge-calibration probe set (g5) flagged as a false positive."""
j = DeterministicJudge()
contradiction = j.entails("Aloe vera is safe for dogs to eat.", ["Aloe vera is toxic to dogs."])
assert not contradiction.passed
assert "polarity" in contradiction.detail
# High lexical overlap alone would otherwise have passed this (coverage >= threshold).
assert contradiction.score >= 0.5
def test_deterministic_judge_equivalent_rejects_antonym_flip() -> None:
"""Near-identical sentences that flip a safety antonym are not equivalent, even
though jaccard similarity is high (probe e3: "toxico" vs "seguro")."""
j = DeterministicJudge()
decision = j.equivalent(
"El potos es toxico para los gatos si lo ingieren.",
"El potos es seguro para los gatos si lo ingieren.",
)
assert not decision.passed
assert "antonym" in decision.detail
def test_build_judge() -> None:
assert isinstance(build_judge("deterministic"), DeterministicJudge)
with pytest.raises(ValueError, match="unknown judge"):
build_judge("nope")
# --- calibration -----------------------------------------------------------------
def test_cohens_kappa() -> None:
assert cohens_kappa([True, True, False, False], [True, True, False, False]) == 1.0
assert cohens_kappa([], []) == 1.0
# Perfectly imbalanced (all same) -> degenerate expected agreement -> 1.0.
assert cohens_kappa([True, True], [True, True]) == 1.0
partial = cohens_kappa([True, False, True, False], [True, True, False, False])
assert -1.0 <= partial < 1.0
def test_calibrate_and_staleness() -> None:
judge = DeterministicJudge()
probes = [
JudgeProbe(
id="p1",
kind="entails",
text_a="leaves yellow from overwatering",
sources=["Yellow leaves come from overwatering."],
human_label=True,
),
JudgeProbe(
id="p2",
kind="contains",
text_a="report within 10 days",
text_b="10 days",
human_label=True,
),
JudgeProbe(
id="p3",
kind="equivalent",
text_a="toxic to cats",
text_b="bright light",
human_label=False,
),
]
record = calibrate(judge, probes)
assert record.n_probes == 3
assert record.agreement == 1.0
assert record.meets_threshold
assert "entails" in record.per_operation
assert not is_stale(record, judge)
# A reconfigured judge invalidates the record.
assert is_stale(record, DeterministicJudge(entail_threshold=0.9))
def test_calibrate_unknown_probe_kind() -> None:
with pytest.raises(ValueError, match="unknown probe kind"):
calibrate(
DeterministicJudge(), [JudgeProbe(id="x", kind="bogus", text_a="a", human_label=True)]
)