Elo Ratings
An arena leaderboard over time — one line per model with a confidence band derived from its battle count, releases annotated with the move they produced, and a pairwise win-rate matrix that doubles as the card's control surface.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-elo-ratings.jsonPrompt
Build a React + TypeScript + Tailwind "ChartEloRatings" card in hand-rolled SVG
plus one HTML grid (no charting library), with zod for the contract.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string;
models: { id, label, points: { date: "YYYY-MM-DD", rating, battles >= 0 }[] }[];
pairwise?: { a, b, winsA, winsB, ties }[];
events?: { date: "YYYY-MM-DD", label }[] }
- Props = z.infer of that schema plus height, bandZ (default 1.96), locale,
defaultSelectedId, onSelectionChange, onRetry, emptyState, className. No
hand-written parallel interface.
- `battles` is the count the rating was fitted on, including battles against
models that are not on this card. `pairwise` is unordered — {a,b} and {b,a}
are one record — and each pair is sent once. Refine: ready needs one model
with one snapshot; model ids unique; a pair may not name itself, an unknown
id, or repeat another pair.
Derived maths — computed in the component, never accepted from the payload
- Standard error of a rating: a Bradley-Terry fit takes n/4 units of
information from n comparisons at even odds, so
SE = 2 * (400 / ln 10) / sqrt(n) ~= 347.4 / sqrt(n). The band is bandZ * SE.
Zero battles yields NO band: an unrated model has no error bar, which is not
the same as a tight one.
- Standings: last snapshot per model, sorted by rating descending with payload
order breaking exact ties; drift = current minus first rating.
- Statistical tie: two neighbours are separated only when
gap > bandZ * sqrt(SEi^2 + SEj^2). Print "N of M neighbouring gaps clear both
bands" and name the pairs that do not.
- Event delta: for each event inside the plotted window, difference the first
snapshot at-or-after it against the last one strictly before it, per model;
the biggest absolute mover is printed beside the event.
- Matrix cell: winsRow / (winsRow + winsCol) — the arena convention, ties
excluded. The diagonal is that model against every recorded opponent.
- Expected score from the ladder: 1 / (1 + 10^((ratingCol - ratingRow) / 400)),
weighted by decided battles on the diagonal. Print observed against implied
plus the residual in points, and call out the pair where they disagree most.
- Win / tie / loss shares are apportioned by LARGEST REMAINDER over the full
battle count, so every selection prints three numbers summing to exactly 100.
Behavior
- Four first-class branches in one bg-card panel: loading (a pulsing plot
silhouette plus standings bars), empty (why a ratings card needs dated
rating+battles pairs), error (message plus a "Try again" button only when
onRetry exists), ready. A ready payload whose models carry no snapshots
renders the empty branch rather than an axis with nothing on it.
- The plot: one polyline per model on a UTC time axis, a filled band with
dashed edges for the models currently being read, a dot on each model's
current snapshot, dashed rules at events with numbered badges nudged apart
when two would collide, month ticks, and the date range as the axis caption.
The y step is the smallest 1 / 2 / 2.5 / 5 rung that fits the domain, and a
tick prints exactly the decimals its own step needs: a 2.5 rung labelled in
whole points names a rating half a point off the line it sits on, and on a
tight domain it labels two lines the same.
- The grid is the control surface, not a second table: role="grid" over a
<table>, column 0 the standings row (swatch, rank, label, rating +- band,
battles) and columns 1..N the matrix. Roving tabindex — exactly one cell is
tabbable; arrows / Home / End move focus AND the reading, and a click does
the same. Selecting a cell lights those two lines in the plot and dims the
rest; a row header or a diagonal reads that model against the field.
- Defensive ingest: snapshots with an unparseable day, an unreadable rating or
a repeat of a day already taken are dropped and counted; so are duplicate
model ids, pairs naming a model that is not on the board, and events outside
the plotted window. Every count is reported under the chart, never silently.
Rendering & styling
- Semantic tokens only. Series ink is var(--chart-1..5) by payload position,
with a dash pattern stepping once the tokens repeat; bg-card, border,
text-muted-foreground, bg-muted and ring-ring for focus and selection.
- Matrix tiles are color-mix(in oklab, <token or var(--muted-foreground)>
<strength>%, var(--card)) — mixed toward the card so the tile stays legible
in both themes, capped near 38% so the printed percentage keeps its
contrast. The row's own token when the row is ahead, a neutral when behind.
- The band is a fill at ~0.26 opacity PLUS dashed edge strokes: a low-alpha
wash alone is the first thing to vanish on a near-black card.
- Plot width comes from a ResizeObserver, disconnected on unmount and on node
change. Line dimming transitions with motion-reduce:transition-none; the
skeleton pulses with motion-reduce:animate-none.
- Accessibility: the plot is role="img" with a spoken summary plus a sr-only
data table of the snapshots; grid cells carry an aria-label naming the
matchup and its rate, and aria-selected marks the reading. Dates are parsed
and formatted in UTC, so a snapshot never slides onto the previous day.
- cn() merges className; the root spreads the remaining props.
Customization levers
- Interval width: bandZ — 1.96 for 95%, 1 for +-1 SE, 2.58 for 99%. It drives
the band, the tie test and every printed "+-N" from one number.
- Density: height (150-420) for the plot; drop the events strip by omitting
`events`, drop the whole matrix by omitting `pairwise` (the grid degrades to
a single navigable standings column).
- Palette: SERIES_INK is the one place ink is chosen — map it to a fixed token
per vendor instead of cycling, and re-point tileTint's neutral if "behind"
should read as destructive rather than grey.
- Matrix encoding: swap the cell from decisive win rate to the residual
against the ladder (observed minus implied) when the audience is calibrating
the rating fit rather than reading matchups; the tint formula takes any 0..1.
- Column width: the standings column is 46% of the table — widen it for long
model ids, or drop its second line and lean on the readout panel instead.
- Copy: the caption sentences are assembled from derived values, so translate
the strings without touching the maths.Concepts
- Band from the sample, not from the payload — the interval is computed from
battlesalone (SE = 2 · 400/ln10 / √n), so a model with 1,900 battles gets a visibly fat funnel that narrows as it accumulates. Nobody can send a flattering interval, and a rating with zero battles gets no band at all rather than a confident-looking hairline. - Statistical tie as a first-class verdict — neighbouring rows count as separated only when their gap clears
z · √(SEa² + SEb²). The card counts how many gaps on the ladder survive that test and names the pairs that do not, which is the difference between a leaderboard and a ranking. - Matrix as control surface — the pairwise grid is not a second read-only table: it owns a roving tabindex, and moving through it re-lights the plot above. One
role="grid"replaces a legend, a filter row and a set of toggles, and the diagonal is a door to "this model against the whole field". - Observed against implied — every cell is compared with the Elo expectation
1 / (1 + 10^(Δ/400))for the same pair, so a matchup that disagrees with the ladder surfaces as a residual in points instead of being smoothed away by the fit that produced the ratings. - Largest-remainder shares — win / tie / loss are apportioned so the three printed percentages sum to exactly 100 in every selection, and those same three numbers set the widths of the stacked bar above them: a segment is never sized by one figure and labelled with another.
- Events answer "why did it move?" — a release is a dated label, and the delta beside it is differenced out of the plotted snapshots rather than typed in, so an annotation can never claim a jump the line does not show.
Cost-Quality Frontier
A four-state price/quality scatter for a model shortlist — Pareto frontier drawn through the non-dominated models, dominated ones greyed and named their beater, and a movable iso-value line that names the best buy at the reader's own quality-per-dollar rate.
Retrieval Recall@k
A four-state RAG retrieval card: recall@k over precision@k against a log k axis, one line per retriever, with the shipped operating k marked, an MRR@k readout and a coverage bar whose percentages always total 100.