dsh-model-arena 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.js CHANGED
@@ -1,18 +1,20 @@
1
- import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH } from './arena-view.js';
2
- export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH };
1
+ import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH, ARENA_COMPARE_PATH, ARENA_DELETE_PATH } from './arena-view.js';
2
+ export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH, ARENA_COMPARE_PATH, ARENA_DELETE_PATH };
3
3
  export function registerArenaRoutes(ctx, arena) {
4
4
  ctx.inject(['connection'], (connectionCtx) => {
5
5
  const connection = connectionCtx.connection;
6
+ // Panel payload (with leaderboard + stats)
6
7
  connection.fetch.register({
7
8
  path: ARENA_PANEL_PATH,
8
9
  methods: ['GET'],
9
10
  requestBody: 'buffered',
10
- // The panel view is written against `PanelPayload`; returning the bare run list
11
- // here made every array method on `payload.recentRuns` throw in the browser.
12
- fetch: () => Promise.resolve(Response.json({ recentRuns: arena.listRuns() }, {
13
- headers: { 'cache-control': 'no-store' },
14
- })),
11
+ fetch: () => Promise.resolve(Response.json({
12
+ recentRuns: arena.listRuns(),
13
+ leaderboard: arena.leaderboard(),
14
+ stats: arena.stats(),
15
+ }, { headers: { 'cache-control': 'no-store' } })),
15
16
  });
17
+ // Run detail
16
18
  connection.fetch.register({
17
19
  path: ARENA_RUN_PATH,
18
20
  methods: ['GET'],
@@ -28,6 +30,7 @@ export function registerArenaRoutes(ctx, arena) {
28
30
  return Response.json(run, { headers: { 'cache-control': 'no-store' } });
29
31
  },
30
32
  });
33
+ // Rate a model in a run
31
34
  connection.fetch.register({
32
35
  path: ARENA_RATE_PATH,
33
36
  methods: ['POST'],
@@ -49,5 +52,46 @@ export function registerArenaRoutes(ctx, arena) {
49
52
  return Response.json(run, { headers: { 'cache-control': 'no-store' } });
50
53
  },
51
54
  });
55
+ // Compare two models in a run
56
+ connection.fetch.register({
57
+ path: ARENA_COMPARE_PATH,
58
+ methods: ['GET'],
59
+ requestBody: 'buffered',
60
+ fetch: async (request) => {
61
+ const url = new URL(request.url);
62
+ const runId = url.searchParams.get('runId');
63
+ const modelA = url.searchParams.get('modelA');
64
+ const modelB = url.searchParams.get('modelB');
65
+ if (!runId || !modelA || !modelB) {
66
+ return Response.json({ error: 'missing runId, modelA, or modelB' }, { status: 400 });
67
+ }
68
+ const result = arena.compare(runId, modelA, modelB);
69
+ if (!result)
70
+ return Response.json({ error: 'not found' }, { status: 404 });
71
+ return Response.json(result, { headers: { 'cache-control': 'no-store' } });
72
+ },
73
+ });
74
+ // Delete a run
75
+ connection.fetch.register({
76
+ path: ARENA_DELETE_PATH,
77
+ methods: ['POST'],
78
+ requestBody: 'buffered',
79
+ fetch: async (request) => {
80
+ let body;
81
+ try {
82
+ body = await request.json();
83
+ }
84
+ catch {
85
+ return Response.json({ error: 'request body must be JSON' }, { status: 400 });
86
+ }
87
+ if (!body.runId) {
88
+ return Response.json({ error: 'missing runId' }, { status: 400 });
89
+ }
90
+ const ok = arena.deleteRun(body.runId);
91
+ if (!ok)
92
+ return Response.json({ error: 'run not found' }, { status: 404 });
93
+ return Response.json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
94
+ },
95
+ });
52
96
  });
53
97
  }
package/lib/scoring.d.ts CHANGED
@@ -1,5 +1,10 @@
1
- import type { ModelResult, Score } from './types.js';
1
+ import type { ModelResult, Score, LeaderboardEntry } from './types.js';
2
2
  /** Score outputs automatically based on simple metrics. */
3
3
  export declare function autoScore(results: ModelResult[], reference?: string): Record<string, Score>;
4
4
  /** Simple character-level similarity (Jaccard on character bigrams). */
5
5
  export declare function similarity(a: string, b: string): number;
6
+ /** Build a leaderboard from all runs using Elo rating. */
7
+ export declare function buildLeaderboard(runs: {
8
+ results: ModelResult[];
9
+ scores: Record<string, Score>;
10
+ }[]): LeaderboardEntry[];
package/lib/scoring.js CHANGED
@@ -33,3 +33,101 @@ export function similarity(a, b) {
33
33
  intersection++;
34
34
  return intersection / (bigramsA.size + bigramsB.size - intersection);
35
35
  }
36
+ // ── Elo rating system ────────────────────────────────────────
37
+ const INITIAL_ELO = 1200;
38
+ const K_FACTOR = 32;
39
+ /** Compute expected score (probability of winning). */
40
+ function expectedScore(ratingA, ratingB) {
41
+ return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
42
+ }
43
+ /** Build a leaderboard from all runs using Elo rating. */
44
+ export function buildLeaderboard(runs) {
45
+ const records = new Map();
46
+ const getOrInit = (model) => {
47
+ if (!records.has(model)) {
48
+ records.set(model, { elo: INITIAL_ELO, wins: 0, losses: 0, draws: 0, games: 0, totalLatencyMs: 0, totalTokens: 0, totalRuns: 0, totalRating: 0, ratingCount: 0 });
49
+ }
50
+ return records.get(model);
51
+ };
52
+ for (const run of runs) {
53
+ // accumulate raw stats
54
+ for (const r of run.results) {
55
+ const rec = getOrInit(r.model);
56
+ rec.totalLatencyMs += r.latencyMs;
57
+ rec.totalTokens += r.promptTokens + r.completionTokens;
58
+ rec.totalRuns++;
59
+ if (r.rating !== undefined) {
60
+ rec.totalRating += r.rating;
61
+ rec.ratingCount++;
62
+ }
63
+ }
64
+ // pairwise Elo updates based on user rating (if any) or composite auto-score
65
+ const models = run.results.map((r) => r.model);
66
+ for (let i = 0; i < models.length; i++) {
67
+ for (let j = i + 1; j < models.length; j++) {
68
+ const a = models[i];
69
+ const b = models[j];
70
+ const recA = getOrInit(a);
71
+ const recB = getOrInit(b);
72
+ const sA = compositeScore(run.scores[a]);
73
+ const sB = compositeScore(run.scores[b]);
74
+ const eA = expectedScore(recA.elo, recB.elo);
75
+ let actualA;
76
+ if (Math.abs(sA - sB) < 0.01) {
77
+ // draw
78
+ actualA = 0.5;
79
+ recA.draws++;
80
+ recB.draws++;
81
+ }
82
+ else if (sA > sB) {
83
+ actualA = 1;
84
+ recA.wins++;
85
+ recB.losses++;
86
+ }
87
+ else {
88
+ actualA = 0;
89
+ recA.losses++;
90
+ recB.wins++;
91
+ }
92
+ recA.elo += K_FACTOR * (actualA - eA);
93
+ recB.elo += K_FACTOR * ((1 - actualA) - (1 - eA));
94
+ recA.games++;
95
+ recB.games++;
96
+ }
97
+ }
98
+ }
99
+ const entries = [];
100
+ for (const [model, rec] of records) {
101
+ entries.push({
102
+ model,
103
+ elo: Math.round(rec.elo),
104
+ wins: rec.wins,
105
+ losses: rec.losses,
106
+ draws: rec.draws,
107
+ games: rec.games,
108
+ totalRuns: rec.totalRuns,
109
+ avgRating: rec.ratingCount > 0 ? Math.round((rec.totalRating / rec.ratingCount) * 10) / 10 : 0,
110
+ avgLatencyMs: rec.games > 0 ? Math.round(rec.totalLatencyMs / rec.games) : 0,
111
+ avgTokens: rec.games > 0 ? Math.round(rec.totalTokens / rec.games) : 0,
112
+ });
113
+ }
114
+ entries.sort((a, b) => b.elo - a.elo);
115
+ return entries;
116
+ }
117
+ /** Combine score dimensions into a single number for ranking. */
118
+ function compositeScore(s) {
119
+ if (!s)
120
+ return 0;
121
+ // User rating dominates if present (1-5 scale → 0-1)
122
+ if (s.userRating !== undefined)
123
+ return s.userRating / 5;
124
+ // Otherwise average available auto-scores
125
+ const parts = [];
126
+ if (s.referenceMatch !== undefined)
127
+ parts.push(s.referenceMatch);
128
+ if (s.lengthScore !== undefined)
129
+ parts.push(s.lengthScore);
130
+ if (s.latencyScore !== undefined)
131
+ parts.push(s.latencyScore);
132
+ return parts.length > 0 ? parts.reduce((a, b) => a + b, 0) / parts.length : 0;
133
+ }
package/lib/store.d.ts CHANGED
@@ -9,5 +9,6 @@ export declare class ArenaStore {
9
9
  listRuns(): ArenaRun[];
10
10
  saveSuite(suite: Suite): void;
11
11
  getSuite(name: string): Suite | null;
12
+ deleteRun(id: string): boolean;
12
13
  static generateId(prompt: string, models: string[]): string;
13
14
  }
package/lib/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  export class ArenaStore {
@@ -40,6 +40,13 @@ export class ArenaStore {
40
40
  return null;
41
41
  return JSON.parse(readFileSync(path, 'utf8'));
42
42
  }
43
+ deleteRun(id) {
44
+ const path = join(this.arenaDir, `${id}.json`);
45
+ if (!existsSync(path))
46
+ return false;
47
+ unlinkSync(path);
48
+ return true;
49
+ }
43
50
  static generateId(prompt, models) {
44
51
  const hash = createHash('sha256');
45
52
  hash.update(prompt);
package/lib/types.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface ModelResult {
5
5
  completionTokens: number;
6
6
  latencyMs: number;
7
7
  error?: string;
8
+ rating?: number;
8
9
  }
9
10
  export interface Score {
10
11
  userRating?: number;
@@ -18,6 +19,7 @@ export interface ArenaRun {
18
19
  timestamp: number;
19
20
  results: ModelResult[];
20
21
  scores: Record<string, Score>;
22
+ reference?: string | undefined;
21
23
  }
22
24
  export interface Suite {
23
25
  name: string;
@@ -36,11 +38,53 @@ export interface RegressionReport {
36
38
  currentOutput: string;
37
39
  }[];
38
40
  }
39
- export interface PanelPayload {
40
- recentRuns: {
41
- id: string;
42
- prompt: string;
43
- timestamp: number;
44
- modelCount: number;
41
+ export interface LeaderboardEntry {
42
+ model: string;
43
+ elo: number;
44
+ wins: number;
45
+ losses: number;
46
+ draws: number;
47
+ games: number;
48
+ totalRuns: number;
49
+ avgRating: number;
50
+ avgLatencyMs: number;
51
+ avgTokens: number;
52
+ }
53
+ export interface RunSummary {
54
+ id: string;
55
+ prompt: string;
56
+ timestamp: number;
57
+ modelCount: number;
58
+ winner?: string | undefined;
59
+ }
60
+ export interface ComparePayload {
61
+ runId: string;
62
+ modelA: string;
63
+ modelB: string;
64
+ outputA: string;
65
+ outputB: string;
66
+ diff: {
67
+ type: 'same' | 'added' | 'removed';
68
+ text: string;
45
69
  }[];
70
+ summary: {
71
+ added: number;
72
+ removed: number;
73
+ changed: number;
74
+ };
75
+ scoreA: Score;
76
+ scoreB: Score;
77
+ }
78
+ export interface ArenaStats {
79
+ totalRuns: number;
80
+ totalModels: number;
81
+ uniqueModels: number;
82
+ uniquePrompts: number;
83
+ totalRatings: number;
84
+ avgModelsPerRun: number;
85
+ }
86
+ export interface PanelPayload {
87
+ recentRuns: RunSummary[];
88
+ leaderboard: LeaderboardEntry[];
89
+ stats: ArenaStats;
46
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-arena",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Run the same prompt through every model you have, and see the difference without squinting.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",