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 +3 -0
- agentstats/adapters.py +115 -0
- agentstats/analysis.py +342 -0
- agentstats/checks.py +46 -0
- agentstats/ci_templates.py +101 -0
- agentstats/cli.py +191 -0
- agentstats/comparisons.py +115 -0
- agentstats/gate.py +382 -0
- agentstats/intervals.py +123 -0
- agentstats/judge.py +159 -0
- agentstats/policy.py +105 -0
- agentstats/prereg.py +139 -0
- agentstats/records.py +150 -0
- agentstats/reliability.py +58 -0
- agentstats/report.py +152 -0
- agentstats/sample.py +68 -0
- agentstats/sizing.py +86 -0
- agentstats/switch.py +180 -0
- agentstats/switch_cli.py +140 -0
- agentstats/switch_record.py +99 -0
- agentstats/traces.py +161 -0
- agentstats-0.3.0.dist-info/METADATA +384 -0
- agentstats-0.3.0.dist-info/RECORD +26 -0
- agentstats-0.3.0.dist-info/WHEEL +4 -0
- agentstats-0.3.0.dist-info/entry_points.txt +2 -0
- agentstats-0.3.0.dist-info/licenses/LICENSE +21 -0
agentstats/__init__.py
ADDED
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
|
+
}
|