localarena 0.1.0 → 0.2.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.
@@ -0,0 +1,355 @@
1
+ import { writeFile } from "node:fs/promises";
2
+
3
+ import { EvaluationRun } from "./evaluation.js";
4
+
5
+ function escapeHTML(value) {
6
+ const text = value === null || value === undefined ? "" : String(value);
7
+ return text
8
+ .replaceAll("&", "&")
9
+ .replaceAll("<", "&lt;")
10
+ .replaceAll(">", "&gt;")
11
+ .replaceAll('"', "&quot;")
12
+ .replaceAll("'", "&#x27;");
13
+ }
14
+
15
+ function formatNumber(value, digits = 3) {
16
+ if (value === null || value === undefined) {
17
+ return "—";
18
+ }
19
+ if (typeof value !== "number") {
20
+ return String(value);
21
+ }
22
+ return value.toFixed(digits).replace(/\.?0+$/u, "");
23
+ }
24
+
25
+ export function renderHTMLReport(run, options = {}) {
26
+ if (!(run instanceof EvaluationRun)) {
27
+ throw new TypeError("run must be an EvaluationRun");
28
+ }
29
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
30
+ throw new TypeError("options must be an object");
31
+ }
32
+ if (options.title !== undefined && typeof options.title !== "string") {
33
+ throw new TypeError("title must be a string");
34
+ }
35
+
36
+ const reportTitle = options.title || run.name;
37
+ const summary = run.summary();
38
+ const taskIds = run.tasks.map(({ id }) => String(id));
39
+ const errors = run.records.filter(({ error }) => error !== null).length;
40
+ const scored = run.records.filter(({ score }) => score !== null).length;
41
+ const totalSeconds = run.records.reduce(
42
+ (total, record) => total + record.durationSeconds,
43
+ 0,
44
+ );
45
+ const judgeRows = run.tasks
46
+ .filter(
47
+ (task) =>
48
+ task.evaluator?.type === "model_judge" &&
49
+ task.evaluator.target !== null &&
50
+ typeof task.evaluator.target === "object",
51
+ )
52
+ .map((task) => {
53
+ const target = task.evaluator.target;
54
+ const parameters =
55
+ target.parameters !== null &&
56
+ typeof target.parameters === "object"
57
+ ? target.parameters
58
+ : {};
59
+ return `
60
+ <tr>
61
+ <td>${escapeHTML(task.id)}</td>
62
+ <td><strong>${escapeHTML(target.name)}</strong></td>
63
+ <td>${escapeHTML(target.provider)}</td>
64
+ <td>${escapeHTML(target.model)}</td>
65
+ <td class="metric">${escapeHTML(parameters.max_tokens)}</td>
66
+ <td class="metric">${formatNumber(parameters.temperature)}</td>
67
+ </tr>`;
68
+ })
69
+ .join("");
70
+ const judgeSection =
71
+ judgeRows.length === 0
72
+ ? ""
73
+ : `
74
+ <section>
75
+ <h2>Live judges</h2>
76
+ <div class="table-wrap">
77
+ <table>
78
+ <thead><tr><th>Task</th><th>Target</th><th>Provider</th><th>Model</th><th>Max tokens</th><th>Temperature</th></tr></thead>
79
+ <tbody>${judgeRows}</tbody>
80
+ </table>
81
+ </div>
82
+ </section>`;
83
+
84
+ const scoreRows = summary
85
+ .map((row) => {
86
+ const score = row.average_score;
87
+ const width =
88
+ score === null ? 0 : Math.max(0, Math.min(100, score * 100));
89
+ return `
90
+ <tr>
91
+ <td><strong>${escapeHTML(row.name)}</strong><br>
92
+ <span class="muted">${escapeHTML(row.provider)} · ${escapeHTML(row.model)}</span>
93
+ </td>
94
+ <td class="metric">${formatNumber(score)}</td>
95
+ <td class="chart-cell"><div class="bar-track"><div class="bar" style="width:${width.toFixed(2)}%"></div></div></td>
96
+ <td class="metric">${formatNumber(row.pass_rate)}</td>
97
+ <td class="metric">${formatNumber(row.elo, 1)}</td>
98
+ <td class="metric">${formatNumber(row.average_latency_seconds)}</td>
99
+ <td class="metric">${escapeHTML(row.input_tokens)} / ${escapeHTML(row.output_tokens)}</td>
100
+ <td class="metric">${formatNumber(row.judge_latency_seconds)}</td>
101
+ <td class="metric">${escapeHTML(row.judge_input_tokens)} / ${escapeHTML(row.judge_output_tokens)}</td>
102
+ <td class="metric">${escapeHTML(row.successful)} / ${escapeHTML(row.runs)}</td>
103
+ <td class="metric error-count">${escapeHTML(row.errors)}</td>
104
+ </tr>`;
105
+ })
106
+ .join("");
107
+
108
+ const grouped = new Map();
109
+ const failures = new Set();
110
+ for (const record of run.records) {
111
+ const key = JSON.stringify([record.target, record.taskId]);
112
+ if (record.score !== null) {
113
+ if (!grouped.has(key)) {
114
+ grouped.set(key, []);
115
+ }
116
+ grouped.get(key).push(record.score.value);
117
+ }
118
+ if (record.error !== null) {
119
+ failures.add(key);
120
+ }
121
+ }
122
+
123
+ const matrixHeader = taskIds
124
+ .map((taskId) => `<th>${escapeHTML(taskId)}</th>`)
125
+ .join("");
126
+ const matrixRows = run.models
127
+ .map((model) => {
128
+ const name = String(model.name);
129
+ const cells = taskIds
130
+ .map((taskId) => {
131
+ const key = JSON.stringify([name, taskId]);
132
+ const values = grouped.get(key) ?? [];
133
+ if (values.length > 0) {
134
+ const value =
135
+ values.reduce((total, item) => total + item, 0) /
136
+ values.length;
137
+ const hue = value * 120;
138
+ return `<td class="heat" style="--heat:${hue.toFixed(1)}">${formatNumber(value)}</td>`;
139
+ }
140
+ if (failures.has(key)) {
141
+ return '<td class="heat failed">error</td>';
142
+ }
143
+ return '<td class="heat missing">—</td>';
144
+ })
145
+ .join("");
146
+ return `<tr><th>${escapeHTML(name)}</th>${cells}</tr>`;
147
+ })
148
+ .join("");
149
+
150
+ const detailRows = run.records
151
+ .map((record) => {
152
+ const scoreText =
153
+ record.score === null ? "—" : formatNumber(record.score.value);
154
+ const answer =
155
+ run.includeContent && record.generation !== null
156
+ ? record.generation.text
157
+ : "";
158
+ const reason =
159
+ run.includeContent && record.score !== null
160
+ ? record.score.reason
161
+ : record.score === null
162
+ ? "No score"
163
+ : "Not retained";
164
+ const error =
165
+ run.includeContent && record.error !== null
166
+ ? `<h4>Error</h4><pre>${escapeHTML(record.error)}</pre>`
167
+ : "";
168
+ return `
169
+ <details class="record ${record.error ? "record-error" : ""}">
170
+ <summary>
171
+ <span class="status status-${escapeHTML(record.status)}">${escapeHTML(record.status)}</span>
172
+ <strong>${escapeHTML(record.target)}</strong>
173
+ <span>× ${escapeHTML(record.taskId)}</span>
174
+ <span class="grow"></span>
175
+ <span>score ${scoreText}</span>
176
+ <span>${formatNumber(record.durationSeconds)}s</span>
177
+ </summary>
178
+ <div class="record-grid">
179
+ <div><h4>Answer</h4><pre>${answer ? escapeHTML(answer) : "Not retained"}</pre></div>
180
+ <div>
181
+ <h4>Evaluation</h4>
182
+ <p>${escapeHTML(reason)}</p>
183
+ <h4>Provider result</h4>
184
+ <dl>
185
+ <dt>Provider</dt><dd>${escapeHTML(record.provider)}</dd>
186
+ <dt>Model</dt><dd>${escapeHTML(record.model)}</dd>
187
+ <dt>Repetition</dt><dd>${record.repetition}</dd>
188
+ <dt>Finish reason</dt><dd>${record.generation ? escapeHTML(record.generation.finishReason ?? "—") : "—"}</dd>
189
+ </dl>
190
+ ${error}
191
+ </div>
192
+ </div>
193
+ </details>`;
194
+ })
195
+ .join("");
196
+
197
+ return `<!doctype html>
198
+ <html lang="en">
199
+ <head>
200
+ <meta charset="utf-8">
201
+ <meta name="viewport" content="width=device-width, initial-scale=1">
202
+ <meta name="color-scheme" content="dark light">
203
+ <link rel="icon" href="data:,">
204
+ <title>${escapeHTML(reportTitle)} · LocalArena</title>
205
+ <style>
206
+ :root {
207
+ --bg: #0b1020;
208
+ --panel: #121a2d;
209
+ --panel-2: #18233a;
210
+ --text: #e8edf7;
211
+ --muted: #9aa8bf;
212
+ --line: #2a3853;
213
+ --accent: #5eead4;
214
+ --accent-2: #60a5fa;
215
+ --danger: #fb7185;
216
+ --shadow: 0 18px 50px rgba(0,0,0,.22);
217
+ }
218
+ * { box-sizing: border-box; }
219
+ body {
220
+ margin: 0;
221
+ background: radial-gradient(circle at 12% 0%, #172554 0, transparent 30rem), var(--bg);
222
+ color: var(--text);
223
+ font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
224
+ }
225
+ main { width: min(1380px, calc(100% - 32px)); margin: 42px auto 80px; }
226
+ h1 { font-size: clamp(30px, 5vw, 54px); line-height: 1.05; margin: 0 0 10px; letter-spacing: -.04em; }
227
+ h2 { margin: 0 0 20px; font-size: 22px; }
228
+ h4 { margin: 0 0 8px; }
229
+ .eyebrow { color: var(--accent); font-size: 12px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
230
+ .subtitle { color: var(--muted); margin: 0; }
231
+ .cards { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 12px; margin: 28px 0; }
232
+ .card, section {
233
+ border: 1px solid var(--line);
234
+ border-radius: 16px;
235
+ background: color-mix(in srgb, var(--panel) 94%, transparent);
236
+ box-shadow: var(--shadow);
237
+ }
238
+ .card { padding: 18px; }
239
+ .card .value { display: block; font-size: 27px; font-weight: 800; }
240
+ .card .label, .muted { color: var(--muted); }
241
+ section { padding: 22px; margin-top: 18px; overflow: hidden; }
242
+ .table-wrap { overflow-x: auto; }
243
+ table { width: 100%; border-collapse: collapse; }
244
+ th, td { border-bottom: 1px solid var(--line); padding: 12px 10px; text-align: left; vertical-align: middle; }
245
+ th { color: var(--muted); font-size: 12px; letter-spacing: .05em; text-transform: uppercase; }
246
+ tbody tr:last-child td, tbody tr:last-child th { border-bottom: 0; }
247
+ .metric { font-variant-numeric: tabular-nums; white-space: nowrap; }
248
+ .chart-cell { min-width: 180px; width: 28%; }
249
+ .bar-track { width: 100%; height: 11px; border-radius: 999px; background: var(--panel-2); overflow: hidden; }
250
+ .bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
251
+ .error-count { color: var(--danger); }
252
+ .matrix th:first-child { position: sticky; left: 0; background: var(--panel); z-index: 1; }
253
+ .heat { text-align: center; font-weight: 800; color: white; background: hsl(var(--heat) 48% 33%); min-width: 90px; }
254
+ .heat.failed { background: #7f1d1d; }
255
+ .heat.missing { background: var(--panel-2); color: var(--muted); }
256
+ details.record { border-top: 1px solid var(--line); }
257
+ details.record:first-of-type { border-top: 0; }
258
+ summary { cursor: pointer; display: flex; align-items: center; gap: 13px; padding: 14px 4px; list-style: none; }
259
+ summary::-webkit-details-marker { display: none; }
260
+ summary::before { content: "›"; color: var(--muted); font-size: 22px; transition: transform .15s ease; }
261
+ details[open] summary::before { transform: rotate(90deg); }
262
+ .grow { flex: 1; }
263
+ .status { padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; text-transform: uppercase; background: #164e63; }
264
+ .status-generation_error, .status-score_error { background: #881337; }
265
+ .record-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 18px; padding: 4px 4px 20px 42px; }
266
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 13px; border-radius: 10px; background: #070b15; color: #dbeafe; max-height: 420px; overflow: auto; }
267
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: 5px 12px; margin: 0; }
268
+ dt { color: var(--muted); }
269
+ dd { margin: 0; overflow-wrap: anywhere; }
270
+ footer { color: var(--muted); text-align: center; margin-top: 26px; font-size: 13px; }
271
+ @media (max-width: 820px) {
272
+ .cards { grid-template-columns: repeat(2, 1fr); }
273
+ .record-grid { grid-template-columns: 1fr; padding-left: 4px; }
274
+ summary span:not(.status):not(.grow) { display: none; }
275
+ }
276
+ @media (prefers-color-scheme: light) {
277
+ :root {
278
+ --bg: #f5f7fb;
279
+ --panel: #ffffff;
280
+ --panel-2: #edf2f7;
281
+ --text: #172033;
282
+ --muted: #60708a;
283
+ --line: #d8e0eb;
284
+ --shadow: 0 16px 45px rgba(35,50,80,.09);
285
+ }
286
+ body { background: radial-gradient(circle at 12% 0%, #dbeafe 0, transparent 30rem), var(--bg); }
287
+ pre { background: #111827; color: #e5edff; }
288
+ .matrix th:first-child { background: var(--panel); }
289
+ }
290
+ </style>
291
+ </head>
292
+ <body>
293
+ <main>
294
+ <header>
295
+ <div class="eyebrow">LocalArena live evaluation</div>
296
+ <h1>${escapeHTML(reportTitle)}</h1>
297
+ <p class="subtitle">Run ${escapeHTML(run.id)} · ${escapeHTML(run.startedAt)} → ${escapeHTML(run.finishedAt)}</p>
298
+ </header>
299
+
300
+ <div class="cards">
301
+ <div class="card"><span class="value">${run.models.length}</span><span class="label">models</span></div>
302
+ <div class="card"><span class="value">${run.tasks.length}</span><span class="label">tasks</span></div>
303
+ <div class="card"><span class="value">${run.records.length}</span><span class="label">evaluation rows</span></div>
304
+ <div class="card"><span class="value">${scored}</span><span class="label">scored</span></div>
305
+ <div class="card"><span class="value">${errors}</span><span class="label">errors · ${formatNumber(totalSeconds, 1)}s total</span></div>
306
+ </div>
307
+
308
+ <section>
309
+ <h2>Leaderboard</h2>
310
+ <div class="table-wrap">
311
+ <table>
312
+ <thead><tr><th>Model</th><th>Score</th><th>Relative score</th><th>Pass rate</th><th>Elo</th><th>Candidate latency (s)</th><th>Candidate tokens in / out</th><th>Judge latency (s)</th><th>Judge tokens in / out</th><th>Successful / runs</th><th>Errors</th></tr></thead>
313
+ <tbody>${scoreRows}</tbody>
314
+ </table>
315
+ </div>
316
+ </section>
317
+
318
+ ${judgeSection}
319
+
320
+ <section>
321
+ <h2>Task matrix</h2>
322
+ <div class="table-wrap">
323
+ <table class="matrix">
324
+ <thead><tr><th>Model</th>${matrixHeader}</tr></thead>
325
+ <tbody>${matrixRows}</tbody>
326
+ </table>
327
+ </div>
328
+ </section>
329
+
330
+ <section>
331
+ <h2>Outputs and diagnostics</h2>
332
+ ${detailRows}
333
+ </section>
334
+
335
+ <footer>Generated locally by LocalArena. Provider credentials and endpoint URLs are not included.</footer>
336
+ </main>
337
+ </body>
338
+ </html>
339
+ `;
340
+ }
341
+
342
+ export async function writeHTMLReport(run, path, options = {}) {
343
+ if (
344
+ typeof path !== "string" &&
345
+ !(path instanceof URL) &&
346
+ !Buffer.isBuffer(path)
347
+ ) {
348
+ throw new TypeError("path must be a string, URL, or Buffer");
349
+ }
350
+ await writeFile(path, renderHTMLReport(run, options), "utf8");
351
+ return path;
352
+ }
353
+
354
+ export const renderHtmlReport = renderHTMLReport;
355
+ export const writeHtmlReport = writeHTMLReport;