fix(reports): count errors as misses and stop losing cost on error paths - #63
Open
bai-uipath wants to merge 15 commits into
Open
fix(reports): count errors as misses and stop losing cost on error paths#63bai-uipath wants to merge 15 commits into
bai-uipath wants to merge 15 commits into
Conversation
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>
…priced 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>
…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>
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>
…icing 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>
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>
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>
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>
…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>
… 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>
… 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>
… 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>
bai-uipath
marked this pull request as ready for review
July 29, 2026 03:07
bai-uipath
requested review from
akshaylive,
tmatup and
uipreliga
as code owners
July 29, 2026 03:07
|
Claude finished @bai-uipath's task in 1m 52s —— View job 📋 Code Review in Progress
|
… 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>
…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>
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>
| await streaming.wait() | ||
| turn.cancel() | ||
| with pytest.raises(asyncio.CancelledError): | ||
| await turn |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Cost went missing on the paths where a task did not finish, and the pass rate paid a bonus for erroring. Ordered below by how much each part changes what a run reports.
Scope: the recovery fix below is the only part that touches the run path (the agent's cancel branch and the orchestrator's timeout handler). Everything else changes the rate card and what gets reported, not what gets executed.
git diff main -- evalboard/is empty, so the dashboard is untouched.A hard-killed task recorded nothing at all
A task that runs to the wall spends the most getting there, and it was recording none of it. Not just the cost: no turns, no tokens, no commands, nothing.
A task-level timeout is a different code path from a turn-level one, and that is where it went:
TurnTimeoutError, which reaches_on_attempt_failure, which drainsagent.pending_turnintoresult.iterations. The partial keeps its tokens and gets priced. (What happened to that price is the section below.)ThreadedWatchdog, which SIGKILLs the agent by PID and cancels the asyncio task awaiting the turn. It never reaches_on_attempt_failure, and nothing had parked a record to drain anyway.Reproduced live on a 30s wall (
task_timeouthas age=30floor, so 30s is as tight as it goes), against a task written to outlast it:❌ That is byte-for-byte the shape of the production rows this was chased from:
TIMEOUT, zero turns, no tokens, no cost. Across seven recent runs there are 26 such rows totalling 377 minutes of wall clock that recorded nothing.✅ That spend is now recovered rather than declared missing. The kill does not destroy the telemetry. The agent's
finalizestill reduces everything the event stream delivered into a complete record and prices it from the buckets. What lost it was the last line of that method, which parks the record onpending_turnonly for a turn flagged as crashed: the Claude agent flagged an external cancel asCOMPLETED, so the record was assembled and then dropped. It now finalizes as a crash, which is what Codex and Antigravity already did, and their copy of that fragment moves to a shared kernel on the base class.Reading the slot was the other half, and nothing did on this path: the cancel arrives as a
BaseException, so it never reaches the retry executor's per-attempt hook._drain_killed_turnreads it in theTaskTimeoutErrorhandler, ahead of teardown (whoseagent.stop()clears the slot) and ahead of finalization, so a recovered turn feeds token aggregation, command statistics and model resolution exactly like a turn that ended on its own.Measured on the VM against the same task, same box, with only these two seams toggled:
claude-sonnet-4-6gemini-3.1-pro-previewgpt-5.6-terraTwo things that table settles. Codex never lost this money: its turn runs on a worker thread that finishes the record and returns it before the cancel lands, so the row arrives priced through the ordinary path (
crashed=falseon both runs). It still gains the drain for the timings where the cancel wins the race. And Antigravity's ghost proves the orchestrator half carries its own weight: that agent already parked its partial before this PR, so the row was blank purely because nothing read the slot.total_turnsis the SDK'snum_turns, which only arrives on the terminal result message a hard kill never delivers.visible_turnsandactual_commands(9 and 9 above) do reflect the work, and the tokens and cost are whole.cost_complete: false. Recovery captures what the stream delivered, which is most of it, but the generation the agent was waiting on when it was killed was never delivered by anyone. The total stays a floor and the row says so._cost_completeused to call these rows fully priced, because it asked "does every turn that burned tokens carry a cost?" and a row with no turns answers yes vacuously. It now returns False for anyTIMEOUT, keyed on the status rather than on elapsed time, so a setup failure stays free (it genuinely spent nothing) while a hard kill never does.Keying on the status rather than on emptiness matters, and live testing is what showed it. A first attempt flagged only
TIMEOUTrows with no preserved turn. But a task killed mid-dialog keeps the turns that already finished, and those carry costs, so it still reported as complete while the in-flight turn was just as lost. Re-projecting that captured run through the corrected rule:A killed turn's tokens were captured but never priced
The turns that were recorded had a second, unrelated problem, and it is the one visible on the evalboard: 26 turns, 30m03s, 1,201,607 tokens captured, cost
None.It is a two-step failure, and the second step is the one that surprises:
claude-opus-5being absent from the rate card entirely.test_backfill_noop_for_unknown_modelpins that). Full token counts, no money.The fix is the missing rate, verified by replaying that row's real buckets through the corrected card:
This is also what prices the turn the section above recovers: a hard kill delivers no result message either, so the recovered turn is only worth dollars if the card can price it.
The rate card was wrong in five other places
Audited every entry against the published tables. What was wrong, and by how much:
claude-opus-5claude-opus-4-8,-4-7,-4-6,-4-6-20250514,-4-5-20251101claude-haiku-4-5-20251001gpt-5.6-sol,-terra,-lunaOpus 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 and a newer Opus is not the more expensive one. That is what the old entries got backwards.
Also added the aliases that silently went unpriced because only the dated id was keyed:
claude-opus-4-5,claude-opus-4-1,claude-opus-4,claude-sonnet-4-5,claude-haiku-4-5,claude-haiku-3-5, plusclaude-fable-5andclaude-mythos-5.Verified correct and left alone: every Sonnet 4.x entry, the Claude 3.x entries, all nine remaining OpenAI entries, and the Gemini entries (including the documented >200K-tier caveat).
virtuoso-*correctly stays out: thecoder_eval_uipathplugin registers it throughregister_pricing.One deliberate non-correction:
claude-sonnet-5stays at the standard $3/$15 rather than the $2/$10 introductory rate in effect through 2026-08-31. A static table cannot express a promo window, and of the two available errors this one overstates cost for a few weeks instead of understating it indefinitely afterwards. The comment says so.So the next missing rate cannot hide
The rate card is a static table baked into the installed version, so this recurs the next time a model ships. Three guards, none of which block a run:
check_pricing_coveragewarns at dispatch for any pinnedagent.modelthe card cannot price. A warning, not a refusal, so a new model stays evaluable the day it ships: cost degrades, the evaluation does not. Its limit, worth knowing: it only sees pinnedagent.model, so a model reached throughBEDROCK_MODELgets no dispatch warning.tasks_unpriced/cost_completecount what a run actually lost, per row and per run. The report prints(floor — N task(s) have spend missing from this total), orunavailablewhen nothing could be priced. The wording is cause-agnostic because both failures above land here and the report cannot always tell which applied. One predicate,row_cost_incomplete, is shared by the report andRunSummaryso the two cannot disagree about which rows lost money. One edge it does not cover: a run where every row is a ghost renders no token section at all, so the caveat lives only inrun.json. A run with any priced row shows it.Judge and simulator spend is reported at all
Per-row
judge_cost_usdandsimulator_cost_usd, run-leveleval_overhead_cost_usd. This was captured per criterion and rolled up nowhere, so it was absent from every total.llm_judgeprices its own call fromcriterion.model. The simulator prices from the route model rather than the subject's, because it resolvesmodel=Nonethrough toBEDROCK_MODEL; pricing it off the subject would mis-bill every task that pinsagent.model.Kept beside the agent bill rather than folded into 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.
**Total Cost**on the report always means the two added together.Errors count as misses
RunSummary.pass_rateistasks_succeeded / tasks_run. The old rate divided bytasks_run - tasks_error, so the more a harness fell over, the smaller its denominator got.How much this actually moves: on a healthy run, very little. Measured across the last six runs:
So this is not a headline-number change for the Claude nightly: it costs well under a point. It matters exactly when a harness errors a lot, which is also exactly when the old formula flattered it most. The
virtuoso-1-5row is a live 6.7-point example, and the degenerate case is real: a run that errored on 854 of 861 rows reported 100.0% because 7 of its 7 evaluable rows passed.pass_rateis a pydanticcomputed_field, so it serializes intorun.jsonand cannot drift from the counts it derives from.VariantAggregategets the same property so an A/B whose variants error at different rates compares like with like.error_shareships beside it, diagnostic only, never adjusting the rate: a drop at a high error share is an infrastructure night, the same drop at a normal share is the model.Minor points
One denominator across every surface. Four surfaces derived their own rate; each now reads the published field (
reports.py,reports_experiment.py,reports_html.py). The dashboard was never part of this:evalboard/lib/trends.tsalready counted every row in its denominator, so its numbers were correct before this PR and are unchanged by it.Error diagnostics on the row.
error_message(truncated to 400 chars) anderror_category, projected from the existingerror_details. Nothing new is captured, only surfaced. Without them an errored run is untriageable without fetching per-task artifacts one at a time.A crash that burned nothing is free, and reported free. An error before the agent ran genuinely cost zero. On a live errored run the zero-token row came back
cost_complete: truewithtasks_unpriced: 0, which is what keeps the flag a signal rather than something that fires on every error.Budget kills (
TOKEN_BUDGET_EXCEEDED/COST_BUDGET_EXCEEDED) are categoryfailed, noterror: they carry their spend on the row like any other task and sit in the denominator as misses.simulator_cost_usdis a floor.UserSimulatorrecords onlyuncached_input_tokensand drops both cache buckets, so a cached prefix is largely absent from the count. Widening that changes what the run captures rather than what this reports, so it is not done here. Same for a kill that lands inside the simulator between agent turns: the agent's turns are safe, that one simulator call is not.How it was verified
✅ Live hard kill on three harnesses, on the VM, at this commit: Claude Code, Codex and Antigravity, each against a task built to outlast the 30s floor, each run twice with only the two recovery seams toggled. That is the table above. Claude and Antigravity go from a blank row to a priced one; Codex was already whole. A mid-dialog kill from an earlier session, re-projected through the rule, is what caught the emptiness version of
_cost_completeunder-flagging.✅ Live Bedrock run for the judge and simulator paths, which had no live evidence: a dialog task with a simulated user and an
llm_judge, subject and judge both sonnet-4-6.The route-versus-subject branch was then exercised against that run's real telemetry: repinning the subject to haiku left the simulator cost unchanged, proving it prices at the route; removing the route dropped it to the haiku rate, proving the fallback. A run with no judge reports
eval_overhead_cost_usd: nullrather than$0.00.✅ Real-run replay for the cost and rate work: the timed-out row and the nine killed rows above, pulled from blob and repriced through the corrected card. Historical replay for the denominator, plus the six-run table above.
✅ Recovery is pinned by a test that fails without it. An agent-level test streams a turn carrying real usage, cancels it from outside exactly as the task watchdog does, and asserts the record lands on
pending_turnwith its tokens and a cost backfilled from the buckets. With the one-line finalize change reverted, that assertion reportspartial = None: the production symptom. An orchestrator-level test drives a real task timeout and asserts the parked turn reaches bothresult.iterationsandtotal_token_usage, and a companion asserts an empty slot still lands a cleanTIMEOUTrow, since the drain runs on the way to a saved row and must not raise.✅ Every remaining spend path traced rather than assumed. Subject-agent turns and their internal sub-agents fold into the turn total.
llm_judgemakes exactly one call per criterion with no retry loop and no text-channel fallback, and itscriterion.modeldefaults to a priced model, so it cannot silently go unpriced.agent_judgeruns a singlecommunicate(), so itsTurnRecordcarries the whole sub-agent loop with the SDK's cost. Variant reports feed the same token section, so an A/B gets the same cost lines. Two things specifically looked for and not found: a retry around the judge invoke that would keep only the last attempt's usage, and any double-count between the agent bill and the overhead.✅ Unit, snapshot and CI. Tests pin the pass-rate reductio, the empty-run
None, the error-message truncation, the pricing pre-flight's silent / warning / unpinned paths, the floor andunavailablerenderings, and a legacy row reading as cost-complete. Two pre-existing tests that hardcoded the old Opus and Haiku rates were updated to the corrected ones, which is the check that the card change took effect. Report snapshots regenerated: the only diffs are theSuccess Rate→Pass Ratelabel, with no rate value moving.Local: 3840 passed / 8 skipped,
make check/typecheck/lintclean. Three failures intest_reports_stats_nonfinite.pyare unrelated and pre-existing (cohens_draises on non-finite input; reproduces with this branch's changes stashed).🤖 Generated with Claude Code