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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agentstats: statistics for agent evals."""
2
+
3
+ __version__ = "0.3.0"
agentstats/adapters.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ Build attempt records from agent run output: Harbor job directories and ATIF trajectories.
3
+
4
+ Harbor (harborframework.com) writes one directory per trial with `result.json` (verifier reward, exception,
5
+ tokens, cost, timings) and `agent/trajectory.json` in ATIF, the Agent Trajectory Interchange Format. A trial that
6
+ raised an exception is an infrastructure failure, not an agent failure, and is kept apart from pass rates.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agentstats.records import Attempt
16
+
17
+
18
+ def is_harbor_job(path: Path) -> bool:
19
+ return path.is_dir() and any((t / "result.json").exists() and (t / "config.json").exists()
20
+ for t in path.iterdir() if t.is_dir())
21
+
22
+
23
+ def _seconds(span: dict | None) -> float | None:
24
+ if not span or not span.get("started_at") or not span.get("finished_at"):
25
+ return None
26
+ start = datetime.fromisoformat(span["started_at"].replace("Z", "+00:00"))
27
+ end = datetime.fromisoformat(span["finished_at"].replace("Z", "+00:00"))
28
+ return (end - start).total_seconds()
29
+
30
+
31
+ def tool_calls(trajectory: dict | str | Path) -> list[dict]:
32
+ """The agent's tool calls in order, from an ATIF trajectory: [{"name", "args", "step"}]."""
33
+ d = trajectory if isinstance(trajectory, dict) else json.loads(Path(trajectory).read_text())
34
+ calls = []
35
+ for i, st in enumerate(d.get("steps") or []):
36
+ if st.get("source") != "agent":
37
+ continue
38
+ for call in st.get("tool_calls") or []:
39
+ args = call.get("arguments") or {}
40
+ if isinstance(args, str):
41
+ try:
42
+ args = json.loads(args)
43
+ except json.JSONDecodeError:
44
+ args = {"_raw": args}
45
+ calls.append({"name": call.get("function_name"), "args": args, "step": i})
46
+ return calls
47
+
48
+
49
+ def trajectory_metrics(trajectory: dict | str | Path) -> dict[str, float]:
50
+ d = trajectory if isinstance(trajectory, dict) else json.loads(Path(trajectory).read_text())
51
+ agent_steps = [s for s in d.get("steps") or [] if s.get("source") == "agent"]
52
+ out: dict[str, float] = {"steps": float(len(agent_steps)),
53
+ "tool_calls": float(sum(len(s.get("tool_calls") or []) for s in agent_steps))}
54
+ fm = d.get("final_metrics") or {}
55
+ for src, dst in (("total_prompt_tokens", "input_tokens"), ("total_completion_tokens", "output_tokens"),
56
+ ("total_cached_tokens", "cached_tokens"), ("total_cost_usd", "cost_usd")):
57
+ if fm.get(src) is not None:
58
+ out[dst] = float(fm[src])
59
+ return out
60
+
61
+
62
+ def harbor_trial(trial_dir: str | Path, *, arm: str | None = None, run: int = 1,
63
+ pass_threshold: float = 1.0) -> Attempt:
64
+ trial_dir = Path(trial_dir)
65
+ result: dict[str, Any] = json.loads((trial_dir / "result.json").read_text())
66
+ case = result.get("task_name") or trial_dir.name.rsplit("__", 1)[0]
67
+ exc = result.get("exception_info")
68
+ reward = ((result.get("verifier_result") or {}).get("rewards") or {}).get("reward")
69
+ infra = (exc or {}).get("exception_type") or ("Exception" if exc else None)
70
+ passed = None if infra or reward is None else reward >= pass_threshold
71
+ if not infra and reward is None:
72
+ infra = "NoReward"
73
+
74
+ metrics: dict[str, float] = {}
75
+ traj = trial_dir / "agent" / "trajectory.json"
76
+ if traj.exists():
77
+ try:
78
+ metrics.update(trajectory_metrics(traj))
79
+ except (json.JSONDecodeError, OSError):
80
+ pass
81
+ ar = result.get("agent_result") or {}
82
+ for src, dst in (("n_input_tokens", "input_tokens"), ("n_cache_tokens", "cached_tokens"),
83
+ ("n_output_tokens", "output_tokens"), ("cost_usd", "cost_usd")):
84
+ if ar.get(src) is not None:
85
+ metrics[dst] = float(ar[src]) # result.json is the billing source of truth
86
+ if (secs := _seconds(result.get("agent_execution"))) is not None:
87
+ metrics["latency_s"] = secs
88
+ agent = (result.get("agent_info") or {}).get("name") or "agent"
89
+ return Attempt(case=case, passed=passed, arm=arm or agent, run=run, infra_error=infra, metrics=metrics)
90
+
91
+
92
+ def harbor_job(job_dir: str | Path, *, arm: str | None = None, run: int = 1,
93
+ pass_threshold: float = 1.0) -> list[Attempt]:
94
+ """Every trial in a Harbor job directory. Repeated trials of one task (Harbor's n_attempts) become
95
+ separate runs, numbered from `run`."""
96
+ job_dir = Path(job_dir)
97
+ trials = sorted(t for t in job_dir.iterdir() if t.is_dir() and (t / "result.json").exists())
98
+ out, seen = [], {}
99
+ for t in trials:
100
+ a = harbor_trial(t, arm=arm or job_dir.parent.name, run=run, pass_threshold=pass_threshold)
101
+ seen[a.case] = seen.get(a.case, 0) + 1
102
+ a.run = run + seen[a.case] - 1
103
+ out.append(a)
104
+ return out
105
+
106
+
107
+ def atif_dir(directory: str | Path, verdicts: dict[str, bool | None], *, arm: str = "default",
108
+ run: int = 1) -> list[Attempt]:
109
+ """A folder of ATIF trajectories named `<case>.json`, with verdicts from your own grader."""
110
+ out = []
111
+ for p in sorted(Path(directory).glob("*.json")):
112
+ out.append(Attempt(case=p.stem, passed=verdicts.get(p.stem), arm=arm, run=run,
113
+ infra_error=None if p.stem in verdicts else "NoVerdict",
114
+ metrics=trajectory_metrics(p)))
115
+ return out
agentstats/analysis.py ADDED
@@ -0,0 +1,342 @@
1
+ """
2
+ Summaries and comparisons that pick their statistics from how the eval set was built, and say which claim the
3
+ numbers support.
4
+
5
+ Eval set designs (after Yesha Shah, "Agentic Evals" Part 2, and Miller 2024, arXiv 2411.00640):
6
+ curated Hand-picked cases (most eval sets and benchmarks). Cases aren't a random sample of anything bigger,
7
+ so the claim is "on this eval set": the cases are fixed and the only randomness is the agent's own,
8
+ measured from repeated trials of each case (comparisons.fixed_cases). If whole runs vary more than
9
+ that per-case randomness predicts (a run-wide shock, such as a provider's bad hour), it falls back
10
+ to treating each complete run as one observation: Student's t over runs, Welch's t to compare.
11
+ sampled Cases drawn at random from real traffic, unrelated to each other. Each case is an observation:
12
+ per-case t interval, paired per-case differences to compare. Claim: "on the population the cases
13
+ were drawn from".
14
+ clustered Sampled, but cases come in groups that fail together (same feature). Cluster-robust standard
15
+ errors over groups. Needs every case in exactly one group; overlapping groups can't be modelled
16
+ credibly, so it falls back to run-level statistics.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from collections import Counter
22
+ from collections.abc import Sequence
23
+
24
+ from scipy import stats
25
+
26
+ from agentstats import comparisons, reliability
27
+ from agentstats.intervals import Interval, bootstrap, clustered, mean, quantile, t_interval, wilson
28
+ from agentstats.records import NUMERIC_FIELDS, Attempt, by_case, by_run
29
+
30
+ DESIGNS = ("curated", "sampled", "clustered")
31
+ INFRA_WARN = 0.05
32
+
33
+ CLAIM = {
34
+ "fixed_cases": "on this eval set",
35
+ "run_level": "on this eval set",
36
+ "per_case": "on the population the cases were sampled from",
37
+ "clustered": "on the population the case groups were sampled from",
38
+ "wilson": "on this eval set, from a single run (indicative: run-to-run noise is unmeasured)",
39
+ "welch": "on this eval set",
40
+ "paired": "on the population the cases were sampled from",
41
+ "paired_clustered": "on the population the case groups were sampled from",
42
+ "mcnemar": "on this eval set, from one trial per case per arm (assumes no run-wide shocks, which one run "
43
+ "can't check)",
44
+ }
45
+
46
+
47
+ def _clusters(cases: Sequence[str], case_tags: dict[str, list[str]],
48
+ cluster_by: str | None) -> tuple[list[str] | None, str]:
49
+ if not cluster_by:
50
+ return None, "clustered design needs --cluster-by (a tag prefix such as 'feature')"
51
+ out, overlapping, missing = [], [], []
52
+ for c in cases:
53
+ tags = case_tags.get(c, [])
54
+ vals = [t for t in tags if t.startswith(cluster_by + ":")] if cluster_by != "tag" else tags
55
+ if not vals:
56
+ missing.append(c)
57
+ elif len(set(vals)) > 1:
58
+ overlapping.append(c)
59
+ else:
60
+ out.append(vals[0])
61
+ if missing:
62
+ return None, f"{len(missing)} cases have no '{cluster_by}' group (e.g. {missing[0]})"
63
+ if overlapping:
64
+ return None, (f"{len(overlapping)} cases belong to more than one group (e.g. {overlapping[0]}); "
65
+ "overlapping groups can't be modelled credibly")
66
+ return out, ""
67
+
68
+
69
+ def _clip(iv: Interval, low: float, high: float) -> None:
70
+ """Pass rates live in [0, 1] and their differences in [-1, 1]; a t interval from few runs can spill over."""
71
+ if iv.low == iv.low and iv.low < low:
72
+ iv.low = low
73
+ iv.note = (iv.note + "; " if iv.note else "") + "clipped to the possible range"
74
+ if iv.high == iv.high and iv.high > high:
75
+ iv.high = high
76
+ if "clipped" not in iv.note:
77
+ iv.note = (iv.note + "; " if iv.note else "") + "clipped to the possible range"
78
+
79
+
80
+ def _case_tags(attempts: list[Attempt]) -> dict[str, list[str]]:
81
+ tags: dict[str, set[str]] = {}
82
+ for a in attempts:
83
+ tags.setdefault(a.case, set()).update(a.tags)
84
+ return {c: sorted(t) for c, t in tags.items()}
85
+
86
+
87
+ def _common_cases(attempts: list[Attempt], notes: list[str], label: str = "") -> list[Attempt]:
88
+ """Run-level pass rates only mean something if every run covers the same cases: keep the cases attempted
89
+ in every run. An infrastructure failure doesn't remove a case; it only leaves that attempt out of its run."""
90
+ runs = by_run(attempts)
91
+ if len(runs) < 2:
92
+ return attempts
93
+ shared = set.intersection(*({a.case for a in atts} for atts in runs.values()))
94
+ allc = {a.case for a in attempts}
95
+ if shared != allc:
96
+ notes.append(f"{label}Runs cover different cases; using the {len(shared)} cases attempted in every run "
97
+ f"(dropped {len(allc - shared)}).")
98
+ return [a for a in attempts if a.case in shared]
99
+
100
+
101
+ def _pass_rates(valid: list[Attempt]) -> tuple[dict[int, float], dict[str, tuple[int, int]]]:
102
+ runs = {r: mean([1.0 if a.passed else 0.0 for a in atts]) for r, atts in by_run(valid).items()}
103
+ counts = {c: (len(atts), sum(1 for a in atts if a.passed)) for c, atts in by_case(valid).items()}
104
+ return runs, counts
105
+
106
+
107
+ def _headline(design: str, runs: dict[int, float], counts: dict[str, tuple[int, int]], case_tags, cluster_by,
108
+ conf: float) -> tuple[Interval, list[str], dict[str, Interval]]:
109
+ """The interval the design supports, notes, and the alternatives that were not used (for the report)."""
110
+ notes: list[str] = []
111
+ k = len(runs)
112
+ p = {c: pc / n for c, (n, pc) in counts.items()}
113
+ total_n = sum(n for n, _ in counts.values())
114
+ total_c = sum(c for _, c in counts.values())
115
+ alt: dict[str, Interval] = {}
116
+ if k >= 2:
117
+ alt["run_level"] = t_interval(list(runs.values()), conf, method="run_level")
118
+ if len(p) >= 2:
119
+ alt["per_case"] = t_interval(list(p.values()), conf, method="per_case")
120
+
121
+ if design == "clustered":
122
+ clusters, why = _clusters(list(p), case_tags, cluster_by)
123
+ if clusters is not None:
124
+ iv = clustered(list(p.values()), clusters, conf)
125
+ return iv, notes, alt
126
+ notes.append(f"Can't use a clustered interval: {why}. Falling back to the curated (run-level) design.")
127
+ design = "curated"
128
+ if design == "sampled":
129
+ if k == 1:
130
+ notes.append("One run: per-case outcomes are binary, so a Wilson interval is used.")
131
+ return wilson(total_c, total_n, conf), notes, alt
132
+ if len(p) < 30:
133
+ notes.append(f"Only {len(p)} cases: a per-case interval this small may be unreliable; add cases.")
134
+ return alt["per_case"], notes, alt
135
+ # curated
136
+ if k >= 2:
137
+ n = len(counts)
138
+ var = sum((c / t) * (1 - c / t) / (t - 1) for t, c in counts.values() if t >= 2) / n ** 2
139
+ se = math.sqrt(var)
140
+ zc = float(stats.norm.ppf(0.5 + conf / 2))
141
+ est = mean(list(p.values()))
142
+ fixed = Interval(est, est - zc * se, est + zc * se, "fixed_cases", n, se=se)
143
+ alt["fixed_cases"] = fixed
144
+ shock = comparisons.overdispersion(list(runs.values()), counts)
145
+ if shock and shock["p_value"] < 0.05:
146
+ msg = (f"Whole runs vary {shock['ratio']:.1f}x more than per-case randomness predicts "
147
+ f"(p = {shock['p_value']:.3f}): a run-wide shock.")
148
+ if k >= 3:
149
+ notes.append(msg + " Using complete runs as observations instead.")
150
+ return alt["run_level"], notes, alt
151
+ notes.append(msg + " With fewer than 3 runs there's nothing safer to fall back to; treat this with care.")
152
+ return fixed, notes, alt
153
+ notes.append("One run of a hand-picked set: run-to-run noise is unmeasured. Run the eval at least 3 times "
154
+ "for an interval you can rely on.")
155
+ return wilson(total_c, total_n, conf), notes, alt
156
+
157
+
158
+ def _efficiency(valid: list[Attempt], conf: float) -> dict:
159
+ out: dict = {}
160
+ cases = by_case(valid)
161
+ for f in NUMERIC_FIELDS:
162
+ per_case = [mean([a.metrics[f] for a in atts]) for atts in cases.values()
163
+ if all(f in a.metrics for a in atts)]
164
+ if len(per_case) >= 2:
165
+ out[f] = {"mean": t_interval(per_case, conf, method="per_case").to_json(),
166
+ "p50": quantile([a.metrics[f] for a in valid if f in a.metrics], 0.5),
167
+ "p95": quantile([a.metrics[f] for a in valid if f in a.metrics], 0.95)}
168
+ if all("cost_usd" in a.metrics for a in valid) and any(a.passed for a in valid):
169
+ units = [(sum(a.metrics["cost_usd"] for a in atts), sum(1 for a in atts if a.passed))
170
+ for atts in cases.values()]
171
+
172
+ def cost_per_resolved(us):
173
+ solved = sum(s for _, s in us)
174
+ return float("nan") if solved == 0 else sum(c for c, _ in us) / solved
175
+ out["cost_per_resolved_task"] = bootstrap(units, cost_per_resolved, conf,
176
+ method="bootstrap over cases").to_json()
177
+ return out
178
+
179
+
180
+ def no_answer_stats(valid: list[Attempt]) -> dict:
181
+ """Attempts that ended with no usable answer (step limit, unparseable output): counted as failures, shown apart."""
182
+ na = [a for a in valid if a.no_answer]
183
+ return {"count": len(na), "rate": len(na) / len(valid) if valid else 0.0,
184
+ "kinds": dict(Counter(a.no_answer for a in na))}
185
+
186
+
187
+ def summarize(attempts: list[Attempt], design: str = "curated", cluster_by: str | None = None,
188
+ conf: float = 0.95) -> dict:
189
+ if design not in DESIGNS:
190
+ raise ValueError(f"design must be one of {DESIGNS}")
191
+ pre_notes: list[str] = []
192
+ in_scope = _common_cases(attempts, pre_notes)
193
+ valid = [a for a in in_scope if a.valid]
194
+ infra = [a for a in in_scope if a.infra_error]
195
+ ungraded = [a for a in in_scope if not a.infra_error and a.passed is None]
196
+ runs, counts = _pass_rates(valid)
197
+ case_tags = _case_tags(attempts)
198
+ iv, notes, alt = _headline(design, runs, counts, case_tags, cluster_by, conf)
199
+ _clip(iv, 0.0, 1.0)
200
+ for v in alt.values():
201
+ _clip(v, 0.0, 1.0)
202
+ notes[:0] = pre_notes
203
+
204
+ out: dict = {
205
+ "design": design, "attempts_loaded": len(attempts), "attempts": len(valid), "cases": len(counts),
206
+ "runs": len(runs),
207
+ "pass_rate": iv.to_json(), "claim": CLAIM.get(iv.method, ""), "notes": notes,
208
+ "not_used": {m: v.to_json() for m, v in alt.items() if m != iv.method},
209
+ "run_pass_rates": runs,
210
+ "infra": {"count": len(infra), "rate": len(infra) / len(in_scope) if in_scope else 0.0,
211
+ "kinds": dict(Counter(a.infra_error for a in infra)), "ungraded": len(ungraded)},
212
+ "efficiency": _efficiency(valid, conf),
213
+ "no_answer": no_answer_stats(valid),
214
+ }
215
+ if out["no_answer"]["count"]:
216
+ na = out["no_answer"]
217
+ notes.append(f"{na['count']} of {len(valid) - sum(1 for a in valid if a.passed)} failures had no usable answer "
218
+ f"({na['kinds']}): counted as failures, but usually a harness or loop problem, not judgement.")
219
+ if out["infra"]["rate"] > INFRA_WARN:
220
+ notes.append(f"{out['infra']['rate']:.0%} of attempts were infrastructure failures (excluded). "
221
+ "Above 5%: check the environment before trusting the numbers.")
222
+ if len(runs) >= 2:
223
+ out["reliability"] = reliability.summarize(counts, conf)
224
+ tags = sorted({t for ts in case_tags.values() for t in ts})
225
+ if tags:
226
+ out["slices"] = {}
227
+ for t in tags:
228
+ atts = [a for a in valid if t in a.tags]
229
+ if atts:
230
+ cases_t = len({a.case for a in atts})
231
+ out["slices"][t] = {"cases": cases_t,
232
+ "pass_rate": wilson(sum(1 for a in atts if a.passed), len(atts), conf).to_json()}
233
+ if len(counts) < 100:
234
+ notes.append("Fewer than 100 cases: per-slice intervals are wide; treat slices as leads, not results.")
235
+ behaviour = [a for a in in_scope if not a.infra_error] # rules need no verdict: graded or not
236
+ rules = sorted({r for a in behaviour for r in a.checks})
237
+ if rules:
238
+ out["checks"] = {r: wilson(sum(1 for a in behaviour if a.checks.get(r)),
239
+ sum(1 for a in behaviour if r in a.checks), conf).to_json() for r in rules}
240
+ if not valid and behaviour:
241
+ notes.insert(0, f"Rules-only rows: {len(behaviour)} attempts with no pass/fail verdict (e.g. production "
242
+ "traces). There's no pass rate; the trajectory rule checks below are the result.")
243
+ return out
244
+
245
+
246
+ def compare(a_attempts: list[Attempt], b_attempts: list[Attempt], design: str = "curated",
247
+ cluster_by: str | None = None, conf: float = 0.95, min_effect: float | None = None) -> dict:
248
+ if design not in DESIGNS:
249
+ raise ValueError(f"design must be one of {DESIGNS}")
250
+ notes: list[str] = []
251
+ a_valid = [a for a in _common_cases(a_attempts, notes, "A: ") if a.valid]
252
+ b_valid = [b for b in _common_cases(b_attempts, notes, "B: ") if b.valid]
253
+ a_cases, b_cases = set(by_case(a_valid)), set(by_case(b_valid))
254
+ matched = sorted(a_cases & b_cases)
255
+ dropped = {"only_a": sorted(a_cases - b_cases), "only_b": sorted(b_cases - a_cases)}
256
+ a_m = [x for x in a_valid if x.case in matched]
257
+ b_m = [x for x in b_valid if x.case in matched]
258
+ a_runs, a_counts = _pass_rates(a_m)
259
+ b_runs, b_counts = _pass_rates(b_m)
260
+ if not matched:
261
+ raise ValueError("no cases graded in both arms")
262
+
263
+ pa = {c: s / n for c, (n, s) in a_counts.items()}
264
+ pb = {c: s / n for c, (n, s) in b_counts.items()}
265
+ diffs = [pb[c] - pa[c] for c in matched]
266
+ single = len(a_runs) == 1 and len(b_runs) == 1
267
+ mcn = None
268
+ if single:
269
+ a1 = {x.case: bool(x.passed) for x in a_m}
270
+ b1 = {x.case: bool(x.passed) for x in b_m}
271
+ mcn = comparisons.mcnemar([a1[c] for c in matched], [b1[c] for c in matched])
272
+
273
+ shock = {"detected": False, "fallback": False, "arms": {}}
274
+ method_design = design
275
+ if design == "clustered":
276
+ clusters, why = _clusters(matched, _case_tags(a_attempts + b_attempts), cluster_by)
277
+ if clusters is None:
278
+ notes.append(f"Can't use a clustered comparison: {why}. Falling back to the curated design.")
279
+ method_design = "curated"
280
+ else:
281
+ iv = comparisons.paired_clustered(diffs, clusters, conf)
282
+ if method_design == "sampled":
283
+ iv = comparisons.paired(diffs, conf)
284
+ elif method_design == "curated":
285
+ iv = comparisons.fixed_cases(a_counts, b_counts, conf)
286
+ if single:
287
+ iv.method = "mcnemar"
288
+ shocks = {arm: comparisons.overdispersion(list(r.values()), cnt)
289
+ for arm, r, cnt in (("A", a_runs, a_counts), ("B", b_runs, b_counts))}
290
+ hit = {arm: s for arm, s in shocks.items() if s and s["p_value"] < 0.05}
291
+ shock = {"detected": bool(hit), "fallback": False, "arms": hit}
292
+ if hit:
293
+ desc = "; ".join(f"{arm}: runs vary {s['ratio']:.1f}x more than per-case randomness predicts "
294
+ f"(p = {s['p_value']:.3f})" for arm, s in hit.items())
295
+ if len(a_runs) >= 3 and len(b_runs) >= 3:
296
+ iv = comparisons.welch(list(a_runs.values()), list(b_runs.values()), conf)
297
+ shock["fallback"] = True
298
+ notes.append(f"Run-wide shock ({desc}). Using complete runs as observations (Welch's t) instead.")
299
+ else:
300
+ notes.append(f"Run-wide shock ({desc}). With fewer than 3 runs per arm there's nothing safer to fall "
301
+ "back to; treat this with care.")
302
+
303
+ _clip(iv, -1.0, 1.0)
304
+ if iv.excludes_zero:
305
+ verdict = "BETTER" if iv.estimate > 0 else "WORSE"
306
+ else:
307
+ verdict = "NO DETECTABLE DIFFERENCE"
308
+ if single and mcn is not None and iv.method == "mcnemar":
309
+ verdict = ("BETTER" if mcn["b_only"] > mcn["a_only"] else "WORSE") if mcn["p_value"] < 1 - conf \
310
+ else "NO DETECTABLE DIFFERENCE"
311
+ ship = None
312
+ if min_effect is not None:
313
+ ship = bool(iv.low > min_effect) if iv.low == iv.low else False
314
+
315
+ infra_a = sum(1 for x in a_attempts if x.infra_error) / max(len(a_attempts), 1)
316
+ infra_b = sum(1 for x in b_attempts if x.infra_error) / max(len(b_attempts), 1)
317
+ if abs(infra_a - infra_b) > 0.05 or max(infra_a, infra_b) > INFRA_WARN:
318
+ notes.append(f"Infrastructure failure rates differ or are high (A {infra_a:.0%}, B {infra_b:.0%}): "
319
+ "one arm may have hit an outage. Infra failures are excluded, but check before trusting this.")
320
+ if dropped["only_a"] or dropped["only_b"]:
321
+ notes.append(f"Compared on {len(matched)} cases graded in both arms; dropped "
322
+ f"{len(dropped['only_a'])} only in A and {len(dropped['only_b'])} only in B.")
323
+
324
+ out = {
325
+ "design": design, "method": iv.method, "claim": CLAIM.get(iv.method, ""),
326
+ "a": {"pass_rate": mean(list(a_runs.values())), "runs": len(a_runs), "infra_rate": infra_a},
327
+ "b": {"pass_rate": mean(list(b_runs.values())), "runs": len(b_runs), "infra_rate": infra_b},
328
+ "cases": len(matched), "dropped": dropped,
329
+ "delta": iv.to_json(), "verdict": verdict,
330
+ "smallest_detectable": comparisons.smallest_detectable(iv, conf),
331
+ "min_effect": min_effect, "ship": ship, "notes": notes, "shock": shock,
332
+ "moved": {"b_better": sorted(c for c in matched if pb[c] > pa[c]),
333
+ "b_worse": sorted(c for c in matched if pb[c] < pa[c])},
334
+ }
335
+ if mcn:
336
+ out["mcnemar"] = mcn
337
+ if all("cost_usd" in x.metrics for x in a_m + b_m):
338
+ ca = {c: mean([x.metrics["cost_usd"] for x in a_m if x.case == c]) for c in matched}
339
+ cb = {c: mean([x.metrics["cost_usd"] for x in b_m if x.case == c]) for c in matched}
340
+ out["cost_per_attempt_delta"] = comparisons.paired([cb[c] - ca[c] for c in matched], conf).to_json()
341
+ out["cost_per_attempt"] = {"a": mean(list(ca.values())), "b": mean(list(cb.values()))}
342
+ return out
agentstats/checks.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ Deterministic trajectory checks: the rule-based raters that are cheap, fast and don't drift.
3
+
4
+ Rules (JSON list), each with a name:
5
+ {"name": "looked_up_order", "called": "get_order_details"}
6
+ {"name": "no_refund", "not_called": "issue_refund"}
7
+ {"name": "lookup_first", "before": ["get_order_details", "issue_refund"]} # if both happen, first one first
8
+ {"name": "right_order", "args": {"tool": "get_order_details", "match": {"order_id": "42"}}}
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ RULE_KINDS = ("called", "not_called", "before", "args")
16
+
17
+
18
+ def load_rules(path: str | Path) -> list[dict]:
19
+ rules = json.loads(Path(path).read_text())
20
+ for r in rules:
21
+ kinds = [k for k in RULE_KINDS if k in r]
22
+ if "name" not in r or len(kinds) != 1:
23
+ raise ValueError(f"each rule needs a name and exactly one of {RULE_KINDS}: {r}")
24
+ return rules
25
+
26
+
27
+ def check(rule: dict, calls: list[dict]) -> bool:
28
+ names = [c["name"] for c in calls]
29
+ if "called" in rule:
30
+ return rule["called"] in names
31
+ if "not_called" in rule:
32
+ return rule["not_called"] not in names
33
+ if "before" in rule:
34
+ first, second = rule["before"]
35
+ if second not in names:
36
+ return True
37
+ return first in names and names.index(first) < names.index(second)
38
+ if "args" in rule:
39
+ tool, match = rule["args"]["tool"], rule["args"]["match"]
40
+ return any(c["name"] == tool and all(str(c["args"].get(k)) == str(v) for k, v in match.items())
41
+ for c in calls)
42
+ raise ValueError(f"unknown rule: {rule}")
43
+
44
+
45
+ def run_checks(rules: list[dict], calls: list[dict]) -> dict[str, bool]:
46
+ return {r["name"]: check(r, calls) for r in rules}
@@ -0,0 +1,101 @@
1
+ """Starter files written by `agentstats init-ci`: a policy and three GitHub Actions workflows (smoke, nightly,
2
+ release). Each workflow has one step to replace: running your agent and writing results as JSONL or Harbor jobs."""
3
+
4
+ POLICY = '''# agentstats policy: evals in CI, in tiers. Thresholds are yours to set; these are starting points.
5
+ design = "curated" # curated (hand-picked cases) | sampled (random from traffic) | clustered
6
+
7
+ [smoke] # every pull request: minutes, a stratified sample + rule checks
8
+ sample = 20
9
+ stratify_by = "feature" # cases tagged feature:<name>
10
+ always_include = ["critical"]
11
+ runs = 1
12
+ require.rules = 1.0
13
+ require.min_pass_rate = 0.5
14
+ require.max_infra = 0.10
15
+
16
+ [nightly] # every night: the whole eval set, several runs per case
17
+ runs = 3
18
+ require.no_regression = 0.03
19
+ require.max_infra = 0.05
20
+
21
+ [release] # before a release: the whole set against the production baseline
22
+ runs = 5
23
+ require.no_regression = 0.02
24
+ require.max_cost_ratio = 1.10
25
+ require.max_p95_latency_ratio = 1.20
26
+ require.min_pass_hat_k = 0.70
27
+ require.max_infra = 0.05
28
+ '''
29
+
30
+ _SETUP = ''' runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: actions/setup-python@v5
34
+ with:
35
+ python-version: "3.12"
36
+ - run: pip install agentstats
37
+ '''
38
+
39
+ SMOKE = '''name: agent-evals-smoke
40
+ on:
41
+ pull_request:
42
+
43
+ jobs:
44
+ smoke:
45
+ ''' + _SETUP + ''' - name: Pick the smoke sample
46
+ run: agentstats sample evals/cases.jsonl --policy agentstats.toml --tier smoke > smoke_cases.txt
47
+ - name: Run the agent on the sample # REPLACE: write one JSONL row per attempt (see the agentstats README)
48
+ run: python run_evals.py --cases smoke_cases.txt --runs 1 --out results/smoke.jsonl
49
+ - name: Gate
50
+ run: agentstats gate --policy agentstats.toml --tier smoke --candidate results/smoke.jsonl
51
+ '''
52
+
53
+ NIGHTLY = '''name: agent-evals-nightly
54
+ on:
55
+ schedule:
56
+ - cron: "0 7 * * *"
57
+ workflow_dispatch:
58
+
59
+ jobs:
60
+ nightly:
61
+ ''' + _SETUP + ''' # REPLACE: one file per run, or one file with a "run" field
62
+ - name: Run the agent on the full set
63
+ run: python run_evals.py --runs 3 --out results/nightly.jsonl
64
+ - name: Gate against the baseline
65
+ run: >
66
+ agentstats gate --policy agentstats.toml --tier nightly
67
+ --candidate results/nightly.jsonl --baseline evals/baseline.jsonl
68
+ - uses: actions/upload-artifact@v4
69
+ if: always()
70
+ with:
71
+ name: nightly-results
72
+ path: results/
73
+ '''
74
+
75
+ RELEASE = '''name: agent-evals-release
76
+ on:
77
+ workflow_dispatch:
78
+ push:
79
+ tags: ["v*"]
80
+
81
+ jobs:
82
+ release:
83
+ ''' + _SETUP + ''' - name: Run the agent on the full set # REPLACE
84
+ run: python run_evals.py --runs 5 --out results/release.jsonl
85
+ - name: Gate against the production baseline
86
+ run: >
87
+ agentstats gate --policy agentstats.toml --tier release
88
+ --candidate results/release.jsonl --baseline evals/baseline.jsonl
89
+ - uses: actions/upload-artifact@v4
90
+ # A passing release run is the next baseline: commit it as evals/baseline.jsonl after you ship.
91
+ with:
92
+ name: release-results-candidate-baseline
93
+ path: results/release.jsonl
94
+ '''
95
+
96
+ FILES = {
97
+ "agentstats.toml": POLICY,
98
+ ".github/workflows/agent-evals-smoke.yml": SMOKE,
99
+ ".github/workflows/agent-evals-nightly.yml": NIGHTLY,
100
+ ".github/workflows/agent-evals-release.yml": RELEASE,
101
+ }