Rating methodology
Blind pairwise votes become a statistically principled leaderboard. A standalone Python worker (worker/, omniarena_rating) runs off the request hot path: screen → aggregate → fit → interval → connectivity → write back, appending a history snapshot per refit so ratings can be charted over time. Everything here needs pairwise input — see What the engine cannot rate.
Pipeline
Bradley-Terry + Rao-Kupper ties
Votes are pairwise preferences, so each model gets a latent log-strength r_i = log θ_i and outcomes follow the logistic of d = r_a − r_b. Ties (both_good/both_bad) use the Rao-Kupper threshold η ≥ 0 (a fitted parameter), not a y=0.5 hack; skip is dropped.
P(i beats j) = σ(d − η)
P(j beats i) = σ(−d − η) d = r_i − r_j
P(tie) = σ(d + η) − σ(d − η)
What the engine cannot rate
Bradley-Terry is a model of comparisons: every term in its likelihood is a contest between two models, and there is no term for a verdict on one answer viewed alone. That bounds what OmniArena can rate.
| Situation | What the engine gets |
|---|---|
| A single round (ARENA_TRIGGER=manual, no opt-in; one model from ARENA_DEFAULT_MODEL) | Nothing. The round persists no matchups row, no responses, no vote token, so it is not filtered out of the fit like a skip vote — it never reaches the database to be filtered. |
| One-sided feedback (a thumbs-up on a lone answer) | No ingestion path. It says nothing about a pair, and BT has nowhere to put it. POST /api/arena/vote accepts only an HMAC-signed matchup token naming two slots; no endpoint, column, or worker path records a per-response rating. |
| A deployment serving mostly single rounds | No ratings. rating, ratingStdError, confidenceInterval, componentId stay null; the leaderboard falls back to counts from whatever matchup rounds occurred, of which there may be none. A model only ever served in single mode never enters the comparison graph. |
Not built: regenerate-as-slot-B
Identifiability: anchoring + ridge
Raw BT strengths are defined only up to an additive constant. Two mechanisms pin them down:
| Mechanism | What it does |
|---|---|
| Anchoring | Re-centre sum-to-zero (default) or pin a reference model, done per connected component (offsets between components are unidentified). |
| Ridge prior ½·ridge·‖r‖² | One term, three jobs: (1) identifiability — strictly convex objective; (2) regularization — sparse models pulled to mean 0 with wide CIs; (3) conditioning — invertible Hessian for Fisher CIs. Never applied to η. RATING_RIDGE=0.01, STYLE_RIDGE=0.05. |
Confidence intervals
| Method | How |
|---|---|
| Primary — Fisher information | Laplace approximation at the optimum ⇒ covariance = inverse Hessian of the penalized NLL (observed Fisher info). Hessian built by central-differencing the analytic gradient (O(n)); covariance projected through the anchoring contrast so the common-mode direction doesn't dominate. One matrix computation, not hundreds of refits. |
| Validation — multinomial bootstrap | Flatten triples → draw each dataset from Multinomial(N, proportions) → re-aggregate → warm-started refit. Column std-devs confirm the analytic SEs, never touching raw rows. A test asserts they agree. |
Connectivity → per-component leaderboards
connectivity.py runs union-find over the aggregated pairs; ratings are only comparable within a connected component. Each model gets a componentId so clients can render per-component boards. Isolated models get their own component and wide intervals.
Display scale
display = 1000 + (400 / ln 10) · r SCALE = 400/ln10 ≈ 173.72
Refit cadence & scaling
| Aspect | Detail |
|---|---|
| One-shot vs loop | python -m omniarena_rating (once) or --loop every REFIT_INTERVAL_SECONDS (300). Docker's worker service runs the loop. |
| Warm start | Most loop refits warm-start from the previous [r…, η] vector (in-memory only) ⇒ few iterations; skipped (cold refit) when the model set changes. |
| Periodic full recompute | Every FULL_REFIT_EVERY refits (12 ⇒ hourly at the default interval; 0 disables) the warm state is discarded and the fit runs from scratch — the ground-truth pass that bounds how long an incremental chain can accumulate drift. The loop's first refit is already cold, so forced passes land on refits 1, 13, 25, … Each refit logs mode=full or mode=incremental. |
| Warm-path validation | The ridge makes the raw optimiser vector the unique minimiser, so the ground-truth pass also re-runs the incremental path over the same aggregates and warns when they disagree by more than half a standard error. SE units, not display points: L-BFGS-B stops on a relative function tolerance, so absolute accuracy loosens as vote volume grows. |
| Aggregate-then-compute | SQL GROUP BY → (model_lo, model_hi, wins_lo, wins_hi, ties) triples; fit input bounded by ~3·C(n,2) rows regardless of vote volume (O(votes) → O(pairs)). Raw rows stay in Postgres. |
Pre-fit anomaly screen
anomaly.py screens anonymous sessions before aggregation (Bonferroni α/3); a rejection excludes the session from both passes. On by default (--no-anomaly-filter off).
| Test | Null | Catches | Runs when |
|---|---|---|---|
| Volume | Poisson upper tail P(X≥n) vs mean votes/session | vote-stuffing | ≥ 20 votes in the session |
| Position bias | two-sided binomial on decisive left/right vs p=0.5 | always-left/right bots | ≥ 15 decisive votes |
| Speed | median inter-vote gap below floor (1.5 s) | automated clicking | ≥ 8 vote timestamps |
Style-controlled ratings
Voters reward superficial traits. Following LMSYS, style.py folds them into the same BT regression as covariates so strengths and style betas fit jointly:
d = (r_a − r_b) + β · x P(A ≻ B) = σ(d − η)
| Feature | Delta of (A − B) |
|---|---|
| position | constant 1.0 → coefficient = systematic left-slot advantage |
| verbosity | output_token_count |
| formatting | markdown_density |
| latency_ttft | ttft_ms |
| latency_duration | stream_duration_ms |
Leaderboard fields
| Field | Meaning |
|---|---|
| rating | Bradley-Terry rating, Elo-like scale |
| ratingStdError | Standard error (same scale) |
| confidenceInterval | 95% CI { lower, upper } from Fisher information |
| componentId | Connected component; ratings comparable only within it |
| styleControlledRating* | Style-controlled rating, SE, and CI (heavier --style pass) |
Where the counts come from
The win/loss/tie columns are not the worker's. The server aggregates them directly over models ⋈ matchups ⋈ preferences for every enabled model, so they exist from the first vote and never wait on a refit.
| Field | Counted as |
|---|---|
| wins | votes whose winner_model_id is this model |
| losses | left/right votes on its matchups where the winner is another model |
| ties | both_good + both_bad votes on its matchups |
| skips | skip votes on its matchups |
| totalVotes | every vote on its matchups, skips included |
| winRate | wins / (wins + losses + ties), or 0 when that denominator is zero |
Rating history
model_ratings is an upsert keyed by model, so it holds only the latest fit — it says where a model stands, never how it got there. Every refit therefore also appends one snapshot row per model to model_rating_history (migration 005_rating_history.sql), in the same transaction as the upsert, so the two can never disagree about a refit.
| Column | Meaning |
|---|---|
| model_id, computed_at | Primary key. computed_at is NOW() — transaction-stable, so every row a refit writes shares one timestamp and one refit is one point on the x-axis |
| rating, rating_stderr, ci_lower, ci_upper | Exactly the display-scale values written to model_ratings |
| component_id | The component this fit put the model in; it can change between refits as bridging games arrive |
| games | Non-skip comparisons behind this snapshot (see where the counts come from) |
| Before reading a chart | Why |
|---|---|
| The series is per refit, not per vote | One point every REFIT_INTERVAL_SECONDS at the default cadence; a refit that skips (no enabled models, or no comparisons yet) writes nothing at all |
| Both refit modes append | A warm-started incremental refit and a cold ground-truth pass produce indistinguishable rows — which is what makes the warm-drift check a check on the solver rather than something a reader does by eye |
| Ratings move without any model changing | They are anchored sum-to-zero per component, so one model's improvement lowers every other rating in its component, and a component split or merge re-anchors the whole group |
| The style pass has no history | model_style_ratings and style_control_coefficients are upserts with no append-only sibling, so style-controlled ratings have a current value only |
Verification
The worker suite (worker/tests/, pure-Python pytest, no database) is what keeps the claims above honest: