agentstats 0.3.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.
agentstats/gate.py ADDED
@@ -0,0 +1,382 @@
1
+ """
2
+ A CI gate for one tier: check a tier's requirements on a candidate's results, against a baseline where needed.
3
+
4
+ Each requirement is PASS, FAIL or INCONCLUSIVE. Requirements that compare with a baseline are non-inferiority
5
+ tests: "no regression" PASSes only when the lower confidence bound of (candidate - baseline) clears -margin.
6
+ "The interval includes zero" is not evidence that nothing regressed; an under-powered run is INCONCLUSIVE, with
7
+ an estimate of how many runs would settle it, never PASS.
8
+
9
+ Exit codes: 0 pass, 1 fail, 2 inconclusive, 3 unhealthy (too many infrastructure failures to judge the agent).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ from agentstats import analysis
16
+ from agentstats.comparisons import fixed_cases
17
+ from agentstats.intervals import Interval, bootstrap, mean, quantile, wilson
18
+ from agentstats.policy import NEEDS_BASELINE, Policy, Tier
19
+ from agentstats.records import Attempt, by_case
20
+
21
+ PASS, FAIL, INCONCLUSIVE, UNHEALTHY = "PASS", "FAIL", "INCONCLUSIVE", "UNHEALTHY"
22
+ # Requirements that need pass/fail verdicts (everything except the behaviour rules, infrastructure and no-answer).
23
+ GRADE_REQUIREMENTS = ("min_pass_rate", "no_regression", "no_significant_drop", "max_slice_drop", "min_pass_hat_k",
24
+ "max_cost_ratio", "max_p95_latency_ratio")
25
+ EXIT = {PASS: 0, FAIL: 1, INCONCLUSIVE: 2, UNHEALTHY: 3}
26
+
27
+
28
+ def _row(name, measured, threshold, result, detail=""):
29
+ return {"requirement": name, "measured": measured, "threshold": threshold, "result": result, "detail": detail}
30
+
31
+
32
+ def _ratio_interval(baseline: list[Attempt], candidate: list[Attempt], field: str, stat,
33
+ conf: float) -> Interval | None:
34
+ """Bootstrap over matched cases of stat(candidate values) / stat(baseline values)."""
35
+ a, b = by_case([x for x in baseline if x.valid]), by_case([x for x in candidate if x.valid])
36
+ units = []
37
+ for c in sorted(set(a) & set(b)):
38
+ av = [x.metrics[field] for x in a[c] if field in x.metrics]
39
+ bv = [x.metrics[field] for x in b[c] if field in x.metrics]
40
+ if av and bv:
41
+ units.append((av, bv))
42
+ if len(units) < 2:
43
+ return None
44
+
45
+ def ratio(us):
46
+ den = stat([v for av, _ in us for v in av])
47
+ return float("nan") if den == 0 else stat([v for _, bv in us for v in bv]) / den
48
+ return bootstrap(units, ratio, conf, method="bootstrap over matched cases")
49
+
50
+
51
+ def _upper_bound_check(name, iv: Interval | None, limit: float, fmt) -> dict:
52
+ if iv is None or math.isnan(iv.low):
53
+ return _row(name, "n/a", f"<= {fmt(limit)}", INCONCLUSIVE, "not enough matched cases with this metric")
54
+ measured = f"{fmt(iv.estimate)} (CI {fmt(iv.low)} to {fmt(iv.high)})"
55
+ if iv.high <= limit:
56
+ return _row(name, measured, f"<= {fmt(limit)}", PASS)
57
+ if iv.low > limit:
58
+ return _row(name, measured, f"<= {fmt(limit)}", FAIL)
59
+ return _row(name, measured, f"<= {fmt(limit)}", INCONCLUSIVE, "the interval straddles the limit; add runs")
60
+
61
+
62
+ def _lower_bound_check(name, iv: dict | None, floor: float, fmt) -> dict:
63
+ if not iv or iv.get("low") is None or math.isnan(iv["low"]):
64
+ return _row(name, "n/a", f">= {fmt(floor)}", INCONCLUSIVE, "not measurable with these runs")
65
+ measured = f"{fmt(iv['estimate'])} (CI {fmt(iv['low'])} to {fmt(iv['high'])})"
66
+ if iv["low"] >= floor:
67
+ return _row(name, measured, f">= {fmt(floor)}", PASS)
68
+ if iv["high"] < floor:
69
+ return _row(name, measured, f">= {fmt(floor)}", FAIL)
70
+ return _row(name, measured, f">= {fmt(floor)}", INCONCLUSIVE, "the interval straddles the floor; add runs")
71
+
72
+
73
+ def _pct(x):
74
+ return f"{100 * x:.1f}%"
75
+
76
+
77
+ def _pts(x):
78
+ return f"{100 * x:+.1f} pts"
79
+
80
+
81
+ def _x(x):
82
+ return f"{x:.2f}x"
83
+
84
+
85
+ def runs_needed(delta: dict, margin: float, runs: int, crit: float = 1.96) -> str:
86
+ """Rough number of runs per arm for the lower bound to clear -margin, if the estimate holds."""
87
+ est, se = delta["estimate"], delta.get("se")
88
+ if est + margin <= 0:
89
+ return "the estimate itself is below the margin; more runs are unlikely to make this pass"
90
+ if not se or math.isnan(se):
91
+ return "measure run-to-run noise first: run at least 3 times per arm"
92
+ target = (est + margin) / crit
93
+ need = math.ceil(max(runs, 1) * (se / target) ** 2)
94
+ return f"about {max(need, runs + 1)} runs per arm would settle it if the estimate holds"
95
+
96
+
97
+ def slice_comparisons(baseline: list[Attempt], candidate: list[Attempt], prefix: str, conf: float) -> list[dict]:
98
+ """Candidate vs baseline per slice (cases tagged prefix:<name>), fixed-cases test on the cases both arms graded.
99
+ With many slices some will look worse by chance, so each interval is Bonferroni-adjusted across slices."""
100
+ def counts(atts: list[Attempt]) -> dict[str, tuple[int, int]]:
101
+ return {c: (len(v), sum(1 for a in v if a.passed)) for c, v in by_case([a for a in atts if a.valid]).items()}
102
+
103
+ def tags_of(atts: list[Attempt]) -> dict[str, str]:
104
+ return {a.case: t[len(prefix) + 1:] for a in atts for t in a.tags if t.startswith(prefix + ":")}
105
+
106
+ a_counts, b_counts = counts(baseline), counts(candidate)
107
+ names = tags_of(baseline) | tags_of(candidate)
108
+ groups: dict[str, list[str]] = {}
109
+ for c in sorted(set(a_counts) & set(b_counts) & set(names)):
110
+ groups.setdefault(names[c], []).append(c)
111
+ adj = 1 - (1 - conf) / max(len(groups), 1)
112
+ out = []
113
+ for name, cases in sorted(groups.items()):
114
+ iv = fixed_cases({c: a_counts[c] for c in cases}, {c: b_counts[c] for c in cases}, adj)
115
+ def rate(cnt: dict[str, tuple[int, int]], cases: list[str] = cases) -> float:
116
+ return sum(cnt[c][1] for c in cases) / sum(cnt[c][0] for c in cases)
117
+ out.append({"slice": name, "cases": len(cases), "baseline": rate(a_counts), "candidate": rate(b_counts),
118
+ "delta": iv.estimate, "low": max(iv.low, -1.0), "high": min(iv.high, 1.0), "conf": adj})
119
+ return out
120
+
121
+
122
+ def _rule_counts(atts: list[Attempt]) -> dict[str, dict[str, tuple[int, int]]]:
123
+ """rule -> case -> (attempts where the rule was checked, attempts where it held). Every attempt that wasn't an
124
+ infrastructure failure counts, graded or not: behaviour rules need no verdict (rules-only rows from traces)."""
125
+ out: dict[str, dict[str, list[int]]] = {}
126
+ for a in atts:
127
+ if a.infra_error:
128
+ continue
129
+ for rule, ok in a.checks.items():
130
+ n_s = out.setdefault(rule, {}).setdefault(a.case, [0, 0])
131
+ n_s[0] += 1
132
+ n_s[1] += bool(ok)
133
+ return {r: {c: (n, s) for c, (n, s) in cases.items()} for r, cases in out.items()}
134
+
135
+
136
+ def rule_comparisons(baseline: list[Attempt] | None, candidate: list[Attempt], conf: float) -> list[dict]:
137
+ """Per trajectory rule: how often it held in the candidate (Wilson interval over attempts) and, with a baseline,
138
+ candidate vs baseline on the cases both checked (fixed-cases, Bonferroni-adjusted across rules). These need no
139
+ labels: they measure behaviour ("called the lookup tool"), so they still work when the labels are wrong."""
140
+ cand = _rule_counts(candidate)
141
+ base = _rule_counts(baseline) if baseline is not None else {}
142
+ adj = 1 - (1 - conf) / max(len(cand), 1)
143
+ out = []
144
+ for rule in sorted(cand):
145
+ n = sum(k for k, _ in cand[rule].values())
146
+ s = sum(v for _, v in cand[rule].values())
147
+ iv = wilson(s, n, conf)
148
+ row = {"rule": rule, "attempts": n, "rate": s / n, "low": iv.low, "high": iv.high, "conf": adj}
149
+ shared = sorted(set(cand[rule]) & set(base.get(rule, {})))
150
+ if shared:
151
+ b_counts = {c: base[rule][c] for c in shared}
152
+ d = fixed_cases(b_counts, {c: cand[rule][c] for c in shared}, adj)
153
+ row.update(baseline_rate=sum(v for _, v in b_counts.values()) / sum(k for k, _ in b_counts.values()),
154
+ delta=d.estimate, delta_low=max(d.low, -1.0), delta_high=min(d.high, 1.0), cases=len(shared))
155
+ out.append(row)
156
+ return out
157
+
158
+
159
+ def evaluate(tier: Tier, policy: Policy, candidate: list[Attempt], baseline: list[Attempt] | None = None,
160
+ conf: float = 0.95) -> dict:
161
+ req = dict(tier.require)
162
+ rows: list[dict] = []
163
+ notes: list[str] = []
164
+ # Rules-only rows (no pass/fail verdicts, e.g. production traces): requirements about pass rates can't be judged.
165
+ for arm, atts in (("candidate", candidate), ("baseline", baseline or [])):
166
+ if atts and not any(a.valid for a in atts) and any(a.passed is None and not a.infra_error for a in atts):
167
+ for k in [k for k in req if k in GRADE_REQUIREMENTS and (arm == "candidate" or k in NEEDS_BASELINE)]:
168
+ rows.append(_row(k, "n/a", "", INCONCLUSIVE, f"the {arm} rows have no pass/fail verdicts (rules-only)"))
169
+ del req[k]
170
+ missing = [r for r in req if r in NEEDS_BASELINE and baseline is None]
171
+ if missing:
172
+ raise ValueError(f"[{tier.name}] requirements {missing} need --baseline")
173
+ summ = analysis.summarize(candidate, policy.design, policy.cluster_by, conf)
174
+ runs = summ["runs"]
175
+ behaviour = [a for a in candidate if not a.infra_error]
176
+ rules_only_c = not any(a.valid for a in candidate) and any(a.passed is None for a in behaviour)
177
+ if rules_only_c: # no verdicts: count what the rules see, not graded attempts
178
+ runs = len({a.run for a in behaviour})
179
+ summ = {**summ, "cases": len({a.case for a in behaviour})}
180
+ if runs < tier.runs:
181
+ rows.append(_row("runs", str(runs), f">= {tier.runs}", INCONCLUSIVE,
182
+ f"the {tier.name} tier expects {tier.runs} complete runs"))
183
+
184
+ if "max_infra" in req:
185
+ rate = summ["infra"]["rate"]
186
+ rows.append(_row("max_infra", _pct(rate), f"<= {_pct(req['max_infra'])}",
187
+ PASS if rate <= req["max_infra"] else UNHEALTHY, str(summ["infra"]["kinds"] or "")))
188
+
189
+ if "min_pass_rate" in req:
190
+ est = summ["pass_rate"]["estimate"]
191
+ rows.append(_row("min_pass_rate", f"{_pct(est)} (CI {_pct(summ['pass_rate']['low'])} to "
192
+ f"{_pct(summ['pass_rate']['high'])})", f">= {_pct(req['min_pass_rate'])}",
193
+ PASS if est >= req["min_pass_rate"] else FAIL, "point estimate: a breakage check"))
194
+
195
+ if "rules" in req:
196
+ outcomes = [v for a in candidate if a.valid for v in a.checks.values()]
197
+ if not outcomes:
198
+ rows.append(_row("rules", "n/a", f">= {_pct(req['rules'])}", INCONCLUSIVE, "no rule checks in the results"))
199
+ else:
200
+ share = sum(outcomes) / len(outcomes)
201
+ failing = sorted({f"{a.case}:{r}" for a in candidate if a.valid for r, ok in a.checks.items() if not ok})
202
+ rows.append(_row("rules", f"{_pct(share)} of {len(outcomes)} checks", f">= {_pct(req['rules'])}",
203
+ PASS if share >= req["rules"] else FAIL, ", ".join(failing[:8])))
204
+
205
+ rules = None
206
+ if "min_rule_rate" in req or "no_rule_regression" in req or any(a.checks for a in candidate):
207
+ rules = rule_comparisons(baseline, candidate, conf) or None
208
+ if "min_rule_rate" in req:
209
+ floor = req["min_rule_rate"]
210
+ if not rules:
211
+ rows.append(_row("min_rule_rate", "n/a", f">= {_pct(floor)}", INCONCLUSIVE,
212
+ "no rule checks in the results"))
213
+ else:
214
+ below = [r for r in rules if r["high"] < floor]
215
+ unsure = [r for r in rules if r["rate"] < floor and r not in below]
216
+ worst = min(rules, key=lambda r: r["rate"])
217
+ rows.append(_row("min_rule_rate", f"lowest: {worst['rule']} {_pct(worst['rate'])} "
218
+ f"(CI {_pct(worst['low'])} to {_pct(worst['high'])})", f">= {_pct(floor)}",
219
+ FAIL if below else INCONCLUSIVE if unsure else PASS,
220
+ (f"below: {', '.join(r['rule'] for r in below)}; " if below else "")
221
+ + (f"can't tell yet: {', '.join(r['rule'] for r in unsure)}; " if unsure else "")
222
+ + f"{len(rules)} rules"))
223
+ if "no_rule_regression" in req:
224
+ m = req["no_rule_regression"]
225
+ compared = [r for r in (rules or []) if "delta" in r]
226
+ if not compared:
227
+ rows.append(_row("no_rule_regression", "n/a", f"no rule proven < {_pts(-m)}", INCONCLUSIVE,
228
+ "no rule was checked in both the baseline and the candidate"))
229
+ else:
230
+ dropped = [r for r in compared if r["delta_high"] < -m]
231
+ worst = min(compared, key=lambda r: r["delta"])
232
+ rows.append(_row("no_rule_regression", f"worst: {worst['rule']} {_pts(worst['delta'])} "
233
+ f"(CI {_pts(worst['delta_low'])} to {_pts(worst['delta_high'])})",
234
+ f"no rule proven < {_pts(-m)}", FAIL if dropped else PASS,
235
+ (f"proven worse: {', '.join(r['rule'] for r in dropped)}; " if dropped else "")
236
+ + f"{len(compared)} rules vs baseline, {100 * compared[0]['conf']:.2f}% intervals "
237
+ "(Bonferroni)"))
238
+
239
+ comparison = None
240
+ graded_c = [a for a in candidate if a.valid]
241
+ graded_b = [a for a in baseline if a.valid] if baseline is not None else None
242
+ rules_only = not graded_c and any(a.passed is None and not a.infra_error for a in candidate)
243
+ if (not graded_c and not rules_only) or (graded_b is not None and not graded_b
244
+ and any(r in req for r in NEEDS_BASELINE)):
245
+ who = "candidate" if not graded_c else "baseline"
246
+ rows.append(_row("graded attempts", "0", "> 0", UNHEALTHY,
247
+ f"every {who} attempt failed for infrastructure reasons: "
248
+ f"{summ['infra']['kinds'] if who == 'candidate' else 'see the baseline'}"))
249
+ req = {k: v for k, v in req.items() if k not in NEEDS_BASELINE and k != "min_pass_hat_k"}
250
+ if "no_regression" in req:
251
+ m = req["no_regression"]
252
+ comparison = analysis.compare(baseline, candidate, policy.design, policy.cluster_by, conf)
253
+ d = comparison["delta"]
254
+ measured = f"{_pts(d['estimate'])} (CI {_pts(d['low'])} to {_pts(d['high'])}, {comparison['method']})"
255
+ shock = comparison.get("shock") or {}
256
+ if shock.get("detected") and not shock.get("fallback"):
257
+ arms = ", ".join("baseline" if a == "A" else "candidate" for a in shock["arms"])
258
+ rows.append(_row("no_regression", measured, f"lower bound >= {_pts(-m)}", INCONCLUSIVE,
259
+ f"run-wide shock in the {arms} runs (whole runs vary more than per-case randomness "
260
+ "predicts) with too few runs to correct for it: rerun"))
261
+ elif d["low"] is None or math.isnan(d["low"]):
262
+ rows.append(_row("no_regression", measured, f"lower bound >= {_pts(-m)}", INCONCLUSIVE,
263
+ runs_needed(d, m, runs)))
264
+ elif d["low"] >= -m:
265
+ detail = "a real but small drop, inside the margin" if d["high"] < 0 else ""
266
+ rows.append(_row("no_regression", measured, f"lower bound >= {_pts(-m)}", PASS, detail))
267
+ elif d["high"] < -m:
268
+ rows.append(_row("no_regression", measured, f"lower bound >= {_pts(-m)}", FAIL,
269
+ "worse than the baseline by more than the margin"))
270
+ else:
271
+ rows.append(_row("no_regression", measured, f"lower bound >= {_pts(-m)}", INCONCLUSIVE,
272
+ runs_needed(d, m, min(comparison["a"]["runs"], comparison["b"]["runs"]))))
273
+ notes += [f"Comparison: {n}" for n in comparison["notes"]]
274
+
275
+ if "no_significant_drop" in req:
276
+ m = req["no_significant_drop"]
277
+ comparison = comparison or analysis.compare(baseline, candidate, policy.design, policy.cluster_by, conf)
278
+ d = comparison["delta"]
279
+ measured = f"{_pts(d['estimate'])} (CI {_pts(d['low'])} to {_pts(d['high'])}, {comparison['method']})"
280
+ sd = comparison.get("smallest_detectable")
281
+ catch = f"this run reliably catches drops of about {100 * sd:.1f} points or more" if sd else ""
282
+ shock = comparison.get("shock") or {}
283
+ if d["high"] is None or math.isnan(d["high"]):
284
+ rows.append(_row("no_significant_drop", measured, f"upper bound >= {_pts(-m)}", INCONCLUSIVE,
285
+ runs_needed(d, m, runs)))
286
+ elif d["high"] < -m:
287
+ rows.append(_row("no_significant_drop", measured, f"upper bound >= {_pts(-m)}", FAIL,
288
+ "a real drop: the whole interval is below the threshold"))
289
+ elif shock.get("detected") and not shock.get("fallback"):
290
+ rows.append(_row("no_significant_drop", measured, f"upper bound >= {_pts(-m)}", INCONCLUSIVE,
291
+ "run-wide shock with too few runs to correct for it: rerun"))
292
+ else:
293
+ rows.append(_row("no_significant_drop", measured, f"upper bound >= {_pts(-m)}", PASS, catch))
294
+ if "no_regression" not in req:
295
+ notes += [f"Comparison: {n}" for n in comparison["notes"]]
296
+
297
+ graded_cand = [a for a in candidate if a.valid]
298
+ na = analysis.no_answer_stats(graded_cand)
299
+ na_base = analysis.no_answer_stats([a for a in baseline if a.valid]) if baseline is not None else None
300
+ if "max_no_answer" in req:
301
+ detail = (f"{na['count']} attempts: {na['kinds']}" if na["count"] else "")
302
+ if na_base is not None:
303
+ detail += f"{'; ' if detail else ''}baseline {_pct(na_base['rate'])}"
304
+ rows.append(_row("max_no_answer", _pct(na["rate"]), f"<= {_pct(req['max_no_answer'])}",
305
+ PASS if na["rate"] <= req["max_no_answer"] else FAIL, detail))
306
+ elif na["count"] and (na_base is None or na["rate"] > na_base["rate"]):
307
+ notes.append(f"{na['count']} candidate attempts ended with no usable answer ({_pct(na['rate'])}: "
308
+ f"{na['kinds']})" + (f", baseline {_pct(na_base['rate'])}" if na_base is not None else "")
309
+ + ". They count as failures; a jump usually means the harness or loop broke.")
310
+
311
+ slices = None
312
+ if tier.slice_by and baseline is not None:
313
+ slices = slice_comparisons(baseline, candidate, tier.slice_by, conf)
314
+ if "max_slice_drop" in req and slices:
315
+ m = req["max_slice_drop"]
316
+ worst = min(slices, key=lambda x: x["delta"])
317
+ dropped = [x for x in slices if x["high"] < -m]
318
+ leads = [x for x in slices if x["delta"] < -m and x not in dropped]
319
+ detail = (f"proven worse: {', '.join(x['slice'] for x in dropped)}; " if dropped else "")
320
+ detail += (f"estimated beyond the limit but not proven (few cases): "
321
+ f"{', '.join(x['slice'] + ' ' + _pts(x['delta']) for x in leads)}; " if leads else "")
322
+ detail += f"{len(slices)} slices by {tier.slice_by}, {100 * slices[0]['conf']:.2f}% intervals (Bonferroni)"
323
+ rows.append(_row("max_slice_drop", f"worst: {worst['slice']} {_pts(worst['delta'])} "
324
+ f"(CI {_pts(worst['low'])} to {_pts(worst['high'])})", f"no slice proven < {_pts(-m)}",
325
+ FAIL if dropped else PASS, detail))
326
+
327
+ if "max_cost_ratio" in req:
328
+ rows.append(_upper_bound_check("max_cost_ratio", _ratio_interval(baseline, candidate, "cost_usd", mean, conf),
329
+ req["max_cost_ratio"], _x))
330
+ if "max_p95_latency_ratio" in req:
331
+ iv = _ratio_interval(baseline, candidate, "latency_s", lambda xs: quantile(xs, 0.95), conf)
332
+ rows.append(_upper_bound_check("max_p95_latency_ratio", iv, req["max_p95_latency_ratio"], _x))
333
+ if "min_pass_hat_k" in req:
334
+ rel = summ.get("reliability") or {}
335
+ k = min(tier.runs, rel.get("k_max", 0))
336
+ rows.append(_lower_bound_check(f"min_pass_hat_k (k={k or tier.runs})",
337
+ (rel.get("pass_hat") or {}).get(k) if k >= 2 else None,
338
+ req["min_pass_hat_k"], _pct))
339
+
340
+ # What a small tier can't see: say so, so nobody reads a green smoke run as a regression check.
341
+ if tier.sample and summ["cases"]:
342
+ p = summ["pass_rate"]["estimate"]
343
+ mde = 2.8 * math.sqrt(max(2 * p * (1 - p), 1e-9) / (summ["cases"] * max(runs, 1)))
344
+ notes.append(f"With {summ['cases']} cases and {runs} run(s), this tier can only see drops larger than "
345
+ f"about {100 * mde:.0f} points: it catches breakage, not regressions.")
346
+
347
+ results = {r["result"] for r in rows}
348
+ overall = (UNHEALTHY if UNHEALTHY in results else FAIL if FAIL in results
349
+ else INCONCLUSIVE if INCONCLUSIVE in results else PASS)
350
+ return {"tier": tier.name, "overall": overall, "exit_code": EXIT[overall], "rows": rows, "notes": notes,
351
+ "cases": summ["cases"], "runs": runs, "pass_rate": summ["pass_rate"], "comparison": comparison,
352
+ "slices": slices, "slice_by": tier.slice_by, "no_answer": na, "rules": rules, "rules_only": rules_only_c}
353
+
354
+
355
+ def render(g: dict) -> str:
356
+ icon = {PASS: "✅", FAIL: "❌", INCONCLUSIVE: "⚠️", UNHEALTHY: "🚧"}
357
+ lines = [f"## agentstats gate: {g['tier']} {icon[g['overall']]} {g['overall']}", "",
358
+ f"{g['cases']} cases, {g['runs']} run(s)" + (" (rules-only rows: no pass/fail verdicts)."
359
+ if g.get("rules_only") else "."), "",
360
+ "| requirement | measured | threshold | result | detail |", "|---|---|---|---|---|"]
361
+ for r in g["rows"]:
362
+ lines.append(f"| {r['requirement']} | {r['measured']} | {r['threshold']} | {icon[r['result']]} {r['result']} "
363
+ f"| {r['detail']} |")
364
+ if g["notes"]:
365
+ lines.append("")
366
+ lines += [f"- {n}" for n in g["notes"]]
367
+ if g.get("rules"):
368
+ lines += ["", "Trajectory rules (how often each held; vs baseline where both checked it):", "",
369
+ "| rule | attempts | held | baseline | change (CI) |", "|---|---|---|---|---|"]
370
+ for r in sorted(g["rules"], key=lambda r: r.get("delta", 0)):
371
+ change = (f"{_pts(r['delta'])} ({_pts(r['delta_low'])} to {_pts(r['delta_high'])})" if "delta" in r
372
+ else "n/a")
373
+ base = _pct(r["baseline_rate"]) if "baseline_rate" in r else "n/a"
374
+ lines.append(f"| {r['rule']} | {r['attempts']} | {_pct(r['rate'])} | {base} | {change} |")
375
+ if g.get("slices"):
376
+ lines += ["", f"Per slice ({g['slice_by']}), candidate vs baseline, "
377
+ f"{100 * g['slices'][0]['conf']:.2f}% intervals (Bonferroni across slices):", "",
378
+ "| slice | cases | baseline | candidate | change (CI) |", "|---|---|---|---|---|"]
379
+ for x in sorted(g["slices"], key=lambda x: x["delta"]):
380
+ lines.append(f"| {x['slice']} | {x['cases']} | {_pct(x['baseline'])} | {_pct(x['candidate'])} | "
381
+ f"{_pts(x['delta'])} ({_pts(x['low'])} to {_pts(x['high'])}) |")
382
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,123 @@
1
+ """
2
+ Confidence intervals. Each returns an Interval that records its method, so a report can say what was done.
3
+
4
+ Which interval fits depends on how the eval set was built (see claims.py):
5
+ - run_level (Student's t over complete runs): hand-picked sets; supports "on this eval set".
6
+ - per_case (t over per-case mean pass rates): cases randomly sampled and independent; supports "on the
7
+ population the cases came from". Miller 2024, arXiv 2411.00640, with t (N-1 df) instead of 1.96 so small
8
+ sets aren't overconfident.
9
+ - clustered (cluster-robust standard error over groups of related cases): cases sampled, but related within
10
+ groups (same feature). Miller 2024 §2.3; CR0 sandwich with a G/(G-1) correction and t with G-1 df.
11
+ - wilson: one run only, per-case binary outcomes.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import random
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import asdict, dataclass
19
+
20
+ from scipy import stats
21
+
22
+
23
+ @dataclass
24
+ class Interval:
25
+ estimate: float
26
+ low: float
27
+ high: float
28
+ method: str
29
+ n: int
30
+ se: float | None = None
31
+ df: float | None = None
32
+ note: str = ""
33
+ z: float | None = None
34
+
35
+ @property
36
+ def excludes_zero(self) -> bool:
37
+ return self.low > 0 or self.high < 0
38
+
39
+ def to_json(self) -> dict:
40
+ return {k: (round(v, 6) if isinstance(v, float) else v) for k, v in asdict(self).items()}
41
+
42
+
43
+ def _t(conf: float, df: float) -> float:
44
+ return float(stats.t.ppf(0.5 + conf / 2, df))
45
+
46
+
47
+ def mean(xs: Sequence[float]) -> float:
48
+ return sum(xs) / len(xs)
49
+
50
+
51
+ def sd(xs: Sequence[float]) -> float:
52
+ if len(xs) < 2:
53
+ return float("nan")
54
+ m = mean(xs)
55
+ return math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1))
56
+
57
+
58
+ def t_interval(values: Sequence[float], conf: float = 0.95, method: str = "t") -> Interval:
59
+ """Mean of independent observations with a Student's t interval (df = n - 1)."""
60
+ n = len(values)
61
+ m = mean(values)
62
+ if n < 2:
63
+ return Interval(m, float("nan"), float("nan"), method, n, note="needs at least 2 observations")
64
+ se = sd(values) / math.sqrt(n)
65
+ half = _t(conf, n - 1) * se
66
+ note = "zero spread: every observation equal; the interval understates uncertainty" if se == 0 else ""
67
+ return Interval(m, m - half, m + half, method, n, se=se, df=n - 1, note=note)
68
+
69
+
70
+ def wilson(successes: int, n: int, conf: float = 0.95) -> Interval:
71
+ if n == 0:
72
+ return Interval(float("nan"), float("nan"), float("nan"), "wilson", 0)
73
+ z = float(stats.norm.ppf(0.5 + conf / 2))
74
+ p = successes / n
75
+ denom = 1 + z * z / n
76
+ centre = (p + z * z / (2 * n)) / denom
77
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
78
+ return Interval(p, max(0.0, centre - half), min(1.0, centre + half), "wilson", n,
79
+ se=math.sqrt(p * (1 - p) / n))
80
+
81
+
82
+ def clustered(values: Sequence[float], clusters: Sequence[str], conf: float = 0.95) -> Interval:
83
+ """Mean with a cluster-robust standard error: observations in the same cluster may be correlated."""
84
+ n = len(values)
85
+ groups: dict[str, list[float]] = {}
86
+ for v, c in zip(values, clusters, strict=True):
87
+ groups.setdefault(c, []).append(v)
88
+ g = len(groups)
89
+ m = mean(values)
90
+ if g < 2:
91
+ return Interval(m, float("nan"), float("nan"), "clustered", n, note="needs at least 2 clusters")
92
+ meat = sum(sum(v - m for v in vs) ** 2 for vs in groups.values())
93
+ se = math.sqrt(meat * g / (g - 1)) / n
94
+ half = _t(conf, g - 1) * se
95
+ return Interval(m, m - half, m + half, "clustered", n, se=se, df=g - 1, note=f"{g} clusters")
96
+
97
+
98
+ def bootstrap(units: Sequence, statistic: Callable[[Sequence], float], conf: float = 0.95,
99
+ n_boot: int = 4000, seed: int = 0, method: str = "bootstrap") -> Interval:
100
+ """Percentile bootstrap, resampling whole units (cases), so per-case correlation is kept."""
101
+ rng = random.Random(seed)
102
+ est = statistic(units)
103
+ draws = []
104
+ for _ in range(n_boot):
105
+ sample = [units[rng.randrange(len(units))] for _ in units]
106
+ v = statistic(sample)
107
+ if not math.isnan(v):
108
+ draws.append(v)
109
+ if not draws:
110
+ return Interval(est, float("nan"), float("nan"), method, len(units))
111
+ draws.sort()
112
+ lo = draws[int((1 - conf) / 2 * (len(draws) - 1))]
113
+ hi = draws[int((1 + conf) / 2 * (len(draws) - 1))]
114
+ return Interval(est, lo, hi, method, len(units), se=sd(draws))
115
+
116
+
117
+ def quantile(xs: Sequence[float], q: float) -> float:
118
+ s = sorted(xs)
119
+ if not s:
120
+ return float("nan")
121
+ pos = q * (len(s) - 1)
122
+ lo, hi = math.floor(pos), math.ceil(pos)
123
+ return s[lo] + (s[hi] - s[lo]) * (pos - lo)
agentstats/judge.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ Can you trust your autorater (LLM judge)? Three checks, after Yesha Shah's "Agentic Evals" Part 3.
3
+
4
+ Input rows: {"item": "...", "rater": "human:alice" | "judge", "verdict": "pass" | "fail" | "na",
5
+ "run": 1, "split": "tune" | "heldout"}
6
+
7
+ 1. Human agreement (are the labels themselves trustworthy?): Fleiss' kappa over items with >= 2 human labels.
8
+ 2. Judge vs humans (alignment): judge verdict vs the human majority. accuracy, strictness (false fails among
9
+ human passes), leniency (false passes among human fails: the dangerous one when a pass justifies a release),
10
+ Cohen's kappa. Reported on the held-out split when one is marked, so tuning the judge doesn't flatter it.
11
+ 3. Judge consistency (is the judge itself stable?): over repeated judge runs on the same item, verdict flip rate
12
+ and mean pairwise disagreement (or mean pairwise |delta| for numeric scores).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections import Counter, defaultdict
18
+ from pathlib import Path
19
+
20
+ from agentstats.intervals import wilson
21
+
22
+
23
+ def load(path: str | Path) -> list[dict]:
24
+ return [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
25
+
26
+
27
+ def _norm(v) -> str | float:
28
+ if isinstance(v, bool):
29
+ return "pass" if v else "fail"
30
+ if isinstance(v, (int, float)):
31
+ return float(v)
32
+ return str(v).strip().lower()
33
+
34
+
35
+ def cohen_kappa(a: list, b: list) -> float:
36
+ n = len(a)
37
+ if n == 0:
38
+ return float("nan")
39
+ cats = sorted(set(a) | set(b), key=str)
40
+ po = sum(x == y for x, y in zip(a, b, strict=True)) / n
41
+ ca, cb = Counter(a), Counter(b)
42
+ pe = sum(ca[c] * cb[c] for c in cats) / (n * n)
43
+ return 1.0 if pe == 1 else (po - pe) / (1 - pe)
44
+
45
+
46
+ def fleiss_kappa(table: list[list[int]]) -> float:
47
+ """table[i][j]: how many raters put item i in category j. Every item needs the same number of raters."""
48
+ n_items = len(table)
49
+ if n_items == 0:
50
+ return float("nan")
51
+ m = sum(table[0])
52
+ if m < 2 or any(sum(r) != m for r in table):
53
+ raise ValueError("Fleiss' kappa needs the same number (>= 2) of raters on every item")
54
+ p_items = [(sum(c * c for c in r) - m) / (m * (m - 1)) for r in table]
55
+ p_bar = sum(p_items) / n_items
56
+ totals = [sum(r[j] for r in table) for j in range(len(table[0]))]
57
+ p_cats = [t / (n_items * m) for t in totals]
58
+ pe = sum(p * p for p in p_cats)
59
+ return 1.0 if pe == 1 else (p_bar - pe) / (1 - pe)
60
+
61
+
62
+ def pairwise_disagreement(verdicts: list) -> float:
63
+ """Probability two distinct verdicts on the same item differ (categorical). For pass/fail with s passes
64
+ out of n this is 2 s (n - s) / (n (n - 1))."""
65
+ n = len(verdicts)
66
+ if n < 2:
67
+ return float("nan")
68
+ same = sum(c * (c - 1) for c in Counter(verdicts).values())
69
+ return 1 - same / (n * (n - 1))
70
+
71
+
72
+ def pairwise_delta(scores: list[float]) -> float:
73
+ n = len(scores)
74
+ if n < 2:
75
+ return float("nan")
76
+ total = sum(abs(scores[i] - scores[j]) for i in range(n) for j in range(i + 1, n))
77
+ return 2 * total / (n * (n - 1))
78
+
79
+
80
+ def analyze(rows: list[dict], positive: str = "pass") -> dict:
81
+ humans: dict[str, dict[str, str]] = defaultdict(dict)
82
+ judge: dict[str, list] = defaultdict(list)
83
+ split: dict[str, str] = {}
84
+ for r in rows:
85
+ item, rater, v = str(r["item"]), str(r["rater"]), _norm(r["verdict"])
86
+ if r.get("split"):
87
+ split[item] = str(r["split"])
88
+ if rater.startswith("human"):
89
+ humans[item][rater] = v
90
+ else:
91
+ judge[item].append(v)
92
+ out: dict = {"items": len(set(humans) | set(judge))}
93
+
94
+ # 1. Human agreement
95
+ multi = {i: list(h.values()) for i, h in humans.items() if len(h) >= 2}
96
+ if multi:
97
+ cats = sorted({v for vs in multi.values() for v in vs}, key=str)
98
+ sizes = Counter(len(vs) for vs in multi.values())
99
+ m = sizes.most_common(1)[0][0]
100
+ rows_same = [vs for vs in multi.values() if len(vs) == m]
101
+ table = [[vs.count(c) for c in cats] for vs in rows_same]
102
+ out["human_agreement"] = {
103
+ "items": len(rows_same), "raters_per_item": m, "fleiss_kappa": fleiss_kappa(table),
104
+ "unanimous": sum(1 for vs in rows_same if len(set(vs)) == 1),
105
+ }
106
+
107
+ # Human label per item: majority; ties dropped.
108
+ labels = {}
109
+ ties = 0
110
+ for i, h in humans.items():
111
+ top = Counter(h.values()).most_common()
112
+ if len(top) > 1 and top[0][1] == top[1][1]:
113
+ ties += 1
114
+ continue
115
+ labels[i] = top[0][0]
116
+
117
+ # 2. Judge vs humans (first judge run per item, or its majority over runs)
118
+ def judge_label(vs: list):
119
+ top = Counter(vs).most_common()
120
+ return top[0][0]
121
+
122
+ eval_items = [i for i in labels if i in judge and labels[i] != "na" and judge_label(judge[i]) != "na"]
123
+ heldout = [i for i in eval_items if split.get(i) == "heldout"]
124
+ scope = heldout if heldout else eval_items
125
+ if scope:
126
+ h = [labels[i] for i in scope]
127
+ j = [judge_label(judge[i]) for i in scope]
128
+ tp = sum(1 for x, y in zip(h, j, strict=True) if x == positive and y == positive)
129
+ fn = sum(1 for x, y in zip(h, j, strict=True) if x == positive and y != positive)
130
+ fp = sum(1 for x, y in zip(h, j, strict=True) if x != positive and y == positive)
131
+ tn = len(scope) - tp - fn - fp
132
+ out["alignment"] = {
133
+ "scope": "heldout" if heldout else "all labelled items", "n": len(scope), "human_ties_dropped": ties,
134
+ "tp": tp, "fn": fn, "fp": fp, "tn": tn,
135
+ "accuracy": (tp + tn) / len(scope),
136
+ "strictness": fn / (tp + fn) if tp + fn else None,
137
+ "leniency": fp / (tn + fp) if tn + fp else None,
138
+ "cohen_kappa": cohen_kappa(h, j),
139
+ "ci": {"accuracy": wilson(tp + tn, len(scope)).to_json(),
140
+ "strictness": wilson(fn, tp + fn).to_json() if tp + fn else None,
141
+ "leniency": wilson(fp, tn + fp).to_json() if tn + fp else None},
142
+ "disagreements": [{"item": i, "human": labels[i], "judge": judge_label(judge[i])}
143
+ for i in scope if labels[i] != judge_label(judge[i])],
144
+ }
145
+
146
+ # 3. Judge consistency
147
+ repeated = {i: vs for i, vs in judge.items() if len(vs) >= 2}
148
+ if repeated:
149
+ numeric = all(isinstance(v, float) for vs in repeated.values() for v in vs)
150
+ cons = {"items": len(repeated), "runs_per_item": min(len(vs) for vs in repeated.values()),
151
+ "flip_rate": sum(1 for vs in repeated.values() if len(set(vs)) > 1) / len(repeated),
152
+ "flipped": sorted(i for i, vs in repeated.items() if len(set(vs)) > 1)}
153
+ if numeric:
154
+ cons["mean_pairwise_delta"] = sum(pairwise_delta(vs) for vs in repeated.values()) / len(repeated)
155
+ else:
156
+ cons["mean_pairwise_disagreement"] = (sum(pairwise_disagreement(vs) for vs in repeated.values())
157
+ / len(repeated))
158
+ out["consistency"] = cons
159
+ return out