Docs · Architecture · API · Integration · Rating methodology · Data model · Setup · SDK

Data model

PostgreSQL owns all arena state. SQL migrations in server/src/db/migrations/ apply in filename order, each in its own transaction, tracked in schema_migrations.

Migrations

FileWhat it creates
001_initial.sqlmodels · matchups · responses · preferences · index preferences_winner_idx
002_conversations_and_turns.sqlconversations · turns · index turns_conversation_idx; adds matchups.harness_version and the six response-metrics columns; backfills one conversation + turn 0 per pre-existing matchup
003_model_ratings.sqlmodel_ratings
004_style_ratings.sqlmodel_style_ratings · style_control_coefficients
005_rating_history.sqlmodel_rating_history · index model_rating_history_computed_idx
006_arena_mode.sqlmatchups.mode (blind | shadow, default blind)
007_matchup_steers.sqlmatchups.steers JSONB array of mid-stream operator instructions
008_matchup_provider_models.sqlmatchups.slot_a/b_provider_model_id — snapshot of each slot's models.provider_model_id at insert time; backfills existing rows from the current roster
Renamed after shipping. Migrations 002–004 once had phase-era filenames, so the runner first rewrites the legacy rows (002_phase_one.sql, 003_phase_two.sql, 004_phase_three.sql) to the current names — an existing database does not re-run them. Never edit an applied migration; add the next NNN_description.sql.

Who writes what

WriterTables
Chat route POST /api/arena/chatconversations · matchups · turns · responses; appends to matchups.steers on mid-stream steer
Vote route POST /api/arena/votepreferences
Model seed db:seed / db:seed:mockmodels
Rating worker, default passmodel_ratings
Rating worker, --style passmodel_style_ratings · style_control_coefficients
Demo-data seeder db:seed:demoeverything above except models — see Setup → Demo data
The default worker pass upserts model_ratings and appends the same rows to model_rating_history in one transaction. The split is strict both ways: the server only reads the four worker-owned rating tables (via LEFT JOIN), and the worker only reads the request-path tables. The demo seeder is the single exception — it writes both sides, which is why it is opt-in and never automatic.

Tables at a glance

models
catalog of comparable models; only enabled ones enter matchmaking
conversations
linear chat container + anonymous session ownership
turns
ordered matchup; parent points only to the prior winning response
matchups
blind or shadow head-to-head; randomized slots, provider-model snapshots, token hash, harness version, mode
responses
content + TTFT, duration, tokens, Markdown density, model version
preferences
one vote per matchup (unique constraint), winner nullable
model_ratings
worker-written Bradley-Terry rating, CI, component id, games
model_style_ratings
style-controlled rating, CI, component id — heavier periodic pass
style_control_coefficients
fitted style confounder coefficients, one row per feature
model_rating_history
append-only rating snapshot per model per refit — the rating-over-time trail
schema_migrations
migration bookkeeping: name + applied_at

Key columns

TableColumnWhy it matters
modelsprovider + provider_model_idUnique pair; provider is google, openai, ollama, vllm, host-proxy, or mock (demos/e2e). Every column is NOT NULL; enabled defaults to TRUE and gates matchmaking, the roster route, and the leaderboard.
modelsidUUID minted per deployment by the seed (randomUUID()). ARENA_DEFAULT_MODEL still accepts this id, and also resolves human identifiers (provider_model_id, display_name, provider:provider_model_id) to it at boot.
conversationsanonymous_session_idNullable; when set, only that browser session may continue the conversation. Inserted only on turn 0, in the same transaction as the matchup and turn.
turnsparent_response_idNull only at turn 0; unique thereafter, preventing two follow-ups from branching from one winner. A table check enforces the pairing: turn_index = 0 ⇔ parent IS NULL.
turnsconversation_id + turn_indexUnique ordered position (turn_index >= 0); prompt persisted as received. A mismatch or unique violation surfaces as conversation_conflict 409.
matchupsslot_a/b_model_idRandomized display assignment, distinct from the raw pair columns; checks forbid self-matches. A non-votable single round writes no row here at all. Shadow sets A = incumbent, B = challenger.
matchupsslot_a/b_provider_model_idSnapshot of each slot's models.provider_model_id at insert time so historical rows keep what ran if a roster alias is later repointed. Migration 008_matchup_provider_models.sql.
matchupsmatchup_token_hashSHA-256 of the signed token — the token itself is never stored.
matchupsharness_versionPrompt/orchestration version captured for temporal drift analysis. Defaults to 'v1'; demo-seeded rows carry 'demo'.
matchupsmodeblind (default, human-votable) or shadow (persisted, not votable). Migration 006_arena_mode.sql. Vote endpoint rejects shadow rows with 403.
matchupssteersJSONB array (default []) of {instruction, at} objects appended by mid-stream steer so later analysis can control for operator interventions. Migration 007_matchup_steers.sql.
responsesttft_ms + stream_duration_msFirst-token and total latency, both >= 0; latency_ms remains the compatibility field. ttft_ms, model_version, and error are the only nullable columns.
responsesoutput_token_count + token_count_sourceProvider usage when available, deterministic lexical estimate otherwise (check-constrained to provider / estimated).
responsesmarkdown_density + model_versionMarker density checked to 0–1, and provider-reported checkpoint identity. Written ON CONFLICT (matchup_id, slot) DO NOTHING, so a replayed slot_done cannot duplicate a row.
preferenceswinner_model_idNull for ties and skips; derived server-side from the voted slot, never taken from the client.
preferencesposition_bias_metaJSONB NOT NULL DEFAULT '{}', currently { selectedSlot } — the style/bias analytics hook.
preferencesanonymous_session_idCarried from the matchup token claims; the unit the worker's anomaly screen operates on.
model_ratingsrating + ci_lower/ci_upperElo-like rating 1000 + (400/ln10)·r with 95% Fisher-information CI. Worker-written (migration 003_model_ratings.sql).
model_ratingscomponent_idConnected-component id; ratings only comparable within a component.
model_ratingsgames + computed_atNon-skip votes involving the model and the refit timestamp; upserted per model.
model_style_ratingsstyle_controlled_rating + style_ci_lower/upperElo-like rating with verbosity/formatting/latency/position regressed out, with 95% CI. Style-pass-written (migration 004_style_ratings.sql).
style_control_coefficientsfeature + coefficientOne row per confounder (position, verbosity, formatting, latency_ttft, latency_duration); upserted each style pass.
model_rating_history(model_id, computed_at) PKAppend-only: same rating/stderr/CI/component/games columns as model_ratings, inserted in the same transaction as each upsert (migration 005_rating_history.sql). Indexed on computed_at; feeds /api/arena/analytics/rating-history and the rating-over-time chart.
Persistence: prompts and responses are stored as received; OmniArena does not transform or redact content.

Indexes

Only what the read paths need; everything else is served by primary keys and unique constraints.

IndexTablePurpose
preferences_winner_idxpreferences (winner_model_id)Win/loss aggregation on the leaderboard
turns_conversation_idxturns (conversation_id, turn_index)Reconstructing a conversation's history in turn order
model_rating_history_computed_idxmodel_rating_history (computed_at)?since= range scans for the rating-over-time chart

Vote semantics

left → slot A wins right → slot B wins both_good → tie both_bad → tie skip → excluded from winRate
Leaderboard: winRate = wins / (wins + losses + ties), computed by SQL aggregation in PostgresRepository.getLeaderboard() over enabled models only; 0 when a model has no votes. A LEFT JOIN on model_ratings adds rating, ratingStdError, confidenceInterval, and componentId (null until the worker runs); a LEFT JOIN on model_style_ratings adds styleControlledRating, styleControlledStdError, and styleControlledConfidenceInterval (null until the style pass runs). Sorted rating DESC NULLS LAST, then wins DESC, total_votes DESC, display_name — an unrated model always sorts below a rated one, and a fresh install is ordered purely by its win-rate record.
A missing model_ratings row means the worker has not rated that model — either it has not run, or the model has no comparisons to rate. A non-votable single round persists no matchups row, so it produces nothing pairwise; see Rating methodology → what the engine cannot rate.

Reads beyond the leaderboard

Port methodReadsNotes
getRatingContext()model_ratings · style_control_coefficientsThe two qualifiers before comparing ratings: connected-component count + per-component head-counts (null before the worker runs), and the fitted style coefficients restated on the display scale (log-odds, rating points, and per interpretable unit where the deltas carry enough spread).
getSummary()matchups · preferences · models · model_ratingsArena-wide totals, slot-A/slot-B decisive split, pairs sampled vs possible, rating components.
getHeadToHead()preferences ⋈ matchupsCanonical (unordered) pair records.
getModelMetrics()responses · preferences ⋈ matchupsp50/p90 TTFT and duration, mean tokens and Markdown density, per-slot record. Skips failed slots (WHERE error IS NULL).
getActivity(bucket)preferences ⋈ matchupsVote volume by outcome and cumulative games, bucketed by day or hour.
getStyleControl()style_control_coefficients · model_ratings · model_style_ratingsCoefficients plus the models rated in both passes.
getRatingHistory(since)model_rating_historyThe only reader of the append-only trail.
Percentiles and time bucketing are computed in TypeScript, not SQL, so every analytics query stays runnable under pg-mem in tests.