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/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
+ }