turnloop 0.1.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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Report rendering.
|
|
2
|
+
|
|
3
|
+
Medians, not means, and never a single run. Agent behavior is heavy-tailed: one
|
|
4
|
+
task where a model looped 20 times before giving up drags a mean somewhere that
|
|
5
|
+
describes nothing. The report also always shows n, because a pass rate over three
|
|
6
|
+
repeats is a much weaker claim than one over thirty and the reader deserves to know
|
|
7
|
+
which they are looking at.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from statistics import median
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_results(run_dir: Path) -> list[dict]:
|
|
20
|
+
path = run_dir / "results.jsonl"
|
|
21
|
+
if not path.is_file():
|
|
22
|
+
return []
|
|
23
|
+
rows = []
|
|
24
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
25
|
+
if line.strip():
|
|
26
|
+
try:
|
|
27
|
+
rows.append(json.loads(line))
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
continue
|
|
30
|
+
return rows
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_report(run_dir: Path) -> str:
|
|
34
|
+
run_dir = Path(run_dir)
|
|
35
|
+
rows = load_results(run_dir)
|
|
36
|
+
if not rows:
|
|
37
|
+
return f"no results in {run_dir}"
|
|
38
|
+
|
|
39
|
+
config = {}
|
|
40
|
+
config_path = run_dir / "config.json"
|
|
41
|
+
if config_path.is_file():
|
|
42
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
43
|
+
|
|
44
|
+
out: list[str] = [
|
|
45
|
+
f"# {config.get('experiment', run_dir.name)}",
|
|
46
|
+
"",
|
|
47
|
+
f"{len(rows)} runs · {len({r['arm'] for r in rows})} arms · "
|
|
48
|
+
f"{len({r['task'] for r in rows})} tasks · {config.get('repeats', '?')} repeats",
|
|
49
|
+
f"artifacts: `{run_dir}`",
|
|
50
|
+
"",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
by_arm: dict[str, list[dict]] = defaultdict(list)
|
|
54
|
+
for row in rows:
|
|
55
|
+
by_arm[row["arm"]].append(row)
|
|
56
|
+
|
|
57
|
+
out += _arm_table(by_arm)
|
|
58
|
+
out += _pass_rate_gate_note(by_arm)
|
|
59
|
+
out += _reliability_table(by_arm)
|
|
60
|
+
out += _efficiency_section(by_arm)
|
|
61
|
+
out += _efficiency_by_task(rows)
|
|
62
|
+
out += _task_matrix(rows)
|
|
63
|
+
out += _delta_section(by_arm)
|
|
64
|
+
out += _failures(rows)
|
|
65
|
+
out += _skipped_section(rows)
|
|
66
|
+
return "\n".join(out)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pass_rate_gate_note(by_arm: dict[str, list[dict]]) -> list[str]:
|
|
70
|
+
"""Say plainly when pass rate has stopped being able to tell arms apart.
|
|
71
|
+
|
|
72
|
+
GLM-5.2 clears every task in the default suite, every ablation, every
|
|
73
|
+
repeat — so a table of identical 100%s answers nothing about which harness
|
|
74
|
+
configuration is better. It still has to run (a crash is not a valid
|
|
75
|
+
efficiency datapoint), it just isn't where the finding lives anymore.
|
|
76
|
+
"""
|
|
77
|
+
rates = []
|
|
78
|
+
for rows in by_arm.values():
|
|
79
|
+
ran = [r for r in rows if not r.get("skipped")]
|
|
80
|
+
if ran:
|
|
81
|
+
rates.append(sum(1 for r in ran if r["passed"]) / len(ran))
|
|
82
|
+
if rates and all(r == 1.0 for r in rates):
|
|
83
|
+
return [
|
|
84
|
+
"Every arm passed every task here — pass rate is a gate, not a "
|
|
85
|
+
"discriminator, once it saturates like this. A run still has to clear "
|
|
86
|
+
"it (a crash is not a valid efficiency datapoint), but the finding in "
|
|
87
|
+
"this report lives in the Efficiency section below, not this table.",
|
|
88
|
+
"",
|
|
89
|
+
]
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _arm_table(by_arm: dict[str, list[dict]]) -> list[str]:
|
|
94
|
+
lines = [
|
|
95
|
+
"## Outcome by arm",
|
|
96
|
+
"",
|
|
97
|
+
"| arm | model | pass | n | skipped | median cost | median wall | median TTFT | "
|
|
98
|
+
"tok in | tok out | compactions |",
|
|
99
|
+
"|---|---|---|---|---|---|---|---|---|---|---|",
|
|
100
|
+
]
|
|
101
|
+
any_wall_clock = False
|
|
102
|
+
for arm, rows in sorted(by_arm.items()):
|
|
103
|
+
ran = [r for r in rows if not r.get("skipped")]
|
|
104
|
+
skipped = len(rows) - len(ran)
|
|
105
|
+
passed = sum(1 for r in ran if r["passed"])
|
|
106
|
+
ttfts = [r["ttft_ms"] for r in ran if r.get("ttft_ms")]
|
|
107
|
+
wall_clock = any(r.get("cost_basis") == "wall_clock" for r in ran)
|
|
108
|
+
any_wall_clock = any_wall_clock or wall_clock
|
|
109
|
+
# A wall-clock-billed arm's cost tracks GPU time, not tokens — marked
|
|
110
|
+
# so it is never read as comparable to a token-billed arm's cost. See
|
|
111
|
+
# the footnote below the table.
|
|
112
|
+
cost_marker = "†" if wall_clock else ""
|
|
113
|
+
lines.append(
|
|
114
|
+
"| {arm} | {model} | {rate} | {n} | {skipped} | ${cost:.4f}{marker} | "
|
|
115
|
+
"{wall:.1f}s | {ttft} | {tin:,} | {tout:,} | {comp} |".format(
|
|
116
|
+
arm=arm,
|
|
117
|
+
model=rows[0].get("model", "?"),
|
|
118
|
+
rate=f"{passed / len(ran):.0%}" if ran else "—",
|
|
119
|
+
n=len(ran),
|
|
120
|
+
skipped=skipped,
|
|
121
|
+
cost=median(r["cost_usd"] for r in ran) if ran else 0.0,
|
|
122
|
+
marker=cost_marker,
|
|
123
|
+
wall=median(r["wall_s"] for r in ran) if ran else 0.0,
|
|
124
|
+
ttft=f"{median(ttfts):.0f}ms" if ttfts else "—",
|
|
125
|
+
tin=int(median(r["tokens_in"] for r in ran)) if ran else 0,
|
|
126
|
+
tout=int(median(r["tokens_out"] for r in ran)) if ran else 0,
|
|
127
|
+
comp=int(median(r["compactions"] for r in ran)) if ran else 0,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
lines.append("")
|
|
131
|
+
if any_wall_clock:
|
|
132
|
+
lines.append(
|
|
133
|
+
"† cost is derived from wall-clock time on a per-hour self-hosted GPU, not "
|
|
134
|
+
"from token usage — a token delta and this cost delta are not measuring the "
|
|
135
|
+
"same thing and should not be read side by side."
|
|
136
|
+
)
|
|
137
|
+
lines.append("")
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _reliability_table(by_arm: dict[str, list[dict]]) -> list[str]:
|
|
142
|
+
lines = [
|
|
143
|
+
"## Tool-calling reliability",
|
|
144
|
+
"",
|
|
145
|
+
"Measured passively from the traces: every dispatch records why it failed.",
|
|
146
|
+
"",
|
|
147
|
+
"| arm | tool calls | error rate | malformed args | schema violations | recovery |",
|
|
148
|
+
"|---|---|---|---|---|---|",
|
|
149
|
+
]
|
|
150
|
+
for arm, rows in sorted(by_arm.items()):
|
|
151
|
+
calls = sum(r["tool_calls"] for r in rows)
|
|
152
|
+
errors = sum(sum(r["tool_errors"].values()) for r in rows)
|
|
153
|
+
recoveries = [r["recovery_rate"] for r in rows if r.get("recovery_rate") is not None]
|
|
154
|
+
lines.append(
|
|
155
|
+
"| {arm} | {calls} | {rate} | {malformed} | {schema} | {rec} |".format(
|
|
156
|
+
arm=arm,
|
|
157
|
+
calls=calls,
|
|
158
|
+
rate=f"{errors / calls:.1%}" if calls else "—",
|
|
159
|
+
malformed=sum(r["malformed_args"] for r in rows),
|
|
160
|
+
schema=sum(r["schema_violations"] for r in rows),
|
|
161
|
+
rec=f"{median(recoveries):.0%}" if recoveries else "—",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
lines.append("")
|
|
165
|
+
return lines
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _efficiency_section(by_arm: dict[str, list[dict]]) -> list[str]:
|
|
169
|
+
"""The metrics that still have headroom once pass rate has saturated.
|
|
170
|
+
|
|
171
|
+
Same shape as the reliability table above by design — this is the section
|
|
172
|
+
meant to carry the actual finding when every arm passes everything, so it
|
|
173
|
+
puts tokens and tool-error rate in the same table as pass-rate-adjacent
|
|
174
|
+
numbers (recovery, wall time) instead of scattering them.
|
|
175
|
+
"""
|
|
176
|
+
lines = [
|
|
177
|
+
"## Efficiency",
|
|
178
|
+
"",
|
|
179
|
+
"| arm | tok in (median) | tok out (median) | tool calls | error rate | "
|
|
180
|
+
"recovery | median wall |",
|
|
181
|
+
"|---|---|---|---|---|---|---|",
|
|
182
|
+
]
|
|
183
|
+
for arm, rows in sorted(by_arm.items()):
|
|
184
|
+
ran = [r for r in rows if not r.get("skipped")]
|
|
185
|
+
if not ran:
|
|
186
|
+
lines.append(f"| {arm} | — | — | — | — | — | — |")
|
|
187
|
+
continue
|
|
188
|
+
calls = sum(r["tool_calls"] for r in ran)
|
|
189
|
+
errors = sum(sum(r["tool_errors"].values()) for r in ran)
|
|
190
|
+
recoveries = [r["recovery_rate"] for r in ran if r.get("recovery_rate") is not None]
|
|
191
|
+
lines.append(
|
|
192
|
+
"| {arm} | {tin:,} | {tout:,} | {calls} | {rate} | {rec} | {wall:.1f}s |".format(
|
|
193
|
+
arm=arm,
|
|
194
|
+
tin=int(median(r["tokens_in"] for r in ran)),
|
|
195
|
+
tout=int(median(r["tokens_out"] for r in ran)),
|
|
196
|
+
calls=calls,
|
|
197
|
+
rate=f"{errors / calls:.1%}" if calls else "—",
|
|
198
|
+
rec=f"{median(recoveries):.0%}" if recoveries else "—",
|
|
199
|
+
wall=median(r["wall_s"] for r in ran),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
lines.append("")
|
|
203
|
+
return lines
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _efficiency_by_task(rows: list[dict], min_tasks: int = 2) -> list[str]:
|
|
207
|
+
"""Median input tokens per (task, arm) — an arm-level median can hide a
|
|
208
|
+
difference that only shows up on the tasks big enough to expose it."""
|
|
209
|
+
tasks = sorted({r["task"] for r in rows})
|
|
210
|
+
if len(tasks) < min_tasks:
|
|
211
|
+
return []
|
|
212
|
+
arms = sorted({r["arm"] for r in rows})
|
|
213
|
+
grid: dict[tuple[str, str], list[int]] = defaultdict(list)
|
|
214
|
+
for row in rows:
|
|
215
|
+
if not row.get("skipped"):
|
|
216
|
+
grid[(row["task"], row["arm"])].append(row["tokens_in"])
|
|
217
|
+
|
|
218
|
+
lines = [
|
|
219
|
+
"## Efficiency by task (median input tokens)",
|
|
220
|
+
"",
|
|
221
|
+
"| task | " + " | ".join(arms) + " |",
|
|
222
|
+
"|---" * (len(arms) + 1) + "|",
|
|
223
|
+
]
|
|
224
|
+
for task in tasks:
|
|
225
|
+
cells = []
|
|
226
|
+
for arm in arms:
|
|
227
|
+
values = grid.get((task, arm), [])
|
|
228
|
+
cells.append(f"{int(median(values)):,}" if values else "—")
|
|
229
|
+
lines.append(f"| {task} | " + " | ".join(cells) + " |")
|
|
230
|
+
lines.append("")
|
|
231
|
+
return lines
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _task_matrix(rows: list[dict]) -> list[str]:
|
|
235
|
+
arms = sorted({r["arm"] for r in rows})
|
|
236
|
+
tasks = sorted({r["task"] for r in rows})
|
|
237
|
+
grid: dict[tuple[str, str], list[bool]] = defaultdict(list)
|
|
238
|
+
for row in rows:
|
|
239
|
+
grid[(row["task"], row["arm"])].append(bool(row["passed"]))
|
|
240
|
+
|
|
241
|
+
lines = ["## Pass rate by task", "", "| task | " + " | ".join(arms) + " |",
|
|
242
|
+
"|---" * (len(arms) + 1) + "|"]
|
|
243
|
+
for task in tasks:
|
|
244
|
+
cells = []
|
|
245
|
+
for arm in arms:
|
|
246
|
+
outcomes = grid.get((task, arm), [])
|
|
247
|
+
if not outcomes:
|
|
248
|
+
cells.append("—")
|
|
249
|
+
else:
|
|
250
|
+
passed = sum(outcomes)
|
|
251
|
+
cells.append(f"{passed}/{len(outcomes)}")
|
|
252
|
+
lines.append(f"| {task} | " + " | ".join(cells) + " |")
|
|
253
|
+
lines.append("")
|
|
254
|
+
return lines
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _delta_section(by_arm: dict[str, list[dict]]) -> list[str]:
|
|
258
|
+
"""Compare every arm to the first, which is the declared baseline."""
|
|
259
|
+
names = sorted(by_arm)
|
|
260
|
+
if len(names) < 2:
|
|
261
|
+
return []
|
|
262
|
+
baseline = names[0]
|
|
263
|
+
base = [r for r in by_arm[baseline] if not r.get("skipped")]
|
|
264
|
+
if not base:
|
|
265
|
+
return []
|
|
266
|
+
base_pass = sum(1 for r in base if r["passed"]) / len(base)
|
|
267
|
+
base_cost = median(r["cost_usd"] for r in base) or 0.0
|
|
268
|
+
base_tokens = median(r["tokens_in"] for r in base)
|
|
269
|
+
base_calls = median(r["tool_calls"] for r in base)
|
|
270
|
+
base_call_total = sum(r["tool_calls"] for r in base)
|
|
271
|
+
base_call_errors = sum(sum(r["tool_errors"].values()) for r in base)
|
|
272
|
+
base_err_rate = base_call_errors / base_call_total if base_call_total else 0.0
|
|
273
|
+
base_wall_clock = any(r.get("cost_basis") == "wall_clock" for r in base)
|
|
274
|
+
|
|
275
|
+
lines = [f"## Change vs `{baseline}`", ""]
|
|
276
|
+
for arm in names[1:]:
|
|
277
|
+
rows = [r for r in by_arm[arm] if not r.get("skipped")]
|
|
278
|
+
if not rows:
|
|
279
|
+
lines.append(f"- **{arm}**: skipped — provider was down for this run.")
|
|
280
|
+
continue
|
|
281
|
+
rate = sum(1 for r in rows if r["passed"]) / len(rows)
|
|
282
|
+
cost = median(r["cost_usd"] for r in rows)
|
|
283
|
+
tokens = median(r["tokens_in"] for r in rows)
|
|
284
|
+
calls = median(r["tool_calls"] for r in rows)
|
|
285
|
+
call_total = sum(r["tool_calls"] for r in rows)
|
|
286
|
+
call_errors = sum(sum(r["tool_errors"].values()) for r in rows)
|
|
287
|
+
err_rate = call_errors / call_total if call_total else 0.0
|
|
288
|
+
wall_clock = base_wall_clock or any(r.get("cost_basis") == "wall_clock" for r in rows)
|
|
289
|
+
# A wall-clock-billed cost tracks GPU time, not tokens, so a token
|
|
290
|
+
# delta and this cost delta can move in opposite directions without
|
|
291
|
+
# contradicting each other. Labeling it prevents reading them as the
|
|
292
|
+
# same measurement.
|
|
293
|
+
cost_label = "cost (wall-clock time, not tokens)" if wall_clock else "cost"
|
|
294
|
+
lines.append(
|
|
295
|
+
f"- **{arm}**: pass {_signed(rate - base_pass, pct=True)}, "
|
|
296
|
+
f"input tokens {_signed_ratio(tokens, base_tokens)}, "
|
|
297
|
+
f"{cost_label} {_signed_ratio(cost, base_cost)}, "
|
|
298
|
+
f"tool calls {_signed_ratio(calls, base_calls)}, "
|
|
299
|
+
f"tool-call error rate {_signed_pp(err_rate - base_err_rate)}"
|
|
300
|
+
)
|
|
301
|
+
lines.append("")
|
|
302
|
+
return lines
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _failures(rows: list[dict], limit: int = 12) -> list[str]:
|
|
306
|
+
"""Rows that actually ran and did not pass — never a skipped row.
|
|
307
|
+
|
|
308
|
+
A skipped row and a genuine failure both have `passed=False`, but they mean
|
|
309
|
+
opposite things: one is the agent trying and getting it wrong, the other is
|
|
310
|
+
a run that never happened. Mixing them here would hide a provider outage
|
|
311
|
+
inside what looks like a normal failure list.
|
|
312
|
+
"""
|
|
313
|
+
failed = [r for r in rows if not r["passed"] and not r.get("skipped")]
|
|
314
|
+
if not failed:
|
|
315
|
+
return ["## Failures", "", "None.", ""]
|
|
316
|
+
lines = ["## Failures", "", "| arm | task | rep | why |", "|---|---|---|---|"]
|
|
317
|
+
for row in failed[:limit]:
|
|
318
|
+
why = row.get("error") or row.get("grader_detail") or "?"
|
|
319
|
+
lines.append(
|
|
320
|
+
f"| {row['arm']} | {row['task']} | {row['repeat']} | {_clean(why)} |"
|
|
321
|
+
)
|
|
322
|
+
if len(failed) > limit:
|
|
323
|
+
lines.append(f"| … | | | {len(failed) - limit} more |")
|
|
324
|
+
lines.append("")
|
|
325
|
+
return lines
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _skipped_section(rows: list[dict]) -> list[str]:
|
|
329
|
+
"""Runs that never executed because their provider was declared down.
|
|
330
|
+
|
|
331
|
+
Kept separate from `_failures` on purpose: reporting a provider outage as
|
|
332
|
+
a wall of ordinary task failures is exactly the "tidy table of zeros" that
|
|
333
|
+
invites a fabricated finding.
|
|
334
|
+
"""
|
|
335
|
+
skipped = [r for r in rows if r.get("skipped")]
|
|
336
|
+
if not skipped:
|
|
337
|
+
return []
|
|
338
|
+
by_provider: dict[str, int] = defaultdict(int)
|
|
339
|
+
for row in skipped:
|
|
340
|
+
by_provider[row["provider"]] += 1
|
|
341
|
+
lines = ["## Skipped (provider outage)", ""]
|
|
342
|
+
for provider, count in sorted(by_provider.items()):
|
|
343
|
+
lines.append(f"- **{provider}**: {count} run(s) skipped without executing.")
|
|
344
|
+
lines.append("")
|
|
345
|
+
return lines
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _signed(value: float, pct: bool = False) -> str:
|
|
349
|
+
formatted = f"{value:+.0%}" if pct else f"{value:+.2f}"
|
|
350
|
+
return formatted
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _signed_ratio(value: float, baseline: float) -> str:
|
|
354
|
+
if not baseline:
|
|
355
|
+
return f"{value:,.4g} (no baseline)"
|
|
356
|
+
change = (value - baseline) / baseline
|
|
357
|
+
return f"{change:+.0%}"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _signed_pp(value: float) -> str:
|
|
361
|
+
"""A percentage-*point* delta (4.1% -> 0.0% is "-4.1pp"), not a ratio.
|
|
362
|
+
|
|
363
|
+
A ratio breaks the moment the baseline error rate is 0%, which a
|
|
364
|
+
tool-calling arm worth shipping should be aiming for.
|
|
365
|
+
"""
|
|
366
|
+
return f"{value * 100:+.1f}pp"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _clean(text: Any, limit: int = 110) -> str:
|
|
370
|
+
flat = " ".join(str(text).split()).replace("|", "\\|")
|
|
371
|
+
return flat if len(flat) <= limit else flat[:limit] + "…"
|