feat(evalboard): multi-harness colour-coded charts + generated cost table - #70
Open
uipreliga wants to merge 8 commits into
Open
feat(evalboard): multi-harness colour-coded charts + generated cost table#70uipreliga wants to merge 8 commits into
uipreliga wants to merge 8 commits into
Conversation
… SSOT lib/pricing.ts declared itself a hand-copy of src/coder_eval/pricing.py and had drifted three ways: 7 shared models carried WRONG rates (Opus 4.6/4.7/4.8 read Opus 4.1's legacy $15/$75, overstating cost 3x on every rendered estimate; Haiku 4.5 read Haiku 3.5's $0.80/$4), 17 priced models were missing, and 2 entries were dead. The DELIBERATELY_UNMIRRORED allowlist meant to hold the line had failed twice over — 17 models drifted past it, and 2 of its own 8 entries were models real runs use, so the board rendered "—" for their cost. Replace the hand-copy with a checked-in artifact generated from the Python table, so rates are written in exactly one file in one language and the check becomes "is the artifact current?" rather than 56 rows x 4 numbers retyped across two languages forever. Rates are unchanged on the Python side. - scripts/gen-pricing.mjs emits lib/pricing.generated.ts (56 models, pricing.py order preserved). It is also the single home of the Python-table parser: the parity test imports it rather than re-declaring the regex. - Because that parser runs on both sides of the drift test, a row ROW_RE cannot match would be missing from the artifact AND from the expectation — passing deep-equal while silently unpricing the model. readTable() cross-checks matched rows against the count of ModelPricing( constructions and refuses to emit on mismatch, closing that hole rather than only documenting it. - The main-vs-import guard uses realpathSync, not a lexical compare: Node realpaths the main module, so any symlink in the invocation path made `pnpm gen:pricing` a silent no-op that wrote nothing and exited 0. - Parser uses a null-prototype accumulator (a __proto__ key would otherwise set the prototype and drop the row) and rejects non-finite rates (a malformed one would emit `inputPerMTok: NaN`, typecheck, and render "$NaN"). - Pricing moves to lib/pricing-types.ts so the generated file and pricing.ts share it without a cycle; pricing.ts re-exports it, so no importer changes. Everything below PRICING is byte-identical — resolvePricing, normalizeModel, tokenBucketUsd and messageCostUsd are untouched. Rates re-verified against Anthropic's published per-MTok card: every Claude row in pricing.py matches, and each cache rate is exactly the documented multiplier (write 1.25x input, read 0.1x). Non-Claude rows are generated faithfully but remain unverified against their vendors — generation makes the mirror faithful, not the source right. One line of lib/__tests__/pricing.test.ts had to change (75 -> 25): it asserted claude-opus-4-8 output at $75, encoding the very bug this fixes. The other 21 assertions in that file pass untouched. Suite: 341 passed / 3 failed -> 346 passed / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d colour
Harness knowledge lived in two parallel lists in two files: display order in
lib/harness.ts and labels + logo paths in harness-badge.tsx. Adding a series
colour plus a 4th harness would have made that four lists whose indices had to
be hand-aligned — an implicit slot map. Collapse them into one ordered table in
the leaf module and derive everything from it, so row order IS display order and
colour order, with no alignment to maintain.
Adds delegate-sdk as a known harness (it is a real production harness:
coder_eval_uipath declares type: Literal["delegate-sdk"]).
Derived from the table: KnownHarness, KNOWN_HARNESSES, knownIndex, harnessRow,
harnessShortLabel (moved from harness-badge.tsx and re-exported there, so all
seven existing importers are untouched). New for the multi-series charts:
harnessColor (pure, total, prototype-safe by construction — a linear find, not a
Record lookup, so harnessColor("toString") returns a real hex), orderHarnesses
(the one known-first-then-alphabetical comparator), and groupByHarness.
groupByHarness lives here rather than in lib/overview.ts on purpose: overview.ts
reaches node:fs and @azure/storage-blob transitively, and a *value* import from a
"use client" chart would pull that graph into the browser bundle. It is generic
over { harness: string } so this module needs nothing from the data layer, and
lib/harness.ts remains a zero-import leaf.
parseHarnessScope takes over the `?h=` body and returns null for
absent/malformed, which is what "all harnesses" will mean on the home page.
parseHarnessParam becomes `parseHarnessScope(raw) ?? DEFAULT_HARNESS` — the
charset and length bound now exist once and cannot drift apart. Observably
identical for every input, so its six pre-existing tests pass byte-unedited.
Each colour is its vendor's brand value, chosen by validator sweep on the strict
all-pairs gate rather than by eye — which ruled out UiPath orange (CVD dE 5.1 vs
Anthropic coral, indistinguishable even with full colour vision) in favour of
violet for Delegate. The comment above the table records the validator command,
its result, and why the two #000000 gate failures are waived, so the next person
to run it does not "fix" a deliberate decision.
One regression caught while reviewing and fixed here: because delegate-sdk is now
a KNOWN row with logo: null, and KNOWN_HARNESSES feeds HarnessSelector on the
/trends and /watchlist skeletons, the badge's raw-id fallback fired for a known
harness and the chip read "delegate-sdkDelegate SDK". HarnessBadge now separates
"unknown harness" (raw id, unchanged) from "known but no logo yet" (render
nothing, since callers already print the label).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getOverview pre-filters perRun to one harness, so it can only ever return a single RunPoint[] and both charts render one hardcoded-blue line. Stamping each point with its harness is what lets a later phase draw one coloured line per harness. The underlying fetch is already unfiltered and cached, and the harness filter is a pure in-memory .filter(), so an all-harness view costs zero extra IO. - RunPoint.harness is required, not optional: getOverview always has a value, so optionality would only invite `?? DEFAULT_HARNESS` fallbacks at consumers. tsc --noEmit is the proof every construction site was updated. - OverviewData.harnesses is the server-side answer to "which harnesses am I looking at", computed once from the windowed, filtered points — deliberately NOT from listRecentHarnesses(), which is a discovery list over a different slice and can name harnesses this window has no runs for. An empty window yields [], so the page cannot claim "all 1 harnesses" and the chart cannot draw a phantom series. - listRecentHarnessesInner drops its inline known-first-then-alphabetical sort for orderHarnesses. This removes a real divergence: the inline version sorted newcomers with .sort() (UTF-16 code units) while orderHarnesses uses localeCompare, so ids like Zebra-Agent / apex-agent would have ordered differently in the switcher than in the chart legend. The never-empty [DEFAULT_HARNESS] fallback stays at this call site, where it belongs. The perRun filter is untouched — it is already null-tolerant, so an all-harness view needs nothing more than passing null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w charts
daily-chart.tsx and turn-budget-chart.tsx were ~117 lines each and identical
apart from a dataKey, a connectNulls value and one tooltip sentence — including
byte-identical shortLabel/fullLabel. Converting both to multi-series would have
meant writing the same conversion, legend, tooltip and height change twice, with
a wrong-dataKey copy-paste as the likeliest error. So there is now one
RunPointLineChart and two ~30-line wrappers, and the possibility is removed
rather than tested for.
One <Line> per harness, each carrying its own data over a shared numeric time
axis, coloured by harness id rather than series order — so a chart line and a
selector chip can never disagree. Adds a per-chart legend (>=2 series only; a
single line is already named by the subtitle) and a harness-naming tooltip.
h-56 -> h-72 absorbs the legend and unpacks the top-30% crowding that a [0,100]
domain gives four harnesses all sitting between 70 and 100%. The domain stays
[0,100]: a zoomed [50,100] read better but misrepresents a percentage.
Two real attribution bugs, both found by review and both fixed here:
- shared={false} is IGNORED by LineChart in recharts 2.15.4 — getTooltipEventType
computes 'item', LineChart only accepts 'axis', so it silently falls back. The
tooltip stayed nearest-x across every harness.
- With the default allowDuplicatedCategory, recharts builds its categorical
domain from the CONCATENATION of all series and then each <Line> indexes its
OWN points with that combined index. Hovering claude-code's Jul 5 dot rendered
two active dots: the right one, plus a bogus highlight on codex's Jul 10 — a
date claude-code has no run for. Four harnesses would light four.
allowDuplicatedCategory={false} on the XAxis fixes both by switching lookup to
findEntryInArray(series, "timestamp", activeLabel). It is load-bearing for
correctness, not de-duplication, and the code says so. New tests hover every dot
and assert harness + date + rate + exactly one active dot; they fail without the
prop. Nothing in the suite mounted a tooltip before, which is how this survived.
describe() now receives the plotted VALUE rather than the point, so a wrapper
cannot narrate one metric while charting another — that combination previously
typechecked and rendered.
LegendStrip and HarnessSwatch live in app/_components/legend.tsx, not in
chart-common.tsx as first planned: ChipLegend consumes LegendStrip and the tag
rail renders on chart-less pages, so importing it from the chart module pulls
recharts into /runs/[id] (163 -> 268 kB First Load JS) and /trends (119 -> 225
kB). Same bundle-correctness rule that keeps groupByHarness in the leaf module.
ChipLegend keeps its zero-arg API and renders identically (the Tailwind-400
classes became their literal hexes), so its three consumers are untouched, and
/path-to-ga needs no edits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… All chip An absent `?h=` on `/` now means ALL harnesses — one colour-coded line per harness in both charts — instead of silently meaning claude-code. An explicit `?h=` narrows the charts AND the rails together, so the view is always internally consistent and shareable. The selector gains a leading "All" chip behind a new `allowAll` prop, so /trends, /watchlist and /path-to-ga keep exactly-one-harness semantics with no edits. Includes bug B1, pulled forward from the next phase because THIS change is what makes it a defect: hrefForTag dropped `h` when it equalled the default, which was harmless while absent `?h=` resolved to claude-code, and is a scope reset now that absent means All. Without it, clicking any tag chip from an isolated Claude Code view silently re-widened the charts, the rails and the selector back to All — exactly the "silently resets the user's scope mid-navigation" failure this phase was warned about. The rule is now stated once, for every consumer: an explicit scope is always emitted; an absent `h` means all harnesses. On /trends the URL merely becomes explicit, with identical behaviour. Two further bugs found in review: - URLSearchParams.size is Safari 17+ only. Where it is undefined the ternary took the falsy branch and navigated to a bare pathname, so on iOS 16 clicking a harness chip would discard window, tag, q AND the scope being set. Uses the toString() idiom buildHref and SearchBox already use. jsdom implements .size, so no test could have caught this. - scopeLabel special-cased zero harnesses but not one, rendering "all 1 harnesses". It now names the single harness, which is also more useful since the chart legend hides itself below two series. The connector became "for" rather than "across", which reads correctly for both a set and one member. scopeLabel is computed once and used in BOTH subtitle branches — the tag/q branch previously never named the harness at all — and its count comes from OverviewData.harnesses, derived from the same points the chart groups, so "all N" always equals the number of lines drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HarnessBadge printed the raw id as text when it had no vendor logo, and every
caller renders harnessShortLabel right after it — so the selector read
"delegate-sdk delegate-sdk". Fixed by deleting the fallback rather than adding an
accessor: the badge now returns null when its row has no logo, so "no logo → no
image" lives in one place, and naming the harness is unambiguously the caller's
job. No hasHarnessLogo export — "has a logo" is `logo != null` on one table row,
read only by the badge.
The run-table Harness column, which relied on that text fallback, now pairs the
badge with the label. It normalizes first, because RunListingRow.harness is
nullable and a legacy unstamped run would otherwise show the Claude Code logo
beside a blank label. Unknown harnesses still render their id, so that cell looks
the same as before; known ones gain a label.
Also drops the logo's alt text. With both callers printing the label beside it,
alt made screen readers announce the harness twice ("Codex · OpenAI Codex") — the
selector test had a substring-regex workaround for precisely that, now replaced
with exact accessible-name matching. The logo is decorative next to a text label;
title still carries the vendor detail on hover.
B1 shipped in the previous commit, where the all-harnesses default is what made
it a live scope reset rather than a URL nicety.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings from the final cross-phase review (two of the first three were raised independently by more than one reviewer). The drift guard was structurally blind. pricing-parity.test.ts imported parsePythonTable, so it compared two products of the same regex — a row that regex cannot match (keyword arguments, scientific notation) is missing from the generated artifact AND from the expectation, so deep-equal passes while the board renders "—" for that model forever. The coverage cross-check existed but only inside the generator, i.e. not on the side that CI would run. readTable is now exported and the test uses it. The same test disclaimed copying the rate card and then copied four rates, so a legitimate vendor repricing would break the suite even after a correct regeneration — the two-places problem generation exists to remove. Every expectation is now derived from pricing.py via the parsed table, with an added guard that the float-fidelity case cannot pass vacuously if those rates ever become round numbers. Same for the one hardcoded rate in pricing.test.ts. The legend named harnesses that draw nothing. groupByHarness deliberately keeps null-metric points (grouping must not filter), but turnBudgetRate is legitimately null for a whole harness when none of its runs carry an expected_turns budget — so "Within Expected Turns" could list three harnesses above two lines, and a reader looking for the missing one would read its absence as 0%. The legend is now derived from series with at least one non-null value for the plotted metric. The chart could draw a line the selector could not isolate. The chart's series come from the windowed points; the selector's chips come from a fixed-count scan of recent runs. A harness that ran once three weeks ago appears in a 30d chart but not in that scan, so it had a visible series and no chip. The page now passes the union. Adds the two guards that were previously only comments, both worth a measured ~105 kB of First Load JS and both violated by adding one import: lib/harness.ts has no imports at all, and app/_components/legend.tsx never reaches recharts or the chart module (with the tag rail's import path pinned, since re-pointing it is the actual regression path). Verified both fail when violated. Also pins the contract this change is named for — the subtitle's "all N harnesses" equals the number of lines drawn — by asserting harnessesInPoints and the rendered curve count over the same input, since those are two derivations of what should be one set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cing run Four candidates, the first raised independently by two reviewers: CI never runs the evalboard suite, so the pricing drift guard the generated-SSOT design rests on is never executed. Deferred rather than applied because adding a required check to pr-checks.yml is a repo-wide CI-policy change outside a plan scoped to evalboard/ — recorded with the concrete job to add. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uipreliga
requested review from
akshaylive,
bai-uipath and
tmatup
as code owners
July 30, 2026 23:53
|
Claude finished @uipreliga's task in 1m 33s —— View job 📋 Review Task List
|
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.

Goal
Make the overview charts plot every harness at once by default with one colour-coded line each (clicking a harness name scopes the analytics block to it), and fix the evalboard's drifted cost mirror so displayed USD is correct and cannot silently drift again.
Six phases, one commit each, plus a review-fix commit and a deferred-guards commit.
The cost bug this fixes
lib/pricing.tsdeclared itself a hand-copy ofsrc/coder_eval/pricing.pyand had drifted three ways:claude-opus-4-6appears in real run data.The
DELIBERATELY_UNMIRROREDallowlist meant to hold the line had failed twice over: 17 models drifted past it, and 2 of its own 8 entries were models real runs use (gpt-5.6-terrais the single most-run model in the recorded data), so it was actively suppressing cost for runs the board does execute.Rather than hand-copy better — which would convert a one-time copy error into a recurring obligation to retype 56 rows × 4 numbers across two languages — rates are now written in exactly one file in one language and generated into a checked-in artifact.
pnpm gen:pricingafter editingpricing.py. No Python file is modified by this PR; if a rate is wrong there, that is a separate backend change.What else changed
HARNESSEStable replaces four parallel sources of harness knowledge (id, display order, label, logo path, series colour). Row order is display and palette order, so there is no slot map to hand-align.delegate-sdkpromoted to a known harness.daily-chart.tsxandturn-budget-chart.tsxwere ~117 lines each and identical apart from adataKey, aconnectNullsvalue and one tooltip sentence. Now oneRunPointLineChartand two ~30-line wrappers, rendering one<Line>per harness over a shared numeric time axis./defaults to all harnesses. An absent?h=no longer silently meansclaude-code. A?h=scope narrows charts and rails together, so the view is internally consistent and shareable./trends,/watchlist,/path-to-gakeep exactly-one-harness semantics with no edits.?h=claude-code(a scope reset under the new semantics);HarnessBadgeprinted the raw id that callers then printed again ("delegate-sdk delegate-sdk").Colour choices
Each harness wears its vendor's brand colour, chosen by validator sweep on the strict all-pairs gate rather than by eye — which changed two of the four. UiPath orange is unusable next to Anthropic coral (CVD ΔE 5.1, normal-vision ΔE 9.5 — hard to tell apart even with full colour vision), so Delegate takes violet. OpenAI black fails two proxy gates (lightness band, chroma floor) and is shipped deliberately: every check that measures distinguishability directly passes, black separates on the one channel colour-vision deficiency does not touch, and there is no black element anywhere in the plot area. The rationale is recorded above the table so the next person to run the validator does not "fix" a decision.
Notable bugs found during review
Two silent-misattribution bugs in the charts, neither anticipated by the plan:
shared={false}is silently ignored byLineChartin recharts 2.15.4.getTooltipEventType()computes'item',LineChartonly accepts'axis', so it falls back — the tooltip stayed nearest-x across every harness.allowDuplicatedCategory, recharts builds its categorical domain from the concatenation of all series, then each<Line>indexes its own points with that combined index. Hovering Claude Code's Jul 5 dot rendered a second, bogus active dot on Codex's Jul 10 — a date Claude Code has no run for.Both fixed by
allowDuplicatedCategory={false}; four new tests hover every dot and fail without it. Nothing in the suite mounted a tooltip before, which is how this survived a spec review.Also fixed:
URLSearchParams.sizeis Safari 17+, so on iOS 16 clicking a harness chip navigated to a bare/, discarding window/tag/q; the pricing drift guard compared two products of the same regex and so was blind to a row that regex cannot match; anddescribe()was decoupled fromdataKeyso a wrapper can no longer narrate one metric while charting another.Verification
pnpm tsc --noEmitclean ·pnpm next buildclean ·pnpm gen:pricingidempotent/runs/[id]163 kB,/trends120 kB First Load JS.LegendStrip/HarnessSwatchlive in a recharts-free module on purpose — importing them from the chart module measured +105 kB on both those chart-less pages. Two new guards enforce that and thelib/harness.tszero-import leaf rule, both previously only comments.EVALBOARD_EDITION=internal pnpm dev:local: All chip, brand-coloured lines and chip swatches, correct tooltip attribution, scope surviving a tag click, single labels in the run table.Nothing in
.github/workflows/runs theevalboard/suite —grep -rn evalboard .github/workflows/yields one hit, a comment sayingevalboard/pnpm-lock.yamlis out of scope. So the 434 tests and the pricing drift guard are green-by-absence on this PR, and the drift class this PR removes stays undetected going forward: a rate edited inpricing.pywithoutpnpm gen:pricingwill pass CI. That is precisely how the 3× Opus rates accumulated.Deliberately not fixed here — adding a required check to
pr-checks.ymlis a repo-wide CI policy change, outside a change scoped toevalboard/. The concrete job to add is written up as the top item in.claude/harness-candidates.md, alongside three other deferred guards.Not in scope
Non-Claude rates (
gpt-5.x,gemini-3.x, OpenRouter, Bedrock open-weight) are generated faithfully but were never verified against their vendors — generation makes the mirror faithful, not the source right. Bedrock-v1suffixes still resolve to no price on both sides. Introductory pricing (Sonnet 5 is $2/$10 through 2026-08-31 against a $3/$15 list) would need time-dependent rates in the Python schema.🤖 Generated with Claude Code