rulelab-cli 0.1.2__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.
- rulelab/__init__.py +3 -0
- rulelab/__main__.py +3 -0
- rulelab/_demo/results.json +956 -0
- rulelab/analysis.py +174 -0
- rulelab/cli.py +344 -0
- rulelab/configs.py +39 -0
- rulelab/engine.py +272 -0
- rulelab/errors.py +9 -0
- rulelab/experiment.py +195 -0
- rulelab/report.py +205 -0
- rulelab/rules.py +225 -0
- rulelab/runner.py +94 -0
- rulelab/stats.py +180 -0
- rulelab/templates/report.html.j2 +379 -0
- rulelab_cli-0.1.2.dist-info/METADATA +260 -0
- rulelab_cli-0.1.2.dist-info/RECORD +19 -0
- rulelab_cli-0.1.2.dist-info/WHEEL +4 -0
- rulelab_cli-0.1.2.dist-info/entry_points.txt +3 -0
- rulelab_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
rulelab/rules.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Split an AGENTS.md / CLAUDE.md file into rules that can be removed one at a time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
Granularity = Literal["section", "bullet"]
|
|
12
|
+
|
|
13
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
14
|
+
_FENCE = re.compile(r"^\s{0,3}(```|~~~)")
|
|
15
|
+
_BULLET = re.compile(r"^( {0,1})([-*+]|\d{1,3}[.)])\s+(.*)$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def approx_tokens(text: str) -> int:
|
|
19
|
+
"""Rough token count (characters / 4). Labelled as approximate everywhere it is shown."""
|
|
20
|
+
stripped = text.strip()
|
|
21
|
+
return math.ceil(len(stripped) / 4) if stripped else 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Rule:
|
|
26
|
+
id: str
|
|
27
|
+
title: str
|
|
28
|
+
text: str
|
|
29
|
+
start_line: int # 1-based, inclusive
|
|
30
|
+
end_line: int # 1-based, inclusive
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def tokens(self) -> int:
|
|
34
|
+
return approx_tokens(self.text)
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"id": self.id,
|
|
39
|
+
"title": self.title,
|
|
40
|
+
"text": self.text,
|
|
41
|
+
"start_line": self.start_line,
|
|
42
|
+
"end_line": self.end_line,
|
|
43
|
+
"tokens": self.tokens,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Segment:
|
|
49
|
+
lines: list[str]
|
|
50
|
+
rule_id: str | None # None = always kept (titles, headings in bullet mode, blank lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RuleFile:
|
|
55
|
+
path: str
|
|
56
|
+
text: str
|
|
57
|
+
granularity: Granularity
|
|
58
|
+
segments: list[Segment] = field(default_factory=list)
|
|
59
|
+
rules: list[Rule] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def tokens(self) -> int:
|
|
63
|
+
return approx_tokens(self.text)
|
|
64
|
+
|
|
65
|
+
def render(self, without: set[str] | frozenset[str] = frozenset()) -> str:
|
|
66
|
+
"""Return the file text with the given rule ids removed."""
|
|
67
|
+
unknown = set(without) - {r.id for r in self.rules}
|
|
68
|
+
if unknown:
|
|
69
|
+
raise ValueError(f"unknown rule id(s): {', '.join(sorted(unknown))}")
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
for seg in self.segments:
|
|
72
|
+
if seg.rule_id is not None and seg.rule_id in without:
|
|
73
|
+
continue
|
|
74
|
+
out.extend(seg.lines)
|
|
75
|
+
text = "\n".join(out)
|
|
76
|
+
text = re.sub(r"\n{3,}", "\n\n", text).strip("\n")
|
|
77
|
+
return text + "\n" if text else ""
|
|
78
|
+
|
|
79
|
+
def rule(self, rule_id: str) -> Rule:
|
|
80
|
+
for r in self.rules:
|
|
81
|
+
if r.id == rule_id:
|
|
82
|
+
return r
|
|
83
|
+
raise KeyError(rule_id)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _code_mask(lines: list[str]) -> list[bool]:
|
|
87
|
+
"""True for lines inside (or delimiting) fenced code blocks."""
|
|
88
|
+
mask: list[bool] = []
|
|
89
|
+
fence: str | None = None
|
|
90
|
+
for line in lines:
|
|
91
|
+
m = _FENCE.match(line)
|
|
92
|
+
if fence is None and m:
|
|
93
|
+
fence = m.group(1)
|
|
94
|
+
mask.append(True)
|
|
95
|
+
elif fence is not None:
|
|
96
|
+
mask.append(True)
|
|
97
|
+
if line.strip().startswith(fence):
|
|
98
|
+
fence = None
|
|
99
|
+
else:
|
|
100
|
+
mask.append(False)
|
|
101
|
+
return mask
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _headings(lines: list[str], mask: list[bool]) -> list[tuple[int, int, str]]:
|
|
105
|
+
out = []
|
|
106
|
+
for i, line in enumerate(lines):
|
|
107
|
+
if mask[i]:
|
|
108
|
+
continue
|
|
109
|
+
m = _HEADING.match(line)
|
|
110
|
+
if m:
|
|
111
|
+
out.append((i, len(m.group(1)), m.group(2).strip()))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _short(text: str, limit: int = 72) -> str:
|
|
116
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
117
|
+
text = re.sub(r"[*_`]", "", text)
|
|
118
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _split_sections(lines: list[str], mask: list[bool]) -> list[tuple[int, int, str | None]]:
|
|
122
|
+
"""Return (start, end_exclusive, title) chunks; title None means fixed preamble."""
|
|
123
|
+
heads = _headings(lines, mask)
|
|
124
|
+
if not heads:
|
|
125
|
+
return [(0, len(lines), "Whole file")]
|
|
126
|
+
levels = [lvl for _, lvl, _ in heads]
|
|
127
|
+
split_level = min(levels)
|
|
128
|
+
# A single top-level title (e.g. "# AGENTS.md") is not a rule: split one level below it.
|
|
129
|
+
if levels.count(split_level) == 1 and heads[0][1] == split_level and len(set(levels)) > 1:
|
|
130
|
+
split_level = min(lvl for lvl in levels if lvl > split_level)
|
|
131
|
+
starts = [(i, t) for i, lvl, t in heads if lvl == split_level]
|
|
132
|
+
chunks: list[tuple[int, int, str | None]] = []
|
|
133
|
+
first = starts[0][0]
|
|
134
|
+
if first > 0:
|
|
135
|
+
pre = lines[:first]
|
|
136
|
+
body = [ln for j, ln in enumerate(pre) if ln.strip() and not _HEADING.match(ln)]
|
|
137
|
+
chunks.append((0, first, "Preamble" if body else None))
|
|
138
|
+
for k, (i, title) in enumerate(starts):
|
|
139
|
+
end = starts[k + 1][0] if k + 1 < len(starts) else len(lines)
|
|
140
|
+
chunks.append((i, end, title))
|
|
141
|
+
return chunks
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _split_bullets(lines: list[str], mask: list[bool]) -> list[tuple[int, int, str | None]]:
|
|
145
|
+
"""Each top-level bullet item (with its nested lines) and each prose paragraph is a rule.
|
|
146
|
+
|
|
147
|
+
Headings and blank lines are kept in every configuration.
|
|
148
|
+
"""
|
|
149
|
+
chunks: list[tuple[int, int, str | None]] = []
|
|
150
|
+
i, n = 0, len(lines)
|
|
151
|
+
while i < n:
|
|
152
|
+
line = lines[i]
|
|
153
|
+
if not mask[i] and (not line.strip() or _HEADING.match(line)):
|
|
154
|
+
chunks.append((i, i + 1, None))
|
|
155
|
+
i += 1
|
|
156
|
+
continue
|
|
157
|
+
start = i
|
|
158
|
+
bullet = None if mask[i] else _BULLET.match(line)
|
|
159
|
+
if bullet:
|
|
160
|
+
title = _short(bullet.group(3))
|
|
161
|
+
i += 1
|
|
162
|
+
while i < n:
|
|
163
|
+
nxt = lines[i]
|
|
164
|
+
if mask[i]:
|
|
165
|
+
i += 1
|
|
166
|
+
continue
|
|
167
|
+
if _HEADING.match(nxt) or _BULLET.match(nxt):
|
|
168
|
+
break
|
|
169
|
+
if not nxt.strip():
|
|
170
|
+
# a blank line ends the item unless the next non-blank line is indented
|
|
171
|
+
j = i
|
|
172
|
+
while j < n and not lines[j].strip():
|
|
173
|
+
j += 1
|
|
174
|
+
if j < n and lines[j].startswith((" ", "\t")) and not _BULLET.match(lines[j]):
|
|
175
|
+
i = j
|
|
176
|
+
continue
|
|
177
|
+
break
|
|
178
|
+
i += 1
|
|
179
|
+
else:
|
|
180
|
+
# prose paragraph or code block: runs until a blank line, heading or bullet
|
|
181
|
+
title = None
|
|
182
|
+
while i < n:
|
|
183
|
+
nxt = lines[i]
|
|
184
|
+
if not mask[i] and (not nxt.strip() or _HEADING.match(nxt) or _BULLET.match(nxt)):
|
|
185
|
+
if i > start:
|
|
186
|
+
break
|
|
187
|
+
if title is None and not mask[i] and nxt.strip():
|
|
188
|
+
title = _short(nxt)
|
|
189
|
+
i += 1
|
|
190
|
+
title = title or "Code block"
|
|
191
|
+
chunks.append((start, i, title))
|
|
192
|
+
return chunks
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_rules(text: str, granularity: Granularity = "section", path: str = "AGENTS.md") -> RuleFile:
|
|
196
|
+
"""Split ``text`` into rules. Every line belongs to exactly one segment."""
|
|
197
|
+
if granularity not in ("section", "bullet"):
|
|
198
|
+
raise ValueError("granularity must be 'section' or 'bullet'")
|
|
199
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
200
|
+
if lines and lines[-1] == "":
|
|
201
|
+
lines = lines[:-1]
|
|
202
|
+
mask = _code_mask(lines)
|
|
203
|
+
chunks = _split_sections(lines, mask) if granularity == "section" else _split_bullets(lines, mask)
|
|
204
|
+
rf = RuleFile(path=path, text=text, granularity=granularity)
|
|
205
|
+
k = 0
|
|
206
|
+
for start, end, title in chunks:
|
|
207
|
+
seg_lines = lines[start:end]
|
|
208
|
+
if title is None or not any(ln.strip() for ln in seg_lines):
|
|
209
|
+
rf.segments.append(Segment(seg_lines, None))
|
|
210
|
+
continue
|
|
211
|
+
k += 1
|
|
212
|
+
rid = f"R{k}"
|
|
213
|
+
body = "\n".join(seg_lines).strip("\n")
|
|
214
|
+
# trailing blank lines are layout, not content
|
|
215
|
+
last = end
|
|
216
|
+
while last > start and not lines[last - 1].strip():
|
|
217
|
+
last -= 1
|
|
218
|
+
rf.rules.append(Rule(rid, title, body, start + 1, last))
|
|
219
|
+
rf.segments.append(Segment(seg_lines, rid))
|
|
220
|
+
return rf
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_rules(path: str | Path, granularity: Granularity = "section") -> RuleFile:
|
|
224
|
+
p = Path(path)
|
|
225
|
+
return parse_rules(p.read_text(encoding="utf-8"), granularity, path=p.name)
|
rulelab/runner.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The interface rulelab needs from an agent runner, plus a deterministic fake for tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RunOutcome:
|
|
12
|
+
success: bool | None # None = infrastructure error, excluded from statistics
|
|
13
|
+
input_tokens: int = 0
|
|
14
|
+
output_tokens: int = 0
|
|
15
|
+
cost_usd: float = 0.0
|
|
16
|
+
turns: int = 0
|
|
17
|
+
duration_s: float = 0.0
|
|
18
|
+
error: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Runner(Protocol):
|
|
22
|
+
"""Runs one agent attempt on one task with a given context file (or none)."""
|
|
23
|
+
|
|
24
|
+
model: str
|
|
25
|
+
estimate_note: str
|
|
26
|
+
|
|
27
|
+
def meta(self) -> dict:
|
|
28
|
+
"""Metadata stored with the results (engine, repo, hardware...)."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def task_ids(self) -> list[str]: ...
|
|
32
|
+
|
|
33
|
+
def estimate_run_cost(self) -> float:
|
|
34
|
+
"""Expected USD for one agent run (0 for local models)."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def run(self, task_id: str, context: str | None, repeat: int) -> RunOutcome: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FakeRunner:
|
|
41
|
+
"""Deterministic runner for tests and offline development.
|
|
42
|
+
|
|
43
|
+
``effects`` maps a marker substring to a success-probability shift: if the marker is present
|
|
44
|
+
in the context, the task succeeds more (positive) or less (negative) often.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
tasks: list[str] | None = None,
|
|
50
|
+
base_rate: float = 0.5,
|
|
51
|
+
effects: dict[str, float] | None = None,
|
|
52
|
+
cost_per_run: float = 0.01,
|
|
53
|
+
model: str = "fake/model",
|
|
54
|
+
fail_on: set[tuple[str, int]] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.tasks = tasks or [f"task-{i}" for i in range(1, 5)]
|
|
57
|
+
self.base_rate = base_rate
|
|
58
|
+
self.effects = effects or {}
|
|
59
|
+
self.cost_per_run = cost_per_run
|
|
60
|
+
self.model = model
|
|
61
|
+
self.fail_on = fail_on or set()
|
|
62
|
+
self.calls: list[tuple[str, str | None, int]] = []
|
|
63
|
+
self.estimate_note = "fake runner"
|
|
64
|
+
|
|
65
|
+
def meta(self) -> dict:
|
|
66
|
+
return {"engine": "fake"}
|
|
67
|
+
|
|
68
|
+
def task_ids(self) -> list[str]:
|
|
69
|
+
return list(self.tasks)
|
|
70
|
+
|
|
71
|
+
def estimate_run_cost(self) -> float:
|
|
72
|
+
return self.cost_per_run
|
|
73
|
+
|
|
74
|
+
def run(self, task_id: str, context: str | None, repeat: int) -> RunOutcome:
|
|
75
|
+
self.calls.append((task_id, context, repeat))
|
|
76
|
+
if (task_id, repeat) in self.fail_on:
|
|
77
|
+
return RunOutcome(None, error="simulated infrastructure error")
|
|
78
|
+
p = self.base_rate
|
|
79
|
+
ctx = context or ""
|
|
80
|
+
for marker, shift in self.effects.items():
|
|
81
|
+
if marker in ctx:
|
|
82
|
+
p += shift
|
|
83
|
+
digest = hashlib.sha256(f"{task_id}|{repeat}|{ctx}".encode()).digest()
|
|
84
|
+
u = int.from_bytes(digest[:8], "big") / 2**64
|
|
85
|
+
ctx_tokens = len(ctx) // 4
|
|
86
|
+
turns = 5
|
|
87
|
+
return RunOutcome(
|
|
88
|
+
success=u < p,
|
|
89
|
+
input_tokens=turns * (2000 + ctx_tokens),
|
|
90
|
+
output_tokens=400,
|
|
91
|
+
cost_usd=self.cost_per_run,
|
|
92
|
+
turns=turns,
|
|
93
|
+
duration_s=0.0,
|
|
94
|
+
)
|
rulelab/stats.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Effect estimates with bootstrap confidence intervals and per-rule verdicts.
|
|
2
|
+
|
|
3
|
+
Effect of a rule = success(full file) - success(full file without that rule), averaged over tasks.
|
|
4
|
+
Positive means the rule helps. Uncertainty comes from a two-level bootstrap:
|
|
5
|
+
|
|
6
|
+
1. resample tasks with replacement (task difficulty varies a lot), then
|
|
7
|
+
2. for each sampled task, draw each configuration's success rate from its
|
|
8
|
+
Beta(successes + 1/2, failures + 1/2) posterior (Jeffreys prior) instead of
|
|
9
|
+
trusting 3/3 or 0/3 as exact.
|
|
10
|
+
|
|
11
|
+
Step 2 matters with small samples: plain resampling of 3 identical outcomes gives a
|
|
12
|
+
zero-width interval and would turn "we saw nothing" into "no effect".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import random
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from statistics import fmean
|
|
20
|
+
from typing import Iterable, Literal
|
|
21
|
+
|
|
22
|
+
Verdict = Literal["helps", "no_effect", "hurts", "inconclusive"]
|
|
23
|
+
|
|
24
|
+
DEFAULT_MARGIN = 0.10 # +/- 10 percentage points counts as "no effect"
|
|
25
|
+
DEFAULT_ITERATIONS = 4000
|
|
26
|
+
CONFIDENCE = 0.95
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Cell:
|
|
31
|
+
successes: int = 0
|
|
32
|
+
failures: int = 0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def n(self) -> int:
|
|
36
|
+
return self.successes + self.failures
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def rate(self) -> float:
|
|
40
|
+
return self.successes / self.n if self.n else float("nan")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Effect:
|
|
45
|
+
estimate: float # observed mean difference over shared tasks
|
|
46
|
+
low: float
|
|
47
|
+
high: float
|
|
48
|
+
tasks: int
|
|
49
|
+
runs_a: int
|
|
50
|
+
runs_b: int
|
|
51
|
+
verdict: Verdict
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return asdict(self)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cells(runs: Iterable[dict], config_id: str) -> dict[str, Cell]:
|
|
58
|
+
"""Success/failure counts per task for one configuration. Errored runs are skipped."""
|
|
59
|
+
out: dict[str, Cell] = {}
|
|
60
|
+
for r in runs:
|
|
61
|
+
if r["config"] != config_id or r.get("success") is None:
|
|
62
|
+
continue
|
|
63
|
+
c = out.setdefault(r["task"], Cell())
|
|
64
|
+
if r["success"]:
|
|
65
|
+
c.successes += 1
|
|
66
|
+
else:
|
|
67
|
+
c.failures += 1
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def classify(low: float, high: float, margin: float = DEFAULT_MARGIN) -> Verdict:
|
|
72
|
+
if low > 0:
|
|
73
|
+
return "helps"
|
|
74
|
+
if high < 0:
|
|
75
|
+
return "hurts"
|
|
76
|
+
if low >= -margin and high <= margin:
|
|
77
|
+
return "no_effect"
|
|
78
|
+
return "inconclusive"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _percentile(sorted_vals: list[float], q: float) -> float:
|
|
82
|
+
if not sorted_vals:
|
|
83
|
+
return float("nan")
|
|
84
|
+
pos = q * (len(sorted_vals) - 1)
|
|
85
|
+
lo = int(pos)
|
|
86
|
+
hi = min(lo + 1, len(sorted_vals) - 1)
|
|
87
|
+
frac = pos - lo
|
|
88
|
+
return sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def success_effect(
|
|
92
|
+
runs: list[dict],
|
|
93
|
+
config_a: str,
|
|
94
|
+
config_b: str,
|
|
95
|
+
*,
|
|
96
|
+
iterations: int = DEFAULT_ITERATIONS,
|
|
97
|
+
margin: float = DEFAULT_MARGIN,
|
|
98
|
+
seed: int = 0,
|
|
99
|
+
) -> Effect:
|
|
100
|
+
"""Bootstrap CI for mean_t[p_a(t) - p_b(t)] over tasks run under both configurations."""
|
|
101
|
+
a, b = cells(runs, config_a), cells(runs, config_b)
|
|
102
|
+
tasks = sorted(set(a) & set(b))
|
|
103
|
+
runs_a = sum(a[t].n for t in tasks)
|
|
104
|
+
runs_b = sum(b[t].n for t in tasks)
|
|
105
|
+
if not tasks:
|
|
106
|
+
return Effect(float("nan"), float("nan"), float("nan"), 0, runs_a, runs_b, "inconclusive")
|
|
107
|
+
observed = fmean(a[t].rate - b[t].rate for t in tasks)
|
|
108
|
+
rng = random.Random(seed)
|
|
109
|
+
draws: list[float] = []
|
|
110
|
+
for _ in range(iterations):
|
|
111
|
+
diffs = []
|
|
112
|
+
for _ in tasks:
|
|
113
|
+
t = tasks[rng.randrange(len(tasks))]
|
|
114
|
+
pa = rng.betavariate(a[t].successes + 0.5, a[t].failures + 0.5)
|
|
115
|
+
pb = rng.betavariate(b[t].successes + 0.5, b[t].failures + 0.5)
|
|
116
|
+
diffs.append(pa - pb)
|
|
117
|
+
draws.append(fmean(diffs))
|
|
118
|
+
draws.sort()
|
|
119
|
+
alpha = (1 - CONFIDENCE) / 2
|
|
120
|
+
low, high = _percentile(draws, alpha), _percentile(draws, 1 - alpha)
|
|
121
|
+
return Effect(observed, low, high, len(tasks), runs_a, runs_b, classify(low, high, margin))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class MeanCI:
|
|
126
|
+
mean: float
|
|
127
|
+
low: float
|
|
128
|
+
high: float
|
|
129
|
+
n: int
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict:
|
|
132
|
+
return asdict(self)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def metric_by_task(runs: list[dict], config_id: str, key: str) -> dict[str, list[float]]:
|
|
136
|
+
out: dict[str, list[float]] = {}
|
|
137
|
+
for r in runs:
|
|
138
|
+
if r["config"] != config_id or r.get("success") is None:
|
|
139
|
+
continue
|
|
140
|
+
out.setdefault(r["task"], []).append(float(r.get(key) or 0))
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def metric_delta(
|
|
145
|
+
runs: list[dict],
|
|
146
|
+
config_a: str,
|
|
147
|
+
config_b: str,
|
|
148
|
+
key: str,
|
|
149
|
+
*,
|
|
150
|
+
iterations: int = DEFAULT_ITERATIONS,
|
|
151
|
+
seed: int = 0,
|
|
152
|
+
) -> MeanCI:
|
|
153
|
+
"""Bootstrap CI for the per-run difference of a numeric field (tokens, cost), paired by task."""
|
|
154
|
+
a, b = metric_by_task(runs, config_a, key), metric_by_task(runs, config_b, key)
|
|
155
|
+
tasks = sorted(set(a) & set(b))
|
|
156
|
+
if not tasks:
|
|
157
|
+
return MeanCI(float("nan"), float("nan"), float("nan"), 0)
|
|
158
|
+
observed = fmean(fmean(a[t]) - fmean(b[t]) for t in tasks)
|
|
159
|
+
rng = random.Random(seed)
|
|
160
|
+
draws = []
|
|
161
|
+
for _ in range(iterations):
|
|
162
|
+
diffs = []
|
|
163
|
+
for _ in tasks:
|
|
164
|
+
t = tasks[rng.randrange(len(tasks))]
|
|
165
|
+
xa = [a[t][rng.randrange(len(a[t]))] for _ in a[t]]
|
|
166
|
+
xb = [b[t][rng.randrange(len(b[t]))] for _ in b[t]]
|
|
167
|
+
diffs.append(fmean(xa) - fmean(xb))
|
|
168
|
+
draws.append(fmean(diffs))
|
|
169
|
+
draws.sort()
|
|
170
|
+
alpha = (1 - CONFIDENCE) / 2
|
|
171
|
+
n = sum(len(a[t]) + len(b[t]) for t in tasks)
|
|
172
|
+
return MeanCI(observed, _percentile(draws, alpha), _percentile(draws, 1 - alpha), n)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def success_rate(runs: list[dict], config_id: str) -> tuple[float, int, int]:
|
|
176
|
+
"""(rate, successes, valid runs) for one configuration, pooled over tasks."""
|
|
177
|
+
cs = cells(runs, config_id).values()
|
|
178
|
+
s = sum(c.successes for c in cs)
|
|
179
|
+
n = sum(c.n for c in cs)
|
|
180
|
+
return (s / n if n else float("nan"), s, n)
|