Skip to content

Commit 93c7fc0

Browse files
bai-uipathclaude
andauthored
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

‎docs/REPORT_SCHEMA.md‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,62 @@ run-level summary; full per-replicate detail lives in each `task.json`.
5151
| `framework_version` | `str` | Coder Eval version chip. |
5252
| `environment_info` | `dict` | Version/dependency info (may nest, e.g. `tool_plugins`). |
5353

54+
These are **computed**, not stored — derived from the counts and rows above on every
55+
serialization, so they cannot drift from what they summarize. Read them rather than
56+
re-deriving your own; independent re-derivations are how two consumers end up
57+
publishing different numbers for the same run.
58+
59+
| Key | Type | Meaning |
60+
| --- | --- | --- |
61+
| `pass_rate` | `float \| None` | `tasks_succeeded / tasks_run` — errors are in the denominator, counted as misses. `None` on an empty run (0/0 is unknown, not 0%). |
62+
| `error_share` | `float \| None` | `tasks_error / tasks_run`. Diagnostic only; never adjusts the rate. |
63+
| `total_cost_usd` | `float \| None` | **The bill**: agent + judge + simulator, summed over the rows. `None` when nothing could be priced. |
64+
| `agent_cost_usd` | `float \| None` | Subject-agent spend alone. The harness-vs-harness comparison figure — judge spend is a property of the suite's criteria and identical across harnesses, so leaving it in would make two harnesses look closer than they are. |
65+
| `eval_overhead_cost_usd` | `float \| None` | Judge + simulator spend. The other half of `total_cost_usd`. |
66+
| `tasks_cost_incomplete` | `int` | Rows whose recorded spend is missing money (unpriced model, or a hard kill that lost an in-flight turn). |
67+
| `cost_complete` | `bool` | `tasks_cost_incomplete == 0`. When false, every cost figure above is a **floor**, not the bill. A run is never failed for this — see [Missing cost is never fatal](#missing-cost-is-never-fatal). |
68+
5469
### `task_results[]` — the flat per-task row
5570

5671
Each entry is an **untyped dict** (a denormalization, not a Pydantic model) with keys
5772
including: `task_id`, `replicate_index`, `variant_id`, `status`
5873
([`FinalStatus`](#finalstatus)), `weighted_score`, `duration`, `iteration_count`,
5974
`tags`, `task_path`, `model_used`, `reference_similarity`, the token buckets
6075
(`input_tokens` = uncached input, `output_tokens`, `cache_creation_input_tokens`,
61-
`cache_read_input_tokens`, `total_tokens`), `total_cost_usd`, `expected_commands`,
76+
`cache_read_input_tokens`, `total_tokens`), the cost fields
77+
(`total_cost_usd` = agent + judge + simulator, plus the `agent_cost_usd` /
78+
`judge_cost_usd` / `simulator_cost_usd` slices and the `cost_complete` flag),
79+
`expected_commands`,
6280
`actual_commands`, `commands_efficiency`, `agent_config`, `sdk_options`,
6381
`installed_tools`, turn accounting (`total_turns`, `visible_turns`, `expected_turns`,
6482
`max_turns_exhausted`, `has_final_reply`), and early-stop fields (`stopped_early`,
6583
`early_stop_reason`, `turns_remaining_at_stop`). `iterations` here is a **reduced**
6684
turn digest (`{iteration, duration_seconds, command_count, assistant_turn_count,
6785
crashed, crash_reason}`) — the full transcript is in `task.json`.
6886

87+
### Missing cost is never fatal
88+
89+
Pricing degrades; the evaluation does not. A model absent from the rate card, a turn
90+
the backend never priced, a hard-killed task that lost its in-flight spend: each one
91+
lowers a total and sets `cost_complete: false`. None of them raises, none of them
92+
books a zero, and none of them changes a run's exit code.
93+
94+
The reasoning is that the two failure modes are not symmetric. A missing cost is
95+
recoverable after the fact — the token counts are on the record, so a corrected rate
96+
card reprices the run from its artifacts. A failed run is not: the tokens are already
97+
spent and the only way back is to run it again. So the framework warns loudly and
98+
keeps going.
99+
100+
The warning fires up front. `check_pricing_coverage` walks every model the run pins
101+
(subject agents and judge criteria) before the first task dispatches, and logs the
102+
ones the card cannot price — early enough to fix the card and restart while it is
103+
still cheap. After that the run is on its own: totals become floors, and
104+
`tasks_cost_incomplete` says how many rows are behind that floor.
105+
106+
Consumers should treat any cost field as a lower bound whenever `cost_complete` is
107+
false, and must not read `None` as `0.0` — "nothing could be priced" and "it was
108+
free" are different facts.
109+
69110
---
70111

71112
## `task.json` — `EvaluationResult`
@@ -247,6 +288,12 @@ respectively), checked after each completed agent turn — see
247288
- `TokenUsage.total_tokens` is not serialized; sum the buckets (or use the computed
248289
`input_tokens` + `output_tokens` + cache buckets).
249290
- `EarlyStopInfo` presence is itself the "stopped early" signal.
291+
- `total_cost_usd` is the whole bill (agent + judge + simulator) at both row and run
292+
level; `agent_cost_usd` is the agent-only slice. `TokenUsage.total_cost_usd` is a
293+
different thing: the cost of those tokens, so always agent-only. `run_limits.max_usd`
294+
gates on that one, since judge and simulator spend is not known mid-run.
295+
- A cost of `None` means unpriced, not free, and any total is a floor while
296+
`cost_complete` is false — see [Missing cost is never fatal](#missing-cost-is-never-fatal).
250297

251298
## See also
252299

‎src/coder_eval/agent.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ def _finalize_and_raise_crash(
147147
raise AgentCrashError(message) from cause
148148
raise AgentCrashError(message)
149149

150+
def _finalize_external_cancel(self, finalize: _FinalizeFn) -> None:
151+
"""Finalize a turn cancelled from outside (the task watchdog) as a crash. Does NOT raise.
152+
153+
Only the ``crashed`` branch parks the record on ``pending_turn``; finalizing
154+
as ``COMPLETED`` drops it, and the unwinding frame takes the return value
155+
with it, so a killed turn's telemetry survives only via this path. The caller
156+
re-raises the ``CancelledError`` afterwards.
157+
"""
158+
self._state = AgentState.ERROR
159+
finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled")
160+
150161
def _capture_partial_turn(self, collector: EventCollector) -> None:
151162
"""Build the crashed partial ``TurnRecord`` into ``pending_turn`` (best-effort).
152163

‎src/coder_eval/agents/antigravity_agent.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,7 @@ def _on_turn_timeout() -> None:
514514
raise
515515
except asyncio.CancelledError:
516516
if not state.finalized:
517-
self._state = AgentState.ERROR
518-
state.finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled")
517+
self._finalize_external_cancel(state.finalize)
519518
raise
520519
except Exception as e:
521520
if state.stopped_early_hit and not state.timeout_hit:

‎src/coder_eval/agents/claude_code_agent.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,11 @@ def _on_turn_timeout() -> None:
980980
if self._timed_out(state.timeout_hit, deadline):
981981
assert timeout is not None
982982
self._finalize_and_raise_timeout(state.finalize, timeout)
983+
# Cancelled from outside this turn: park the telemetry on `pending_turn`
984+
# for the caller to drain. Otherwise the `finally` below finalizes as
985+
# COMPLETED, which keeps no record.
986+
if not state.finalized:
987+
self._finalize_external_cancel(state.finalize)
983988
raise
984989
except ProcessError as e:
985990
# When the watchdog SIGKILLs the subprocess, the SDK surfaces it as a

‎src/coder_eval/agents/codex_agent.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -864,8 +864,7 @@ def _on_turn_timeout() -> None:
864864
# stays balanced and the pending-turn contract holds. finalize is
865865
# idempotent, so the timeout case is a no-op here.
866866
if not state.finalized:
867-
self._state = AgentState.ERROR
868-
state.finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled")
867+
self._finalize_external_cancel(state.finalize)
869868
raise
870869
except Exception as e:
871870
# Catches failures OUTSIDE the inner turn block — notably thread_start

‎src/coder_eval/criteria/llm_judge.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ async def _invoke_tool_channel(
201201
tool_spec=SUBMIT_VERDICT_ANTHROPIC_TOOL,
202202
)
203203
verdict, err = extract_verdict_from_anthropic_response(response)
204-
response_usage = token_usage_from_anthropic_dict(response)
204+
response_usage = token_usage_from_anthropic_dict(response, model=criterion.model)
205205
case DirectRoute():
206206
anthropic_response = await invoke_anthropic_judge_async(
207207
model=criterion.model,
@@ -212,7 +212,7 @@ async def _invoke_tool_channel(
212212
tool_spec=SUBMIT_VERDICT_ANTHROPIC_TOOL,
213213
)
214214
verdict, err = extract_verdict_from_anthropic_response(anthropic_response)
215-
response_usage = token_usage_from_anthropic_dict(anthropic_response)
215+
response_usage = token_usage_from_anthropic_dict(anthropic_response, model=criterion.model)
216216
case LiteLLMRoute():
217217
# Defensive: the evaluation route is pinned to Bedrock/Direct by
218218
# resolve_evaluation_route, so a LiteLLM route should never reach the

‎src/coder_eval/evaluation/judge_usage.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any
1616

1717
from coder_eval.models import TokenUsage
18+
from coder_eval.pricing import calculate_cost
1819

1920

2021
def _coerce_int(value: Any) -> int:
@@ -31,12 +32,17 @@ def _coerce_int(value: Any) -> int:
3132
return 0
3233

3334

34-
def token_usage_from_anthropic_dict(resp: dict[str, Any]) -> TokenUsage | None:
35+
def token_usage_from_anthropic_dict(resp: dict[str, Any], *, model: str | None = None) -> TokenUsage | None:
3536
"""Extract usage from an Anthropic / Bedrock-invoke Messages response dict.
3637
3738
Both ``invoke_anthropic_judge_async`` (``response.model_dump()``) and
3839
``invoke_bedrock_judge_async`` (parsed ``/invoke`` JSON) carry an Anthropic-shaped
3940
``usage`` block. Returns ``None`` when usage is missing or carries no tokens.
41+
42+
``model`` prices the call from the rate card. Neither judge backend returns a
43+
cost, so without it the judge's spend is invisible in every rollup. Left
44+
unpriced (``total_cost_usd=None``) when the model is absent from the card,
45+
which ``RunSummary.tasks_cost_incomplete`` then surfaces.
4046
"""
4147
u = resp.get("usage")
4248
if not isinstance(u, dict):
@@ -47,4 +53,14 @@ def token_usage_from_anthropic_dict(resp: dict[str, Any]) -> TokenUsage | None:
4753
cache_creation_input_tokens=_coerce_int(u.get("cache_creation_input_tokens")),
4854
cache_read_input_tokens=_coerce_int(u.get("cache_read_input_tokens")),
4955
)
50-
return None if tu.is_empty() else tu
56+
if tu.is_empty():
57+
return None
58+
if model:
59+
tu.total_cost_usd = calculate_cost(
60+
model,
61+
uncached_input_tokens=tu.uncached_input_tokens,
62+
output_tokens=tu.output_tokens,
63+
cache_creation_tokens=tu.cache_creation_input_tokens,
64+
cache_read_tokens=tu.cache_read_input_tokens,
65+
)
66+
return tu

‎src/coder_eval/models/__init__.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@
127127
TaskConfigRecord,
128128
ThresholdCheck,
129129
TurnRecord,
130+
eval_overhead_cost,
131+
eval_result_total_cost,
132+
judge_cost_usd,
133+
row_cost_incomplete,
134+
simulator_cost_usd,
135+
sum_costs,
130136
)
131137

132138
# Routing
@@ -291,6 +297,14 @@
291297
"TaskConfigRecord",
292298
"RunSummary",
293299
"SkippedTask",
300+
# Cost helpers, shared by RunSummary's computed fields and the reports so
301+
# every surface agrees on what a total costs and which rows lost money.
302+
"row_cost_incomplete",
303+
"eval_overhead_cost",
304+
"sum_costs",
305+
"eval_result_total_cost",
306+
"judge_cost_usd",
307+
"simulator_cost_usd",
294308
# Judge defaults
295309
"DEFAULT_JUDGE_MODEL",
296310
# Judge

‎src/coder_eval/models/experiment.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from typing import Any, Literal, Self
88

9-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
9+
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
1010

1111
from coder_eval.models.enums import FinalStatus
1212
from coder_eval.models.limits import RunLimits
@@ -194,7 +194,12 @@ class VariantResult(BaseModel): # noqa: CE009 -- persisted result model; round-
194194

195195

196196
class VariantAggregate(BaseModel): # noqa: CE009 -- persisted result model; round-trip leniency like models/results.py
197-
"""Aggregated statistics for a single variant across all tasks."""
197+
"""Aggregated statistics for a single variant across all tasks.
198+
199+
``pass_rate`` uses the same denominator as ``RunSummary.pass_rate``: every task
200+
the variant ran, errors included as misses. Otherwise an A/B whose variants
201+
error at different rates compares two different denominators.
202+
"""
198203

199204
variant_id: str
200205
tasks_run: int
@@ -228,6 +233,12 @@ def _check_task_count_invariant(self) -> VariantAggregate:
228233
raise ValueError(f"Task count invariant violated: {total} != {self.tasks_run}")
229234
return self
230235

236+
@computed_field # type: ignore[prop-decorator]
237+
@property
238+
def pass_rate(self) -> float | None:
239+
"""``tasks_succeeded / tasks_run`` as a 0-1 fraction. ``None`` on an empty variant."""
240+
return self.tasks_succeeded / self.tasks_run if self.tasks_run else None
241+
231242

232243
class TaskExperimentSummary(BaseModel): # noqa: CE009 -- persisted result model; round-trip leniency like models/results.py
233244
"""Cross-variant summary for a single task."""

0 commit comments

Comments
 (0)