agentstats 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentstats/__init__.py +3 -0
- agentstats/adapters.py +115 -0
- agentstats/analysis.py +342 -0
- agentstats/checks.py +46 -0
- agentstats/ci_templates.py +101 -0
- agentstats/cli.py +191 -0
- agentstats/comparisons.py +115 -0
- agentstats/gate.py +382 -0
- agentstats/intervals.py +123 -0
- agentstats/judge.py +159 -0
- agentstats/policy.py +105 -0
- agentstats/prereg.py +139 -0
- agentstats/records.py +150 -0
- agentstats/reliability.py +58 -0
- agentstats/report.py +152 -0
- agentstats/sample.py +68 -0
- agentstats/sizing.py +86 -0
- agentstats/switch.py +180 -0
- agentstats/switch_cli.py +140 -0
- agentstats/switch_record.py +99 -0
- agentstats/traces.py +161 -0
- agentstats-0.3.0.dist-info/METADATA +384 -0
- agentstats-0.3.0.dist-info/RECORD +26 -0
- agentstats-0.3.0.dist-info/WHEEL +4 -0
- agentstats-0.3.0.dist-info/entry_points.txt +2 -0
- agentstats-0.3.0.dist-info/licenses/LICENSE +21 -0
agentstats/sample.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A deterministic, stratified sample of cases for a smoke tier.
|
|
3
|
+
|
|
4
|
+
Every group (tag `<stratify_by>:<value>`) gets a share of the sample proportional to its size (largest
|
|
5
|
+
remainder), and at least one case when the sample is big enough to cover every group. Cases carrying an
|
|
6
|
+
`always_include` tag are always in. Same seed, same cases: a smoke result can be reproduced.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_cases(path: str | Path) -> dict[str, list[str]]:
|
|
17
|
+
"""case -> tags, from a JSONL of cases ({"case": ..., "tags": [...]}) or of attempt rows."""
|
|
18
|
+
out: dict[str, set[str]] = defaultdict(set)
|
|
19
|
+
for line in Path(path).read_text().splitlines():
|
|
20
|
+
if line.strip():
|
|
21
|
+
row = json.loads(line)
|
|
22
|
+
out[str(row["case"])].update(row.get("tags") or [])
|
|
23
|
+
return {c: sorted(t) for c, t in out.items()}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def group_of(tags: list[str], stratify_by: str | None) -> str:
|
|
27
|
+
if not stratify_by:
|
|
28
|
+
return "all"
|
|
29
|
+
vals = sorted(t for t in tags if t.startswith(stratify_by + ":"))
|
|
30
|
+
return vals[0] if vals else f"{stratify_by}:(none)"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stratified(cases: dict[str, list[str]], n: int, stratify_by: str | None = None,
|
|
34
|
+
always_include: list[str] | None = None, seed: int = 0) -> dict:
|
|
35
|
+
rng = random.Random(seed)
|
|
36
|
+
always = sorted(c for c, tags in cases.items() if set(tags) & set(always_include or []))
|
|
37
|
+
notes = []
|
|
38
|
+
if len(always) >= n:
|
|
39
|
+
if len(always) > n:
|
|
40
|
+
notes.append(f"{len(always)} always-include cases exceed the sample size {n}; all are included")
|
|
41
|
+
return {"cases": always, "groups": {}, "notes": notes}
|
|
42
|
+
|
|
43
|
+
pool: dict[str, list[str]] = defaultdict(list)
|
|
44
|
+
for c in sorted(cases):
|
|
45
|
+
if c not in always:
|
|
46
|
+
pool[group_of(cases[c], stratify_by)].append(c)
|
|
47
|
+
left = n - len(always)
|
|
48
|
+
total = sum(len(v) for v in pool.values())
|
|
49
|
+
if left >= total:
|
|
50
|
+
return {"cases": sorted(cases), "groups": {g: len(v) for g, v in pool.items()}, "notes": notes}
|
|
51
|
+
|
|
52
|
+
quotas = {g: left * len(v) / total for g, v in pool.items()}
|
|
53
|
+
alloc = {g: int(q) for g, q in quotas.items()}
|
|
54
|
+
if left >= len(pool):
|
|
55
|
+
for g in pool:
|
|
56
|
+
alloc[g] = max(alloc[g], 1)
|
|
57
|
+
while sum(alloc.values()) > left: # the minimum-one rule overshot: take from the largest
|
|
58
|
+
g = max(alloc, key=lambda k: (alloc[k] - quotas[k], alloc[k]))
|
|
59
|
+
alloc[g] -= 1
|
|
60
|
+
for g in sorted(pool, key=lambda k: quotas[k] - int(quotas[k]), reverse=True):
|
|
61
|
+
if sum(alloc.values()) >= left:
|
|
62
|
+
break
|
|
63
|
+
if alloc[g] < len(pool[g]):
|
|
64
|
+
alloc[g] += 1
|
|
65
|
+
picked = list(always)
|
|
66
|
+
for g, members in sorted(pool.items()):
|
|
67
|
+
picked += rng.sample(members, min(alloc[g], len(members)))
|
|
68
|
+
return {"cases": sorted(picked), "groups": {g: alloc[g] for g in sorted(pool)}, "notes": notes}
|
agentstats/sizing.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Size a gate before trusting it: how often would it pass, fail or be inconclusive for a range of true changes?
|
|
3
|
+
|
|
4
|
+
Simulated from your baseline's own per-case rates, so the answer fits your eval set (many cases near 0% or 100%,
|
|
5
|
+
a few that flip). For each simulation, each case's true pass rate is drawn from its baseline trials (Jeffreys prior,
|
|
6
|
+
so a case that passed 4 of 4 still carries some uncertainty), shifted by `shift` on the log-odds scale for the
|
|
7
|
+
candidate, and BOTH sides are simulated from it: a fresh baseline with the baseline's trial count and a candidate with
|
|
8
|
+
`trials`. Comparing simulated candidates with the observed baseline instead would be biased: a case that passed 4 of 4
|
|
9
|
+
has a true rate below 1 on average, so even no change would look like a drop. (Method from agent-platform's diagnosis
|
|
10
|
+
gate v2 power analysis.)
|
|
11
|
+
|
|
12
|
+
Rules:
|
|
13
|
+
drop FAIL only when the whole interval is below -margin, else PASS (gate requirement no_significant_drop)
|
|
14
|
+
noninferiority PASS when the lower bound >= -margin, FAIL when the upper bound < -margin, else INCONCLUSIVE
|
|
15
|
+
(gate requirement no_regression)
|
|
16
|
+
|
|
17
|
+
Pick trials and margin so that "no change" passes often enough (e.g. >= 90%) and the drops you care about fail often
|
|
18
|
+
enough (e.g. >= 80%).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
import random
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
|
|
26
|
+
from agentstats.comparisons import fixed_cases
|
|
27
|
+
from agentstats.records import Attempt
|
|
28
|
+
|
|
29
|
+
RULES = ("drop", "noninferiority")
|
|
30
|
+
DEFAULT_SHIFTS = (0.0, 0.25, 0.5, 0.75, 1.0, 1.5)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _per_case(baseline: list[Attempt]) -> dict[str, tuple[int, int]]:
|
|
34
|
+
out: dict[str, list[bool]] = defaultdict(list)
|
|
35
|
+
for a in baseline:
|
|
36
|
+
if a.valid:
|
|
37
|
+
out[a.case].append(bool(a.passed))
|
|
38
|
+
return {c: (len(v), sum(v)) for c, v in out.items()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def simulate(baseline: list[Attempt], trials: int, rule: str = "drop", margin: float = 0.0, conf: float = 0.90,
|
|
42
|
+
shifts=DEFAULT_SHIFTS, sims: int = 1000, seed: int = 0) -> dict:
|
|
43
|
+
if rule not in RULES:
|
|
44
|
+
raise ValueError(f"rule must be one of {RULES}")
|
|
45
|
+
counts = _per_case(baseline)
|
|
46
|
+
if not counts:
|
|
47
|
+
raise ValueError("baseline has no graded attempts")
|
|
48
|
+
m = min(n for n, _ in counts.values())
|
|
49
|
+
rng = random.Random(seed)
|
|
50
|
+
rows = []
|
|
51
|
+
for shift in shifts:
|
|
52
|
+
out = {"PASS": 0, "INCONCLUSIVE": 0, "FAIL": 0}
|
|
53
|
+
drop = 0.0
|
|
54
|
+
for _ in range(sims):
|
|
55
|
+
a, b, d = {}, {}, 0.0
|
|
56
|
+
for case, (n, s) in counts.items():
|
|
57
|
+
p = min(max(rng.betavariate(s + 0.5, n - s + 0.5), 1e-6), 1 - 1e-6)
|
|
58
|
+
q = 1 / (1 + math.exp(-(math.log(p / (1 - p)) - shift)))
|
|
59
|
+
d += p - q
|
|
60
|
+
a[case] = (m, sum(rng.random() < p for _ in range(m)))
|
|
61
|
+
b[case] = (trials, sum(rng.random() < q for _ in range(trials)))
|
|
62
|
+
drop += d / len(counts)
|
|
63
|
+
iv = fixed_cases(a, b, conf)
|
|
64
|
+
if rule == "drop":
|
|
65
|
+
out["FAIL" if iv.high < -margin else "PASS"] += 1
|
|
66
|
+
else:
|
|
67
|
+
out["PASS" if iv.low >= -margin else "FAIL" if iv.high < -margin else "INCONCLUSIVE"] += 1
|
|
68
|
+
rows.append({"shift": shift, "mean_drop": drop / sims, **{k: v / sims for k, v in out.items()}})
|
|
69
|
+
return {"cases": len(counts), "baseline_trials": m, "trials": trials, "rule": rule, "margin": margin,
|
|
70
|
+
"conf": conf, "sims": sims, "rows": rows}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render(r: dict) -> str:
|
|
74
|
+
title = f"{r['rule']} at a {100 * r['margin']:.0f}-point margin, {100 * r['conf']:.0f}% interval"
|
|
75
|
+
lines = [f"# agentstats size: {title}",
|
|
76
|
+
"", f"{r['cases']} cases, baseline {r['baseline_trials']} trial(s), candidate {r['trials']} trial(s), "
|
|
77
|
+
f"{r['sims']} simulations per row.", "",
|
|
78
|
+
"| true shift (log-odds) | mean drop | PASS | INCONCLUSIVE | FAIL |", "|---|---|---|---|---|"]
|
|
79
|
+
for row in r["rows"]:
|
|
80
|
+
lines.append(f"| {row['shift']} | {100 * row['mean_drop']:.1f} pts | {row['PASS']:.0%} | "
|
|
81
|
+
f"{row['INCONCLUSIVE']:.0%} | {row['FAIL']:.0%} |")
|
|
82
|
+
zero = next((x for x in r["rows"] if x["shift"] == 0), None)
|
|
83
|
+
if zero:
|
|
84
|
+
lines += ["", f"No change: {zero['PASS']:.0%} pass" + (f", {zero['FAIL']:.0%} false alarms"
|
|
85
|
+
if r["rule"] == "drop" else f", {zero['INCONCLUSIVE']:.0%} inconclusive, {zero['FAIL']:.0%} fail")]
|
|
86
|
+
return "\n".join(lines) + "\n"
|
agentstats/switch.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The switch decision: is the cheaper setup shown to be no worse than the current one, at no higher cost?
|
|
3
|
+
|
|
4
|
+
`agentstats switch` doesn't run agents; the statistics are agentstats' own gate:
|
|
5
|
+
- results come in as agentstats attempt rows (your runner, or `harness-evolve export` for an adapted harness);
|
|
6
|
+
- agentstats does the statistics (non-inferiority on fixed cases, cost ratio, infrastructure failures);
|
|
7
|
+
- this module applies the switch rule, in steps, and switch_record.py writes down what it decided and why.
|
|
8
|
+
|
|
9
|
+
The rule, for one candidate vs the current setup, with a margin m chosen in advance:
|
|
10
|
+
|
|
11
|
+
lower CI bound of (candidate - current) >= -m, and cost ratio upper bound <= max -> SWITCH
|
|
12
|
+
upper CI bound < -m (worse than the margin even at the optimistic end), or costs more -> KEEP
|
|
13
|
+
anything else (the interval crosses -m; too few runs; a run-wide shock) -> NOT YET
|
|
14
|
+
|
|
15
|
+
The steps:
|
|
16
|
+
1. the cheaper model as-is (same harness as the current setup);
|
|
17
|
+
2. if adapted results are given (the cheaper model with a harness adapted to it, e.g. by harness-evolve,
|
|
18
|
+
measured on held-out cases), the adapted setup. The better of the two decisions is the result.
|
|
19
|
+
If step 1 is already SWITCH, adapted results are still compared and reported: switching and improving the new model
|
|
20
|
+
are separate decisions (from the diagnosis replay, where adapting after a passing switch added ~9 points).
|
|
21
|
+
|
|
22
|
+
Slices (--slice-by <tag prefix>): every comparison is repeated per slice. By default slices are reported, not
|
|
23
|
+
enforced, because small slices give wide intervals (agentstats warns below 100 cases). With strict slices, the
|
|
24
|
+
decision can't be better than its worst slice: one slice KEEP makes it KEEP, one slice NOT YET makes it NOT YET.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from agentstats import gate
|
|
31
|
+
from agentstats.policy import parse
|
|
32
|
+
from agentstats.records import Attempt
|
|
33
|
+
|
|
34
|
+
SWITCH, KEEP, NOT_YET, UNHEALTHY = "SWITCH", "KEEP", "NOT YET", "UNHEALTHY"
|
|
35
|
+
ORDER = {SWITCH: 0, NOT_YET: 1, KEEP: 2, UNHEALTHY: 3} # worse decisions sort later
|
|
36
|
+
EXIT = {SWITCH: 0, KEEP: 1, NOT_YET: 2, UNHEALTHY: 3}
|
|
37
|
+
PROVENANCE = ("independent", "current-model", "unknown")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Rule:
|
|
42
|
+
margin: float = 0.03 # how much worse is acceptable, as a fraction (0.03 = 3 points)
|
|
43
|
+
max_cost_ratio: float = 1.0 # candidate cost per attempt / current, upper CI bound
|
|
44
|
+
conf: float = 0.95
|
|
45
|
+
max_infra: float = 0.05
|
|
46
|
+
design: str = "curated"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Comparison:
|
|
51
|
+
name: str
|
|
52
|
+
decision: str
|
|
53
|
+
reason: str
|
|
54
|
+
delta: dict | None = None # agentstats' delta: estimate, low, high
|
|
55
|
+
cost_ratio: str | None = None
|
|
56
|
+
gate_rows: list[dict] = field(default_factory=list)
|
|
57
|
+
slices: dict[str, Comparison] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_cost(attempts: list[Attempt]) -> bool:
|
|
61
|
+
return any("cost_usd" in a.metrics for a in attempts if a.valid)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compare(name: str, current: list[Attempt], candidate: list[Attempt], rule: Rule) -> Comparison:
|
|
65
|
+
"""One candidate vs the current setup, through agentstats' gate, mapped to SWITCH / KEEP / NOT YET."""
|
|
66
|
+
require = {"no_regression": rule.margin, "max_infra": rule.max_infra}
|
|
67
|
+
with_cost = _has_cost(current) and _has_cost(candidate)
|
|
68
|
+
if with_cost:
|
|
69
|
+
require["max_cost_ratio"] = rule.max_cost_ratio
|
|
70
|
+
policy = parse({"design": rule.design, "switch": {"runs": 1, "require": require}})
|
|
71
|
+
g = gate.evaluate(policy.tiers["switch"], policy, candidate, current, rule.conf)
|
|
72
|
+
rows = {r["requirement"]: r for r in g["rows"]}
|
|
73
|
+
nr, cost = rows.get("no_regression"), rows.get("max_cost_ratio")
|
|
74
|
+
delta = (g.get("comparison") or {}).get("delta")
|
|
75
|
+
c = Comparison(name, NOT_YET, "", delta, cost["measured"] if cost else None, g["rows"])
|
|
76
|
+
|
|
77
|
+
if g["overall"] == gate.UNHEALTHY:
|
|
78
|
+
c.decision, c.reason = UNHEALTHY, "too many infrastructure failures to judge: rerun"
|
|
79
|
+
elif nr is None:
|
|
80
|
+
c.decision, c.reason = NOT_YET, "no comparison was possible"
|
|
81
|
+
elif nr["result"] == gate.FAIL:
|
|
82
|
+
c.decision, c.reason = KEEP, f"worse than the margin even at the optimistic end: {nr['measured']}"
|
|
83
|
+
elif cost and cost["result"] == gate.FAIL:
|
|
84
|
+
c.decision, c.reason = KEEP, f"not cheaper: cost ratio {cost['measured']}"
|
|
85
|
+
elif nr["result"] == gate.PASS and (cost is None or cost["result"] == gate.PASS):
|
|
86
|
+
c.decision = SWITCH
|
|
87
|
+
c.reason = f"shown no more than {100 * rule.margin:g} points worse: {nr['measured']}"
|
|
88
|
+
c.reason += f"; cost {cost['measured']}" if cost else "; no cost data, cost not checked"
|
|
89
|
+
else:
|
|
90
|
+
why = nr["detail"] if nr["result"] != gate.PASS else f"cost: {cost['detail'] or cost['measured']}"
|
|
91
|
+
c.decision, c.reason = NOT_YET, f"can't tell yet ({nr['measured']}): {why}"
|
|
92
|
+
return c
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _slices(attempts: list[Attempt], prefix: str) -> dict[str, list[Attempt]]:
|
|
96
|
+
out: dict[str, list[Attempt]] = {}
|
|
97
|
+
for a in attempts:
|
|
98
|
+
for t in a.tags:
|
|
99
|
+
if t.startswith(prefix + ":"):
|
|
100
|
+
out.setdefault(t[len(prefix) + 1:], []).append(a)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compare_with_slices(name: str, current: list[Attempt], candidate: list[Attempt], rule: Rule,
|
|
105
|
+
slice_by: str | None) -> Comparison:
|
|
106
|
+
c = compare(name, current, candidate, rule)
|
|
107
|
+
if slice_by:
|
|
108
|
+
cur, cand = _slices(current, slice_by), _slices(candidate, slice_by)
|
|
109
|
+
for s in sorted(set(cur) & set(cand)):
|
|
110
|
+
c.slices[s] = compare(f"{name} [{slice_by}:{s}]", cur[s], cand[s], rule)
|
|
111
|
+
return c
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cost_per_accepted(attempts: list[Attempt]) -> dict:
|
|
115
|
+
valid = [a for a in attempts if a.valid]
|
|
116
|
+
passed = sum(1 for a in valid if a.passed)
|
|
117
|
+
out = {"attempts": len(valid), "pass_rate": passed / len(valid) if valid else None}
|
|
118
|
+
if _has_cost(attempts):
|
|
119
|
+
cost = sum(a.metrics.get("cost_usd", 0.0) for a in valid)
|
|
120
|
+
out["cost_per_attempt_usd"] = cost / len(valid) if valid else None
|
|
121
|
+
out["cost_per_accepted_usd"] = cost / passed if passed else None
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Decision:
|
|
127
|
+
decision: str
|
|
128
|
+
reason: str
|
|
129
|
+
steps: list[Comparison]
|
|
130
|
+
arms: dict[str, dict]
|
|
131
|
+
warnings: list[str]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decide(current: list[Attempt], candidate: list[Attempt], adapted: list[Attempt] | None, rule: Rule, *,
|
|
135
|
+
slice_by: str | None = None, strict_slices: bool = False, provenance: str = "unknown",
|
|
136
|
+
preregistered: bool = False) -> Decision:
|
|
137
|
+
if provenance not in PROVENANCE:
|
|
138
|
+
raise ValueError(f"provenance must be one of {PROVENANCE}")
|
|
139
|
+
steps = [compare_with_slices("as-is", current, candidate, rule, slice_by)]
|
|
140
|
+
if adapted is not None:
|
|
141
|
+
steps.append(compare_with_slices("adapted", current, adapted, rule, slice_by))
|
|
142
|
+
|
|
143
|
+
def effective(c: Comparison) -> tuple[str, str]:
|
|
144
|
+
"""A step's decision; with strict slices, no better than its worst slice."""
|
|
145
|
+
if strict_slices and c.slices:
|
|
146
|
+
worst = max(c.slices.values(), key=lambda s: ORDER[s.decision])
|
|
147
|
+
if ORDER[worst.decision] > ORDER[c.decision]:
|
|
148
|
+
return worst.decision, f"strict slices: {worst.name}: {worst.reason}"
|
|
149
|
+
return c.decision, f"{c.name}: {c.reason}"
|
|
150
|
+
|
|
151
|
+
# The better of the two steps decides (the adapted step on a tie). Two chances to pass make a false SWITCH a
|
|
152
|
+
# little more likely than the nominal rate; with a 95% interval on each, at most about twice as likely.
|
|
153
|
+
results = [(effective(c), c) for c in steps]
|
|
154
|
+
(decision, reason), final = min(reversed(results), key=lambda x: ORDER[x[0][0]])
|
|
155
|
+
|
|
156
|
+
warnings = []
|
|
157
|
+
if not strict_slices and final.slices:
|
|
158
|
+
blocking = [s.name for s in final.slices.values() if ORDER[s.decision] > ORDER[decision]]
|
|
159
|
+
if blocking:
|
|
160
|
+
warnings.append(f"slices that don't support the decision (reported, not enforced): {', '.join(blocking)}")
|
|
161
|
+
if steps[0].decision == SWITCH and len(steps) > 1 and steps[1].delta and steps[0].delta:
|
|
162
|
+
gain = steps[1].delta["estimate"] - steps[0].delta["estimate"]
|
|
163
|
+
if gain > 0:
|
|
164
|
+
warnings.append(f"switching as-is is enough, but the adapted harness measured {100 * gain:+.1f} points "
|
|
165
|
+
"more than the as-is model: consider shipping it too (a separate decision)")
|
|
166
|
+
if not (_has_cost(current) and _has_cost(candidate)):
|
|
167
|
+
warnings.append("cost not checked: the current or candidate rows have no cost_usd")
|
|
168
|
+
if provenance == "current-model":
|
|
169
|
+
warnings.append("the eval set was created, filtered or labelled by the current model, so the comparison "
|
|
170
|
+
"favours it: a SWITCH still holds, a KEEP or NOT YET may overstate the gap")
|
|
171
|
+
elif provenance == "unknown":
|
|
172
|
+
warnings.append("eval-set provenance not stated: if the current model shaped the cases, the comparison "
|
|
173
|
+
"favours it")
|
|
174
|
+
if not preregistered:
|
|
175
|
+
warnings.append("the margin was not declared before the results were seen")
|
|
176
|
+
|
|
177
|
+
arms = {"current": cost_per_accepted(current), "candidate": cost_per_accepted(candidate)}
|
|
178
|
+
if adapted is not None:
|
|
179
|
+
arms["adapted"] = cost_per_accepted(adapted)
|
|
180
|
+
return Decision(decision, reason, steps, arms, warnings)
|
agentstats/switch_cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentstats preregister --name triage-haiku-5-5 --margin 0.03 --provenance current-model \\
|
|
3
|
+
[--input labels=app/evals/triage_heldout.jsonl] [--slice-by template] --out decisions/triage-haiku-5-5
|
|
4
|
+
(commit the registration, then run both arms)
|
|
5
|
+
|
|
6
|
+
agentstats switch --prereg decisions/triage-haiku-5-5/prereg.json \\
|
|
7
|
+
--current current.jsonl --candidate cheap.jsonl [--adapted cheap-adapted.jsonl] --out decisions/triage-haiku-5-5
|
|
8
|
+
|
|
9
|
+
agentstats switch --current current.jsonl --candidate cheap.jsonl --margin 0.03 --provenance independent \\
|
|
10
|
+
--name my-switch --out decisions/my-switch # without a registration: recorded as not pre-registered
|
|
11
|
+
|
|
12
|
+
Exit codes for switch: 0 SWITCH, 1 KEEP, 2 NOT YET, 3 UNHEALTHY.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from agentstats import prereg, records
|
|
20
|
+
from agentstats import switch as decide
|
|
21
|
+
from agentstats import switch_record as record
|
|
22
|
+
|
|
23
|
+
RULE_DEFAULTS = {"max_cost_ratio": 1.0, "conf": 0.95, "design": "curated"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rule_args(p: argparse.ArgumentParser) -> None:
|
|
27
|
+
p.add_argument("--margin", type=float, help="acceptable loss, e.g. 0.03 for 3 points")
|
|
28
|
+
p.add_argument("--max-cost-ratio", type=float)
|
|
29
|
+
p.add_argument("--conf", type=float)
|
|
30
|
+
p.add_argument("--design", choices=["curated", "sampled", "clustered"])
|
|
31
|
+
p.add_argument("--provenance", choices=decide.PROVENANCE,
|
|
32
|
+
help="who made the eval set: independent of both models, or the current model")
|
|
33
|
+
p.add_argument("--slice-by", help="tag prefix to compare per slice, e.g. repo for repo:<name> tags")
|
|
34
|
+
p.add_argument("--strict-slices", action="store_true", help="the decision can't be better than its worst slice")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rule(args) -> decide.Rule:
|
|
38
|
+
if args.margin is None:
|
|
39
|
+
raise SystemExit("--margin is required (or use --prereg)")
|
|
40
|
+
return decide.Rule(margin=args.margin, **{k: getattr(args, k) if getattr(args, k) is not None else v
|
|
41
|
+
for k, v in RULE_DEFAULTS.items()})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _from_prereg(args) -> tuple[prereg.Registration, list[str]]:
|
|
45
|
+
"""The registered rule; flags that contradict it are an error, not an override."""
|
|
46
|
+
reg = prereg.load(args.prereg)
|
|
47
|
+
given = {"margin": args.margin, "max_cost_ratio": args.max_cost_ratio, "conf": args.conf, "design": args.design}
|
|
48
|
+
conflicts = [f"--{k.replace('_', '-')} {v} (registered {getattr(reg.rule, k)})"
|
|
49
|
+
for k, v in given.items() if v is not None and v != getattr(reg.rule, k)]
|
|
50
|
+
if args.provenance and args.provenance != reg.provenance:
|
|
51
|
+
conflicts.append(f"--provenance {args.provenance} (registered {reg.provenance})")
|
|
52
|
+
if args.slice_by and args.slice_by != reg.slice_by:
|
|
53
|
+
conflicts.append(f"--slice-by {args.slice_by} (registered {reg.slice_by})")
|
|
54
|
+
if args.strict_slices and not reg.strict_slices:
|
|
55
|
+
conflicts.append("--strict-slices (registered without)")
|
|
56
|
+
return reg, conflicts
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add_parsers(sub) -> None:
|
|
60
|
+
"""`preregister` and `switch` subcommands, added to agentstats' own parser."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
p = sub.add_parser("preregister", help="write the switch rule down before any result exists")
|
|
64
|
+
_rule_args(p)
|
|
65
|
+
p.add_argument("--name", required=True)
|
|
66
|
+
p.add_argument("--input", action="append", default=[], metavar="NAME=PATH",
|
|
67
|
+
help="a file the decision depends on (e.g. labels); its hash must match at decision time")
|
|
68
|
+
p.add_argument("--note", default="", help="the plan: arms, cases, trials, how they'll be run")
|
|
69
|
+
p.add_argument("--out", type=Path, required=True, help="directory for prereg.json and prereg.md")
|
|
70
|
+
|
|
71
|
+
d = sub.add_parser("switch", help="switch to the cheaper setup? SWITCH / KEEP / NOT YET, with a decision record")
|
|
72
|
+
d.add_argument("--prereg", type=Path, help="prereg.json from `agentstats preregister`: the rule comes from it")
|
|
73
|
+
d.add_argument("--current", type=Path, required=True, help="agentstats rows for the current setup")
|
|
74
|
+
d.add_argument("--candidate", type=Path, required=True, help="rows for the cheaper model, as-is")
|
|
75
|
+
d.add_argument("--adapted", type=Path, help="rows for the cheaper model with an adapted harness (held-out cases)")
|
|
76
|
+
_rule_args(d)
|
|
77
|
+
d.add_argument("--preregistered", action="store_true",
|
|
78
|
+
help="(without --prereg) you assert the margin was set before any result; recorded as asserted")
|
|
79
|
+
d.add_argument("--name")
|
|
80
|
+
d.add_argument("--note", default="")
|
|
81
|
+
d.add_argument("--out", type=Path, required=True, help="directory for decision.json and decision.md")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(args) -> int:
|
|
85
|
+
|
|
86
|
+
if args.cmd == "preregister":
|
|
87
|
+
if not args.provenance:
|
|
88
|
+
raise SystemExit("--provenance is required: say who made the eval set")
|
|
89
|
+
inputs = {}
|
|
90
|
+
for item in args.input:
|
|
91
|
+
name, _, path = item.partition("=")
|
|
92
|
+
if not path:
|
|
93
|
+
raise SystemExit(f"--input wants NAME=PATH, got {item!r}")
|
|
94
|
+
inputs[name] = Path(path)
|
|
95
|
+
try:
|
|
96
|
+
path = prereg.create(args.out, args.name, _rule(args), args.provenance, slice_by=args.slice_by,
|
|
97
|
+
strict_slices=args.strict_slices, inputs=inputs, note=args.note)
|
|
98
|
+
except (FileExistsError, ValueError) as exc:
|
|
99
|
+
print(exc)
|
|
100
|
+
return 1
|
|
101
|
+
print(f"wrote {path} and {path.with_suffix('.md')}. Commit them before running anything.")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
results = [args.current, args.candidate] + ([args.adapted] if args.adapted else [])
|
|
105
|
+
prereg_info = None
|
|
106
|
+
if args.prereg:
|
|
107
|
+
reg, conflicts = _from_prereg(args)
|
|
108
|
+
if conflicts:
|
|
109
|
+
print("these flags contradict the registration (the rule comes from the file): " + "; ".join(conflicts))
|
|
110
|
+
return 1
|
|
111
|
+
rule, name, provenance = reg.rule, args.name or reg.name, reg.provenance
|
|
112
|
+
slice_by, strict = reg.slice_by, reg.strict_slices
|
|
113
|
+
ok, reasons, info = prereg.verify(args.prereg, results)
|
|
114
|
+
preregistered = ok
|
|
115
|
+
prereg_info = {"file": str(args.prereg), "verified": ok, "reasons": reasons, **info}
|
|
116
|
+
else:
|
|
117
|
+
if not args.name:
|
|
118
|
+
raise SystemExit("--name is required (or use --prereg)")
|
|
119
|
+
rule, name = _rule(args), args.name
|
|
120
|
+
provenance, slice_by, strict = args.provenance or "unknown", args.slice_by, args.strict_slices
|
|
121
|
+
preregistered = args.preregistered
|
|
122
|
+
if preregistered:
|
|
123
|
+
prereg_info = {"file": None, "verified": False, "reasons": ["asserted with --preregistered, not checked"]}
|
|
124
|
+
|
|
125
|
+
current = records.load(args.current, arm="current")
|
|
126
|
+
candidate = records.load(args.candidate, arm="candidate")
|
|
127
|
+
adapted = records.load(args.adapted, arm="adapted") if args.adapted else None
|
|
128
|
+
result = decide.decide(current, candidate, adapted, rule, slice_by=slice_by, strict_slices=strict,
|
|
129
|
+
provenance=provenance, preregistered=preregistered)
|
|
130
|
+
if prereg_info and not prereg_info["verified"] and args.prereg:
|
|
131
|
+
result.warnings.append("pre-registration not verified: " + "; ".join(prereg_info["reasons"]))
|
|
132
|
+
inputs = {"current": args.current, "candidate": args.candidate}
|
|
133
|
+
if args.adapted:
|
|
134
|
+
inputs["adapted"] = args.adapted
|
|
135
|
+
r = record.build(result, rule, inputs, name=name, provenance=provenance, preregistered=preregistered,
|
|
136
|
+
slice_by=slice_by, strict_slices=strict, note=args.note, prereg=prereg_info)
|
|
137
|
+
_, md = record.write(args.out, r)
|
|
138
|
+
print(md.read_text())
|
|
139
|
+
return decide.EXIT[result.decision]
|
|
140
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The decision record: what was compared, under which rule, what was decided and why, written next to the inputs'
|
|
3
|
+
hashes so the decision can be checked later ("why are we still on the expensive model?").
|
|
4
|
+
|
|
5
|
+
<out>/decision.json everything, machine-readable
|
|
6
|
+
<out>/decision.md the same, for people
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import agentstats
|
|
17
|
+
from agentstats.switch import Comparison, Decision, Rule
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sha256(path: Path) -> str:
|
|
21
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _comparison(c: Comparison) -> dict:
|
|
25
|
+
return {"name": c.name, "decision": c.decision, "reason": c.reason, "delta": c.delta, "cost_ratio": c.cost_ratio,
|
|
26
|
+
"gate_rows": c.gate_rows, "slices": {k: _comparison(v) for k, v in c.slices.items()}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build(d: Decision, rule: Rule, inputs: dict[str, Path], *, name: str, provenance: str, preregistered: bool,
|
|
30
|
+
slice_by: str | None, strict_slices: bool, note: str = "", prereg: dict | None = None) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"name": name,
|
|
33
|
+
"decision": d.decision,
|
|
34
|
+
"reason": d.reason,
|
|
35
|
+
"warnings": d.warnings,
|
|
36
|
+
"rule": asdict(rule),
|
|
37
|
+
"margin_declared_before_results": preregistered,
|
|
38
|
+
"preregistration": prereg,
|
|
39
|
+
"provenance": provenance,
|
|
40
|
+
"slices": {"by": slice_by, "strict": strict_slices},
|
|
41
|
+
"inputs": {arm: {"path": str(p), "sha256": _sha256(p)} for arm, p in inputs.items()},
|
|
42
|
+
"arms": d.arms,
|
|
43
|
+
"steps": [_comparison(c) for c in d.steps],
|
|
44
|
+
"note": note,
|
|
45
|
+
"versions": {"agentstats": agentstats.__version__},
|
|
46
|
+
"decided_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pts(x):
|
|
51
|
+
return "n/a" if x is None else f"{100 * x:+.1f}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render(r: dict) -> str:
|
|
55
|
+
lines = [f"# agentstats switch decision: {r['name']}", "", f"**{r['decision']}**: {r['reason']}", ""]
|
|
56
|
+
rule = r["rule"]
|
|
57
|
+
rule_line = (f"Rule: switch only if shown no more than {100 * rule['margin']:g} points worse "
|
|
58
|
+
f"({100 * rule['conf']:g}% interval, {rule['design']} eval set) at a cost ratio of at most "
|
|
59
|
+
f"{rule['max_cost_ratio']:g}x. Margin declared before the results: "
|
|
60
|
+
f"{'yes' if r['margin_declared_before_results'] else 'no'}. Eval-set provenance: {r['provenance']}.")
|
|
61
|
+
lines += [rule_line, ""]
|
|
62
|
+
p = r.get("preregistration")
|
|
63
|
+
if p and p.get("file"):
|
|
64
|
+
status = (f"verified: committed {p.get('registered_at')} ({p.get('commit', '')[:7]}), before the results"
|
|
65
|
+
if p["verified"] else "NOT verified: " + "; ".join(p["reasons"]))
|
|
66
|
+
lines += [f"Pre-registration `{p['file']}`: {status}.", ""]
|
|
67
|
+
elif p:
|
|
68
|
+
lines += ["Pre-registration: asserted with --preregistered, not checked.", ""]
|
|
69
|
+
lines += ["| step | decision | change (pts) | 95% CI | cost ratio |", "|---|---|---|---|---|"]
|
|
70
|
+
for s in r["steps"]:
|
|
71
|
+
d = s["delta"] or {}
|
|
72
|
+
lines.append(f"| {s['name']} | {s['decision']} | {_pts(d.get('estimate'))} | "
|
|
73
|
+
f"{_pts(d.get('low'))} to {_pts(d.get('high'))} | {s['cost_ratio'] or 'n/a'} |")
|
|
74
|
+
for sl in s["slices"].values():
|
|
75
|
+
d = sl["delta"] or {}
|
|
76
|
+
lines.append(f"| {sl['name']} | {sl['decision']} | {_pts(d.get('estimate'))} | "
|
|
77
|
+
f"{_pts(d.get('low'))} to {_pts(d.get('high'))} | {sl['cost_ratio'] or 'n/a'} |")
|
|
78
|
+
lines += ["", "| arm | attempts | pass rate | $ per attempt | $ per accepted result |", "|---|---|---|---|---|"]
|
|
79
|
+
for arm, a in r["arms"].items():
|
|
80
|
+
pr = f"{100 * a['pass_rate']:.1f}%" if a.get("pass_rate") is not None else "n/a"
|
|
81
|
+
cpa = f"{a['cost_per_attempt_usd']:.4f}" if a.get("cost_per_attempt_usd") is not None else "n/a"
|
|
82
|
+
cpr = f"{a['cost_per_accepted_usd']:.4f}" if a.get("cost_per_accepted_usd") is not None else "n/a"
|
|
83
|
+
lines.append(f"| {arm} | {a['attempts']} | {pr} | {cpa} | {cpr} |")
|
|
84
|
+
if r["warnings"]:
|
|
85
|
+
lines += ["", "Warnings:"] + [f"- {w}" for w in r["warnings"]]
|
|
86
|
+
if r["note"]:
|
|
87
|
+
lines += ["", f"Note: {r['note']}"]
|
|
88
|
+
lines += ["", "Inputs:"] + [f"- {arm}: `{i['path']}` (sha256 {i['sha256']})" for arm, i in r["inputs"].items()]
|
|
89
|
+
decided = f"Decided {r['decided_at']} with agentstats {r['versions']['agentstats']}."
|
|
90
|
+
lines += ["", decided]
|
|
91
|
+
return "\n".join(lines) + "\n"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write(out_dir: Path, r: dict) -> tuple[Path, Path]:
|
|
95
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
j, m = out_dir / "decision.json", out_dir / "decision.md"
|
|
97
|
+
j.write_text(json.dumps(r, indent=1))
|
|
98
|
+
m.write_text(render(r))
|
|
99
|
+
return j, m
|