agentstats 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (65) hide show
  1. {agentstats-0.3.0 → agentstats-0.4.0}/CHANGELOG.md +42 -0
  2. {agentstats-0.3.0 → agentstats-0.4.0}/PKG-INFO +54 -2
  3. {agentstats-0.3.0 → agentstats-0.4.0}/README.md +53 -1
  4. {agentstats-0.3.0 → agentstats-0.4.0}/pyproject.toml +4 -1
  5. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/__init__.py +1 -1
  6. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/analysis.py +3 -2
  7. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/cli.py +134 -3
  8. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/comparisons.py +23 -4
  9. agentstats-0.4.0/src/agentstats/inspect_logs.py +184 -0
  10. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/intervals.py +14 -0
  11. agentstats-0.4.0/src/agentstats/judge_run.py +193 -0
  12. agentstats-0.4.0/src/agentstats/promote.py +120 -0
  13. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/records.py +5 -2
  14. agentstats-0.4.0/src/agentstats/review.py +205 -0
  15. agentstats-0.4.0/tests/fixtures/inspect_epochs.json +1085 -0
  16. agentstats-0.4.0/tests/fixtures/inspect_tools.eval +0 -0
  17. agentstats-0.4.0/tests/fixtures/inspect_tools.json +733 -0
  18. agentstats-0.4.0/tests/test_grade.py +161 -0
  19. agentstats-0.4.0/tests/test_inspect_logs.py +131 -0
  20. agentstats-0.4.0/tests/test_promote.py +100 -0
  21. agentstats-0.4.0/tests/test_review.py +125 -0
  22. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_stats.py +58 -0
  23. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_traces.py +9 -0
  24. {agentstats-0.3.0 → agentstats-0.4.0}/.github/workflows/release.yml +0 -0
  25. {agentstats-0.3.0 → agentstats-0.4.0}/.github/workflows/tests.yml +0 -0
  26. {agentstats-0.3.0 → agentstats-0.4.0}/.gitignore +0 -0
  27. {agentstats-0.3.0 → agentstats-0.4.0}/LICENSE +0 -0
  28. {agentstats-0.3.0 → agentstats-0.4.0}/examples/judge_labels.jsonl +0 -0
  29. {agentstats-0.3.0 → agentstats-0.4.0}/examples/rules.json +0 -0
  30. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/README.md +0 -0
  31. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/analysis.md +0 -0
  32. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/decision/decision.json +0 -0
  33. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/decision/decision.md +0 -0
  34. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/preregistration.md +0 -0
  35. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/rows/candidate.jsonl +0 -0
  36. {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/rows/current.jsonl +0 -0
  37. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/adapters.py +0 -0
  38. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/checks.py +0 -0
  39. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/ci_templates.py +0 -0
  40. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/gate.py +0 -0
  41. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/judge.py +0 -0
  42. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/policy.py +0 -0
  43. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/prereg.py +0 -0
  44. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/reliability.py +0 -0
  45. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/report.py +0 -0
  46. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/sample.py +0 -0
  47. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/sizing.py +0 -0
  48. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/switch.py +0 -0
  49. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/switch_cli.py +0 -0
  50. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/switch_record.py +0 -0
  51. {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/traces.py +0 -0
  52. {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/langfuse_traces.json +0 -0
  53. {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/otlp_traces.json +0 -0
  54. {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/rows_from_agent_platform_triage_gate.jsonl +0 -0
  55. {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/rows_from_harness_evolve.jsonl +0 -0
  56. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_adapters.py +0 -0
  57. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_analysis.py +0 -0
  58. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_calibration.py +0 -0
  59. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_gate.py +0 -0
  60. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_hardening.py +0 -0
  61. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_judge.py +0 -0
  62. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_prereg.py +0 -0
  63. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_rule_gates.py +0 -0
  64. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_sizing.py +0 -0
  65. {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_switch.py +0 -0
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-10-30)
4
+
5
+ **Statistics**
6
+ - The sampled design no longer crashes on rules-only rows (no verdicts at all); it reports the rule checks like the
7
+ curated design does. Found by agent-platform's weekly monitoring job.
8
+ - Few failures: when either outcome is rare (under 10 failures, or passes, across both arms) the fixed-cases test
9
+ falls back to Newcombe's hybrid score interval on the pooled counts. Before, no failures at all gave a zero-width
10
+ interval (+0.0 to +0.0), so `switch` could say SWITCH on 120 vs 120 passes and rule comparisons looked exact. In
11
+ simulation (40 cases x 3 trials, base 0.99 vs candidate 0.95) coverage of the true difference rose from about 92%
12
+ to 97%. Found by the Acme example's model switch, which now says NOT YET.
13
+
14
+ **Example**
15
+ - `examples/acme/`: "a week at Acme", two real agents (a tool-calling support agent with OpenTelemetry traces, an
16
+ Inspect AI coding agent) through the whole back half: smoke and nightly gates, behaviour rules on production traces,
17
+ an unavailable model reading as UNHEALTHY, the rubric judge, sampled human review, promote, and a model switch.
18
+ Replays from recorded runs with no API keys (`bash week.sh`). The README records what went wrong while building it.
19
+
20
+ **Inputs**
21
+ - Inspect AI eval logs (`.eval` and `.json`): read directly by every command, or `agentstats convert --format inspect`
22
+ with `--scorer`, `--pass-threshold`, `--errors {infra,fail}`, `--rules`, `--arm`. One row per sample and epoch;
23
+ sample errors are infrastructure failures by default; limits and N scores become `no_answer`; tool calls feed rule
24
+ checks; metadata becomes tags. Uses inspect-ai's reader when installed (no new dependency). A log where no sample
25
+ was scored (e.g. every sample errored) reads as infrastructure failures instead of failing on scorer choice.
26
+
27
+ **Eval sets**
28
+ - `agentstats promote find` / `promote accept`: failures (failed verdicts, or rule violations on rules-only production
29
+ rows) grouped by signature and deduplicated into candidate cases with provenance tags (source, found, label);
30
+ accepting appends approved candidates to the cases file and prints the provenance mix, warning when one model
31
+ labelled most of the eval set.
32
+
33
+ **Judges**
34
+ - `agentstats grade`: an LLM judge on binary rubric questions over any OpenAI-compatible endpoint (standard library,
35
+ own User-Agent: Together's edge returns 403 to Python's default one). Writes judge label rows (each repeat, for
36
+ consistency) and, with `--rows`, attempt rows whose checks are the per-question majority. Unparsed replies are `na`,
37
+ transport failures infrastructure errors. Warns when a question's name is negated (`no_...`) and its passing answer
38
+ is "no": on a real run such a double negative made the judge flip between yes and no on the same reply.
39
+ - `agentstats review sample` / `review import`: sampled human review of graded attempts. The sample favours attempts
40
+ where the verdict and the behaviour rules disagree, then failures, then passes, spread over a tag (`--stratify-by`);
41
+ the sheet (CSV) is blind, with verdicts, strata and sampling weights in a separate key. Import writes label rows for
42
+ `agentstats judge` and reports agreement, leniency and strictness on the sample and reweighted to the population
43
+ (stratified bootstrap 95% intervals).
44
+
3
45
  ## 0.3.0 (2026-10-29)
4
46
 
5
47
  First release on PyPI.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentstats
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Statistics for agent evals: from trajectories to claims you can defend (CIs chosen by how your eval set was built, pass^k, infra failures kept apart, CI gates, behaviour rules, judge checks, pre-registered model-switch decisions).
5
5
  Project-URL: Homepage, https://github.com/sidreddy88/agentstats
6
6
  Project-URL: Repository, https://github.com/sidreddy88/agentstats
@@ -28,14 +28,23 @@ claim the numbers support.
28
28
  pip install agentstats # Python 3.11+
29
29
  agentstats summary jobs/run1 jobs/run2 jobs/run3
30
30
  agentstats compare --a jobs/baseline --b jobs/candidate
31
+ agentstats review sample rows.jsonl --n 40 --stratify-by intent --out review/wk1 # blind sheet for a person
32
+ agentstats grade items.jsonl --rubric rubric.json --model MODEL --base-url URL --repeats 3 --out labels.jsonl
31
33
  agentstats judge labels.jsonl
34
+ agentstats promote find prod_rows.jsonl --source production --existing evals/cases.jsonl --out candidates.jsonl
32
35
  agentstats gate --policy agentstats.toml --tier nightly --candidate results/ --baseline baseline/
33
36
  agentstats convert-traces traces.json --format otlp --rules rules.json --out rows.jsonl # or --format langfuse
37
+ agentstats summary logs/my-task.eval # Inspect AI logs are read directly
34
38
  agentstats preregister --name my-switch --margin 0.03 --provenance independent --out decisions/my-switch
35
39
  agentstats switch --prereg decisions/my-switch/prereg.json --current current.jsonl --candidate cheap.jsonl \
36
40
  --out decisions/my-switch
37
41
  ```
38
42
 
43
+ **See it on two real agents:** [examples/acme](https://github.com/sidreddy88/agentstats/tree/main/examples/acme)
44
+ replays "a week at Acme" (a support agent and an Inspect coding agent) from recorded runs: a PR blocked at smoke, a
45
+ nightly regression found by slice, a lenient LLM judge caught by sampled human review, production failures promoted
46
+ into the eval set, and a model switch. `bash week.sh`, no API keys.
47
+
39
48
  ## Why
40
49
 
41
50
  Agents are stochastic, so a single eval run is a sample, not a measurement. On 100 SWE-bench Verified tasks, the
@@ -55,7 +64,8 @@ Three other things go wrong in practice:
55
64
 
56
65
  Other good tools exist (see [Related tools](#related-tools)). agentstats is built around agent runs:
57
66
 
58
- 1. **Reads trajectories, not score tables.** Harbor job directories and ATIF trajectories directly: pass/fail,
67
+ 1. **Reads trajectories, not score tables.** Harbor job directories, ATIF trajectories, Inspect AI logs and
68
+ OpenTelemetry/Langfuse traces directly: pass/fail,
59
69
  infrastructure errors, tokens, cost, latency, steps and tool calls, plus deterministic rule checks on the
60
70
  tool calls (`called`, `not_called`, `before`, `args`).
61
71
  2. **Picks the statistics from how the eval set was built, and says what claim they support.**
@@ -293,6 +303,45 @@ calls). One trace is one attempt; `--rules` fills its checks. Pass/fail comes fr
293
303
  `trace_id`, `passed`, optional `case`, `run`, `infra_error`); without it the rows are **rules-only**: no pass rate, but
294
304
  the rule gates work, which is what production traces without labels need.
295
305
 
306
+ **From Inspect AI logs** (`.eval` or `.json`; read directly by every command, or
307
+ `agentstats convert --format inspect LOG --rules rules.json --out rows.jsonl`): one row per sample and epoch (case =
308
+ sample id, run = epoch, arm = model or `--arm`). Pass/fail comes from the headline scorer or `--scorer`: C passes; I
309
+ and P fail; N fails as `no_answer`; numbers pass at `--pass-threshold` (default 1.0). A sample that errored is an
310
+ infrastructure failure by default (`--errors fail` counts it as a failure); a sample that hit a limit and didn't pass
311
+ is `no_answer: limit_<type>`. Tool calls from the assistant messages fill `--rules` checks; tokens, time, steps and
312
+ tool calls become metrics; task, model and scalar sample metadata become tags (so `slice_by = "intent"` works).
313
+ `.eval` files are zstd-compressed: they're read with inspect-ai's own reader when it's installed, or the standard
314
+ library on Python 3.14+; otherwise `inspect log convert --to json` first.
315
+
316
+ **LLM judge on yes/no rubric questions** (`agentstats grade items.jsonl --rubric rubric.json --model MODEL
317
+ --base-url URL --api-key-env VAR --repeats 3 --out labels.jsonl --rows rows.jsonl`): items are
318
+ `{"item", "input", "output", ...}` (every field but ids and tags is shown to the judge, or `--fields`); the rubric is a
319
+ list of `{"name", "question", "pass": "yes"|"no"}`, and an item passes when every question gets its passing answer.
320
+ Any OpenAI-compatible endpoint, called with the standard library. A reply that isn't the JSON asked for is recorded
321
+ as `na`, never a silent pass; transport errors are retried, then recorded as infrastructure errors. Repeats are judge
322
+ labels (for `agentstats judge`'s flip rate), never extra attempts; `--rows` writes one attempt row per item with the
323
+ per-question majority as checks, so a rubric question can be gated like a behaviour rule.
324
+
325
+ **Sampled human review** (`agentstats review sample ROWS --n 40 --stratify-by intent --link URL --out DIR`, then
326
+ `agentstats review import DIR/review.csv --key DIR/key.jsonl --rater NAME --out labels.jsonl`): picks graded attempts
327
+ for a person to check, favouring those where the verdict and the behaviour rules disagree (passed with a rule
328
+ violated, or failed with every rule satisfied), then failures, then passes, spread over a tag. The sheet is blind
329
+ (no verdicts, no reason for the pick); the key keeps each item's verdict, stratum and sampling weight. Import appends
330
+ label rows for `agentstats judge` and reports the grader's agreement, leniency and strictness both on the sample and
331
+ **reweighted to the population** (over-sampling disagreements would otherwise make the grader look worse than it is),
332
+ with stratified bootstrap intervals.
333
+
334
+ **Failures become eval cases** (`agentstats promote find ROWS --source production --label-from none --existing
335
+ evals/cases.jsonl --group-by intent --out candidates.jsonl`, then `agentstats promote accept candidates.jsonl --into
336
+ evals/cases.jsonl`): failures from eval runs, or rule violations on rules-only production rows, are grouped by
337
+ signature (the rules that broke, the no-answer reason, optionally a slice tag), so one failure seen thirty times is one
338
+ signature with up to `--per-signature` candidate cases (the most frequent first). Cases already in the eval set are
339
+ skipped. Each candidate is tagged `source:`, `found:` and `label:` (`human`, `none`, or `model:<name>`); a person sets
340
+ `"approved": true` and `accept` appends those to the cases file (the one `agentstats sample` reads) and prints the eval
341
+ set's provenance mix, warning when most labels came from one model: comparisons against that model are then tilted
342
+ in its favour, because its own answers are the answer key. agentstats keeps only case ids and tags; the inputs and
343
+ expected answers go in your runner's dataset.
344
+
296
345
  Judge labels (`agentstats judge`), one row per verdict:
297
346
 
298
347
  ```json
@@ -306,6 +355,9 @@ A synthetic judge-labels file is in `examples/judge_labels.jsonl`.
306
355
  ## Methods and sources
307
356
 
308
357
  - Error bars for evals, clustered standard errors: Miller 2024, [arXiv 2411.00640](https://arxiv.org/abs/2411.00640).
358
+ - Few failures (fewer than 10 of either outcome across both arms): Newcombe's hybrid score interval for a difference
359
+ of proportions (method 10), Newcombe 1998, [Statistics in Medicine 17:873-890](https://doi.org/10.1002/(SICI)1097-0258(19980430)17:8%3C873::AID-SIM779%3E3.0.CO;2-I),
360
+ instead of a normal approximation that collapses to zero width when nothing fails.
309
361
  - Choosing the method by how the eval set was built, evals in CI tiers, judge alignment and consistency metrics:
310
362
  Yesha Shah's "Agentic Evals" series (see [Credits](#credits)).
311
363
  - pass@k: Chen et al. 2021, [arXiv 2107.03374](https://arxiv.org/abs/2107.03374). pass^k: τ-bench, Yao et al.,
@@ -9,14 +9,23 @@ claim the numbers support.
9
9
  pip install agentstats # Python 3.11+
10
10
  agentstats summary jobs/run1 jobs/run2 jobs/run3
11
11
  agentstats compare --a jobs/baseline --b jobs/candidate
12
+ agentstats review sample rows.jsonl --n 40 --stratify-by intent --out review/wk1 # blind sheet for a person
13
+ agentstats grade items.jsonl --rubric rubric.json --model MODEL --base-url URL --repeats 3 --out labels.jsonl
12
14
  agentstats judge labels.jsonl
15
+ agentstats promote find prod_rows.jsonl --source production --existing evals/cases.jsonl --out candidates.jsonl
13
16
  agentstats gate --policy agentstats.toml --tier nightly --candidate results/ --baseline baseline/
14
17
  agentstats convert-traces traces.json --format otlp --rules rules.json --out rows.jsonl # or --format langfuse
18
+ agentstats summary logs/my-task.eval # Inspect AI logs are read directly
15
19
  agentstats preregister --name my-switch --margin 0.03 --provenance independent --out decisions/my-switch
16
20
  agentstats switch --prereg decisions/my-switch/prereg.json --current current.jsonl --candidate cheap.jsonl \
17
21
  --out decisions/my-switch
18
22
  ```
19
23
 
24
+ **See it on two real agents:** [examples/acme](https://github.com/sidreddy88/agentstats/tree/main/examples/acme)
25
+ replays "a week at Acme" (a support agent and an Inspect coding agent) from recorded runs: a PR blocked at smoke, a
26
+ nightly regression found by slice, a lenient LLM judge caught by sampled human review, production failures promoted
27
+ into the eval set, and a model switch. `bash week.sh`, no API keys.
28
+
20
29
  ## Why
21
30
 
22
31
  Agents are stochastic, so a single eval run is a sample, not a measurement. On 100 SWE-bench Verified tasks, the
@@ -36,7 +45,8 @@ Three other things go wrong in practice:
36
45
 
37
46
  Other good tools exist (see [Related tools](#related-tools)). agentstats is built around agent runs:
38
47
 
39
- 1. **Reads trajectories, not score tables.** Harbor job directories and ATIF trajectories directly: pass/fail,
48
+ 1. **Reads trajectories, not score tables.** Harbor job directories, ATIF trajectories, Inspect AI logs and
49
+ OpenTelemetry/Langfuse traces directly: pass/fail,
40
50
  infrastructure errors, tokens, cost, latency, steps and tool calls, plus deterministic rule checks on the
41
51
  tool calls (`called`, `not_called`, `before`, `args`).
42
52
  2. **Picks the statistics from how the eval set was built, and says what claim they support.**
@@ -274,6 +284,45 @@ calls). One trace is one attempt; `--rules` fills its checks. Pass/fail comes fr
274
284
  `trace_id`, `passed`, optional `case`, `run`, `infra_error`); without it the rows are **rules-only**: no pass rate, but
275
285
  the rule gates work, which is what production traces without labels need.
276
286
 
287
+ **From Inspect AI logs** (`.eval` or `.json`; read directly by every command, or
288
+ `agentstats convert --format inspect LOG --rules rules.json --out rows.jsonl`): one row per sample and epoch (case =
289
+ sample id, run = epoch, arm = model or `--arm`). Pass/fail comes from the headline scorer or `--scorer`: C passes; I
290
+ and P fail; N fails as `no_answer`; numbers pass at `--pass-threshold` (default 1.0). A sample that errored is an
291
+ infrastructure failure by default (`--errors fail` counts it as a failure); a sample that hit a limit and didn't pass
292
+ is `no_answer: limit_<type>`. Tool calls from the assistant messages fill `--rules` checks; tokens, time, steps and
293
+ tool calls become metrics; task, model and scalar sample metadata become tags (so `slice_by = "intent"` works).
294
+ `.eval` files are zstd-compressed: they're read with inspect-ai's own reader when it's installed, or the standard
295
+ library on Python 3.14+; otherwise `inspect log convert --to json` first.
296
+
297
+ **LLM judge on yes/no rubric questions** (`agentstats grade items.jsonl --rubric rubric.json --model MODEL
298
+ --base-url URL --api-key-env VAR --repeats 3 --out labels.jsonl --rows rows.jsonl`): items are
299
+ `{"item", "input", "output", ...}` (every field but ids and tags is shown to the judge, or `--fields`); the rubric is a
300
+ list of `{"name", "question", "pass": "yes"|"no"}`, and an item passes when every question gets its passing answer.
301
+ Any OpenAI-compatible endpoint, called with the standard library. A reply that isn't the JSON asked for is recorded
302
+ as `na`, never a silent pass; transport errors are retried, then recorded as infrastructure errors. Repeats are judge
303
+ labels (for `agentstats judge`'s flip rate), never extra attempts; `--rows` writes one attempt row per item with the
304
+ per-question majority as checks, so a rubric question can be gated like a behaviour rule.
305
+
306
+ **Sampled human review** (`agentstats review sample ROWS --n 40 --stratify-by intent --link URL --out DIR`, then
307
+ `agentstats review import DIR/review.csv --key DIR/key.jsonl --rater NAME --out labels.jsonl`): picks graded attempts
308
+ for a person to check, favouring those where the verdict and the behaviour rules disagree (passed with a rule
309
+ violated, or failed with every rule satisfied), then failures, then passes, spread over a tag. The sheet is blind
310
+ (no verdicts, no reason for the pick); the key keeps each item's verdict, stratum and sampling weight. Import appends
311
+ label rows for `agentstats judge` and reports the grader's agreement, leniency and strictness both on the sample and
312
+ **reweighted to the population** (over-sampling disagreements would otherwise make the grader look worse than it is),
313
+ with stratified bootstrap intervals.
314
+
315
+ **Failures become eval cases** (`agentstats promote find ROWS --source production --label-from none --existing
316
+ evals/cases.jsonl --group-by intent --out candidates.jsonl`, then `agentstats promote accept candidates.jsonl --into
317
+ evals/cases.jsonl`): failures from eval runs, or rule violations on rules-only production rows, are grouped by
318
+ signature (the rules that broke, the no-answer reason, optionally a slice tag), so one failure seen thirty times is one
319
+ signature with up to `--per-signature` candidate cases (the most frequent first). Cases already in the eval set are
320
+ skipped. Each candidate is tagged `source:`, `found:` and `label:` (`human`, `none`, or `model:<name>`); a person sets
321
+ `"approved": true` and `accept` appends those to the cases file (the one `agentstats sample` reads) and prints the eval
322
+ set's provenance mix, warning when most labels came from one model: comparisons against that model are then tilted
323
+ in its favour, because its own answers are the answer key. agentstats keeps only case ids and tags; the inputs and
324
+ expected answers go in your runner's dataset.
325
+
277
326
  Judge labels (`agentstats judge`), one row per verdict:
278
327
 
279
328
  ```json
@@ -287,6 +336,9 @@ A synthetic judge-labels file is in `examples/judge_labels.jsonl`.
287
336
  ## Methods and sources
288
337
 
289
338
  - Error bars for evals, clustered standard errors: Miller 2024, [arXiv 2411.00640](https://arxiv.org/abs/2411.00640).
339
+ - Few failures (fewer than 10 of either outcome across both arms): Newcombe's hybrid score interval for a difference
340
+ of proportions (method 10), Newcombe 1998, [Statistics in Medicine 17:873-890](https://doi.org/10.1002/(SICI)1097-0258(19980430)17:8%3C873::AID-SIM779%3E3.0.CO;2-I),
341
+ instead of a normal approximation that collapses to zero width when nothing fails.
290
342
  - Choosing the method by how the eval set was built, evals in CI tiers, judge alignment and consistency metrics:
291
343
  Yesha Shah's "Agentic Evals" series (see [Credits](#credits)).
292
344
  - pass@k: Chen et al. 2021, [arXiv 2107.03374](https://arxiv.org/abs/2107.03374). pass^k: τ-bench, Yao et al.,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentstats"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Statistics for agent evals: from trajectories to claims you can defend (CIs chosen by how your eval set was built, pass^k, infra failures kept apart, CI gates, behaviour rules, judge checks, pre-registered model-switch decisions)."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -36,3 +36,6 @@ target-version = "py311"
36
36
 
37
37
  [tool.ruff.lint]
38
38
  select = ["E", "F", "W", "I", "UP", "B"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ exclude = ["examples/acme"] # recorded runs: on GitHub, not in the package
@@ -1,3 +1,3 @@
1
1
  """agentstats: statistics for agent evals."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -126,8 +126,9 @@ def _headline(design: str, runs: dict[int, float], counts: dict[str, tuple[int,
126
126
  notes.append(f"Can't use a clustered interval: {why}. Falling back to the curated (run-level) design.")
127
127
  design = "curated"
128
128
  if design == "sampled":
129
- if k == 1:
130
- notes.append("One run: per-case outcomes are binary, so a Wilson interval is used.")
129
+ if k <= 1 or len(p) < 2: # one run, or too few graded cases (none, for rules-only rows)
130
+ if k == 1:
131
+ notes.append("One run: per-case outcomes are binary, so a Wilson interval is used.")
131
132
  return wilson(total_c, total_n, conf), notes, alt
132
133
  if len(p) < 30:
133
134
  notes.append(f"Only {len(p)} cases: a per-case interval this small may be unreliable; add cases.")
@@ -35,6 +35,73 @@ def _load_many(paths: list[str], arm: str | None) -> list[records.Attempt]:
35
35
  return out
36
36
 
37
37
 
38
+ def _review(args) -> int:
39
+ from agentstats import review
40
+ if args.review_cmd == "sample":
41
+ atts = _load_many(args.rows, None)
42
+ sheet, key, summary = review.sample(atts, args.n, stratify_by=args.stratify_by, seed=args.seed,
43
+ link=args.link)
44
+ sheet_path, key_path = review.write(args.out, sheet, key, summary)
45
+ print(f"wrote {sheet_path} ({len(sheet)} items: {summary['sampled']}) and {key_path}. "
46
+ "Give the reviewer only review.csv.")
47
+ return 0
48
+ labels, report = review.import_labels(args.sheet, args.key, rater=args.rater)
49
+ out = Path(args.out)
50
+ with out.open("a") as f:
51
+ f.writelines(json.dumps(r) + "\n" for r in labels)
52
+ print(json.dumps(report, indent=2) if args.json else review.render(report))
53
+ print(f"appended {len(labels)} label rows to {out}; run `agentstats judge {out}` for the full history")
54
+ return 0
55
+
56
+
57
+ def _promote(args) -> int:
58
+ from agentstats import promote
59
+ if args.promote_cmd == "find":
60
+ existing = set(sample.load_cases(args.existing)) if args.existing else set()
61
+ cands, report = promote.find(_load_many(args.rows, None), source=args.source, label_from=args.label_from,
62
+ existing=existing, group_by=args.group_by, per_signature=args.per_signature,
63
+ found=args.found)
64
+ Path(args.out).write_text("".join(json.dumps(c) + "\n" for c in cands))
65
+ print(promote.render_report(report))
66
+ print(f"\nwrote {len(cands)} candidates to {args.out}. Set \"approved\": true on the ones to keep, then "
67
+ f"`agentstats promote accept {args.out} --into <cases.jsonl>`.")
68
+ return 0
69
+ added, prov = promote.accept(args.candidates, args.into)
70
+ print(f"added {added} cases to {args.into} ({prov['cases']} in total). Add their inputs and expected answers "
71
+ "to your runner's dataset.")
72
+ print("labels: " + ", ".join(f"{k} {v}" for k, v in sorted(prov["labels"].items())))
73
+ print("sources: " + ", ".join(f"{k} {v}" for k, v in sorted(prov["sources"].items())))
74
+ for w in prov["warnings"]:
75
+ print("WARNING: " + w)
76
+ return 0
77
+
78
+
79
+ def _grade(args, chat=None) -> int:
80
+ from agentstats import judge_run
81
+ rubric, items = judge_run.load_rubric(args.rubric), judge_run.load_items(args.items)
82
+ for w in judge_run.rubric_warnings(rubric):
83
+ print("WARNING: " + w)
84
+ if chat is None:
85
+ key = os.environ.get(args.api_key_env)
86
+ if not key:
87
+ print(f"set {args.api_key_env} (or choose another variable with --api-key-env)")
88
+ return 2
89
+ chat = judge_run.http_chat(args.base_url, args.model, key,
90
+ temperature=None if args.temperature < 0 else args.temperature)
91
+ fields = [f.strip() for f in args.fields.split(",")] if args.fields else None
92
+ labels, rows, counts = judge_run.run(items, rubric, chat, repeats=args.repeats, workers=args.workers,
93
+ fields=fields)
94
+ Path(args.out).write_text("".join(json.dumps(r) + "\n" for r in labels))
95
+ if args.rows:
96
+ Path(args.rows).write_text("".join(json.dumps(r) + "\n" for r in rows))
97
+ print(f"{counts['calls']} judge calls: {counts['judged']} judged, {counts['unparsed']} unparsed replies, "
98
+ f"{counts['errors']} errors. Wrote {args.out}" + (f" and {args.rows}" if args.rows else "") + ".")
99
+ if args.repeats > 1:
100
+ print(f"Judge consistency: `agentstats judge {args.out}`; add human labels to the same file to check "
101
+ "agreement.")
102
+ return 0 if counts["judged"] else 3
103
+
104
+
38
105
  def main(argv: list[str] | None = None) -> int:
39
106
  ap = argparse.ArgumentParser(prog="agentstats", description="Statistics for agent evals.")
40
107
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -60,9 +127,16 @@ def main(argv: list[str] | None = None) -> int:
60
127
  k.add_argument("--rules", required=True, help="rules JSON (see agentstats.checks)")
61
128
  k.add_argument("--out", required=True, help="output JSONL with checks filled in")
62
129
 
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")
130
+ v = sub.add_parser("convert", help="Harbor job dir(s) or Inspect AI logs to agentstats JSONL")
131
+ v.add_argument("jobs", nargs="+",
132
+ help="Harbor job directories, or Inspect logs (.eval / .json) with --format inspect")
133
+ v.add_argument("--format", choices=["harbor", "inspect"], default="harbor")
134
+ v.add_argument("--scorer", help="Inspect: which scorer gives pass/fail (default: the log's headline scorer)")
135
+ v.add_argument("--pass-threshold", type=float, default=1.0, help="Inspect: numeric scores pass at or above this")
136
+ v.add_argument("--errors", choices=["infra", "fail"], default="infra",
137
+ help="Inspect: a sample that errored is an infrastructure failure (default) or a failed attempt")
138
+ v.add_argument("--rules", help="Inspect: rules JSON; fills each row's checks from the sample's tool calls")
139
+ v.add_argument("--arm", help="arm name (Inspect default: the log's model)")
66
140
  v.add_argument("--out", required=True)
67
141
 
68
142
  tr = sub.add_parser("convert-traces", help="OpenTelemetry (OTLP JSON) or Langfuse traces to agentstats JSONL")
@@ -108,10 +182,59 @@ def main(argv: list[str] | None = None) -> int:
108
182
  ic.add_argument("--dir", default=".")
109
183
  ic.add_argument("--force", action="store_true")
110
184
 
185
+ rv = sub.add_parser("review", help="sampled human review of graded attempts: is the grader right?")
186
+ rsub = rv.add_subparsers(dest="review_cmd", required=True)
187
+ rs = rsub.add_parser("sample", help="pick attempts for a person to review (blind sheet + key)")
188
+ rs.add_argument("rows", nargs="+", help="attempt rows: JSONL, Inspect logs or Harbor jobs (one per run)")
189
+ rs.add_argument("--n", type=int, default=40, help="how many to review (default 40)")
190
+ rs.add_argument("--stratify-by", help="tag prefix to spread the sample over, e.g. intent for intent:<name>")
191
+ rs.add_argument("--seed", type=int, default=0)
192
+ rs.add_argument("--link", help="link template for each item, e.g. 'https://traces/{case}?run={run}' ({arm} too)")
193
+ rs.add_argument("--out", required=True, help="a new directory: review.csv (for the reviewer), key.jsonl")
194
+ ri = rsub.add_parser("import", help="read a filled review sheet: label rows for `judge`, plus agreement")
195
+ ri.add_argument("sheet", help="the filled review.csv")
196
+ ri.add_argument("--key", required=True, help="key.jsonl written next to the sheet")
197
+ ri.add_argument("--rater", default="human", help="the reviewer's name (labels get rater human:<name>)")
198
+ ri.add_argument("--out", required=True, help="label rows (JSONL) for `agentstats judge`; appended if it exists")
199
+ ri.add_argument("--json", action="store_true")
200
+ pm = sub.add_parser("promote", help="failures become candidate eval cases, deduplicated, with provenance")
201
+ psub = pm.add_subparsers(dest="promote_cmd", required=True)
202
+ pf = psub.add_parser("find", help="group failures by signature and propose new cases")
203
+ pf.add_argument("rows", nargs="+", help="attempt rows: eval runs or rules-only production rows")
204
+ pf.add_argument("--source", required=True, help="where the failures came from, e.g. production or nightly")
205
+ pf.add_argument("--label-from", default="none",
206
+ help="who will write the expected answer: none (default), human, or model:<name>")
207
+ pf.add_argument("--existing", help="the eval set's cases file (JSONL of {case, tags}); its cases are skipped")
208
+ pf.add_argument("--group-by", help="tag prefix that splits signatures, e.g. intent")
209
+ pf.add_argument("--per-signature", type=int, default=3, help="cases proposed per signature (default 3)")
210
+ pf.add_argument("--found", help="date tag (default: today, UTC)")
211
+ pf.add_argument("--out", required=True, help="candidates JSONL; set \"approved\": true on the ones to keep")
212
+ pa = psub.add_parser("accept", help="append approved candidates to the eval set; print its provenance mix")
213
+ pa.add_argument("candidates")
214
+ pa.add_argument("--into", required=True, help="the eval set's cases file (created if missing)")
215
+ gr = sub.add_parser("grade", help="LLM judge on yes/no rubric questions (any OpenAI-compatible endpoint)")
216
+ gr.add_argument("items", help="JSONL: {item, input, output, ...}; case/run/arm/tags are carried into --rows")
217
+ gr.add_argument("--rubric", required=True, help='JSON list: [{"name", "question", "pass": "yes"|"no"}]')
218
+ gr.add_argument("--model", required=True)
219
+ gr.add_argument("--base-url", default="https://api.openai.com/v1", help="OpenAI-compatible API base URL")
220
+ gr.add_argument("--api-key-env", default="OPENAI_API_KEY", help="environment variable holding the API key")
221
+ gr.add_argument("--temperature", type=float, default=0.0, help="omit with a negative value for models that "
222
+ "reject one")
223
+ gr.add_argument("--repeats", type=int, default=1, help="judge each item this many times (for consistency)")
224
+ gr.add_argument("--workers", type=int, default=4)
225
+ gr.add_argument("--fields", help="comma-separated item fields to show the judge (default: all but ids/tags)")
226
+ gr.add_argument("--out", required=True, help="judge label rows (JSONL) for `agentstats judge`")
227
+ gr.add_argument("--rows", help="also write attempt rows whose checks are the rubric answers")
111
228
  switch_cli.add_parsers(sub)
112
229
  args = ap.parse_args(argv)
113
230
  if args.cmd in ("preregister", "switch"):
114
231
  return switch_cli.run(args)
232
+ if args.cmd == "review":
233
+ return _review(args)
234
+ if args.cmd == "promote":
235
+ return _promote(args)
236
+ if args.cmd == "grade":
237
+ return _grade(args)
115
238
  if args.cmd == "summary":
116
239
  res = analysis.summarize(_load_many(args.results, None), args.design, args.cluster_by, args.conf)
117
240
  print(json.dumps(res, indent=2, default=str) if args.json else report.summary(res))
@@ -181,6 +304,14 @@ def main(argv: list[str] | None = None) -> int:
181
304
  graded = sum(1 for a in atts if a.passed is not None)
182
305
  print(f"wrote {args.out}: {len(atts)} traces, {graded} with a verdict"
183
306
  + ("" if graded else " (rules-only)"))
307
+ elif args.cmd == "convert" and args.format == "inspect":
308
+ from agentstats import inspect_logs
309
+ rules = checks.load_rules(args.rules) if args.rules else None
310
+ atts = [a for p in args.jobs
311
+ for a in inspect_logs.load(p, scorer=args.scorer, pass_threshold=args.pass_threshold,
312
+ errors=args.errors, rules=rules, arm=args.arm)]
313
+ records.write_jsonl(atts, args.out)
314
+ print(f"wrote {args.out}: {len(atts)} attempts")
184
315
  elif args.cmd == "convert":
185
316
  records.write_jsonl(_load_many(args.jobs, args.arm), args.out)
186
317
  print(f"wrote {args.out}")
@@ -15,7 +15,9 @@ Is B different from A? Each test returns an Interval for the difference B - A (p
15
15
  test from agent-platform (app/evals/gate_v2.py) plus that correction. Cases that pass (or fail) every trial on
16
16
  both sides contribute nothing; the cases that sometimes pass carry the signal. With one trial per arm it
17
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.
18
+ provider's bad hour) breaks that, which analysis.py checks for. With fewer than 10 of the rarer outcome (e.g.
19
+ no failures at all) the variance can't be estimated and would collapse to zero; the test then uses Newcombe's
20
+ interval on the pooled counts instead (conservative, never zero width).
19
21
 
20
22
  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
23
  """
@@ -26,7 +28,9 @@ from collections.abc import Sequence
26
28
 
27
29
  from scipy import stats
28
30
 
29
- from agentstats.intervals import Interval, clustered, mean, sd, t_interval
31
+ from agentstats.intervals import Interval, clustered, mean, newcombe, sd, t_interval
32
+
33
+ MIN_EVENTS = 10 # below this many of the rarer outcome, the normal approximation isn't trusted
30
34
 
31
35
 
32
36
  def welch(a_runs: Sequence[float], b_runs: Sequence[float], conf: float = 0.95) -> Interval:
@@ -59,10 +63,25 @@ def paired_clustered(diffs: Sequence[float], clusters: Sequence[str], conf: floa
59
63
 
60
64
 
61
65
  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."""
66
+ correction: bool = True, min_events: int = MIN_EVENTS) -> Interval:
67
+ """a_counts / b_counts: case -> (trials, passes), on the same cases.
68
+
69
+ With fewer than `min_events` of the rarer outcome (failures, or passes) across both arms, the per-case variance
70
+ can't be estimated (with no failures at all it is exactly zero, and the interval would have zero width). Then
71
+ the interval is Newcombe's on the pooled counts: conservative here (attempts at different per-case rates vary
72
+ less than one binomial with the same mean, and pairing is ignored), never zero width."""
64
73
  cases = sorted(set(a_counts) & set(b_counts))
65
74
  n = len(cases)
75
+ a_n, a_s = sum(a_counts[c][0] for c in cases), sum(a_counts[c][1] for c in cases)
76
+ b_n, b_s = sum(b_counts[c][0] for c in cases), sum(b_counts[c][1] for c in cases)
77
+ rarer = min(a_s + b_s, (a_n - a_s) + (b_n - b_s))
78
+ if n and rarer < min_events:
79
+ iv = newcombe(a_s, a_n, b_s, b_n, conf)
80
+ iv.method, iv.n = "fixed_cases (few failures: newcombe)", n
81
+ iv.note = (f"only {rarer} {'failures' if a_s + b_s > (a_n - a_s) + (b_n - b_s) else 'passes'} across both "
82
+ f"arms: too few to estimate per-case noise, so this is Newcombe's interval on pooled counts "
83
+ "(conservative). More trials, or harder cases, would narrow it")
84
+ return iv
66
85
  t = v = 0.0
67
86
  for c in cases:
68
87
  ka, sa = a_counts[c]