evalproof 0.2.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.
evalproof/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """evalproof: treat LLM evaluation as measurement.
2
+
3
+ An LLM judge is a classifier with error rates. evalproof calibrates it against a
4
+ small gold-labelled sample, reports prevalence-corrected pass rates with
5
+ confidence intervals, compares runs on paired cases, and gates CI on
6
+ statistically meaningful regressions rather than on noise.
7
+
8
+ The names below are the supported Python API; everything else is internal and may
9
+ change between minor versions.
10
+ """
11
+
12
+ from evalproof._version import __version__
13
+ from evalproof.calibration import CalibrationReport, Confusion, Estimate, calibrate
14
+ from evalproof.compare import Comparison, MetricComparison, RunStats, compare_runs
15
+ from evalproof.config import ConfigError, GateConfig, SuiteConfig, load_config
16
+ from evalproof.dataset import DatasetError, load_dataset
17
+ from evalproof.gate import (
18
+ EXIT_BUDGET,
19
+ EXIT_CANNOT_GATE,
20
+ EXIT_PASS,
21
+ EXIT_REGRESSION,
22
+ EXIT_USAGE,
23
+ GateDecision,
24
+ decide,
25
+ )
26
+ from evalproof.models import Case, CaseResult, Run, RunInfo, Score, SystemOutput
27
+ from evalproof.report import calibration_markdown, comparison_markdown, run_markdown
28
+ from evalproof.runner import load_run, run_suite, save_run
29
+ from evalproof.stats import mcnemar_exact, paired_bootstrap, wilson
30
+
31
+ __all__ = [
32
+ "EXIT_BUDGET",
33
+ "EXIT_CANNOT_GATE",
34
+ "EXIT_PASS",
35
+ "EXIT_REGRESSION",
36
+ "EXIT_USAGE",
37
+ "CalibrationReport",
38
+ "Case",
39
+ "CaseResult",
40
+ "Comparison",
41
+ "ConfigError",
42
+ "Confusion",
43
+ "DatasetError",
44
+ "Estimate",
45
+ "GateConfig",
46
+ "GateDecision",
47
+ "MetricComparison",
48
+ "Run",
49
+ "RunInfo",
50
+ "RunStats",
51
+ "Score",
52
+ "SuiteConfig",
53
+ "SystemOutput",
54
+ "__version__",
55
+ "calibrate",
56
+ "calibration_markdown",
57
+ "compare_runs",
58
+ "comparison_markdown",
59
+ "decide",
60
+ "load_config",
61
+ "load_dataset",
62
+ "load_run",
63
+ "mcnemar_exact",
64
+ "paired_bootstrap",
65
+ "run_markdown",
66
+ "run_suite",
67
+ "save_run",
68
+ "wilson",
69
+ ]
evalproof/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The package version: the single place it is written. pyproject.toml reads it at build time."""
2
+
3
+ __version__ = "0.2.0"
evalproof/cache.py ADDED
@@ -0,0 +1,89 @@
1
+ """Content-addressed response cache.
2
+
3
+ The key is everything that determines a model's reply: provider, model, endpoint,
4
+ messages and generation parameters. Re-running an unchanged suite costs nothing and
5
+ returns byte-identical results; changing one prompt re-runs only the calls it touches.
6
+
7
+ A hit returns the *original* latency and token counts, flagged ``cached=True``, so a
8
+ re-run reproduces the run artifact; the run's own wall time shows the real speed-up.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from evalproof.config import GenerationParams
18
+ from evalproof.providers.base import ChatResponse, Message, Provider
19
+
20
+
21
+ def cache_key(
22
+ provider: str,
23
+ model: str,
24
+ base_url: str | None,
25
+ messages: list[Message],
26
+ params: GenerationParams,
27
+ ) -> str:
28
+ material = {
29
+ "provider": provider,
30
+ "model": model,
31
+ "base_url": base_url,
32
+ "messages": [m.model_dump() for m in messages],
33
+ "params": params.model_dump(mode="json"),
34
+ }
35
+ blob = json.dumps(material, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
36
+ return hashlib.sha256(blob.encode()).hexdigest()
37
+
38
+
39
+ class ResponseCache:
40
+ def __init__(self, root: str | Path) -> None:
41
+ self.root = Path(root)
42
+
43
+ def _path(self, key: str) -> Path:
44
+ return self.root / key[:2] / f"{key}.json"
45
+
46
+ def get(self, key: str) -> ChatResponse | None:
47
+ path = self._path(key)
48
+ if not path.exists():
49
+ return None
50
+ try:
51
+ resp = ChatResponse.model_validate_json(path.read_text(encoding="utf-8"))
52
+ except ValueError:
53
+ return None # a torn write is a miss, not a crash
54
+ return resp.model_copy(update={"cached": True})
55
+
56
+ def put(self, key: str, response: ChatResponse) -> None:
57
+ path = self._path(key)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ tmp = path.with_suffix(".tmp")
60
+ tmp.write_text(response.model_copy(update={"cached": False}).model_dump_json(), "utf-8")
61
+ tmp.replace(path)
62
+
63
+ def stats(self) -> dict[str, int]:
64
+ files = list(self.root.glob("*/*.json")) if self.root.exists() else []
65
+ return {"entries": len(files), "bytes": sum(f.stat().st_size for f in files)}
66
+
67
+
68
+ class CachedProvider:
69
+ """Wraps any provider; transparent on a miss, free on a hit."""
70
+
71
+ def __init__(self, inner: Provider, cache: ResponseCache, base_url: str | None = None) -> None:
72
+ self.inner = inner
73
+ self.cache = cache
74
+ self.name = inner.name
75
+ self.model = inner.model
76
+ self.base_url = base_url
77
+ self.hits = 0
78
+ self.misses = 0
79
+
80
+ def chat(self, messages: list[Message], params: GenerationParams) -> ChatResponse:
81
+ key = cache_key(self.name, self.model, self.base_url, messages, params)
82
+ hit = self.cache.get(key)
83
+ if hit is not None:
84
+ self.hits += 1
85
+ return hit
86
+ self.misses += 1
87
+ resp = self.inner.chat(messages, params)
88
+ self.cache.put(key, resp)
89
+ return resp
@@ -0,0 +1,266 @@
1
+ """Judge calibration: measure the judge against gold labels, then correct its pass rate.
2
+
3
+ A judge's raw pass rate is classify-and-count. If the judge passes bad answers at
4
+ rate FPR and passes good answers at rate TPR, the raw rate converges to
5
+ ``q = FPR + (TPR - FPR) * p``, not to the true pass rate ``p``. Two corrections:
6
+
7
+ * **ACC** (adjusted classify-and-count, Rogan–Gladen): ``p = (q - FPR) / (TPR - FPR)``,
8
+ with TPR and FPR estimated on the gold sample. Bootstrap interval.
9
+ * **PPI** (prediction-powered inference, Angelopoulos et al., 2023): the judge's mean on
10
+ the unlabelled cases plus the mean label-minus-judge residual on the gold sample.
11
+ Needs no assumption about the judge; unbiased whenever the gold sample is random.
12
+
13
+ Background: https://basantbhattarai.com.np/writing/count-prevalence-not-classifications/
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import random
19
+ from dataclasses import dataclass, field
20
+
21
+ import numpy as np
22
+
23
+ from evalproof.models import Run
24
+ from evalproof.stats import FloatArray, wilson, z_for
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Confusion:
29
+ tp: int # judge pass, gold pass
30
+ fp: int # judge pass, gold fail
31
+ fn: int # judge fail, gold pass
32
+ tn: int # judge fail, gold fail
33
+
34
+ @property
35
+ def n(self) -> int:
36
+ return self.tp + self.fp + self.fn + self.tn
37
+
38
+ @property
39
+ def tpr(self) -> float | None:
40
+ pos = self.tp + self.fn
41
+ return self.tp / pos if pos else None
42
+
43
+ @property
44
+ def fpr(self) -> float | None:
45
+ neg = self.fp + self.tn
46
+ return self.fp / neg if neg else None
47
+
48
+ @property
49
+ def accuracy(self) -> float | None:
50
+ return (self.tp + self.tn) / self.n if self.n else None
51
+
52
+ @property
53
+ def kappa(self) -> float | None:
54
+ """Cohen's kappa: agreement beyond what the two marginals give by chance."""
55
+ n = self.n
56
+ if not n:
57
+ return None
58
+ po = (self.tp + self.tn) / n
59
+ pe = (
60
+ (self.tp + self.fp) * (self.tp + self.fn) + (self.fn + self.tn) * (self.fp + self.tn)
61
+ ) / (n * n)
62
+ return None if pe == 1 else (po - pe) / (1 - pe)
63
+
64
+
65
+ def confusion(judge: list[bool], gold: list[bool]) -> Confusion:
66
+ if len(judge) != len(gold):
67
+ raise ValueError("judge and gold must be paired")
68
+ tp = sum(j and g for j, g in zip(judge, gold, strict=True))
69
+ fp = sum(j and not g for j, g in zip(judge, gold, strict=True))
70
+ fn = sum((not j) and g for j, g in zip(judge, gold, strict=True))
71
+ tn = sum((not j) and (not g) for j, g in zip(judge, gold, strict=True))
72
+ return Confusion(tp, fp, fn, tn)
73
+
74
+
75
+ def acc_point(q: float, tpr: float, fpr: float) -> float | None:
76
+ """Rogan–Gladen. None when the judge is no better than chance (TPR <= FPR)."""
77
+ if tpr - fpr <= 1e-12:
78
+ return None
79
+ return min(1.0, max(0.0, (q - fpr) / (tpr - fpr)))
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Estimate:
84
+ method: str
85
+ point: float | None
86
+ low: float | None
87
+ high: float | None
88
+ n: int
89
+ note: str = ""
90
+
91
+
92
+ def raw_estimate(judge_all: list[bool], alpha: float = 0.05) -> Estimate:
93
+ k, n = sum(judge_all), len(judge_all)
94
+ lo, hi = wilson(k, n, alpha)
95
+ return Estimate("raw judge pass rate", k / n if n else None, lo, hi, n)
96
+
97
+
98
+ def gold_only_estimate(gold: list[bool], alpha: float = 0.05) -> Estimate:
99
+ k, n = sum(gold), len(gold)
100
+ lo, hi = wilson(k, n, alpha)
101
+ return Estimate("gold labels only", k / n if n else None, lo, hi, n)
102
+
103
+
104
+ def acc_estimate(
105
+ judge_all: list[bool],
106
+ judge_gold: list[bool],
107
+ gold: list[bool],
108
+ *,
109
+ alpha: float = 0.05,
110
+ n_boot: int = 5000,
111
+ seed: int = 0,
112
+ ) -> Estimate:
113
+ """ACC point estimate with a bootstrap interval that resamples both the full judged set
114
+ (uncertainty in q) and the gold sample (uncertainty in TPR and FPR)."""
115
+ cm = confusion(judge_gold, gold)
116
+ q = sum(judge_all) / len(judge_all)
117
+ if cm.tpr is None or cm.fpr is None:
118
+ return Estimate("ACC", None, None, None, len(judge_all), "gold sample has only one class")
119
+ point = acc_point(q, cm.tpr, cm.fpr)
120
+ if point is None:
121
+ return Estimate("ACC", None, None, None, len(judge_all), "judge no better than chance")
122
+ rng = np.random.default_rng(seed)
123
+ ja = np.asarray(judge_all, dtype=np.float64)
124
+ jg = np.asarray(judge_gold, dtype=bool)
125
+ gg = np.asarray(gold, dtype=bool)
126
+ reps: list[float] = []
127
+ undefined = 0
128
+ for _ in range(n_boot):
129
+ qa = float(ja[rng.integers(0, ja.size, ja.size)].mean())
130
+ idx = rng.integers(0, gg.size, gg.size)
131
+ j, g = jg[idx], gg[idx]
132
+ pos, neg = int(g.sum()), int((~g).sum())
133
+ if pos == 0 or neg == 0:
134
+ undefined += 1
135
+ continue
136
+ est = acc_point(qa, float((j & g).sum() / pos), float((j & ~g).sum() / neg))
137
+ if est is None:
138
+ undefined += 1
139
+ continue
140
+ reps.append(est)
141
+ if not reps:
142
+ return Estimate("ACC", point, None, None, len(judge_all), "no defined bootstrap replicates")
143
+ lo, hi = np.quantile(np.asarray(reps), [alpha / 2, 1 - alpha / 2])
144
+ note = f"{undefined}/{n_boot} replicates undefined" if undefined else ""
145
+ return Estimate("ACC", point, float(lo), float(hi), len(judge_all), note)
146
+
147
+
148
+ def ppi_estimate(
149
+ judge_unlabelled: list[bool],
150
+ judge_labelled: list[bool],
151
+ gold: list[bool],
152
+ *,
153
+ alpha: float = 0.05,
154
+ ) -> Estimate:
155
+ """Prediction-powered mean: ``mean(f_unlabelled) + mean(y - f_labelled)``, normal CI."""
156
+ if not judge_labelled or not judge_unlabelled:
157
+ return Estimate("PPI", None, None, None, 0, "needs labelled and unlabelled cases")
158
+ fu: FloatArray = np.asarray(judge_unlabelled, dtype=np.float64)
159
+ fl: FloatArray = np.asarray(judge_labelled, dtype=np.float64)
160
+ y: FloatArray = np.asarray(gold, dtype=np.float64)
161
+ resid = y - fl
162
+ point = float(fu.mean() + resid.mean())
163
+ var_u = float(fu.var(ddof=1)) if fu.size > 1 else 0.0
164
+ var_r = float(resid.var(ddof=1)) if resid.size > 1 else 0.0
165
+ se = (var_u / fu.size + var_r / resid.size) ** 0.5
166
+ z = z_for(alpha)
167
+ n = int(fu.size + fl.size)
168
+ return Estimate(
169
+ "PPI", min(1.0, max(0.0, point)), max(0.0, point - z * se), min(1.0, point + z * se), n
170
+ )
171
+
172
+
173
+ @dataclass
174
+ class CalibrationReport:
175
+ judge_scorer: str
176
+ gold_source: str
177
+ n_judged: int
178
+ n_gold: int
179
+ undecided_judge: int
180
+ confusion: Confusion
181
+ estimates: list[Estimate]
182
+ truth: Estimate | None = None
183
+ disagreements: list[str] = field(default_factory=list)
184
+
185
+
186
+ def calibrate(
187
+ run: Run,
188
+ judge_scorer: str,
189
+ *,
190
+ labels: dict[str, bool] | None = None,
191
+ gold_scorer: str | None = None,
192
+ gold_sample: int | None = None,
193
+ seed: int = 0,
194
+ alpha: float = 0.05,
195
+ n_boot: int = 5000,
196
+ ) -> CalibrationReport:
197
+ """Calibrate ``judge_scorer`` against human ``labels`` or a trusted ``gold_scorer``.
198
+
199
+ With ``gold_scorer`` and ``gold_sample``, a random sample of that size stands in for a
200
+ human-reviewed set; the remaining gold labels are used only to report the truth the
201
+ estimates are trying to recover.
202
+ """
203
+ if (labels is None) == (gold_scorer is None):
204
+ raise ValueError("pass exactly one of labels or gold_scorer")
205
+ judged: dict[str, bool] = {}
206
+ undecided = 0
207
+ for r in run.results:
208
+ s = r.scores.get(judge_scorer)
209
+ if s is None:
210
+ continue
211
+ if s.passed is None:
212
+ undecided += 1
213
+ else:
214
+ judged[r.case_id] = s.passed
215
+ if not judged:
216
+ raise ValueError(f"no decided verdicts from scorer {judge_scorer!r}")
217
+
218
+ truth_all: dict[str, bool] = {}
219
+ if labels is not None:
220
+ gold_ids = [cid for cid in labels if cid in judged]
221
+ gold_map = {cid: labels[cid] for cid in gold_ids}
222
+ source = "labels file"
223
+ else:
224
+ assert gold_scorer is not None
225
+ for r in run.results:
226
+ s = r.scores.get(gold_scorer)
227
+ if s is not None and s.passed is not None and r.case_id in judged:
228
+ truth_all[r.case_id] = s.passed
229
+ ids = sorted(truth_all)
230
+ if gold_sample is not None and gold_sample < len(ids):
231
+ ids = sorted(random.Random(seed).sample(ids, gold_sample))
232
+ source = f"{gold_scorer} (random sample of {gold_sample})"
233
+ else:
234
+ source = gold_scorer
235
+ gold_map = {cid: truth_all[cid] for cid in ids}
236
+
237
+ if not gold_map:
238
+ raise ValueError("no overlap between gold labels and judged cases")
239
+ lab_ids = sorted(gold_map)
240
+ unl_ids = sorted(set(judged) - set(lab_ids))
241
+ jl = [judged[c] for c in lab_ids]
242
+ gl = [gold_map[c] for c in lab_ids]
243
+ ja = [judged[c] for c in sorted(judged)]
244
+ estimates = [
245
+ raw_estimate(ja, alpha),
246
+ gold_only_estimate(gl, alpha),
247
+ acc_estimate(ja, jl, gl, alpha=alpha, n_boot=n_boot, seed=seed),
248
+ ppi_estimate([judged[c] for c in unl_ids], jl, gl, alpha=alpha),
249
+ ]
250
+ truth = None
251
+ if truth_all and len(truth_all) > len(gold_map):
252
+ k = sum(truth_all.values())
253
+ lo, hi = wilson(k, len(truth_all), alpha)
254
+ truth = Estimate("truth (all gold labels)", k / len(truth_all), lo, hi, len(truth_all))
255
+ disagreements = [c for c in lab_ids if judged[c] != gold_map[c]]
256
+ return CalibrationReport(
257
+ judge_scorer=judge_scorer,
258
+ gold_source=source,
259
+ n_judged=len(judged),
260
+ n_gold=len(gold_map),
261
+ undecided_judge=undecided,
262
+ confusion=confusion(jl, gl),
263
+ estimates=estimates,
264
+ truth=truth,
265
+ disagreements=disagreements,
266
+ )
evalproof/cli.py ADDED
@@ -0,0 +1,265 @@
1
+ """Command line: ``evalproof run | compare | calibrate | report | pairwise``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from evalproof._version import __version__
12
+ from evalproof.cache import CachedProvider, ResponseCache
13
+ from evalproof.calibration import calibrate
14
+ from evalproof.compare import compare_runs
15
+ from evalproof.config import ConfigError, GateConfig, load_config
16
+ from evalproof.dataset import DatasetError, load_dataset
17
+ from evalproof.gate import EXIT_PASS, EXIT_USAGE, decide
18
+ from evalproof.models import CaseResult
19
+ from evalproof.providers import ProviderError, make_provider
20
+ from evalproof.report import (
21
+ calibration_markdown,
22
+ comparison_markdown,
23
+ run_markdown,
24
+ to_json,
25
+ )
26
+ from evalproof.runner import load_run, run_suite, save_run
27
+ from evalproof.scorers import pairwise
28
+
29
+
30
+ def _emit(markdown: str, path: str | None) -> None:
31
+ sys.stdout.write(markdown)
32
+ if path:
33
+ # Append, so it can target $GITHUB_STEP_SUMMARY alongside other steps.
34
+ with Path(path).open("a", encoding="utf-8") as fh:
35
+ fh.write(markdown + "\n")
36
+
37
+
38
+ def cmd_run(args: argparse.Namespace) -> int:
39
+ cfg = load_config(args.suite)
40
+ override = None
41
+ if args.system or args.system_prompt_file or args.label:
42
+ override = cfg.system.model_copy(deep=True)
43
+ if args.system:
44
+ override.target = args.system
45
+ if args.label:
46
+ override.label = args.label
47
+ if args.system_prompt_file:
48
+ override.system_prompt = Path(args.system_prompt_file).read_text(encoding="utf-8")
49
+
50
+ def progress(i: int, n: int, res: CaseResult) -> None:
51
+ if not args.quiet:
52
+ flag = " (cached)" if res.output.cached else ""
53
+ err = f" ERROR {res.output.error[:60]}" if res.output.error else ""
54
+ sys.stderr.write(f"\r[{i}/{n}] {res.case_id}{flag}{err} ")
55
+ if i == n:
56
+ sys.stderr.write("\n")
57
+
58
+ run = run_suite(
59
+ cfg,
60
+ limit=args.limit,
61
+ use_cache=not args.no_cache,
62
+ progress=progress,
63
+ system_override=override,
64
+ )
65
+ out = args.out or f"runs/{cfg.name}-{run.info.started_at.replace(':', '')}.json"
66
+ save_run(run, out)
67
+ sys.stderr.write(f"wrote {out}\n")
68
+ _emit(run_markdown(run), args.markdown)
69
+ return EXIT_PASS
70
+
71
+
72
+ def _gate_config(args: argparse.Namespace) -> GateConfig | None:
73
+ base: GateConfig | None = None
74
+ if args.suite:
75
+ base = load_config(args.suite).gate
76
+ overrides = {
77
+ k: v
78
+ for k, v in {
79
+ "metric": args.metric,
80
+ "max_drop": args.max_drop,
81
+ "alpha": args.alpha,
82
+ "min_cases": args.min_cases,
83
+ "max_p95_latency_ms": args.max_p95_ms,
84
+ "max_cost_usd": args.max_cost,
85
+ }.items()
86
+ if v is not None
87
+ }
88
+ if base is None and "metric" not in overrides:
89
+ return None
90
+ if base is None:
91
+ return GateConfig(**overrides)
92
+ return base.model_copy(update=overrides)
93
+
94
+
95
+ def cmd_compare(args: argparse.Namespace) -> int:
96
+ baseline, candidate = load_run(args.baseline), load_run(args.candidate)
97
+ gate = _gate_config(args)
98
+ alpha = gate.alpha if gate else (args.alpha or 0.05)
99
+ cmp = compare_runs(baseline, candidate, alpha=alpha, seed=args.seed)
100
+ decision = decide(cmp, gate) if gate else None
101
+ if args.gate and decision is None:
102
+ raise ConfigError("--gate needs a gate: pass --suite with a gate block, or --metric")
103
+ _emit(comparison_markdown(cmp, decision, gate.metric if gate else None), args.markdown)
104
+ if args.json:
105
+ payload = {"comparison": json.loads(to_json(cmp))}
106
+ if decision:
107
+ payload["gate"] = json.loads(to_json(decision))
108
+ Path(args.json).write_text(json.dumps(payload, indent=2), encoding="utf-8")
109
+ if args.gate and decision is not None:
110
+ return decision.exit_code
111
+ return EXIT_PASS
112
+
113
+
114
+ def cmd_calibrate(args: argparse.Namespace) -> int:
115
+ run = load_run(args.run)
116
+ labels = None
117
+ if args.labels:
118
+ labels = {}
119
+ for line in Path(args.labels).read_text(encoding="utf-8").splitlines():
120
+ if line.strip():
121
+ rec = json.loads(line)
122
+ labels[str(rec["id"])] = bool(rec["label"])
123
+ rep = calibrate(
124
+ run,
125
+ args.judge_scorer,
126
+ labels=labels,
127
+ gold_scorer=args.gold_scorer,
128
+ gold_sample=args.gold_sample,
129
+ seed=args.seed,
130
+ alpha=args.alpha,
131
+ )
132
+ _emit(calibration_markdown(rep), args.markdown)
133
+ if args.json:
134
+ Path(args.json).write_text(to_json(rep), encoding="utf-8")
135
+ return EXIT_PASS
136
+
137
+
138
+ def cmd_report(args: argparse.Namespace) -> int:
139
+ _emit(run_markdown(load_run(args.run)), args.markdown)
140
+ return EXIT_PASS
141
+
142
+
143
+ def cmd_pairwise(args: argparse.Namespace) -> int:
144
+ cfg = load_config(args.suite)
145
+ if cfg.judge is None:
146
+ raise ConfigError("pairwise needs a 'judge' in the suite")
147
+ judge = make_provider(cfg.judge)
148
+ if not args.no_cache:
149
+ judge = CachedProvider(judge, ResponseCache(cfg.resolve(cfg.cache_dir)), cfg.judge.base_url)
150
+ cases = {c.id: c for c in load_dataset(cfg.resolve(cfg.dataset))}
151
+ a_by, b_by = load_run(args.baseline).by_id(), load_run(args.candidate).by_id()
152
+ ids = [cid for cid in sorted(set(a_by) & set(b_by)) if cid in cases][: args.limit]
153
+ tally = {"a": 0, "b": 0, "tie": 0}
154
+ inconsistent = 0
155
+ rows = []
156
+ for cid in ids:
157
+ res = pairwise(
158
+ judge,
159
+ cfg.judge.params,
160
+ cases[cid],
161
+ a_by[cid].output.output,
162
+ b_by[cid].output.output,
163
+ swap=not args.no_swap,
164
+ )
165
+ tally[res.winner] += 1
166
+ inconsistent += 0 if res.consistent else 1
167
+ rows.append({"id": cid, **res.__dict__})
168
+ n = len(ids)
169
+ md = [
170
+ "## Pairwise preference",
171
+ "",
172
+ f"{n} cases · baseline wins {tally['a']} · candidate wins {tally['b']} · "
173
+ f"ties {tally['tie']}",
174
+ ]
175
+ if not args.no_swap:
176
+ md.append(
177
+ f"Position-inconsistent verdicts (flipped when the order was swapped): "
178
+ f"{inconsistent}/{n} ({100 * inconsistent / n if n else 0:.1f}%) — reported as ties"
179
+ )
180
+ _emit("\n".join(md) + "\n", args.markdown)
181
+ if args.json:
182
+ Path(args.json).write_text(json.dumps(rows, indent=2), encoding="utf-8")
183
+ return EXIT_PASS
184
+
185
+
186
+ def build_parser() -> argparse.ArgumentParser:
187
+ p = argparse.ArgumentParser(prog="evalproof", description=__doc__)
188
+ p.add_argument("--version", action="version", version=f"evalproof {__version__}")
189
+ sub = p.add_subparsers(dest="command", required=True)
190
+
191
+ r = sub.add_parser("run", help="run a suite and write a run artifact")
192
+ r.add_argument("suite")
193
+ r.add_argument("--out")
194
+ r.add_argument("--limit", type=int)
195
+ r.add_argument("--no-cache", action="store_true")
196
+ r.add_argument("--system", help="override the system target")
197
+ r.add_argument("--system-prompt-file", help="override the system prompt")
198
+ r.add_argument("--label", help="name this system in reports")
199
+ r.add_argument("--markdown", help="also append the report to this file")
200
+ r.add_argument("--quiet", action="store_true")
201
+ r.set_defaults(func=cmd_run)
202
+
203
+ c = sub.add_parser(
204
+ "compare", help="compare two runs; --gate turns the result into an exit code"
205
+ )
206
+ c.add_argument("baseline")
207
+ c.add_argument("candidate")
208
+ c.add_argument("--suite", help="take gate settings from this suite")
209
+ c.add_argument("--metric")
210
+ c.add_argument("--max-drop", type=float)
211
+ c.add_argument("--alpha", type=float)
212
+ c.add_argument("--min-cases", type=int)
213
+ c.add_argument("--max-p95-ms", type=float)
214
+ c.add_argument("--max-cost", type=float)
215
+ c.add_argument("--gate", action="store_true", help="exit non-zero when the gate fails")
216
+ c.add_argument("--seed", type=int, default=0)
217
+ c.add_argument("--markdown")
218
+ c.add_argument("--json")
219
+ c.set_defaults(func=cmd_compare)
220
+
221
+ k = sub.add_parser("calibrate", help="measure a judge against gold labels and correct its rate")
222
+ k.add_argument("run")
223
+ k.add_argument("--judge-scorer", required=True)
224
+ src = k.add_mutually_exclusive_group(required=True)
225
+ src.add_argument("--labels", help="JSONL of {id, label} human labels")
226
+ src.add_argument("--gold-scorer", help="a trusted scorer in the same run")
227
+ k.add_argument(
228
+ "--gold-sample", type=int, help="use a random sample of this size as the gold set"
229
+ )
230
+ k.add_argument("--seed", type=int, default=0)
231
+ k.add_argument("--alpha", type=float, default=0.05)
232
+ k.add_argument("--markdown")
233
+ k.add_argument("--json")
234
+ k.set_defaults(func=cmd_calibrate)
235
+
236
+ rep = sub.add_parser("report", help="summarise one run")
237
+ rep.add_argument("run")
238
+ rep.add_argument("--markdown")
239
+ rep.set_defaults(func=cmd_report)
240
+
241
+ pw = sub.add_parser("pairwise", help="judge baseline vs candidate outputs head to head")
242
+ pw.add_argument("baseline")
243
+ pw.add_argument("candidate")
244
+ pw.add_argument("--suite", required=True)
245
+ pw.add_argument("--limit", type=int)
246
+ pw.add_argument("--no-swap", action="store_true")
247
+ pw.add_argument("--no-cache", action="store_true")
248
+ pw.add_argument("--markdown")
249
+ pw.add_argument("--json")
250
+ pw.set_defaults(func=cmd_pairwise)
251
+ return p
252
+
253
+
254
+ def main(argv: Sequence[str] | None = None) -> int:
255
+ args = build_parser().parse_args(argv)
256
+ try:
257
+ code: int = args.func(args)
258
+ except (ConfigError, DatasetError, ProviderError, FileNotFoundError, ValueError) as exc:
259
+ sys.stderr.write(f"evalproof: error: {exc}\n")
260
+ return EXIT_USAGE
261
+ return code
262
+
263
+
264
+ if __name__ == "__main__": # pragma: no cover
265
+ raise SystemExit(main())