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/policy.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ Release-process tiers, from a TOML policy file (after Yesha Shah, "Agentic Evals" Part 1: evals in CI in tiers).
3
+
4
+ design = "curated" # how the eval set was built (see analysis.py)
5
+
6
+ [smoke] # every change, minutes: a stratified sample + rule checks
7
+ sample = 20
8
+ stratify_by = "feature" # tag prefix: feature:search, feature:checkout, ...
9
+ always_include = ["critical"] # tags always in the sample
10
+ runs = 1
11
+ require.rules = 1.0 # every trajectory rule check passes
12
+ require.min_pass_rate = 0.5 # a crash threshold, not a regression test
13
+
14
+ [pr] # every pull request: fail only on a proven drop
15
+ runs = 2
16
+ require.no_significant_drop = 0.0
17
+
18
+ [nightly] # the whole eval set, k runs per case
19
+ runs = 3
20
+ require.no_regression = 0.03 # vs baseline: CI lower bound of the change >= -3 pts
21
+ require.max_infra = 0.05
22
+
23
+ [release] # the whole set, against the production baseline, with cost and latency
24
+ runs = 5
25
+ require.no_regression = 0.02
26
+ require.max_cost_ratio = 1.10
27
+ require.max_p95_latency_ratio = 1.20
28
+ require.min_pass_hat_k = 0.70
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import tomllib
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ from agentstats.analysis import DESIGNS
37
+
38
+ REQUIREMENTS = {
39
+ "min_pass_rate": "pass rate (point estimate) at least this; a crash threshold",
40
+ "rules": "share of trajectory rule checks passing, at least this",
41
+ "min_rule_rate": "every trajectory rule holds at least this often: PASS when the estimate clears it, FAIL when "
42
+ "the whole interval is below it, INCONCLUSIVE in between",
43
+ "no_rule_regression": "vs baseline, per trajectory rule: FAIL if any rule is proven to hold less often than in "
44
+ "the baseline by more than this (fixed-cases test, Bonferroni across rules). Needs no labels",
45
+ "no_regression": "vs baseline: lower CI bound of (candidate - baseline) at least -margin (non-inferiority: "
46
+ "proves it isn't worse; needs many runs)",
47
+ "no_significant_drop": "vs baseline: FAIL only if the whole CI is below -margin (proves it got worse; "
48
+ "otherwise PASS, with the smallest drop this run could catch)",
49
+ "max_infra": "infrastructure failure rate at most this; above it the run is unhealthy",
50
+ "max_cost_ratio": "cost per attempt, candidate / baseline, at most this (CI-based)",
51
+ "max_p95_latency_ratio": "p95 latency, candidate / baseline, at most this (CI-based)",
52
+ "min_pass_hat_k": "pass^k (k = the tier's runs) at least this (CI-based)",
53
+ "max_no_answer": "share of attempts that ended with no usable answer (step limit, unparseable output) at most "
54
+ "this; a jump usually means the harness or loop broke",
55
+ "max_slice_drop": "vs baseline, per slice (the tier's slice_by tag prefix): FAIL if any slice is proven worse by "
56
+ "more than this (Bonferroni-adjusted interval across slices)",
57
+ }
58
+ NEEDS_BASELINE = ("no_regression", "no_significant_drop", "max_cost_ratio", "max_p95_latency_ratio", "max_slice_drop",
59
+ "no_rule_regression")
60
+
61
+
62
+ @dataclass
63
+ class Tier:
64
+ name: str
65
+ runs: int = 1
66
+ sample: int | None = None
67
+ stratify_by: str | None = None
68
+ always_include: list[str] = field(default_factory=list)
69
+ seed: int = 0
70
+ slice_by: str | None = None # tag prefix to compare per slice against the baseline, e.g. "template"
71
+ require: dict[str, float] = field(default_factory=dict)
72
+
73
+
74
+ @dataclass
75
+ class Policy:
76
+ design: str
77
+ cluster_by: str | None
78
+ tiers: dict[str, Tier]
79
+
80
+
81
+ def parse(data: dict) -> Policy:
82
+ design = data.get("design", "curated")
83
+ if design not in DESIGNS:
84
+ raise ValueError(f"design must be one of {DESIGNS}, not {design!r}")
85
+ tiers = {}
86
+ for name, body in data.items():
87
+ if not isinstance(body, dict):
88
+ continue
89
+ req = dict(body.get("require") or {})
90
+ unknown = set(req) - set(REQUIREMENTS)
91
+ if unknown:
92
+ raise ValueError(f"[{name}] unknown requirement(s) {sorted(unknown)}; known: {sorted(REQUIREMENTS)}")
93
+ tiers[name] = Tier(name=name, runs=int(body.get("runs", 1)), sample=body.get("sample"),
94
+ stratify_by=body.get("stratify_by"), always_include=list(body.get("always_include") or []),
95
+ seed=int(body.get("seed", 0)), slice_by=body.get("slice_by"),
96
+ require={k: float(v) for k, v in req.items()})
97
+ if "max_slice_drop" in req and not tiers[name].slice_by:
98
+ raise ValueError(f"[{name}] max_slice_drop needs slice_by (a tag prefix, e.g. slice_by = \"template\")")
99
+ if not tiers:
100
+ raise ValueError("policy defines no tiers")
101
+ return Policy(design=design, cluster_by=data.get("cluster_by"), tiers=tiers)
102
+
103
+
104
+ def load(path: str | Path) -> Policy:
105
+ return parse(tomllib.loads(Path(path).read_text()))
agentstats/prereg.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ Pre-registration: write the switch rule down before any result exists, and check later that it was.
3
+
4
+ agentstats preregister --name triage-haiku-5-5 --margin 0.03 --provenance current-model \\
5
+ --input labels=app/evals/triage_heldout.jsonl --out decisions/triage-haiku-5-5
6
+ git add decisions/triage-haiku-5-5 && git commit -m "pre-register" && git push
7
+ ... run both arms ...
8
+ agentstats switch --prereg decisions/triage-haiku-5-5/prereg.json --current ... --candidate ... --out ...
9
+
10
+ Why: the margin, confidence level and slices are easy to pick after seeing the numbers, and an answer chosen that way
11
+ isn't evidence of anything. A decision only counts as pre-registered when it can be shown, so `decide --prereg` checks:
12
+
13
+ 1. prereg.json is committed in git, and unchanged since that commit;
14
+ 2. that commit is older than every result file's last modification (a good-faith check: file times can be changed,
15
+ but this catches the honest mistake of registering after running);
16
+ 3. every declared input (e.g. the labels) still has the hash it had when registered.
17
+
18
+ If any check fails the decision is still made with the registered rule, but recorded as NOT pre-registered, with the
19
+ reasons. The rule itself always comes from the file; conflicting command-line flags are an error.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import subprocess
26
+ from dataclasses import asdict, dataclass, field
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+
30
+ import agentstats
31
+ from agentstats.switch import PROVENANCE, Rule
32
+
33
+
34
+ @dataclass
35
+ class Registration:
36
+ name: str
37
+ rule: Rule
38
+ provenance: str
39
+ slice_by: str | None = None
40
+ strict_slices: bool = False
41
+ inputs: dict[str, dict] = field(default_factory=dict) # name -> {"path", "sha256"}
42
+ note: str = ""
43
+ created_at: str = ""
44
+ agentstats_version: str = ""
45
+
46
+
47
+ def _sha256(path: Path) -> str:
48
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
49
+
50
+
51
+ def create(out_dir: Path, name: str, rule: Rule, provenance: str, *, slice_by: str | None = None,
52
+ strict_slices: bool = False, inputs: dict[str, Path] | None = None, note: str = "") -> Path:
53
+ """Write prereg.json and prereg.md. Refuses to overwrite an existing registration."""
54
+ if provenance not in PROVENANCE:
55
+ raise ValueError(f"provenance must be one of {PROVENANCE}")
56
+ out_dir = Path(out_dir)
57
+ if (out_dir / "prereg.json").exists():
58
+ raise FileExistsError(f"{out_dir / 'prereg.json'} exists: a registration is never rewritten")
59
+ reg = Registration(name, rule, provenance, slice_by, strict_slices,
60
+ {k: {"path": str(p), "sha256": _sha256(p)} for k, p in (inputs or {}).items()}, note,
61
+ datetime.now(UTC).isoformat(timespec="seconds"), agentstats.__version__)
62
+ out_dir.mkdir(parents=True, exist_ok=True)
63
+ data = asdict(reg)
64
+ (out_dir / "prereg.json").write_text(json.dumps(data, indent=1) + "\n")
65
+ (out_dir / "prereg.md").write_text(render(data))
66
+ return out_dir / "prereg.json"
67
+
68
+
69
+ def load(path: Path) -> Registration:
70
+ d = json.loads(Path(path).read_text())
71
+ return Registration(d["name"], Rule(**d["rule"]), d["provenance"], d.get("slice_by"), d.get("strict_slices", False),
72
+ d.get("inputs") or {}, d.get("note", ""), d.get("created_at", ""),
73
+ d.get("agentstats_version") or d.get("stepdown_version", "")) # stepdown-era files
74
+
75
+
76
+ def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
77
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
78
+
79
+
80
+ def verify(path: Path, results: list[Path]) -> tuple[bool, list[str], dict]:
81
+ """Was the registration committed before the results, and are the declared inputs unchanged?"""
82
+ path = Path(path).resolve()
83
+ reasons: list[str] = []
84
+ info: dict = {}
85
+ d = path.parent
86
+ top = _git(d, "rev-parse", "--show-toplevel")
87
+ if top.returncode != 0:
88
+ return False, ["the registration isn't in a git repository, so when it was written can't be shown"], info
89
+ root = Path(top.stdout.strip())
90
+ rel = str(path.relative_to(root.resolve()))
91
+ d = root # git pathspecs below are relative to the repository root
92
+ log = _git(d, "log", "-1", "--format=%H %ct", "--", rel)
93
+ if not log.stdout.strip():
94
+ return False, ["the registration was never committed"], info
95
+ commit, ts = log.stdout.split()
96
+ info.update(commit=commit, committed_at=datetime.fromtimestamp(int(ts), UTC).isoformat(timespec="seconds"))
97
+ if _git(d, "diff", "--quiet", "HEAD", "--", rel).returncode != 0:
98
+ reasons.append("the registration has uncommitted changes")
99
+ first = _git(d, "log", "--diff-filter=A", "--format=%ct", "--", rel).stdout.split()
100
+ registered_at = int(first[-1]) if first else int(ts)
101
+ if len(_git(d, "log", "--format=%H", "--", rel).stdout.split()) > 1:
102
+ reasons.append("the registration was changed after it was first committed")
103
+ later = [str(p) for p in results if Path(p).stat().st_mtime < registered_at]
104
+ if later:
105
+ reasons.append(f"result file(s) older than the registration: {', '.join(later)}")
106
+ reg = load(path)
107
+ for name, inp in reg.inputs.items():
108
+ p = Path(inp["path"]) # as given at registration: relative to where it ran,
109
+ if not p.exists(): # else to the repository root
110
+ p = root / inp["path"]
111
+ if not p.exists():
112
+ reasons.append(f"declared input {name} ({inp['path']}) is missing")
113
+ elif _sha256(p) != inp["sha256"]:
114
+ reasons.append(f"declared input {name} ({inp['path']}) changed since registration")
115
+ info["registered_at"] = datetime.fromtimestamp(registered_at, UTC).isoformat(timespec="seconds")
116
+ return not reasons, reasons, info
117
+
118
+
119
+ def render(d: dict) -> str:
120
+ r = d["rule"]
121
+ lines = [f"# Pre-registration: {d['name']}", "",
122
+ (f"Written {d['created_at']} with agentstats {d['agentstats_version']}, before any result. Commit this "
123
+ "file before running anything; `agentstats switch --prereg` checks that it was."), "",
124
+ "## Rule", "",
125
+ (f"- **SWITCH** if the lower {100 * r['conf']:g}% bound of (candidate - current) is at least "
126
+ f"-{100 * r['margin']:g} points and the cost ratio's upper bound is at most {r['max_cost_ratio']:g}x;"),
127
+ f"- **KEEP** if the upper bound is below -{100 * r['margin']:g} points, or it costs more;",
128
+ "- **NOT YET** otherwise, reported as is.", "",
129
+ (f"Eval set design: {r['design']}. Infrastructure failures capped at {100 * r['max_infra']:g}%. "
130
+ f"Eval-set provenance: {d['provenance']}. Slices: "
131
+ + (f"by `{d['slice_by']}`, {'enforced' if d['strict_slices'] else 'reported, not enforced'}."
132
+ if d["slice_by"] else "none.")), ""]
133
+ if d["inputs"]:
134
+ lines += ["## Declared inputs (must be unchanged at decision time)", ""]
135
+ lines += [f"- {k}: `{v['path']}` (sha256 {v['sha256'][:16]})" for k, v in d["inputs"].items()]
136
+ lines.append("")
137
+ if d.get("note"):
138
+ lines += ["## Plan", "", d["note"], ""]
139
+ return "\n".join(lines)
agentstats/records.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Attempt records: one row per (arm, run, case) attempt, the unit everything else works on.
3
+
4
+ {"arm": "baseline", "run": 1, "case": "django-11099", "passed": true, "infra_error": null,
5
+ "cost_usd": 0.12, "input_tokens": 41000, "output_tokens": 2100, "steps": 14, "tool_calls": 9,
6
+ "latency_s": 33.0, "tags": ["search"], "checks": {"no_refund_before_lookup": true}}
7
+
8
+ A *run* is one full pass over the eval set. Run-level statistics (the right ones for a hand-picked eval set)
9
+ need several runs; per-case statistics need several cases. `infra_error` marks attempts that failed for reasons
10
+ outside the agent (timeout, quota, billing, sandbox crash): they are excluded from pass rates and reported apart.
11
+ `no_answer` marks attempts where the agent produced no usable answer at all (it hit its step limit, or its output
12
+ couldn't be parsed): they count as failures, like any wrong answer, but are reported apart, because a jump in them
13
+ usually means the harness or the loop broke, not that the agent's judgement got worse.
14
+
15
+ Rows are validated on load, with the field and the value in the error. `schema_version` is optional (default 1);
16
+ a row written for a newer format is refused rather than half-read.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections import defaultdict
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ NUMERIC_FIELDS = ("cost_usd", "input_tokens", "cached_tokens", "output_tokens", "steps", "tool_calls", "latency_s")
26
+ SCHEMA_VERSION = 1
27
+
28
+
29
+ class RowError(ValueError):
30
+ """A row that doesn't match the attempt format; the message names the field and what was wrong."""
31
+
32
+
33
+ @dataclass
34
+ class Attempt:
35
+ case: str
36
+ passed: bool | None
37
+ arm: str = "default"
38
+ run: int = 1
39
+ infra_error: str | None = None
40
+ no_answer: str | None = None # why there was no usable answer, e.g. "step_limit", "parse_error"
41
+ tags: list[str] = field(default_factory=list)
42
+ checks: dict[str, bool] = field(default_factory=dict)
43
+ metrics: dict[str, float] = field(default_factory=dict)
44
+
45
+ @property
46
+ def valid(self) -> bool:
47
+ """Counts toward pass rates: graded, and not an infrastructure failure."""
48
+ return self.infra_error is None and self.passed is not None
49
+
50
+ def to_json(self) -> dict:
51
+ row = {"arm": self.arm, "run": self.run, "case": self.case, "passed": self.passed,
52
+ "infra_error": self.infra_error, "tags": self.tags, "checks": self.checks}
53
+ if self.no_answer:
54
+ row["no_answer"] = self.no_answer
55
+ row.update(self.metrics)
56
+ return row
57
+
58
+
59
+ def _number(row: dict, k: str) -> float:
60
+ v = row[k]
61
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
62
+ hint = " (a list of steps? write its length)" if isinstance(v, list) else ""
63
+ raise RowError(f"'{k}' must be a number, got {type(v).__name__} {str(v)[:60]!r}{hint}")
64
+ return float(v)
65
+
66
+
67
+ def from_dict(row: dict, *, arm: str | None = None) -> Attempt:
68
+ if not isinstance(row, dict):
69
+ raise RowError(f"a row must be a JSON object, got {type(row).__name__}")
70
+ version = row.get("schema_version", SCHEMA_VERSION)
71
+ if not isinstance(version, int) or version > SCHEMA_VERSION:
72
+ raise RowError(f"schema_version {version!r}: this agentstats reads version {SCHEMA_VERSION}; upgrade it")
73
+ if row.get("case") in (None, ""):
74
+ raise RowError("'case' is missing")
75
+ if not isinstance(row["case"], (str, int)):
76
+ raise RowError(f"'case' must be a string, got {type(row['case']).__name__}")
77
+ passed = row.get("passed")
78
+ if isinstance(passed, (int, float)) and not isinstance(passed, bool) and passed in (0, 1):
79
+ passed = bool(passed)
80
+ if passed is not None and not isinstance(passed, bool):
81
+ raise RowError(f"'passed' must be true, false or null, got {passed!r}")
82
+ run = row.get("run", 1)
83
+ if isinstance(run, bool) or not isinstance(run, int) and not (isinstance(run, str) and run.isdigit()):
84
+ raise RowError(f"'run' must be an integer (the trial or run number), got {run!r}")
85
+ for k in ("infra_error", "no_answer"):
86
+ if row.get(k) is not None and not isinstance(row[k], str):
87
+ raise RowError(f"'{k}' must be a string (the reason) or null, got {row[k]!r}")
88
+ no_answer = row.get("no_answer") or None
89
+ if no_answer and passed:
90
+ raise RowError(f"'no_answer' is {no_answer!r} but 'passed' is true: an attempt with no answer can't pass")
91
+ if no_answer and passed is None and not row.get("infra_error"):
92
+ passed = False # no usable answer is a failed attempt
93
+ tags = row.get("tags") or []
94
+ if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
95
+ raise RowError(f"'tags' must be a list of strings, got {tags!r}"[:160])
96
+ checks = row.get("checks") or {}
97
+ if not isinstance(checks, dict) or not all(isinstance(v, bool) for v in checks.values()):
98
+ raise RowError(f"'checks' must map rule names to true/false, got {checks!r}"[:160])
99
+ metrics = {k: _number(row, k) for k in NUMERIC_FIELDS if row.get(k) is not None}
100
+ return Attempt(case=str(row["case"]), passed=passed, arm=arm or str(row.get("arm", "default")),
101
+ run=int(run), infra_error=row.get("infra_error") or None, no_answer=no_answer,
102
+ tags=list(tags), checks=dict(checks), metrics=metrics)
103
+
104
+
105
+ def load_jsonl(path: str | Path, *, arm: str | None = None) -> list[Attempt]:
106
+ out = []
107
+ for i, line in enumerate(Path(path).read_text().splitlines(), 1):
108
+ if line.strip():
109
+ try:
110
+ out.append(from_dict(json.loads(line), arm=arm))
111
+ except (json.JSONDecodeError, ValueError) as e:
112
+ raise ValueError(f"{path}:{i}: {e}") from e
113
+ return out
114
+
115
+
116
+ def write_jsonl(attempts: list[Attempt], path: str | Path) -> None:
117
+ Path(path).write_text("".join(json.dumps(a.to_json()) + "\n" for a in attempts))
118
+
119
+
120
+ def load(path: str | Path, *, arm: str | None = None, run: int | None = None) -> list[Attempt]:
121
+ """A JSONL file, a Harbor job directory, or a directory of Harbor jobs (one per run)."""
122
+ from agentstats import adapters
123
+
124
+ path = Path(path)
125
+ if path.is_file():
126
+ return load_jsonl(path, arm=arm)
127
+ if adapters.is_harbor_job(path):
128
+ return adapters.harbor_job(path, arm=arm, run=run or 1)
129
+ jobs = sorted(p for p in path.iterdir() if p.is_dir() and adapters.is_harbor_job(p))
130
+ if jobs:
131
+ return [a for i, job in enumerate(jobs, 1) for a in adapters.harbor_job(job, arm=arm, run=i)]
132
+ raise ValueError(f"{path}: not a JSONL file, a Harbor job directory, or a directory of Harbor jobs")
133
+
134
+
135
+ def by_case(attempts: list[Attempt]) -> dict[str, list[Attempt]]:
136
+ out: dict[str, list[Attempt]] = defaultdict(list)
137
+ for a in attempts:
138
+ out[a.case].append(a)
139
+ return dict(out)
140
+
141
+
142
+ def by_run(attempts: list[Attempt]) -> dict[int, list[Attempt]]:
143
+ out: dict[int, list[Attempt]] = defaultdict(list)
144
+ for a in attempts:
145
+ out[a.run].append(a)
146
+ return dict(sorted(out.items()))
147
+
148
+
149
+ def arms(attempts: list[Attempt]) -> list[str]:
150
+ return sorted({a.arm for a in attempts})
@@ -0,0 +1,58 @@
1
+ """
2
+ Capability and consistency from repeated attempts per case.
3
+
4
+ pass@k P(at least one of k attempts passes): capability ([Chen et al. 2021], arXiv 2107.03374).
5
+ pass^k P(all k attempts pass): consistency, what a user relying on the agent sees (tau-bench, Yao et al.,
6
+ arXiv 2406.12045).
7
+
8
+ Unbiased per-case estimates from n attempts with c passes, averaged over cases:
9
+ pass@k = 1 - C(n - c, k) / C(n, k) pass^k = C(c, k) / C(n, k)
10
+
11
+ A large pass@k - pass@1 means retries help; a large pass@1 - pass^k means success depends on luck.
12
+ CIs come from a bootstrap over cases. Use a deterministic grader here: an LLM judge adds its own randomness.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from math import comb
17
+
18
+ from agentstats.intervals import Interval, bootstrap, mean
19
+
20
+
21
+ def pass_at_k(n: int, c: int, k: int) -> float:
22
+ if n < k:
23
+ raise ValueError(f"need n >= k attempts (n={n}, k={k})")
24
+ return 1.0 - comb(n - c, k) / comb(n, k)
25
+
26
+
27
+ def pass_hat_k(n: int, c: int, k: int) -> float:
28
+ if n < k:
29
+ raise ValueError(f"need n >= k attempts (n={n}, k={k})")
30
+ return comb(c, k) / comb(n, k)
31
+
32
+
33
+ def summarize(counts: dict[str, tuple[int, int]], conf: float = 0.95, seed: int = 0) -> dict:
34
+ """counts: case -> (attempts n, passes c). pass@k and pass^k for every k up to the smallest n."""
35
+ if not counts:
36
+ return {"k_max": 0, "pass_at": {}, "pass_hat": {}, "cases": {}}
37
+ k_max = min(n for n, _ in counts.values())
38
+ units = list(counts.values())
39
+ out: dict = {"k_max": k_max, "pass_at": {}, "pass_hat": {}}
40
+ for k in range(1, k_max + 1):
41
+ out["pass_at"][k] = bootstrap(units, lambda us, k=k: mean([pass_at_k(n, c, k) for n, c in us]),
42
+ conf, seed=seed, method="bootstrap over cases").to_json()
43
+ out["pass_hat"][k] = bootstrap(units, lambda us, k=k: mean([pass_hat_k(n, c, k) for n, c in us]),
44
+ conf, seed=seed, method="bootstrap over cases").to_json()
45
+ out["cases"] = {
46
+ "always": sorted(c for c, (n, p) in counts.items() if p == n),
47
+ "never": sorted(c for c, (n, p) in counts.items() if p == 0),
48
+ "flaky": sorted(c for c, (n, p) in counts.items() if 0 < p < n),
49
+ }
50
+ return out
51
+
52
+
53
+ def flake_rate(counts: dict[str, tuple[int, int]]) -> Interval | None:
54
+ multi = [(n, c) for n, c in counts.values() if n >= 2]
55
+ if not multi:
56
+ return None
57
+ return bootstrap(multi, lambda us: mean([1.0 if 0 < c < n else 0.0 for n, c in us]),
58
+ method="bootstrap over cases")
agentstats/report.py ADDED
@@ -0,0 +1,152 @@
1
+ """Plain-text/markdown rendering of summary, compare and judge results."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+
6
+ METHOD_NAME = {
7
+ "fixed_cases": "fixed cases, noise from repeated trials per case",
8
+ "run_level": "Student's t over complete runs",
9
+ "per_case": "t over per-case pass rates",
10
+ "clustered": "cluster-robust standard error",
11
+ "wilson": "Wilson interval",
12
+ "welch": "Welch's t-test over complete runs",
13
+ "paired": "paired per-case differences",
14
+ "paired_clustered": "paired differences, cluster-robust",
15
+ "mcnemar": "exact McNemar test (one trial per case per arm)",
16
+ }
17
+
18
+
19
+ WHEN_USED = {
20
+ "fixed_cases": "for hand-picked sets when runs don't show run-wide shocks",
21
+ "run_level": "for hand-picked sets with 3+ runs that show run-wide shocks",
22
+ "per_case": "when the cases were sampled at random from real traffic",
23
+ "clustered": "for sampled cases in groups that fail together",
24
+ }
25
+
26
+
27
+ def _pct(x) -> str:
28
+ return "n/a" if x is None or (isinstance(x, float) and math.isnan(x)) else f"{100 * x:.1f}%"
29
+
30
+
31
+ def _pp(x) -> str:
32
+ return "n/a" if x is None or (isinstance(x, float) and math.isnan(x)) else f"{100 * x:+.1f} pts"
33
+
34
+
35
+ def _ci(iv: dict, fmt=_pct) -> str:
36
+ return f"{fmt(iv['estimate'])} (95% CI {fmt(iv['low'])} to {fmt(iv['high'])})"
37
+
38
+
39
+ def _num(x: float) -> str:
40
+ if x is None or math.isnan(x):
41
+ return "n/a"
42
+ return f"{x:,.4f}" if abs(x) < 1 else f"{x:,.1f}"
43
+
44
+
45
+ def summary(s: dict) -> str:
46
+ pr = s["pass_rate"]
47
+ lines = [f"# agentstats summary ({s['design']} eval set)", "",
48
+ f"**Pass rate: {_ci(pr)}**, {s['claim']}.",
49
+ f"Method: {METHOD_NAME.get(pr['method'], pr['method'])}. {s['cases']} cases, {s['runs']} run(s), "
50
+ f"{s['attempts']} graded attempts used ({s['attempts_loaded']} loaded)."
51
+ + (f" Interval {pr['note']}." if "clipped" in pr.get("note", "") else ""), ""]
52
+ for m, iv in s.get("not_used", {}).items():
53
+ lines.append(f"- Not used: {METHOD_NAME.get(m, m)} would give {_ci(iv)} ({WHEN_USED.get(m, '')}).")
54
+ for n in s["notes"]:
55
+ lines.append(f"- Note: {n}")
56
+ if s.get("run_pass_rates") and len(s["run_pass_rates"]) > 1:
57
+ lines.append("- Per run: " + ", ".join(f"run {r} {_pct(v)}" for r, v in s["run_pass_rates"].items()))
58
+ inf = s["infra"]
59
+ lines += ["", "## Infrastructure",
60
+ f"{inf['count']} infrastructure failures ({_pct(inf['rate'])}), excluded from pass rates"
61
+ + (f": {inf['kinds']}" if inf["kinds"] else "") + "."]
62
+ na = s.get("no_answer") or {}
63
+ if na.get("count"):
64
+ lines.append(f"{na['count']} attempts ended with no usable answer ({_pct(na['rate'])}), counted as failures: "
65
+ f"{na['kinds']}.")
66
+ if "reliability" in s:
67
+ r = s["reliability"]
68
+ lines += ["", "## Reliability", "| k | pass@k (capability) | pass^k (consistency) |", "|---|---|---|"]
69
+ for k in r["pass_at"]:
70
+ lines.append(f"| {k} | {_ci(r['pass_at'][k])} | {_ci(r['pass_hat'][k])} |")
71
+ c = r["cases"]
72
+ lines.append(f"\nAlways pass: {len(c['always'])}, never pass: {len(c['never'])}, "
73
+ f"flaky: {len(c['flaky'])}" + (f" ({', '.join(c['flaky'][:10])}"
74
+ + (", ..." if len(c['flaky']) > 10 else "") + ")"
75
+ if c["flaky"] else ""))
76
+ if s["efficiency"]:
77
+ lines += ["", "## Efficiency (per attempt, mean over cases)", "| metric | mean (95% CI) | p50 | p95 |",
78
+ "|---|---|---|---|"]
79
+ for f, v in s["efficiency"].items():
80
+ if f == "cost_per_resolved_task":
81
+ continue
82
+ lines.append(f"| {f} | {_ci(v['mean'], _num)} | {_num(v['p50'])} | {_num(v['p95'])} |")
83
+ if "cost_per_resolved_task" in s["efficiency"]:
84
+ lines.append(f"\nCost per resolved task: ${_ci(s['efficiency']['cost_per_resolved_task'], _num)}")
85
+ if s.get("slices"):
86
+ lines += ["", "## Slices", "| tag | cases | pass rate |", "|---|---|---|"]
87
+ for t, v in s["slices"].items():
88
+ lines.append(f"| {t} | {v['cases']} | {_ci(v['pass_rate'])} |")
89
+ if s.get("checks"):
90
+ lines += ["", "## Trajectory checks", "| rule | pass rate |", "|---|---|"]
91
+ for r, v in s["checks"].items():
92
+ lines.append(f"| {r} | {_ci(v)} |")
93
+ return "\n".join(lines) + "\n"
94
+
95
+
96
+ def compare(c: dict, a_name: str = "A", b_name: str = "B") -> str:
97
+ d = c["delta"]
98
+ lines = [f"# agentstats compare: {b_name} vs {a_name} ({c['design']} eval set)", "",
99
+ f"**{c['verdict']}**: {b_name} minus {a_name} = {_ci(d, _pp)}, {c['claim']}.",
100
+ f"Method: {METHOD_NAME.get(c['method'], c['method'])}. {c['cases']} matched cases; "
101
+ f"{a_name} {_pct(c['a']['pass_rate'])} over {c['a']['runs']} run(s), "
102
+ f"{b_name} {_pct(c['b']['pass_rate'])} over {c['b']['runs']} run(s).", ""]
103
+ if c.get("mcnemar"):
104
+ m = c["mcnemar"]
105
+ lines.append(f"- McNemar (exact): {m['b_only']} cases pass only with {b_name}, {m['a_only']} only with "
106
+ f"{a_name}; p = {m['p_value']:.3g}.")
107
+ if c.get("smallest_detectable") is not None:
108
+ lines.append(f"- Smallest difference this setup can reliably detect (80% power): "
109
+ f"about {_pp(c['smallest_detectable'])}.")
110
+ if c.get("min_effect") is not None:
111
+ lines.append(f"- Ship rule (CI lower bound > {_pp(c['min_effect'])}): {'PASS' if c['ship'] else 'FAIL'}.")
112
+ for n in c["notes"]:
113
+ lines.append(f"- Note: {n}")
114
+ mv = c["moved"]
115
+ lines.append(f"- Cases that moved: {len(mv['b_better'])} better with {b_name}, {len(mv['b_worse'])} worse.")
116
+ if "cost_per_attempt_delta" in c:
117
+ lines.append(f"- Cost per attempt: {a_name} ${_num(c['cost_per_attempt']['a'])}, "
118
+ f"{b_name} ${_num(c['cost_per_attempt']['b'])}; difference "
119
+ f"${_ci(c['cost_per_attempt_delta'], _num)}.")
120
+ return "\n".join(lines) + "\n"
121
+
122
+
123
+ def judge(j: dict) -> str:
124
+ lines = ["# agentstats judge check", "", f"{j['items']} items."]
125
+ if "human_agreement" in j:
126
+ h = j["human_agreement"]
127
+ lines += ["", "## Human agreement",
128
+ f"Fleiss' kappa {h['fleiss_kappa']:.2f} over {h['items']} items with {h['raters_per_item']} "
129
+ f"raters each; {h['unanimous']} unanimous."]
130
+ if "alignment" in j:
131
+ a = j["alignment"]
132
+
133
+ def r(key):
134
+ iv = a["ci"].get(key)
135
+ return "n/a" if iv is None else _ci(iv)
136
+ lines += ["", f"## Judge vs human majority ({a['scope']}, n = {a['n']})",
137
+ f"- Accuracy {r('accuracy')}",
138
+ f"- Strictness, false fails among human passes: {r('strictness')}",
139
+ f"- Leniency, false passes among human fails: {r('leniency')}",
140
+ f"- Cohen's kappa {a['cohen_kappa']:.2f}",
141
+ f"TP {a['tp']}, FN {a['fn']}, FP {a['fp']}, TN {a['tn']}."]
142
+ if a["disagreements"]:
143
+ lines.append("Disagreements: " + ", ".join(f"{x['item']} (human {x['human']}, judge {x['judge']})"
144
+ for x in a["disagreements"][:10]))
145
+ if "consistency" in j:
146
+ c = j["consistency"]
147
+ lines += ["", "## Judge consistency",
148
+ f"{c['items']} items judged {c['runs_per_item']}+ times: flip rate {100 * c['flip_rate']:.1f}%"
149
+ + (f", mean pairwise disagreement {c['mean_pairwise_disagreement']:.3f}"
150
+ if "mean_pairwise_disagreement" in c else
151
+ f", mean pairwise |delta| {c['mean_pairwise_delta']:.3f}") + "."]
152
+ return "\n".join(lines) + "\n"