eval-builder 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eval_builder/__init__.py +3 -0
- eval_builder/cli.py +354 -0
- eval_builder/data/SKILL.md +78 -0
- eval_builder/draft.py +271 -0
- eval_builder/export.py +284 -0
- eval_builder/ingest/__init__.py +181 -0
- eval_builder/ingest/formats.py +645 -0
- eval_builder/io.py +82 -0
- eval_builder/judge/__init__.py +1 -0
- eval_builder/judge/check.py +420 -0
- eval_builder/judge/plan.py +151 -0
- eval_builder/judge/run.py +149 -0
- eval_builder/judge/stats.py +76 -0
- eval_builder/mcp_server.py +175 -0
- eval_builder/redact.py +101 -0
- eval_builder/report.py +263 -0
- eval_builder/schema.py +242 -0
- eval_builder/select.py +412 -0
- eval_builder/setup_agents.py +190 -0
- eval_builder/status.py +32 -0
- eval_builder/workspace.py +67 -0
- eval_builder-0.1.0.dist-info/METADATA +154 -0
- eval_builder-0.1.0.dist-info/RECORD +26 -0
- eval_builder-0.1.0.dist-info/WHEEL +4 -0
- eval_builder-0.1.0.dist-info/entry_points.txt +2 -0
- eval_builder-0.1.0.dist-info/licenses/LICENSE +21 -0
eval_builder/io.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Small file helpers shared by every step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sha256_file(path: str | Path) -> str:
|
|
15
|
+
h = hashlib.sha256()
|
|
16
|
+
with open(path, "rb") as f:
|
|
17
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
18
|
+
h.update(chunk)
|
|
19
|
+
return h.hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_jsonl(path: str | Path) -> Iterator[dict[str, Any]]:
|
|
23
|
+
with open(path, encoding="utf-8") as f:
|
|
24
|
+
for line in f:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if line:
|
|
27
|
+
yield json.loads(line)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_jsonl(path: str | Path, rows: Iterable[dict[str, Any]]) -> int:
|
|
31
|
+
n = 0
|
|
32
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
34
|
+
for row in rows:
|
|
35
|
+
f.write(json.dumps(row, ensure_ascii=False, sort_keys=False) + "\n")
|
|
36
|
+
n += 1
|
|
37
|
+
return n
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_json(path: str | Path, data: Any) -> None:
|
|
41
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
43
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
44
|
+
f.write("\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_json(path: str | Path) -> Any:
|
|
48
|
+
with open(path, encoding="utf-8") as f:
|
|
49
|
+
return json.load(f)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _LiteralDumper(yaml.SafeDumper):
|
|
53
|
+
"""Writes multi-line strings as YAML block literals so case files stay readable."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _str_presenter(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode:
|
|
57
|
+
if "\n" in data:
|
|
58
|
+
# Trailing spaces before newlines break block style; strip them.
|
|
59
|
+
cleaned = "\n".join(line.rstrip() for line in data.split("\n"))
|
|
60
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", cleaned, style="|")
|
|
61
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_LiteralDumper.add_representer(str, _str_presenter)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dump_yaml(data: Any) -> str:
|
|
68
|
+
return yaml.dump(data, Dumper=_LiteralDumper, sort_keys=False, allow_unicode=True, width=100)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_yaml(path: str | Path, data: Any, header: str = "") -> None:
|
|
72
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
74
|
+
if header:
|
|
75
|
+
for line in header.strip().splitlines():
|
|
76
|
+
f.write(f"# {line}\n" if line else "#\n")
|
|
77
|
+
f.write(dump_yaml(data))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_yaml(path: str | Path) -> Any:
|
|
81
|
+
with open(path, encoding="utf-8") as f:
|
|
82
|
+
return yaml.safe_load(f)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Judge reliability: plan repeated judge calls and probes, run them (opt-in), check them."""
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Measure how far each judge can be trusted.
|
|
2
|
+
|
|
3
|
+
For every judge: self-consistency over repeated trials (flip rate), agreement with
|
|
4
|
+
human labels (accuracy with a Wilson interval, Cohen's kappa with an approximate
|
|
5
|
+
interval), a position probe (swap answer order) and a verbosity probe (pad with
|
|
6
|
+
irrelevant text). A verdict is derived from explicit, configurable thresholds and
|
|
7
|
+
every verdict lists the numbers behind it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..io import read_jsonl, write_json
|
|
19
|
+
from ..workspace import Workspace
|
|
20
|
+
from .stats import cohen_kappa, kappa_interval, majority, majority_vote_stability, wilson
|
|
21
|
+
|
|
22
|
+
NO_MAJORITY = "(no majority)"
|
|
23
|
+
POSITIVE = {"pass", "yes", "true", "good", "correct", "acceptable", "1"}
|
|
24
|
+
VERDICTS = ("trustworthy", "unstable", "biased", "misaligned", "not_enough_data")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Thresholds:
|
|
29
|
+
min_trials: int = 3
|
|
30
|
+
min_cases: int = 10
|
|
31
|
+
max_flip_rate: float = 0.2
|
|
32
|
+
min_position_consistency: float = 0.8
|
|
33
|
+
max_toward_padded_rate: float = 0.1
|
|
34
|
+
min_kappa: float = 0.4
|
|
35
|
+
min_labeled: int = 20
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_PAIR_A = re.compile(r"^\W*(?:\[\[)?\s*(?:answer\s*|assistant\s*|response\s*)?a\b", re.I)
|
|
39
|
+
_PAIR_B = re.compile(r"^\W*(?:\[\[)?\s*(?:answer\s*|assistant\s*|response\s*)?b\b", re.I)
|
|
40
|
+
_PAIR_TIE = re.compile(r"^\W*(?:\[\[)?\s*(?:tie|c|equal|draw|both)\b", re.I)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_verdict(v: Any, mode: str) -> str:
|
|
44
|
+
"""Canonical label for a judge's raw output. Unparseable pairwise outputs become 'invalid'."""
|
|
45
|
+
if v is None:
|
|
46
|
+
return "invalid"
|
|
47
|
+
if isinstance(v, bool):
|
|
48
|
+
return "pass" if v else "fail"
|
|
49
|
+
if isinstance(v, (int, float)):
|
|
50
|
+
return str(int(v)) if float(v).is_integer() else str(v)
|
|
51
|
+
s = str(v).strip()
|
|
52
|
+
if mode == "pairwise":
|
|
53
|
+
if _PAIR_TIE.match(s):
|
|
54
|
+
return "tie"
|
|
55
|
+
if _PAIR_A.match(s):
|
|
56
|
+
return "A"
|
|
57
|
+
if _PAIR_B.match(s):
|
|
58
|
+
return "B"
|
|
59
|
+
if s in ("1", "first"):
|
|
60
|
+
return "A"
|
|
61
|
+
if s in ("2", "second"):
|
|
62
|
+
return "B"
|
|
63
|
+
return "invalid"
|
|
64
|
+
s = s.lower().strip(" .!\"'")
|
|
65
|
+
try:
|
|
66
|
+
f = float(s)
|
|
67
|
+
return str(int(f)) if f.is_integer() else str(f)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return s or "invalid"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalize_label(v: Any, mode: str) -> str:
|
|
73
|
+
if mode == "pairwise" and isinstance(v, str) and v.lower() in ("model_a", "a"):
|
|
74
|
+
return "A"
|
|
75
|
+
if mode == "pairwise" and isinstance(v, str) and v.lower() in ("model_b", "b"):
|
|
76
|
+
return "B"
|
|
77
|
+
return normalize_verdict(v, mode)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def unswap(label: str) -> str:
|
|
81
|
+
return {"A": "B", "B": "A"}.get(label, label)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _favorable(label: str | None, other: str | None, mode: str, pad_side: str | None) -> bool:
|
|
85
|
+
"""Did the verdict move toward the padded answer?"""
|
|
86
|
+
if label is None:
|
|
87
|
+
return False
|
|
88
|
+
if mode == "pairwise":
|
|
89
|
+
return label == pad_side and other != pad_side
|
|
90
|
+
try:
|
|
91
|
+
return other is not None and float(label) > float(other)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return label in POSITIVE and (other not in POSITIVE)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _mean(xs: list[float]) -> float | None:
|
|
97
|
+
return round(sum(xs) / len(xs), 4) if xs else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rate(k: int, n: int) -> dict[str, Any]:
|
|
101
|
+
ci = wilson(k, n)
|
|
102
|
+
return {
|
|
103
|
+
"k": k,
|
|
104
|
+
"n": n,
|
|
105
|
+
"rate": round(k / n, 4) if n else None,
|
|
106
|
+
"ci95": [round(ci[0], 4), round(ci[1], 4)] if ci else None,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_judgments(path: Path, requests_path: Path | None) -> list[dict[str, Any]]:
|
|
111
|
+
req_index: dict[str, dict[str, Any]] = {}
|
|
112
|
+
if requests_path and requests_path.exists():
|
|
113
|
+
req_index = {r["request_id"]: r for r in read_jsonl(requests_path)}
|
|
114
|
+
rows = []
|
|
115
|
+
for r in read_jsonl(path):
|
|
116
|
+
if "case_id" not in r and r.get("request_id") in req_index:
|
|
117
|
+
base = {
|
|
118
|
+
k: v
|
|
119
|
+
for k, v in req_index[r["request_id"]].items()
|
|
120
|
+
if k not in ("presented", "prompt")
|
|
121
|
+
}
|
|
122
|
+
r = {**base, **r}
|
|
123
|
+
if "judge" not in r or "case_id" not in r:
|
|
124
|
+
raise ValueError(f"judgment row missing judge/case_id: {r}")
|
|
125
|
+
rows.append(r)
|
|
126
|
+
return rows
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_labels(path: Path, mode_by_case: dict[str, str] | None = None) -> dict[str, str]:
|
|
130
|
+
"""Human labels per case. Several rows for one case are resolved by strict majority."""
|
|
131
|
+
raw: dict[str, list[str]] = defaultdict(list)
|
|
132
|
+
for r in read_jsonl(path):
|
|
133
|
+
cid = str(r["case_id"])
|
|
134
|
+
mode = r.get("mode") or (mode_by_case or {}).get(cid, "pointwise")
|
|
135
|
+
raw[cid].append(normalize_label(r.get("label"), mode))
|
|
136
|
+
out = {}
|
|
137
|
+
for cid, labels in raw.items():
|
|
138
|
+
lab, _ = majority(labels)
|
|
139
|
+
if lab is not None:
|
|
140
|
+
out[cid] = lab
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_judge(
|
|
145
|
+
rows: list[dict[str, Any]], labels: dict[str, str], th: Thresholds
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
mode = rows[0].get("mode", "pointwise")
|
|
148
|
+
by_probe: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
|
|
149
|
+
raw_presented: list[str] = []
|
|
150
|
+
pad_side: dict[str, str | None] = {}
|
|
151
|
+
invalid = 0
|
|
152
|
+
for r in rows:
|
|
153
|
+
label = normalize_verdict(r.get("verdict"), mode)
|
|
154
|
+
if label == "invalid":
|
|
155
|
+
invalid += 1
|
|
156
|
+
probe = r.get("probe") or "none"
|
|
157
|
+
if mode == "pairwise" and probe in ("none", "swap") and label in ("A", "B"):
|
|
158
|
+
raw_presented.append(label)
|
|
159
|
+
if probe == "swap":
|
|
160
|
+
label = unswap(label)
|
|
161
|
+
by_probe[probe][str(r["case_id"])].append(label)
|
|
162
|
+
if probe == "pad":
|
|
163
|
+
pad_side[str(r["case_id"])] = r.get("pad_side")
|
|
164
|
+
|
|
165
|
+
base = by_probe.get("none", {})
|
|
166
|
+
per_case: dict[str, dict[str, Any]] = {}
|
|
167
|
+
for cid, labs in base.items():
|
|
168
|
+
maj, share = majority(labs)
|
|
169
|
+
per_case[cid] = {
|
|
170
|
+
"case_id": cid,
|
|
171
|
+
"trials": len(labs),
|
|
172
|
+
"counts": dict(Counter(labs)),
|
|
173
|
+
"majority": maj,
|
|
174
|
+
"majority_share": round(share, 4),
|
|
175
|
+
"flipped": len(set(labs)) > 1,
|
|
176
|
+
"majority_of_3_stability": majority_vote_stability(Counter(labs), 3),
|
|
177
|
+
}
|
|
178
|
+
eligible = [c for c in per_case.values() if c["trials"] >= th.min_trials]
|
|
179
|
+
flipped = sum(1 for c in eligible if c["flipped"])
|
|
180
|
+
stability = {
|
|
181
|
+
"cases": len(eligible),
|
|
182
|
+
"trials_per_case": sorted({c["trials"] for c in per_case.values()}),
|
|
183
|
+
"flip_rate": _rate(flipped, len(eligible)),
|
|
184
|
+
"mean_self_agreement": round(sum(c["majority_share"] for c in eligible) / len(eligible), 4)
|
|
185
|
+
if eligible
|
|
186
|
+
else None,
|
|
187
|
+
"no_majority_cases": sum(1 for c in eligible if c["majority"] is None),
|
|
188
|
+
"majority_of_3_stability": _mean(
|
|
189
|
+
[
|
|
190
|
+
c["majority_of_3_stability"]
|
|
191
|
+
for c in eligible
|
|
192
|
+
if c["majority_of_3_stability"] is not None
|
|
193
|
+
]
|
|
194
|
+
),
|
|
195
|
+
"invalid_outputs": invalid,
|
|
196
|
+
"calls": len(rows),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# agreement with human labels, using each case's majority verdict
|
|
200
|
+
agreement: dict[str, Any] | None = None
|
|
201
|
+
labeled = [c for c in eligible if c["case_id"] in labels]
|
|
202
|
+
if labeled:
|
|
203
|
+
judge_labels = [c["majority"] or NO_MAJORITY for c in labeled]
|
|
204
|
+
human = [labels[c["case_id"]] for c in labeled]
|
|
205
|
+
for c in labeled:
|
|
206
|
+
c["human"] = labels[c["case_id"]]
|
|
207
|
+
correct = sum(j == h for j, h in zip(judge_labels, human, strict=True))
|
|
208
|
+
kappa, po, pe = cohen_kappa(judge_labels, human)
|
|
209
|
+
kci = kappa_interval(po, pe, len(labeled))
|
|
210
|
+
single = [(lab, labels[cid]) for cid, labs in base.items() if cid in labels for lab in labs]
|
|
211
|
+
agreement = {
|
|
212
|
+
"cases": len(labeled),
|
|
213
|
+
"accuracy": _rate(correct, len(labeled)),
|
|
214
|
+
"kappa": round(kappa, 4) if kappa is not None else None,
|
|
215
|
+
"kappa_ci95": [round(kci[0], 4), round(kci[1], 4)] if kci else None,
|
|
216
|
+
"chance_agreement": round(pe, 4),
|
|
217
|
+
"single_call_accuracy": round(sum(a == b for a, b in single) / len(single), 4)
|
|
218
|
+
if single
|
|
219
|
+
else None,
|
|
220
|
+
"human_label_counts": dict(Counter(human)),
|
|
221
|
+
"judge_label_counts": dict(Counter(judge_labels)),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# position probe: majority in swapped order vs original order
|
|
225
|
+
position: dict[str, Any] | None = None
|
|
226
|
+
if mode == "pairwise" and by_probe.get("swap"):
|
|
227
|
+
pairs = []
|
|
228
|
+
for cid, labs in by_probe["swap"].items():
|
|
229
|
+
smaj, _ = majority(labs)
|
|
230
|
+
if cid in per_case:
|
|
231
|
+
per_case[cid]["swap_majority"] = smaj
|
|
232
|
+
pairs.append((per_case[cid]["majority"], smaj))
|
|
233
|
+
consistent = sum(1 for a, b in pairs if a is not None and a == b)
|
|
234
|
+
first = sum(1 for x in raw_presented if x == "A")
|
|
235
|
+
position = {
|
|
236
|
+
"cases": len(pairs),
|
|
237
|
+
"consistency": _rate(consistent, len(pairs)),
|
|
238
|
+
"first_position_rate": _rate(first, len(raw_presented)),
|
|
239
|
+
"note": "consistency = share of cases whose majority verdict survives swapping the "
|
|
240
|
+
"answer order; first_position_rate = share of A/B verdicts naming whichever "
|
|
241
|
+
"answer was shown first (0.5 means no position preference)",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# verbosity probe: majority with irrelevant padding vs original
|
|
245
|
+
verbosity: dict[str, Any] | None = None
|
|
246
|
+
if by_probe.get("pad"):
|
|
247
|
+
moved = toward = 0
|
|
248
|
+
n = 0
|
|
249
|
+
for cid, labs in by_probe["pad"].items():
|
|
250
|
+
pmaj, _ = majority(labs)
|
|
251
|
+
if cid not in per_case:
|
|
252
|
+
continue
|
|
253
|
+
n += 1
|
|
254
|
+
orig = per_case[cid]["majority"]
|
|
255
|
+
per_case[cid]["pad_majority"] = pmaj
|
|
256
|
+
if pmaj != orig:
|
|
257
|
+
moved += 1
|
|
258
|
+
if _favorable(pmaj, orig, mode, pad_side.get(cid)):
|
|
259
|
+
toward += 1
|
|
260
|
+
verbosity = {
|
|
261
|
+
"cases": n,
|
|
262
|
+
"changed": _rate(moved, n),
|
|
263
|
+
"toward_padded": _rate(toward, n),
|
|
264
|
+
"note": "padding appends an irrelevant paragraph; toward_padded = share of cases "
|
|
265
|
+
"where the padded answer's verdict improved",
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
verdict, reasons, checks = _verdict(stability, agreement, position, verbosity, th)
|
|
269
|
+
return {
|
|
270
|
+
"mode": mode,
|
|
271
|
+
"verdict": verdict,
|
|
272
|
+
"reasons": reasons,
|
|
273
|
+
"checks": checks,
|
|
274
|
+
"stability": stability,
|
|
275
|
+
"human_agreement": agreement,
|
|
276
|
+
"position_probe": position,
|
|
277
|
+
"verbosity_probe": verbosity,
|
|
278
|
+
"cases": sorted(per_case.values(), key=lambda c: c["case_id"]),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _verdict(
|
|
283
|
+
stability: dict[str, Any],
|
|
284
|
+
agreement: dict[str, Any] | None,
|
|
285
|
+
position: dict[str, Any] | None,
|
|
286
|
+
verbosity: dict[str, Any] | None,
|
|
287
|
+
th: Thresholds,
|
|
288
|
+
) -> tuple[str, list[str], dict[str, str]]:
|
|
289
|
+
reasons: list[str] = []
|
|
290
|
+
checks: dict[str, str] = {}
|
|
291
|
+
if stability["cases"] < th.min_cases:
|
|
292
|
+
checks["stability"] = "not enough data"
|
|
293
|
+
reasons.append(
|
|
294
|
+
f"only {stability['cases']} case(s) with >= {th.min_trials} trials "
|
|
295
|
+
f"(need {th.min_cases})"
|
|
296
|
+
)
|
|
297
|
+
return "not_enough_data", reasons, checks
|
|
298
|
+
fr = stability["flip_rate"]["rate"]
|
|
299
|
+
unstable = fr > th.max_flip_rate
|
|
300
|
+
checks["stability"] = "fail" if unstable else "pass"
|
|
301
|
+
if unstable:
|
|
302
|
+
reasons.append(
|
|
303
|
+
f"verdict changed across repeated trials on {fr:.0%} of cases "
|
|
304
|
+
f"(limit {th.max_flip_rate:.0%})"
|
|
305
|
+
)
|
|
306
|
+
biased = False
|
|
307
|
+
if position and position["cases"] >= th.min_cases:
|
|
308
|
+
pc = position["consistency"]["rate"]
|
|
309
|
+
if pc < th.min_position_consistency:
|
|
310
|
+
biased = True
|
|
311
|
+
checks["position"] = "fail"
|
|
312
|
+
reasons.append(
|
|
313
|
+
f"verdict survived swapping answer order on only {pc:.0%} of cases "
|
|
314
|
+
f"(need {th.min_position_consistency:.0%}); first-shown answer "
|
|
315
|
+
f"picked {position['first_position_rate']['rate']:.0%} of the time"
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
checks["position"] = "pass"
|
|
319
|
+
else:
|
|
320
|
+
checks["position"] = "not run" if not position else "not enough data"
|
|
321
|
+
if verbosity and verbosity["cases"] >= th.min_cases:
|
|
322
|
+
tp = verbosity["toward_padded"]["rate"]
|
|
323
|
+
if tp > th.max_toward_padded_rate:
|
|
324
|
+
biased = True
|
|
325
|
+
checks["verbosity"] = "fail"
|
|
326
|
+
reasons.append(
|
|
327
|
+
f"irrelevant padding moved the verdict toward the padded answer on "
|
|
328
|
+
f"{tp:.0%} of cases (limit {th.max_toward_padded_rate:.0%})"
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
checks["verbosity"] = "pass"
|
|
332
|
+
else:
|
|
333
|
+
checks["verbosity"] = "not run" if not verbosity else "not enough data"
|
|
334
|
+
misaligned = False
|
|
335
|
+
if agreement and agreement["cases"] >= th.min_labeled:
|
|
336
|
+
k = agreement["kappa"]
|
|
337
|
+
if k is None or k < th.min_kappa:
|
|
338
|
+
misaligned = True
|
|
339
|
+
checks["human_agreement"] = "fail"
|
|
340
|
+
reasons.append(
|
|
341
|
+
f"agreement with human labels is low: kappa "
|
|
342
|
+
f"{'undefined' if k is None else f'{k:.2f}'} (need {th.min_kappa}), "
|
|
343
|
+
f"accuracy {agreement['accuracy']['rate']:.0%}"
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
checks["human_agreement"] = "pass"
|
|
347
|
+
else:
|
|
348
|
+
checks["human_agreement"] = "not enough data"
|
|
349
|
+
if unstable:
|
|
350
|
+
return "unstable", reasons, checks
|
|
351
|
+
if biased:
|
|
352
|
+
return "biased", reasons, checks
|
|
353
|
+
if misaligned:
|
|
354
|
+
return "misaligned", reasons, checks
|
|
355
|
+
if checks["human_agreement"] != "pass":
|
|
356
|
+
have = agreement["cases"] if agreement else 0
|
|
357
|
+
reasons.append(
|
|
358
|
+
f"stable, but only {have} case(s) have human labels (need "
|
|
359
|
+
f"{th.min_labeled}); cannot tell whether it measures the right thing"
|
|
360
|
+
)
|
|
361
|
+
return "not_enough_data", reasons, checks
|
|
362
|
+
k = agreement["kappa"] if agreement else None
|
|
363
|
+
reasons.append(
|
|
364
|
+
f"stable (flip rate {fr:.0%}), kappa {k:.2f} with humans"
|
|
365
|
+
+ (
|
|
366
|
+
""
|
|
367
|
+
if checks["position"] == "pass" or checks["verbosity"] == "pass"
|
|
368
|
+
else "; bias probes not run"
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
return "trustworthy", reasons, checks
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def judge_check(
|
|
375
|
+
workspace: str | Path,
|
|
376
|
+
judgments: str | Path | None = None,
|
|
377
|
+
labels: str | Path | None = None,
|
|
378
|
+
thresholds: Thresholds | None = None,
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
ws = Workspace.at(workspace)
|
|
381
|
+
th = thresholds or Thresholds()
|
|
382
|
+
jpath = Path(judgments) if judgments else ws.judgments
|
|
383
|
+
if not jpath.exists():
|
|
384
|
+
raise FileNotFoundError(f"{jpath} not found; record judge outputs first")
|
|
385
|
+
rows = _load_judgments(jpath, ws.judge_requests)
|
|
386
|
+
by_judge: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
387
|
+
for r in rows:
|
|
388
|
+
by_judge[str(r["judge"])].append(r)
|
|
389
|
+
mode_by_case = {str(r["case_id"]): r.get("mode", "pointwise") for r in rows}
|
|
390
|
+
lpath = Path(labels) if labels else ws.labels
|
|
391
|
+
human = load_labels(lpath, mode_by_case) if lpath.exists() else {}
|
|
392
|
+
judges = {jid: check_judge(jrows, human, th) for jid, jrows in sorted(by_judge.items())}
|
|
393
|
+
result = {
|
|
394
|
+
"judgments_file": str(jpath),
|
|
395
|
+
"labels_file": str(lpath) if lpath.exists() else None,
|
|
396
|
+
"labeled_cases": len(human),
|
|
397
|
+
"thresholds": asdict(th),
|
|
398
|
+
"summary": [
|
|
399
|
+
{
|
|
400
|
+
"judge": jid,
|
|
401
|
+
"mode": r["mode"],
|
|
402
|
+
"verdict": r["verdict"],
|
|
403
|
+
"flip_rate": r["stability"]["flip_rate"]["rate"],
|
|
404
|
+
"kappa": (r["human_agreement"] or {}).get("kappa"),
|
|
405
|
+
"accuracy": ((r["human_agreement"] or {}).get("accuracy") or {}).get("rate"),
|
|
406
|
+
"position_consistency": ((r["position_probe"] or {}).get("consistency") or {}).get(
|
|
407
|
+
"rate"
|
|
408
|
+
),
|
|
409
|
+
"toward_padded": ((r["verbosity_probe"] or {}).get("toward_padded") or {}).get(
|
|
410
|
+
"rate"
|
|
411
|
+
),
|
|
412
|
+
"reasons": r["reasons"],
|
|
413
|
+
}
|
|
414
|
+
for jid, r in judges.items()
|
|
415
|
+
],
|
|
416
|
+
"judges": judges,
|
|
417
|
+
"trustworthy": [j for j, r in judges.items() if r["verdict"] == "trustworthy"],
|
|
418
|
+
}
|
|
419
|
+
write_json(ws.judge_check, result)
|
|
420
|
+
return result
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Build the list of judge calls (with probes) that someone has to run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..draft import load_rubric, ready_cases
|
|
9
|
+
from ..io import write_jsonl
|
|
10
|
+
from ..workspace import Workspace
|
|
11
|
+
|
|
12
|
+
PROBES = ("swap", "pad")
|
|
13
|
+
|
|
14
|
+
# Deliberately irrelevant filler for the verbosity probe. It adds length without
|
|
15
|
+
# adding anything that answers the question, so a judge's verdict should not move.
|
|
16
|
+
PAD_TEXT = (
|
|
17
|
+
"Additional background: The metric system was first adopted in France in 1795, and "
|
|
18
|
+
"the meter was originally defined as one ten-millionth of the distance from the equator "
|
|
19
|
+
"to the North Pole along a meridian. Over the following two centuries most countries "
|
|
20
|
+
"adopted it for trade and science. Standard units have been redefined several times, "
|
|
21
|
+
"most recently in 2019, when the kilogram, ampere, kelvin and mole were tied to fixed "
|
|
22
|
+
"values of physical constants. Many everyday objects, from paper sizes to bottle volumes, "
|
|
23
|
+
"follow metric conventions. This background is provided for completeness."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pad(text: str) -> str:
|
|
28
|
+
return f"{text.rstrip()}\n\n{PAD_TEXT}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render(template: str | None, fields: dict[str, str]) -> str | None:
|
|
32
|
+
if not template:
|
|
33
|
+
return None
|
|
34
|
+
out = template
|
|
35
|
+
for k, v in fields.items():
|
|
36
|
+
out = out.replace("{" + k + "}", v)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _presented(case: dict[str, Any], mode: str, probe: str, pad_side: str | None) -> dict[str, str]:
|
|
41
|
+
inp = str(case.get("input", ""))
|
|
42
|
+
out = str(case.get("observed_output") or "")
|
|
43
|
+
if mode == "pointwise":
|
|
44
|
+
return {"input": inp, "output": pad(out) if probe == "pad" else out}
|
|
45
|
+
a, b = out, str(case.get("compare_output") or "")
|
|
46
|
+
if probe == "pad":
|
|
47
|
+
if pad_side == "A":
|
|
48
|
+
a = pad(a)
|
|
49
|
+
else:
|
|
50
|
+
b = pad(b)
|
|
51
|
+
if probe == "swap":
|
|
52
|
+
a, b = b, a
|
|
53
|
+
return {"input": inp, "answer_a": a, "answer_b": b}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_requests(
|
|
57
|
+
cases: list[dict[str, Any]],
|
|
58
|
+
rubric: dict[str, Any],
|
|
59
|
+
judges: list[str] | None = None,
|
|
60
|
+
trials: int = 5,
|
|
61
|
+
probes: list[str] | None = None,
|
|
62
|
+
probe_trials: int = 3,
|
|
63
|
+
) -> list[dict[str, Any]]:
|
|
64
|
+
if trials < 1 or probe_trials < 1:
|
|
65
|
+
raise ValueError("trials and probe_trials must be >= 1")
|
|
66
|
+
probes = list(probes or [])
|
|
67
|
+
for p in probes:
|
|
68
|
+
if p not in PROBES:
|
|
69
|
+
raise ValueError(f"unknown probe {p!r}; expected one of {PROBES}")
|
|
70
|
+
all_judges = {j["id"]: j for j in rubric.get("judges") or []}
|
|
71
|
+
if not all_judges:
|
|
72
|
+
raise ValueError("rubric.yaml defines no judges")
|
|
73
|
+
chosen = judges or list(all_judges)
|
|
74
|
+
missing = [j for j in chosen if j not in all_judges]
|
|
75
|
+
if missing:
|
|
76
|
+
raise ValueError(f"judges not in rubric.yaml: {missing}")
|
|
77
|
+
crit_desc = {c["id"]: c.get("description", "") for c in rubric.get("criteria") or []}
|
|
78
|
+
rows: list[dict[str, Any]] = []
|
|
79
|
+
for jid in chosen:
|
|
80
|
+
judge = all_judges[jid]
|
|
81
|
+
mode = judge.get("mode", "pointwise")
|
|
82
|
+
for ci, case in enumerate(cases):
|
|
83
|
+
if mode == "pairwise" and not case.get("compare_output"):
|
|
84
|
+
continue
|
|
85
|
+
criteria = [c for c in case.get("criteria") or [] if c in crit_desc]
|
|
86
|
+
criteria_text = "\n".join(f"- {c}: {crit_desc[c]}" for c in criteria)
|
|
87
|
+
context_text = (
|
|
88
|
+
"\n\n".join(
|
|
89
|
+
f"{m.get('role', 'user')}: {m.get('content', '')}"
|
|
90
|
+
for m in case.get("context") or []
|
|
91
|
+
)
|
|
92
|
+
or "(none)"
|
|
93
|
+
)
|
|
94
|
+
# swap only makes sense for pairwise judges
|
|
95
|
+
plan = [("none", trials)] + [
|
|
96
|
+
(p, probe_trials) for p in probes if not (p == "swap" and mode != "pairwise")
|
|
97
|
+
]
|
|
98
|
+
for probe, n in plan:
|
|
99
|
+
pad_side = None
|
|
100
|
+
if probe == "pad":
|
|
101
|
+
pad_side = "output" if mode == "pointwise" else ("A" if ci % 2 == 0 else "B")
|
|
102
|
+
shown = _presented(case, mode, probe, pad_side)
|
|
103
|
+
fields = {
|
|
104
|
+
**shown,
|
|
105
|
+
"context": context_text,
|
|
106
|
+
"expected_behavior": str(case.get("expected_behavior", "")),
|
|
107
|
+
"criteria": criteria_text,
|
|
108
|
+
}
|
|
109
|
+
for t in range(n):
|
|
110
|
+
rows.append(
|
|
111
|
+
{
|
|
112
|
+
"request_id": f"{jid}:{case['id']}:{probe}:{t}",
|
|
113
|
+
"judge": jid,
|
|
114
|
+
"case_id": case["id"],
|
|
115
|
+
"mode": mode,
|
|
116
|
+
"probe": probe,
|
|
117
|
+
"trial": t,
|
|
118
|
+
"pad_side": pad_side,
|
|
119
|
+
"labels": judge.get("labels") or [],
|
|
120
|
+
"presented": shown,
|
|
121
|
+
"expected_behavior": case.get("expected_behavior"),
|
|
122
|
+
"criteria": criteria,
|
|
123
|
+
"prompt": render(judge.get("prompt"), fields),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
return rows
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def judge_plan(
|
|
130
|
+
workspace: str | Path,
|
|
131
|
+
judges: list[str] | None = None,
|
|
132
|
+
trials: int = 5,
|
|
133
|
+
probes: list[str] | None = None,
|
|
134
|
+
probe_trials: int = 3,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
ws = Workspace.at(workspace)
|
|
137
|
+
rows = build_requests(
|
|
138
|
+
ready_cases(ws.root), load_rubric(ws.root), judges, trials, probes, probe_trials
|
|
139
|
+
)
|
|
140
|
+
write_jsonl(ws.judge_requests, rows)
|
|
141
|
+
per_judge: dict[str, int] = {}
|
|
142
|
+
for r in rows:
|
|
143
|
+
per_judge[r["judge"]] = per_judge.get(r["judge"], 0) + 1
|
|
144
|
+
return {
|
|
145
|
+
"requests_file": str(ws.judge_requests),
|
|
146
|
+
"requests": len(rows),
|
|
147
|
+
"per_judge": per_judge,
|
|
148
|
+
"next": "run every request and append {request_id, verdict} rows (plus the request "
|
|
149
|
+
"fields) to judgments.jsonl, or use `eval-builder judge-run` with your own "
|
|
150
|
+
"judge command, then `eval-builder judge-check`",
|
|
151
|
+
}
|