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.
- {agentstats-0.3.0 → agentstats-0.4.0}/CHANGELOG.md +42 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/PKG-INFO +54 -2
- {agentstats-0.3.0 → agentstats-0.4.0}/README.md +53 -1
- {agentstats-0.3.0 → agentstats-0.4.0}/pyproject.toml +4 -1
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/__init__.py +1 -1
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/analysis.py +3 -2
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/cli.py +134 -3
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/comparisons.py +23 -4
- agentstats-0.4.0/src/agentstats/inspect_logs.py +184 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/intervals.py +14 -0
- agentstats-0.4.0/src/agentstats/judge_run.py +193 -0
- agentstats-0.4.0/src/agentstats/promote.py +120 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/records.py +5 -2
- agentstats-0.4.0/src/agentstats/review.py +205 -0
- agentstats-0.4.0/tests/fixtures/inspect_epochs.json +1085 -0
- agentstats-0.4.0/tests/fixtures/inspect_tools.eval +0 -0
- agentstats-0.4.0/tests/fixtures/inspect_tools.json +733 -0
- agentstats-0.4.0/tests/test_grade.py +161 -0
- agentstats-0.4.0/tests/test_inspect_logs.py +131 -0
- agentstats-0.4.0/tests/test_promote.py +100 -0
- agentstats-0.4.0/tests/test_review.py +125 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_stats.py +58 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_traces.py +9 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/.github/workflows/release.yml +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/.github/workflows/tests.yml +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/.gitignore +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/LICENSE +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/judge_labels.jsonl +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/rules.json +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/README.md +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/analysis.md +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/decision/decision.json +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/decision/decision.md +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/preregistration.md +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/rows/candidate.jsonl +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/examples/switch-triage-haiku-5-5/rows/current.jsonl +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/adapters.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/checks.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/ci_templates.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/gate.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/judge.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/policy.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/prereg.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/reliability.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/report.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/sample.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/sizing.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/switch.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/switch_cli.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/switch_record.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/src/agentstats/traces.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/langfuse_traces.json +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/otlp_traces.json +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/rows_from_agent_platform_triage_gate.jsonl +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/fixtures/rows_from_harness_evolve.jsonl +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_adapters.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_analysis.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_calibration.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_gate.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_hardening.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_judge.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_prereg.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_rule_gates.py +0 -0
- {agentstats-0.3.0 → agentstats-0.4.0}/tests/test_sizing.py +0 -0
- {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
|
+
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
|
|
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
|
|
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.
|
|
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
|
|
@@ -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
|
|
130
|
-
|
|
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
|
-
|
|
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]
|