Commit 93c7fc0
fix(reports): count errors as misses and stop losing cost on error paths (#63)
* fix(reports): count errors as misses in one canonical pass rate
The reported success rate divided by `tasks_run - tasks_error`, so a run was
rewarded for erroring: whichever harness errored most got the biggest bonus.
Measured on real nightlies, the inflation was +10.0 points for a codex run
(116 errors of 947) and +7.1 for a sonnet-5 run, and one LiteLLM run rendered
as 100.0% while passing 7 of its 861 rows.
`RunSummary.pass_rate` is now `tasks_succeeded / tasks_run` with errors in the
denominator, published as a computed field so consumers read it instead of
deriving their own. Four surfaces across two repos were each deriving a
denominator, which is why the dashboard and the markdown report disagreed by up
to 10 points on the same run.json. An error is still not a failure, so the
reason survives as diagnostics on the row (`error_message`, `error_category`)
and as `error_share` on the run: a bad infrastructure night now shows as a bad
night instead of being absorbed into a flattering denominator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cost): book spend on the error and timeout paths, flag what is unpriced
Cost went missing three ways, each silently.
A turn killed mid-flight carries real billed tokens and no SDK-reported cost,
and those partials were summed as free. The orchestrator now prices any turn
that burned tokens without a cost from the rate card, leaving an SDK-reported
cost untouched as the authoritative figure.
The rate card is a static table baked into the installed version, so a model
released after it prices every turn as null. That understated one nightly by
$209.81 across 62 rows, 18.9% of its true bill, with one log line to show for
it. Runs now pre-flight their models against the card and warn (or refuse under
--strict-pricing), report `tasks_unpriced` / `cost_complete`, and label a total
built from partly-priced rows as the floor it is. A committed experiment whose
model has no rate now fails CI rather than a nightly's cost column.
Judge and simulator spend was captured per criterion and rolled up nowhere.
Both are now priced and reported as `eval_overhead_cost_usd`, deliberately
beside the agent bill rather than inside it: judge cost is a property of the
suite's criteria and identical across harnesses, so folding it in would make
two harnesses look closer than they are.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(evalboard): read the canonical pass rate and surface incomplete cost
The dashboard derived its own pass rate and summed per-task cost treating a
missing cost as zero, so an unpriced row was indistinguishable from a free one
and a run's bill read low with nothing to say so. It now prefers run.json's
`pass_rate`, falling back to the identical formula so historical runs render
unchanged, and counts unpriced rows into the existing cost-partial caveat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(cost): describe the per-turn backfill as the net it is
An A/B on a real timed-out Bedrock run booked its spend with the backfill
disabled, so the earlier claim that killed partials "were summed as free" does
not hold: every in-tree agent already prices its own partials
(ClaudeCodeAgent._backfill_cost; codex and antigravity compute from buckets and
never depend on an SDK cost). The measured $209.81 loss was the rate-card miss
alone.
The backfill stays, described accurately: it makes "tokens on the record imply a
cost on the record" an invariant at one agent-agnostic seam, which the plugin
SPI needs — an out-of-tree agent that registers pricing but never applies it
would otherwise lose all of its spend silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert(cost): drop the speculative turn-cost backfill and --strict-pricing
Both were justified by anticipation rather than by measurement, and this PR's
other claims were all measured. Removing them leaves the orchestrator, the batch
config, and the CLI byte-identical to main, so the PR no longer touches the run
path at all.
The per-turn backfill (Orchestrator._backfill_turn_costs) protected against an
out-of-tree plugin agent that registers pricing but never applies it. No such
agent exists, and the A/B established that every in-tree agent already prices
its own killed partials — so it guarded a hypothetical while adding a mutation
to the aggregate path and forcing cost_data_available to be computed earlier to
stay honest about budget enforceability. Reverting restores main's ordering.
--strict-pricing had no caller: nothing in CI or the nightly passed it, and the
pre-flight warning already surfaces the rate-card miss that cost $209.81. The
warning and the is_priced/unpriced_models seam stay, as does the CI guard that
fails on a committed experiment referencing an unpriced model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cost): define the unpriced-row test once, and only for new runs
Two definitions of "which rows lost money" had appeared: RunSummary.tasks_unpriced
computed one, and the report's token section re-derived the same predicate inline.
That is the duplication this PR exists to remove, reintroduced one layer down. Both
now call row_cost_incomplete / eval_overhead_costs, defined on the row schema where
the reports (which have task dicts, not a RunSummary) can reach them.
The predicate also gets simpler: read the row's cost_complete flag, and treat its
absence as complete. It previously fell back to inferring unpriced-ness from
"burned tokens but carries no cost", which only ever mattered for runs written
before the field existed. Every new run sets the flag, so the fallback bought a
caveat on historical runs at the cost of a second definition living in TypeScript
and drifting from the Python one. Old runs now render exactly as they did before.
Unpriced stays reachable, so the field is not going away: any model released after
the installed framework version prices as null, and the dispatch pre-flight only
sees pinned agent.model values, so a run pointed at a new model through the route
gets no warning at all. tasks_unpriced is the only thing that catches that.
Also drops a stale reference to the reverted turn-cost backfill, and one to the
deleted framework/harness error split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(evalboard): mark a partly-priced run total as a floor, not the bill
The run page's Total cost tile summed the priced tasks and rendered the result with
a p50/p90 subtitle under it. An unpriced task contributes $0 to that sum, so the
2026-07-21 nightly displayed $902.81 as though measured, with $209.81 missing and
percentiles describing only the tasks that happened to be priced.
It now reads ≥$902.81 with "floor · 62 tasks unpriced" in place of the percentiles,
rather than beside them: a number presented as exact is worse than one presented as
a bound. The count is filter-aware and skips mature-skipped tasks, matching how the
tile already scopes cost itself.
This was the surface that mattered. The overview tile already flagged the run, so
the flow a person actually takes — see something off, click the run to find out
what — landed on a page that denied it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert(evalboard): drop the unpriced-cost surface entirely
The dashboard now reads exactly as it did before this PR: `git diff main -- evalboard/`
is empty.
Marking a total as `≥$902.81 · floor · 62 tasks unpriced` is hedging in a place that
should just show a number. A dashboard that qualifies its own figures trains people
to distrust the unqualified ones, and it is worse to read than a number that is
quietly a little low. The run.json fields stay, so anyone who wants the caveat can
compute it; the markdown report still labels a partly-priced total as a floor, which
is a written artifact where a caveat belongs.
This also retires eight declared-but-unread fields, one of which duplicated an
existing `errorMessage` already rendered on the task-detail page from task.json.
The pass rate needed nothing here: lib/trends.ts already counted every row in its
denominator, so the dashboard was correct before this PR and is correct after. Only
the markdown report and the downstream runner were inflating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cost): correct the simulator-cost bound and drop the unread variant error share
The simulator cost is a floor, not an upper bound. UserSimulator keeps
uncached_input_tokens and drops both cache buckets, so a cached prompt prefix is
absent from the count: a live run's full persona-and-goal prompt recorded 6 input
tokens. The docstring claimed the opposite, which would tell a reader the figure
is conservatively high and stop them looking.
Also drops is_priced's reference to a refusal path that was cut, and removes
VariantAggregate.error_share: no surface rendered it, the variant tables already
print Errors beside Pass Rate (n/m), and the experiment JSON has no downstream
reader, so it would have shipped published and unread. pass_rate stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(cost): describe the unpriced-crash mechanism accurately and keep comments framework-general
The pre-flight warning claimed cost "will be recorded as null" for an unpriced
model. That is wrong for any agent whose backend reports its own cost: those turns
price fine. The rate card is the FALLBACK, and the only source for a turn the
backend never priced, so what an unpriced model actually costs you is the killed
and timed-out partials, which arrive with full token counts and no cost. Traced
against a real timed-out row carrying 1.2M tokens and no dollars.
Also strips run ids, dates, dollar amounts and suite names from comments and test
docstrings. The behavioural claim each one made is kept; the incident it came from
is not something a general framework should narrate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(pricing): add the claude-opus-5 rate so killed turns stop booking zero
The rate card had no entry for claude-opus-5. On a clean turn that costs nothing,
since the Claude Code SDK reports its own cost, but the card is the only fallback
for a turn the backend never priced. So every timed-out partial booked its full
token counts against no money.
Verified against a real timed-out row: 32 uncached / 18,519 out / 99,959 cache
write / 1,083,097 cache read priced as None before, $1.6294 now. Across that run's
nine killed rows, $40.37 that previously read as zero.
Rates from the published table: $5/$25 per MTok, with the standard 1.25x cache
write and 0.1x cache read multipliers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(pricing): correct every wrong rate-card entry and close the alias gaps
Audited the whole card against the published tables.
Opus 4.5 and later dropped to $5/$25 while Opus 4.1 and Opus 4 stayed at $15/$75,
so the version boundary is the price boundary. The card had opus-4-5 through 4-8 at
the old $15/$75, a 3x overcharge on every turn the rate card actually prices.
haiku-4-5 carried Haiku 3.5's rates ($0.80/$4 instead of $1/$5). The gpt-5.6 family
had an Anthropic-style 1.25x cache-write rate, where OpenAI bills no cache-write fee
and every other OpenAI entry sets cache_write == input.
Also keys the bare aliases that previously went unpriced because only the dated id
was present (opus-4-5, opus-4-1, opus-4, sonnet-4-5, haiku-4-5, haiku-3-5), and adds
fable-5 / mythos-5.
sonnet-5 deliberately stays at the standard $3/$15 rather than the $2/$10
introductory rate: a static table cannot express a promo window, and this error
overstates cost for a few weeks instead of understating it indefinitely after.
Two pre-existing tests hardcoded the old Opus and Haiku rates; their expected values
are recomputed from the corrected ones.
Verified correct and unchanged: the Sonnet 4.x and Claude 3.x entries, the nine other
OpenAI entries, and the Gemini entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cost): a task timeout with no preserved turn is unrecorded spend, not free
_cost_complete returned True for a row whose iterations list was empty, on the
reasoning that a row which burned nothing is not missing cost. That is right for a
setup error and wrong for a task-level timeout.
TaskTimeoutError comes from the ThreadedWatchdog, which SIGKILLs the agent by PID
from a non-asyncio thread. Unlike a turn-level timeout it never reaches
_on_attempt_failure, so no partial turn is drained and the row lands with zero
turns, zero tokens and no cost. The evaluation loop was still running, so that
spend is real; reporting the row as fully priced is a false claim.
Keyed on TIMEOUT rather than elapsed time, so a slow setup failure stays free while
a task timeout never does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cost): flag every hard-killed task as a cost floor, not just the empty ones
Live testing found the previous rule under-flagged. Keying on "TIMEOUT with no
preserved turn" caught a task killed during its first turn, but a task killed
mid-dialog after two turns had completed still reported cost_complete: true,
because those two turns carry costs. The turn that was in flight when the wall hit
is lost either way.
The watchdog fires while the evaluation loop is running, so a TIMEOUT row always has
an in-flight turn whose spend was never recorded. Keyed on the status alone.
The report wording drops its rate-card explanation, since the two causes (a turn the
rate card could not price, and a hard kill that recorded nothing) reach the same
conclusion and the report cannot always tell which applied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(orchestrator): recover the in-flight turn's spend on a hard kill
A task-level timeout kills the agent and cancels the task awaiting
communicate(), so the turn in flight never returns a record. The Claude
agent finalized that cancel as COMPLETED, which keeps no record, so the
tokens it had already spent were dropped: the row landed with no turns, no
tokens and no cost for work that was billed.
The telemetry is intact at cancel time, so finalize as a crash instead,
which parks it on pending_turn under the existing contract. Codex and
Antigravity already did this; their fragment moves to a shared kernel on
the base class.
Nothing read that slot on this path either: the cancel is a BaseException,
so it never reaches the retry executor's per-attempt hook that drains it on
a turn-level timeout. The task-timeout handler now drains it, before
teardown clears the slot and before finalization, so the recovered turn
feeds token aggregation and command stats like any other.
Rows stay flagged cost_complete=false. Recovery captures everything the
event stream delivered, but the generation the agent was waiting on when it
died was never delivered by anyone, so the total is still a floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(pricing): refresh the rate card and correct gemini-3-flash-preview
gemini-3-flash-preview carried gemini-3.5-flash's rates ($1.50/$9.00), so
every run on it was costed roughly 3x too high; its real rate is
$0.50/$3.00. Adds gemini-3.6-flash plus the 3.5/3.1 Flash-Lite tiers, and
picks up an OpenRouter price drop on z-ai/glm-5.2.
Anthropic, OpenAI, and the Bedrock open-weight entries were re-checked
against their vendor rate cards and needed no value changes; the Bedrock
rates are eu-north-1 and now say so, so they are not "corrected" against
the US column later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(pricing): add the five unpriced codex tiers still on OpenAI's rate card
A coverage sweep over every model id the harness can emit found gpt-5.2-codex,
gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1-codex-mini, and codex-mini-latest
absent from the table. agent.model is a free-form string, so pinning any of
them booked the run's tokens against no money.
codex-mini-latest is the one OpenAI entry whose cached rate is 25% of input
rather than 10%, so it is called out in a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cost): publish one accurate total on every reporting surface
A run's bill was agent-only everywhere except the markdown report's Total Cost
line, which added the judge and simulator itself. Nothing published the sum, so
run.json carried no run-level total at all and the per-task Cost column did not
add up to the total printed above it.
full_cost() is now the one way cost components combine: it skips an unpriced
component instead of counting it as zero, so a partial total is a floor and
never an exception. It backs full_cost_usd on both the task row and RunSummary,
and feeds the markdown report, its per-task Cost column, and the task and
variant HTML. total_cost_usd keeps meaning agent-only at both levels: it is what
stays comparable across harnesses, and redefining it would move every historical
figure. The dashboard is untouched.
The pre-flight also covers criterion.model now. No judge backend reports a cost,
so the rate card is the only source for a judge call, unlike an agent turn the
SDK prices itself; and a criterion model is always pinned in the task YAML, so
unlike agent.model it can never hide behind the route. An unpriced judge or
simulator lowers the total and is warned about at dispatch, rather than failing
the run.
The EvaluationResult cost derivations move to models/results.py beside the model
they derive from, which is also what lets reports_html read them without an
import cycle through reports_experiment.
tasks_unpriced is renamed tasks_cost_incomplete: it counts hard-killed rows too,
which are frequently priced. eval_overhead_costs returns a total rather than a
list both callers immediately summed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cost): cut the commentary and drop unreachable rate-card keys
The cost and reporting code had grown more comment than code: 276 lines of
prose against 228 of implementation. Most of it argued a design decision to an
imagined reviewer rather than describing behaviour to a maintainer, which is
prose that goes stale the moment the decision is revisited and that no test
covers. The behavioural claims are kept, the arguments are not, and the
reasoning survives in the commit messages where it belongs.
Three rate-card keys can never match and are removed: claude-mythos-5 is
invitation-only and unreachable from this harness, and claude-opus-4-6-20250514
/ claude-sonnet-4-6-20250514 pair a 4.6 model with the Opus 4 release date,
where the whole 4.6 generation uses dateless ids.
Also silences a CodeQL "statement has no effect" on a bare `await turn` by
awaiting through wait_for, which additionally stops the test hanging if
cancellation ever regresses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cost)!: total_cost_usd means the whole bill everywhere
`total_cost_usd` meant subject-agent spend only, so every consumer that read
the obvious field for "what did this cost" got a number that excluded judge
and simulator spend. Rather than add a second field and ask each surface to
opt in, redefine the one they already read: at row and run level it is now
agent + judge + simulator. The agent-only slice stays available as
`agent_cost_usd` for harness-vs-harness comparison, where folding in judge
cost (identical across harnesses) would make two harnesses look closer than
they are.
This is why the evalboard needs no change: it sums `total_cost_usd` already,
and that field now carries the real total.
`TokenUsage.total_cost_usd` is untouched — it is the cost of those tokens, so
agent-only is correct, and `run_limits.max_usd` keeps gating on it since judge
and simulator spend is not known mid-run.
Renames the summing primitive `full_cost` -> `sum_costs` so the combinator
stops sharing a name with the metric, and documents the cost schema plus the
never-fail-on-missing-cost policy in REPORT_SCHEMA.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent 0d8e8b3 commit 93c7fc0
30 files changed
Lines changed: 1424 additions & 111 deletions
File tree
- docs
- src/coder_eval
- agents
- criteria
- evaluation
- models
- orchestration
- tests
- _fixtures/report_snapshots
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
54 | 69 | | |
55 | 70 | | |
56 | 71 | | |
57 | 72 | | |
58 | 73 | | |
59 | 74 | | |
60 | 75 | | |
61 | | - | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
62 | 80 | | |
63 | 81 | | |
64 | 82 | | |
65 | 83 | | |
66 | 84 | | |
67 | 85 | | |
68 | 86 | | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
69 | 110 | | |
70 | 111 | | |
71 | 112 | | |
| |||
247 | 288 | | |
248 | 289 | | |
249 | 290 | | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
250 | 297 | | |
251 | 298 | | |
252 | 299 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
150 | 161 | | |
151 | 162 | | |
152 | 163 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
514 | 514 | | |
515 | 515 | | |
516 | 516 | | |
517 | | - | |
518 | | - | |
| 517 | + | |
519 | 518 | | |
520 | 519 | | |
521 | 520 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
980 | 980 | | |
981 | 981 | | |
982 | 982 | | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
983 | 988 | | |
984 | 989 | | |
985 | 990 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
864 | 864 | | |
865 | 865 | | |
866 | 866 | | |
867 | | - | |
868 | | - | |
| 867 | + | |
869 | 868 | | |
870 | 869 | | |
871 | 870 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | | - | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
| |||
212 | 212 | | |
213 | 213 | | |
214 | 214 | | |
215 | | - | |
| 215 | + | |
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
18 | 19 | | |
19 | 20 | | |
20 | 21 | | |
| |||
31 | 32 | | |
32 | 33 | | |
33 | 34 | | |
34 | | - | |
| 35 | + | |
35 | 36 | | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
39 | 40 | | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
40 | 46 | | |
41 | 47 | | |
42 | 48 | | |
| |||
47 | 53 | | |
48 | 54 | | |
49 | 55 | | |
50 | | - | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
130 | 136 | | |
131 | 137 | | |
132 | 138 | | |
| |||
291 | 297 | | |
292 | 298 | | |
293 | 299 | | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
294 | 308 | | |
295 | 309 | | |
296 | 310 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| |||
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
197 | | - | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
198 | 203 | | |
199 | 204 | | |
200 | 205 | | |
| |||
228 | 233 | | |
229 | 234 | | |
230 | 235 | | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
231 | 242 | | |
232 | 243 | | |
233 | 244 | | |
| |||
0 commit comments