/** * Arena scoring — pure, dependency-free helpers shared by the client (winner card, scoreboard view), * the API (scoreboard aggregation, exports, share snapshots) and the unit tests. * * Nothing in here touches the database or the network. */ // --------------------------------------------------------------------------- // Task categories (from the Smart Router's prompt analysis) // --------------------------------------------------------------------------- export type TaskCategory = "coding" | "research" | "writing" | "reasoning" | "general"; export const CATEGORIES: { value: TaskCategory; label: string }[] = [ { value: "coding", label: "Coding" }, { value: "research", label: "Research" }, { value: "writing", label: "Writing" }, { value: "reasoning", label: "Reasoning" }, { value: "general", label: "General" }, ]; /** Map a router `TaskType` (or anything else) to the five scoreboard categories. */ export function categoryFromTask(task: string | null | undefined): TaskCategory { switch (task) { case "coding": return "coding"; case "research": return "research"; case "writing": case "translation": return "writing"; case "reasoning": case "analysis": return "reasoning"; default: return "general"; } } export function isTaskCategory(v: unknown): v is TaskCategory { return typeof v === "string" && CATEGORIES.some((c) => c.value === v); } // --------------------------------------------------------------------------- // Criteria // --------------------------------------------------------------------------- export interface Criterion { id: string; label: string; short: string; /** Legacy `arena_responses.ratings` key kept in sync for backward compatibility. */ ratingKey: string; custom?: boolean; } export const BUILTIN_CRITERIA: Criterion[] = [ { id: "best", label: "Best answer", short: "Best", ratingKey: "best" }, { id: "accurate", label: "Most accurate", short: "Accurate", ratingKey: "bestAccurate" }, { id: "writing", label: "Best writing", short: "Writing", ratingKey: "bestWriting" }, { id: "coding", label: "Best coding", short: "Coding", ratingKey: "bestCoding" }, { id: "value", label: "Best value", short: "Value", ratingKey: "bestValue" }, { id: "fastest", label: "Fastest", short: "Fastest", ratingKey: "bestSpeed" }, ]; export const CUSTOM_CRITERION_PREFIX = "custom:"; export const CRITERION_RE = /^(best|accurate|writing|coding|value|fastest|custom:[a-z0-9][a-z0-9-]{0,31})$/; export function slugifyCriterion(label: string): string { return label .normalize("NFD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); } /** Build a custom criterion from a user-typed label; null when the label is unusable. */ export function customCriterion(label: string): Criterion | null { const clean = label.trim().slice(0, 40); const slug = slugifyCriterion(clean); if (!slug) return null; const id = `${CUSTOM_CRITERION_PREFIX}${slug}`; return { id, label: clean, short: clean, ratingKey: `bestCustom_${slug}`.slice(0, 32), custom: true }; } export function isCustomCriterion(id: string): boolean { return id.startsWith(CUSTOM_CRITERION_PREFIX); } /** Resolve a criterion id to its definition (custom ids fall back to a humanised slug). */ export function criterionById(id: string, custom: Criterion[] = []): Criterion { const found = BUILTIN_CRITERIA.find((c) => c.id === id) ?? custom.find((c) => c.id === id); if (found) return found; const slug = id.startsWith(CUSTOM_CRITERION_PREFIX) ? id.slice(CUSTOM_CRITERION_PREFIX.length) : id; const label = slug.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase()); return { id, label, short: label, ratingKey: `bestCustom_${slug}`.slice(0, 32), custom: true }; } /** Rating key written to `arena_responses.ratings` for a criterion. */ export function ratingKeyFor(criterion: string): string { return criterionById(criterion).ratingKey; } // --------------------------------------------------------------------------- // Winner // --------------------------------------------------------------------------- export interface ResponseLike { id: string; modelKey: string; status: string; ttftMs: number | null; latencyMs: number | null; costUsd: number | null; usage: { inputTokens?: number; outputTokens?: number } | null; } export interface VoteLike { criterion: string; responseId: string; modelKey: string; } export interface WinnerDeltas { /** Winner − mean of the other responses (negative = winner is cheaper / faster / shorter). */ costUsd: number | null; ttftMs: number | null; latencyMs: number | null; outputTokens: number | null; /** Number of other responses the deltas were computed against. */ others: number; } export interface WinnerResult { modelKey: string; responseId: string; criteriaWon: string[]; votes: number; /** How the tie was broken (null = clear majority). */ tieBreak: "fastest" | "order" | null; deltas: WinnerDeltas; } function mean(values: (number | null | undefined)[]): number | null { const v = values.filter((x): x is number => typeof x === "number" && Number.isFinite(x)); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null; } /** * Arena Winner: the response that won the most criteria; ties go to the fastest time to first token * (then total latency, then original order). Returns null when there is no vote at all. */ export function computeWinner(responses: ResponseLike[], votes: VoteLike[]): WinnerResult | null { if (!responses.length || !votes.length) return null; const byResponse = new Map(); for (const v of votes) { const r = responses.find((x) => x.id === v.responseId) ?? responses.find((x) => x.modelKey === v.modelKey); if (!r) continue; const list = byResponse.get(r.id) ?? []; if (!list.includes(v.criterion)) list.push(v.criterion); byResponse.set(r.id, list); } if (!byResponse.size) return null; const ranked = responses .filter((r) => byResponse.has(r.id)) .map((r) => ({ r, won: byResponse.get(r.id)! })) .sort((a, b) => b.won.length - a.won.length); const top = ranked.filter((x) => x.won.length === ranked[0].won.length); let tieBreak: WinnerResult["tieBreak"] = null; let winner = top[0]; if (top.length > 1) { const speed = (r: ResponseLike) => r.ttftMs ?? r.latencyMs ?? Number.POSITIVE_INFINITY; const sorted = [...top].sort((a, b) => speed(a.r) - speed(b.r)); winner = sorted[0]; tieBreak = Number.isFinite(speed(winner.r)) && speed(sorted[0].r) !== speed(sorted[1].r) ? "fastest" : "order"; } const others = responses.filter((r) => r.id !== winner.r.id && r.status === "complete"); const w = winner.r; const delta = (get: (r: ResponseLike) => number | null | undefined): number | null => { const mine = get(w); const avg = mean(others.map(get)); return typeof mine === "number" && avg !== null ? mine - avg : null; }; return { modelKey: w.modelKey, responseId: w.id, criteriaWon: winner.won, votes: winner.won.length, tieBreak, deltas: { costUsd: delta((r) => r.costUsd), ttftMs: delta((r) => r.ttftMs), latencyMs: delta((r) => r.latencyMs), outputTokens: delta((r) => r.usage?.outputTokens), others: others.length, }, }; } // --------------------------------------------------------------------------- // Scoreboard // --------------------------------------------------------------------------- export interface ScoreboardSession { id: string; modelKeys: string[]; category: TaskCategory; } export interface ScoreboardVote extends VoteLike { sessionId: string; } export interface ScoreboardResponse extends ResponseLike { sessionId: string; } export interface ScoreboardFilter { category?: TaskCategory | null; /** Criterion id (e.g. `value` for cost efficiency). */ criterion?: string | null; } export interface ScoreboardRow { modelKey: string; provider: string; /** Sessions the model took part in (after filtering). */ sessions: number; /** Sessions with at least one qualifying vote — the denominator of the win rate. */ decided: number; /** Sessions the model won (Arena Winner on the filtered criteria). */ wins: number; /** Individual criteria won across sessions. */ votes: number; /** wins / decided, 0..1. */ winRate: number; avgCostUsd: number | null; avgTtftMs: number | null; criteria: Record; } /** * Personal scoreboard: for each model, the share of *decided* comparisons it won. * A comparison is decided when it has at least one vote matching the filter; undecided * sessions still count as participation but cannot be won by anyone. */ export function computeScoreboard(input: { sessions: ScoreboardSession[]; responses: ScoreboardResponse[]; votes: ScoreboardVote[] }, filter: ScoreboardFilter = {}): ScoreboardRow[] { const sessions = filter.category ? input.sessions.filter((s) => s.category === filter.category) : input.sessions; const sessionIds = new Set(sessions.map((s) => s.id)); const votes = input.votes.filter((v) => sessionIds.has(v.sessionId) && (!filter.criterion || v.criterion === filter.criterion)); const responses = input.responses.filter((r) => sessionIds.has(r.sessionId)); const rows = new Map(); const row = (key: string) => { let r = rows.get(key); if (!r) { r = { modelKey: key, provider: key.split("/")[0] ?? "", sessions: 0, decided: 0, wins: 0, votes: 0, winRate: 0, avgCostUsd: null, avgTtftMs: null, criteria: {}, costs: [], ttfts: [] }; rows.set(key, r); } return r; }; for (const s of sessions) { const sVotes = votes.filter((v) => v.sessionId === s.id); const sResponses = responses.filter((r) => r.sessionId === s.id); const participants = new Set([...s.modelKeys, ...sResponses.map((r) => r.modelKey)]); for (const k of participants) { const r = row(k); r.sessions += 1; if (sVotes.length) r.decided += 1; } for (const resp of sResponses) { if (resp.status !== "complete") continue; const r = row(resp.modelKey); if (typeof resp.costUsd === "number") r.costs.push(resp.costUsd); if (typeof resp.ttftMs === "number") r.ttfts.push(resp.ttftMs); } for (const v of sVotes) { const r = row(v.modelKey); r.votes += 1; r.criteria[v.criterion] = (r.criteria[v.criterion] ?? 0) + 1; } const winner = computeWinner(sResponses.length ? sResponses : s.modelKeys.map((k) => ({ id: k, modelKey: k, status: "complete", ttftMs: null, latencyMs: null, costUsd: null, usage: null })), sVotes); if (winner) row(winner.modelKey).wins += 1; } return [...rows.values()] .map(({ costs, ttfts, ...r }) => ({ ...r, winRate: r.decided ? r.wins / r.decided : 0, avgCostUsd: mean(costs), avgTtftMs: mean(ttfts) })) .sort((a, b) => b.winRate - a.winRate || b.wins - a.wins || b.votes - a.votes || b.sessions - a.sessions || a.modelKey.localeCompare(b.modelKey)); } // --------------------------------------------------------------------------- // Blind Arena // --------------------------------------------------------------------------- export const BLIND_LETTERS = ["A", "B", "C", "D"] as const; export function blindLabel(index: number): string { return `Model ${BLIND_LETTERS[index] ?? String(index + 1)}`; } /** Fisher–Yates permutation of `0..n-1` (used to hide selection order in Blind Arena). */ export function shuffledOrder(n: number, random: () => number = Math.random): number[] { const order = Array.from({ length: n }, (_, i) => i); for (let i = n - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } return order; } /** Validate a stored permutation against a model count; falls back to identity. */ export function normalizeOrder(order: unknown, n: number): number[] { const identity = Array.from({ length: n }, (_, i) => i); if (!Array.isArray(order) || order.length !== n) return identity; const seen = new Set(); for (const v of order) { if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v >= n || seen.has(v)) return identity; seen.add(v); } return order as number[]; }