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/cli.py ADDED
@@ -0,0 +1,191 @@
1
+ """agentstats command line: summary, compare, judge, check, convert, sample, gate, size, init-ci,
2
+ preregister, switch."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from agentstats import adapters, analysis, checks, gate, judge, policy, records, report, sample, sizing, switch_cli
12
+ from agentstats.analysis import DESIGNS
13
+ from agentstats.ci_templates import FILES as CI_FILES
14
+
15
+ DESIGN_HELP = ("how the eval set was built: curated (hand-picked, the default), sampled (random from real "
16
+ "traffic), clustered (sampled, in groups that fail together; needs --cluster-by)")
17
+
18
+
19
+ def _common(p: argparse.ArgumentParser) -> None:
20
+ p.add_argument("--design", choices=DESIGNS, default="curated", help=DESIGN_HELP)
21
+ p.add_argument("--cluster-by", help="tag prefix that names each case's group, e.g. 'feature' for feature:search")
22
+ p.add_argument("--conf", type=float, default=0.95, help="confidence level (default 0.95)")
23
+ p.add_argument("--json", action="store_true", help="print JSON instead of a report")
24
+
25
+
26
+ def _load_many(paths: list[str], arm: str | None) -> list[records.Attempt]:
27
+ """Several paths for one arm are separate runs (e.g. three Harbor jobs)."""
28
+ out = []
29
+ for i, p in enumerate(paths, 1):
30
+ atts = records.load(p, arm=arm, run=i if len(paths) > 1 else None)
31
+ if len(paths) > 1 and Path(p).is_file():
32
+ for a in atts:
33
+ a.run = i
34
+ out += atts
35
+ return out
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ ap = argparse.ArgumentParser(prog="agentstats", description="Statistics for agent evals.")
40
+ sub = ap.add_subparsers(dest="cmd", required=True)
41
+
42
+ s = sub.add_parser("summary", help="pass rate with the right CI, reliability, efficiency, infra failures")
43
+ s.add_argument("results", nargs="+", help="JSONL file(s) or Harbor job dir(s); several = several runs")
44
+ _common(s)
45
+
46
+ c = sub.add_parser("compare", help="is B different from A?")
47
+ c.add_argument("--a", nargs="+", required=True, help="baseline results (JSONL or Harbor job dirs)")
48
+ c.add_argument("--b", nargs="+", required=True, help="candidate results")
49
+ c.add_argument("--a-name", default="A")
50
+ c.add_argument("--b-name", default="B")
51
+ c.add_argument("--min-effect", type=float, help="ship only if the CI's lower bound beats this (e.g. 0.02)")
52
+ _common(c)
53
+
54
+ j = sub.add_parser("judge", help="judge vs human labels, human agreement, judge consistency")
55
+ j.add_argument("labels", help="JSONL: item, rater (human:<name> | judge), verdict, [run], [split]")
56
+ j.add_argument("--json", action="store_true")
57
+
58
+ k = sub.add_parser("check", help="run deterministic trajectory rules over a Harbor job; writes JSONL")
59
+ k.add_argument("job", help="Harbor job directory")
60
+ k.add_argument("--rules", required=True, help="rules JSON (see agentstats.checks)")
61
+ k.add_argument("--out", required=True, help="output JSONL with checks filled in")
62
+
63
+ v = sub.add_parser("convert", help="Harbor job dir(s) to agentstats JSONL")
64
+ v.add_argument("jobs", nargs="+")
65
+ v.add_argument("--arm")
66
+ v.add_argument("--out", required=True)
67
+
68
+ tr = sub.add_parser("convert-traces", help="OpenTelemetry (OTLP JSON) or Langfuse traces to agentstats JSONL")
69
+ tr.add_argument("traces", nargs="+", help="OTLP JSON export(s), or Langfuse trace JSON / JSONL")
70
+ tr.add_argument("--format", choices=["otlp", "langfuse"], required=True)
71
+ tr.add_argument("--rules", help="rules JSON: fills each row's checks from the trace's tool calls")
72
+ tr.add_argument("--verdicts", help="JSONL with trace_id, passed (and optional case, run, infra_error); "
73
+ "without it the rows are rules-only (passed = null)")
74
+ tr.add_argument("--case-key", default="agentstats.case", help="OTLP span attribute naming the case")
75
+ tr.add_argument("--arm", default="default")
76
+ tr.add_argument("--out", required=True)
77
+
78
+ sm = sub.add_parser("sample", help="deterministic stratified sample of cases for a smoke tier")
79
+ sm.add_argument("cases", help="JSONL with a 'case' and optional 'tags' per row (cases or attempt rows)")
80
+ sm.add_argument("--policy", help="take n, stratify_by, always_include, seed from this policy's tier")
81
+ sm.add_argument("--tier", default="smoke")
82
+ sm.add_argument("--n", type=int)
83
+ sm.add_argument("--stratify-by")
84
+ sm.add_argument("--always-include", nargs="*", default=None)
85
+ sm.add_argument("--seed", type=int)
86
+ sm.add_argument("--json", action="store_true")
87
+
88
+ g = sub.add_parser("gate", help="check a tier's requirements; exit 0 pass, 1 fail, 2 inconclusive, 3 unhealthy")
89
+ g.add_argument("--policy", required=True)
90
+ g.add_argument("--tier", required=True)
91
+ g.add_argument("--candidate", nargs="+", required=True, help="results (several paths = several runs)")
92
+ g.add_argument("--baseline", nargs="+", help="baseline results, for comparison requirements")
93
+ g.add_argument("--conf", type=float, default=0.95)
94
+ g.add_argument("--json", action="store_true")
95
+
96
+ sz = sub.add_parser("size", help="simulate a gate on your baseline: pass/fail rates for a range of true drops")
97
+ sz.add_argument("--baseline", nargs="+", required=True, help="baseline results (several trials per case)")
98
+ sz.add_argument("--trials", type=int, default=2, help="candidate trials per case (what each PR would run)")
99
+ sz.add_argument("--rule", choices=sizing.RULES, default="drop",
100
+ help="drop = no_significant_drop (PR tier); noninferiority = no_regression (release tier)")
101
+ sz.add_argument("--margin", type=float, default=0.0)
102
+ sz.add_argument("--conf", type=float, default=0.90)
103
+ sz.add_argument("--sims", type=int, default=1000)
104
+ sz.add_argument("--seed", type=int, default=0)
105
+ sz.add_argument("--json", action="store_true")
106
+
107
+ ic = sub.add_parser("init-ci", help="write a starter policy and smoke/nightly/release GitHub workflows")
108
+ ic.add_argument("--dir", default=".")
109
+ ic.add_argument("--force", action="store_true")
110
+
111
+ switch_cli.add_parsers(sub)
112
+ args = ap.parse_args(argv)
113
+ if args.cmd in ("preregister", "switch"):
114
+ return switch_cli.run(args)
115
+ if args.cmd == "summary":
116
+ res = analysis.summarize(_load_many(args.results, None), args.design, args.cluster_by, args.conf)
117
+ print(json.dumps(res, indent=2, default=str) if args.json else report.summary(res))
118
+ elif args.cmd == "compare":
119
+ res = analysis.compare(_load_many(args.a, "A"), _load_many(args.b, "B"), args.design, args.cluster_by,
120
+ args.conf, args.min_effect)
121
+ print(json.dumps(res, indent=2, default=str) if args.json else report.compare(res, args.a_name, args.b_name))
122
+ elif args.cmd == "judge":
123
+ res = judge.analyze(judge.load(args.labels))
124
+ print(json.dumps(res, indent=2, default=str) if args.json else report.judge(res))
125
+ elif args.cmd == "check":
126
+ rules = checks.load_rules(args.rules)
127
+ out = []
128
+ for t in sorted(p for p in Path(args.job).iterdir() if p.is_dir() and (p / "result.json").exists()):
129
+ a = adapters.harbor_trial(t)
130
+ traj = t / "agent" / "trajectory.json"
131
+ if traj.exists():
132
+ a.checks = checks.run_checks(rules, adapters.tool_calls(traj))
133
+ out.append(a)
134
+ records.write_jsonl(out, args.out)
135
+ print(f"wrote {len(out)} attempts to {args.out}")
136
+ elif args.cmd == "sample":
137
+ tier = policy.load(args.policy).tiers[args.tier] if args.policy else None
138
+ n = args.n or (tier.sample if tier else None)
139
+ if not n:
140
+ ap.error("sample needs --n or a policy tier with 'sample'")
141
+ res = sample.stratified(sample.load_cases(args.cases), n,
142
+ args.stratify_by or (tier.stratify_by if tier else None),
143
+ args.always_include if args.always_include is not None
144
+ else (tier.always_include if tier else []),
145
+ args.seed if args.seed is not None else (tier.seed if tier else 0))
146
+ for note in res["notes"]:
147
+ print(f"note: {note}", file=sys.stderr)
148
+ print(json.dumps(res, indent=2) if args.json else "\n".join(res["cases"]))
149
+ elif args.cmd == "gate":
150
+ pol = policy.load(args.policy)
151
+ if args.tier not in pol.tiers:
152
+ ap.error(f"tier {args.tier!r} not in the policy (have: {', '.join(pol.tiers)})")
153
+ res = gate.evaluate(pol.tiers[args.tier], pol, _load_many(args.candidate, "candidate"),
154
+ _load_many(args.baseline, "baseline") if args.baseline else None, args.conf)
155
+ text = gate.render(res)
156
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
157
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
158
+ f.write(text)
159
+ print(json.dumps(res, indent=2, default=str) if args.json else text)
160
+ return res["exit_code"]
161
+ elif args.cmd == "size":
162
+ res = sizing.simulate(_load_many(args.baseline, "baseline"), args.trials, args.rule, args.margin, args.conf,
163
+ sims=args.sims, seed=args.seed)
164
+ print(json.dumps(res, indent=2) if args.json else sizing.render(res))
165
+ elif args.cmd == "init-ci":
166
+ root = Path(args.dir)
167
+ clash = [p for p in CI_FILES if (root / p).exists()]
168
+ if clash and not args.force:
169
+ ap.error(f"already exists: {', '.join(clash)} (use --force to overwrite)")
170
+ for p, body in CI_FILES.items():
171
+ (root / p).parent.mkdir(parents=True, exist_ok=True)
172
+ (root / p).write_text(body)
173
+ print(f"wrote {root / p}")
174
+ elif args.cmd == "convert-traces":
175
+ from agentstats import traces
176
+ from agentstats.checks import load_rules
177
+ atts = traces.convert(args.traces, args.format, rules=load_rules(args.rules) if args.rules else None,
178
+ verdicts=traces.load_verdicts(args.verdicts) if args.verdicts else None,
179
+ arm=args.arm, case_key=args.case_key)
180
+ records.write_jsonl(atts, args.out)
181
+ graded = sum(1 for a in atts if a.passed is not None)
182
+ print(f"wrote {args.out}: {len(atts)} traces, {graded} with a verdict"
183
+ + ("" if graded else " (rules-only)"))
184
+ elif args.cmd == "convert":
185
+ records.write_jsonl(_load_many(args.jobs, args.arm), args.out)
186
+ print(f"wrote {args.out}")
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ """
2
+ Is B different from A? Each test returns an Interval for the difference B - A (positive = B better).
3
+
4
+ - welch: complete runs as observations (hand-picked eval sets). Welch's t: unequal run-to-run variance.
5
+ - paired: per-case differences in mean pass rate on the same cases (cases sampled and independent).
6
+ Each case is its own control, so case difficulty cancels out.
7
+ - paired_clustered: paired differences with a cluster-robust standard error.
8
+ - mcnemar: one run each, binary outcomes on the same cases; exact test on the discordant pairs.
9
+
10
+ - fixed_cases: the cases are fixed (a hand-picked set) and the noise is the agent's own randomness, measured
11
+ from repeated trials of each case. Delta = mean over cases of (p_B,i - p_A,i). Variance under "nothing
12
+ changed": each case's pooled rate p_i from all its trials, Var = sum p_i (1 - p_i) (1/k_A + 1/k_B) / N^2,
13
+ with the small-sample correction n_i / (n_i - 1) (n_i = k_A + k_B): without it, a rate estimated from a few
14
+ trials understates its variance and the test raises too many false alarms. This is the diagnosis gate v2
15
+ test from agent-platform (app/evals/gate_v2.py) plus that correction. Cases that pass (or fail) every trial on
16
+ both sides contribute nothing; the cases that sometimes pass carry the signal. With one trial per arm it
17
+ reduces to McNemar's test (normal form). Valid when trials are independent across cases: a run-wide shock (a
18
+ provider's bad hour) breaks that, which analysis.py checks for.
19
+
20
+ Smallest detectable difference: about (t_{1-a/2} + z_{power}) x SE; with a = 0.05 and power 0.8, ~2.8 x SE.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ from collections.abc import Sequence
26
+
27
+ from scipy import stats
28
+
29
+ from agentstats.intervals import Interval, clustered, mean, sd, t_interval
30
+
31
+
32
+ def welch(a_runs: Sequence[float], b_runs: Sequence[float], conf: float = 0.95) -> Interval:
33
+ ka, kb = len(a_runs), len(b_runs)
34
+ delta = mean(b_runs) - mean(a_runs)
35
+ if ka < 2 or kb < 2:
36
+ return Interval(delta, float("nan"), float("nan"), "welch", ka + kb, note="needs at least 2 runs per arm")
37
+ va, vb = sd(a_runs) ** 2 / ka, sd(b_runs) ** 2 / kb
38
+ se = math.sqrt(va + vb)
39
+ if se == 0:
40
+ return Interval(delta, delta, delta, "welch", ka + kb, se=0.0,
41
+ note="no run-to-run variation in either arm; the interval understates uncertainty")
42
+ df = (va + vb) ** 2 / ((va ** 2 / (ka - 1) if ka > 1 else 0) + (vb ** 2 / (kb - 1) if kb > 1 else 0))
43
+ half = float(stats.t.ppf(0.5 + conf / 2, df)) * se
44
+ return Interval(delta, delta - half, delta + half, "welch", ka + kb, se=se, df=df)
45
+
46
+
47
+ def paired(diffs: Sequence[float], conf: float = 0.95) -> Interval:
48
+ iv = t_interval(diffs, conf, method="paired")
49
+ nonzero = sum(1 for d in diffs if d != 0)
50
+ if nonzero and nonzero < 10:
51
+ iv.note = (iv.note + "; " if iv.note else "") + f"only {nonzero} cases differ; the interval may be optimistic"
52
+ return iv
53
+
54
+
55
+ def paired_clustered(diffs: Sequence[float], clusters: Sequence[str], conf: float = 0.95) -> Interval:
56
+ iv = clustered(diffs, clusters, conf)
57
+ iv.method = "paired_clustered"
58
+ return iv
59
+
60
+
61
+ def fixed_cases(a_counts: dict[str, tuple[int, int]], b_counts: dict[str, tuple[int, int]], conf: float = 0.95,
62
+ correction: bool = True) -> Interval:
63
+ """a_counts / b_counts: case -> (trials, passes), on the same cases."""
64
+ cases = sorted(set(a_counts) & set(b_counts))
65
+ n = len(cases)
66
+ t = v = 0.0
67
+ for c in cases:
68
+ ka, sa = a_counts[c]
69
+ kb, sb = b_counts[c]
70
+ t += sb / kb - sa / ka
71
+ p = (sa + sb) / (ka + kb)
72
+ var = p * (1 - p) * (1 / ka + 1 / kb)
73
+ if correction:
74
+ var *= (ka + kb) / (ka + kb - 1)
75
+ v += var
76
+ delta = t / n
77
+ se = math.sqrt(v) / n
78
+ if se == 0:
79
+ return Interval(delta, delta, delta, "fixed_cases", n, se=0.0, z=0.0 if t == 0 else math.copysign(math.inf, t),
80
+ note="every case gave the same result on every trial; nothing varies")
81
+ zc = float(stats.norm.ppf(0.5 + conf / 2))
82
+ return Interval(delta, delta - zc * se, delta + zc * se, "fixed_cases", n, se=se, z=delta / se)
83
+
84
+
85
+ def overdispersion(run_rates: Sequence[float], counts: dict[str, tuple[int, int]]) -> dict | None:
86
+ """Do whole runs vary more than per-case randomness predicts? Predicted variance of a run's pass rate:
87
+ sum over cases of p_i (1 - p_i) / N^2 (per-case rates from all trials, small-sample corrected). Observed: the
88
+ sample variance of the run pass rates. (K - 1) s^2 / sigma^2 ~ chi-square(K - 1) if runs only differ by
89
+ per-case noise; a small p-value means run-wide shocks, and the fixed-cases test is too optimistic."""
90
+ k = len(run_rates)
91
+ if k < 2:
92
+ return None
93
+ n = len(counts)
94
+ pred = sum((s / t) * (1 - s / t) * (t / (t - 1)) for t, s in counts.values() if t >= 2) / n ** 2
95
+ obs = sd(run_rates) ** 2
96
+ if pred == 0:
97
+ return {"ratio": math.inf if obs > 0 else 1.0, "p_value": 0.0 if obs > 0 else 1.0, "runs": k}
98
+ stat = (k - 1) * obs / pred
99
+ return {"ratio": obs / pred, "p_value": float(stats.chi2.sf(stat, k - 1)), "runs": k}
100
+
101
+
102
+ def mcnemar(a: Sequence[bool], b: Sequence[bool]) -> dict:
103
+ """Exact McNemar test on paired binary outcomes (same cases, one run each)."""
104
+ b_only = sum(1 for x, y in zip(a, b, strict=True) if y and not x)
105
+ a_only = sum(1 for x, y in zip(a, b, strict=True) if x and not y)
106
+ n = a_only + b_only
107
+ p = 1.0 if n == 0 else float(stats.binomtest(min(a_only, b_only), n, 0.5).pvalue)
108
+ return {"b_only": b_only, "a_only": a_only, "discordant": n, "p_value": p}
109
+
110
+
111
+ def smallest_detectable(iv: Interval, conf: float = 0.95, power: float = 0.8) -> float | None:
112
+ if iv.se is None or math.isnan(iv.se) or iv.se == 0:
113
+ return None
114
+ crit = float(stats.t.ppf(0.5 + conf / 2, iv.df)) if iv.df else float(stats.norm.ppf(0.5 + conf / 2))
115
+ return (crit + float(stats.norm.ppf(power))) * iv.se