@prismatic-io/lux 0.0.2-preview.4 → 0.0.2-preview.5

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.
Files changed (110) hide show
  1. package/lib/assertions/authoring.d.ts +2 -2
  2. package/lib/assertions/authoring.d.ts.map +1 -1
  3. package/lib/assertions/authoring.js.map +1 -1
  4. package/lib/assertions/core/index.d.ts.map +1 -1
  5. package/lib/assertions/core/index.js +2 -1
  6. package/lib/assertions/core/index.js.map +1 -1
  7. package/lib/assertions/core/resource-checks.d.ts +19 -0
  8. package/lib/assertions/core/resource-checks.d.ts.map +1 -1
  9. package/lib/assertions/core/resource-checks.js +32 -0
  10. package/lib/assertions/core/resource-checks.js.map +1 -1
  11. package/lib/cli/program.d.ts.map +1 -1
  12. package/lib/cli/program.js +6 -65
  13. package/lib/cli/program.js.map +1 -1
  14. package/lib/cli/view.d.ts +13 -0
  15. package/lib/cli/view.d.ts.map +1 -0
  16. package/lib/cli/view.js +126 -0
  17. package/lib/cli/view.js.map +1 -0
  18. package/lib/core/action-events.d.ts +29 -0
  19. package/lib/core/action-events.d.ts.map +1 -0
  20. package/lib/core/action-events.js +51 -0
  21. package/lib/core/action-events.js.map +1 -0
  22. package/lib/core/driver-metrics.d.ts +98 -0
  23. package/lib/core/driver-metrics.d.ts.map +1 -0
  24. package/lib/core/driver-metrics.js +36 -0
  25. package/lib/core/driver-metrics.js.map +1 -0
  26. package/lib/core/events.js +2 -2
  27. package/lib/core/experiment.d.ts +19 -0
  28. package/lib/core/experiment.d.ts.map +1 -1
  29. package/lib/core/experiment.js +2 -0
  30. package/lib/core/experiment.js.map +1 -1
  31. package/lib/core/index.d.ts +2 -0
  32. package/lib/core/index.d.ts.map +1 -1
  33. package/lib/core/index.js +2 -0
  34. package/lib/core/index.js.map +1 -1
  35. package/lib/core/run.d.ts +50 -0
  36. package/lib/core/run.d.ts.map +1 -1
  37. package/lib/core/run.js +6 -0
  38. package/lib/core/run.js.map +1 -1
  39. package/lib/index.d.ts +3 -1
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +3 -1
  42. package/lib/index.js.map +1 -1
  43. package/lib/orchestrator/compare.d.ts +9 -1
  44. package/lib/orchestrator/compare.d.ts.map +1 -1
  45. package/lib/orchestrator/compare.js +8 -0
  46. package/lib/orchestrator/compare.js.map +1 -1
  47. package/lib/orchestrator/experiment-contracts.d.ts +19 -0
  48. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  49. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  50. package/lib/orchestrator/experiment-evaluation.js +17 -0
  51. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  52. package/lib/orchestrator/experiment-report.d.ts +19 -0
  53. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  54. package/lib/orchestrator/experiment-runs.d.ts +38 -0
  55. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  56. package/lib/orchestrator/index.d.ts +1 -1
  57. package/lib/orchestrator/index.d.ts.map +1 -1
  58. package/lib/orchestrator/index.js +1 -1
  59. package/lib/orchestrator/index.js.map +1 -1
  60. package/lib/orchestrator/list-runs.d.ts +6 -1
  61. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  62. package/lib/orchestrator/list-runs.js +15 -1
  63. package/lib/orchestrator/list-runs.js.map +1 -1
  64. package/lib/orchestrator/run-execution.d.ts.map +1 -1
  65. package/lib/orchestrator/run-execution.js +11 -1
  66. package/lib/orchestrator/run-execution.js.map +1 -1
  67. package/lib/orchestrator/suite-compare.d.ts +34 -0
  68. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  69. package/lib/orchestrator/suite-summary.d.ts +68 -0
  70. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  71. package/lib/orchestrator/suite-summary.js +22 -7
  72. package/lib/orchestrator/suite-summary.js.map +1 -1
  73. package/lib/previewer/artifacts.d.ts +25 -0
  74. package/lib/previewer/artifacts.d.ts.map +1 -0
  75. package/lib/previewer/artifacts.js +180 -0
  76. package/lib/previewer/artifacts.js.map +1 -0
  77. package/lib/previewer/server.d.ts +104 -0
  78. package/lib/previewer/server.d.ts.map +1 -0
  79. package/lib/previewer/server.js +541 -0
  80. package/lib/previewer/server.js.map +1 -0
  81. package/package.json +2 -1
  82. package/skills/lux/references/eval-authoring.md +4 -2
  83. package/skills/lux-answerer/SKILL.md +1 -1
  84. package/src/assertions/authoring.ts +2 -0
  85. package/src/assertions/core/index.ts +2 -0
  86. package/src/assertions/core/resource-checks.ts +35 -0
  87. package/src/cli/program.ts +5 -91
  88. package/src/cli/view.ts +168 -0
  89. package/src/core/action-events.ts +59 -0
  90. package/src/core/driver-metrics.ts +51 -0
  91. package/src/core/events.ts +2 -2
  92. package/src/core/experiment.ts +2 -0
  93. package/src/core/index.ts +2 -0
  94. package/src/core/run.ts +6 -0
  95. package/src/index.ts +25 -0
  96. package/src/orchestrator/compare.ts +12 -0
  97. package/src/orchestrator/experiment-evaluation.ts +23 -0
  98. package/src/orchestrator/index.ts +1 -0
  99. package/src/orchestrator/list-runs.ts +25 -0
  100. package/src/orchestrator/run-execution.ts +12 -0
  101. package/src/orchestrator/suite-summary.ts +36 -8
  102. package/src/previewer/artifacts.ts +221 -0
  103. package/src/previewer/server.ts +763 -0
  104. package/viewer/app.css +2022 -0
  105. package/viewer/app.js +601 -0
  106. package/viewer/artifact-view.js +48 -0
  107. package/viewer/charts.js +86 -0
  108. package/viewer/detail.js +1108 -0
  109. package/viewer/format.js +129 -0
  110. package/viewer/index.html +151 -0
@@ -0,0 +1,1108 @@
1
+ import { comparisonBars, compositionFigure, quantizedBar } from "./charts.js";
2
+ import {
3
+ badge,
4
+ compactNumber,
5
+ detailEmpty,
6
+ duration,
7
+ escapeHtml,
8
+ finite,
9
+ fullDate,
10
+ metricName,
11
+ metricValue,
12
+ percentage,
13
+ signedDuration,
14
+ signedPercentage,
15
+ statusLabel,
16
+ toneForDelta,
17
+ totalTokens,
18
+ } from "./format.js";
19
+
20
+ const json = (value) => {
21
+ if (value === undefined) return "";
22
+ try {
23
+ return JSON.stringify(value, null, 2);
24
+ } catch {
25
+ return String(value);
26
+ }
27
+ };
28
+
29
+ const eventContent = (event) => {
30
+ if (event.type === "lifecycle") return [event.stage, "Run lifecycle"];
31
+ if (event.type === "ready") {
32
+ return [
33
+ `Agent ready · ${event.agentId}`,
34
+ event.model ? `Model ${event.model}` : "Driver ready",
35
+ ];
36
+ }
37
+ if (event.type === "interrupt") {
38
+ return [
39
+ `Human-in-the-loop · ${event.kind}`,
40
+ event.question ?? event.request ?? json(event.payload),
41
+ ];
42
+ }
43
+ if (event.type === "responded") {
44
+ let answer = event.text ?? null;
45
+ if (!answer && typeof event.approved === "boolean") {
46
+ answer = event.approved ? "Approved" : "Denied";
47
+ }
48
+ return [`Answerer · ${event.kind}`, answer ?? json(event.payload)];
49
+ }
50
+ if (event.type === "progress") {
51
+ const payload = event.payload ?? {};
52
+ if (event.kind === "action") {
53
+ return [
54
+ `Action · ${payload.name ?? "unknown"}`,
55
+ payload.category ? `Category: ${payload.category}` : json(payload),
56
+ ];
57
+ }
58
+ const name = event.kind === "tool-call" ? payload.name : event.kind;
59
+ return [`Progress · ${name ?? event.kind}`, json(payload)];
60
+ }
61
+ if (event.type === "error") return [`Error · ${event.exitReason}`, event.reason];
62
+ return ["Completed", event.summary ? json(event.summary) : "Driver finished"];
63
+ };
64
+
65
+ const eventTime = (event, startedAt) => {
66
+ if (typeof event.ts !== "number") return "setup";
67
+ if (event.ts > 1_000_000_000_000) return fullDate(event.ts);
68
+ const start = Date.parse(startedAt ?? "");
69
+ if (!Number.isFinite(start)) return duration(event.ts);
70
+ return `+${duration(Math.max(0, event.ts - start))}`;
71
+ };
72
+
73
+ const assertionMeta = (assertion) =>
74
+ [
75
+ assertion.type,
76
+ `threshold ${finite(assertion.threshold)}`,
77
+ `weight ${finite(assertion.weight)}`,
78
+ duration(assertion.durationMs),
79
+ assertion.usage ? `${compactNumber(totalTokens(assertion.usage))} judge tokens` : null,
80
+ ]
81
+ .filter(Boolean)
82
+ .join(" · ");
83
+
84
+ const assertionDiagnostics = (assertion) => {
85
+ const rows = [
86
+ assertion.comment ? `<p class="assertion-comment">${escapeHtml(assertion.comment)}</p>` : "",
87
+ assertion.errorMessage
88
+ ? `<p class="diagnostic-error">${escapeHtml(assertion.errorMessage)}</p>`
89
+ : "",
90
+ assertion.detail !== undefined
91
+ ? `<details class="diagnostic"><summary>Structured detail</summary><pre>${escapeHtml(
92
+ json(assertion.detail),
93
+ )}</pre></details>`
94
+ : "",
95
+ ];
96
+ return rows.join("");
97
+ };
98
+
99
+ const inferredMetricUnit = (name, unit) => {
100
+ if (unit) return unit;
101
+ if (name.endsWith("Ms")) return "milliseconds";
102
+ if (name.endsWith(".bytes")) return "bytes";
103
+ if (name.endsWith(".usd")) return "usd";
104
+ if (name.endsWith("Rate")) return "ratio";
105
+ if (
106
+ name.includes("tokens") ||
107
+ /(?:^|\.)(?:input|output|total|cacheRead|cacheCreation)$/.test(name)
108
+ ) {
109
+ return "tokens";
110
+ }
111
+ return "number";
112
+ };
113
+
114
+ const driverMetricStats = (metrics) =>
115
+ Object.entries(metrics ?? {})
116
+ .map(
117
+ ([name, metric]) =>
118
+ `<div><dt>${escapeHtml(metricName(name, metric.label))}</dt><dd>${escapeHtml(
119
+ metricValue(metric.value, metric.unit),
120
+ )}</dd><small>${escapeHtml(name)}</small></div>`,
121
+ )
122
+ .join("");
123
+
124
+ export const renderRunDetail = (item, detail) => {
125
+ const metadata = detail.metadata;
126
+ const grading = detail.grading;
127
+ const assertions = grading?.assertions ?? [];
128
+ const events = detail.events ?? [];
129
+ const artifacts = detail.artifacts ?? [];
130
+ const agentTokens = totalTokens(metadata?.usage?.agent);
131
+ const answererTokens = totalTokens(metadata?.usage?.answerer);
132
+ const gradingTokens = totalTokens(metadata?.usage?.grading);
133
+ const harnessTokens = answererTokens + gradingTokens;
134
+ const phase = metadata?.phaseDurationMs;
135
+ const actionTiming = metadata?.actionTiming;
136
+ const prompt = detail.case?.prompt;
137
+ const driverMetrics = driverMetricStats(metadata?.driverMetrics);
138
+ return `
139
+ <header class="detail-header">
140
+ <div class="detail-kicker">
141
+ ${badge(item.status)}
142
+ <span class="detail-path">${escapeHtml(item.path)}</span>
143
+ </div>
144
+ <div class="detail-title-row">
145
+ <div>
146
+ <h2>${escapeHtml(detail.case?.id ?? item.caseId)}</h2>
147
+ <p class="detail-subtitle">${escapeHtml(
148
+ [metadata?.driver?.name, metadata?.driver?.model, fullDate(metadata?.startedAt)]
149
+ .filter(Boolean)
150
+ .join(" · "),
151
+ )}</p>
152
+ </div>
153
+ <div class="score-block">
154
+ <strong>${escapeHtml(percentage(grading?.caseScore))}</strong>
155
+ <small>case score</small>
156
+ </div>
157
+ </div>
158
+ </header>
159
+ <div class="detail-body">
160
+ ${
161
+ grading?.incomplete
162
+ ? `<div class="attention-banner">Incomplete ${escapeHtml(
163
+ grading.incomplete.phase ?? "run",
164
+ )}: ${escapeHtml(grading.incomplete.exitReason)}</div>`
165
+ : ""
166
+ }
167
+ <dl class="detail-stats">
168
+ <div><dt>Duration</dt><dd>${escapeHtml(duration(item.durationMs))}</dd></div>
169
+ <div><dt>Agent tokens</dt><dd>${escapeHtml(compactNumber(agentTokens))}</dd></div>
170
+ <div><dt>Harness tokens</dt><dd>${escapeHtml(compactNumber(harnessTokens))}</dd></div>
171
+ <div><dt>Artifacts</dt><dd>${artifacts.length}</dd></div>
172
+ <div><dt>First action</dt><dd>${escapeHtml(duration(actionTiming?.timeToFirstActionMs))}</dd></div>
173
+ <div><dt>First mutation</dt><dd>${escapeHtml(duration(actionTiming?.timeToFirstMutationMs))}</dd></div>
174
+ <div><dt>First durable mutation</dt><dd>${escapeHtml(
175
+ duration(actionTiming?.timeToFirstDurableMutationMs),
176
+ )}</dd></div>
177
+ </dl>
178
+ ${
179
+ driverMetrics
180
+ ? `<section class="detail-section"><h3>Driver metrics <span>reported by ${escapeHtml(
181
+ metadata?.driver?.name ?? "driver",
182
+ )}</span></h3><dl class="detail-stats driver-metrics">${driverMetrics}</dl></section>`
183
+ : ""
184
+ }
185
+ <div class="evidence-visual-grid">
186
+ ${compositionFigure(
187
+ "Judgment outcome",
188
+ [
189
+ {
190
+ key: "passed",
191
+ label: "Passed",
192
+ value: assertions.filter((assertion) => assertion.passed).length,
193
+ },
194
+ {
195
+ key: "regressed",
196
+ label: "Failed",
197
+ value: assertions.filter((assertion) => !assertion.passed).length,
198
+ },
199
+ ],
200
+ `${assertions.filter((assertion) => assertion.passed).length} of ${
201
+ assertions.length
202
+ } assertions passed`,
203
+ )}
204
+ ${
205
+ agentTokens + answererTokens + gradingTokens > 0
206
+ ? compositionFigure(
207
+ "Token composition",
208
+ [
209
+ { key: "agent", label: "Agent", value: agentTokens },
210
+ { key: "answerer", label: "Answerer", value: answererTokens },
211
+ { key: "grading", label: "Grading", value: gradingTokens },
212
+ ],
213
+ `${agentTokens} agent, ${answererTokens} answerer, ${gradingTokens} grading tokens`,
214
+ )
215
+ : ""
216
+ }
217
+ ${
218
+ phase
219
+ ? compositionFigure(
220
+ "Runtime composition",
221
+ [
222
+ { key: "agent", label: "Agent", value: phase.agent },
223
+ { key: "collect", label: "Collect", value: phase.collect },
224
+ { key: "grading", label: "Grading", value: phase.grading },
225
+ ],
226
+ `${duration(phase.agent)} agent, ${duration(phase.collect)} collection, ${duration(
227
+ phase.grading,
228
+ )} grading`,
229
+ )
230
+ : ""
231
+ }
232
+ </div>
233
+ ${
234
+ prompt
235
+ ? `<section class="detail-section"><h3>Case prompt</h3><div class="prompt-block">${escapeHtml(
236
+ prompt,
237
+ )}</div></section>`
238
+ : ""
239
+ }
240
+ <section class="detail-section">
241
+ <h3>Judgment <span>${assertions.filter((entry) => entry.passed).length}/${assertions.length} passed</span></h3>
242
+ <div class="assertion-list">
243
+ ${
244
+ assertions.length
245
+ ? assertions
246
+ .map(
247
+ (assertion) => `
248
+ <article class="assertion${assertion.passed ? " passed" : ""}">
249
+ <span class="assertion-mark">${assertion.passed ? "✓" : "×"}</span>
250
+ <span class="assertion-content">
251
+ <strong>${escapeHtml(assertion.name)}</strong>
252
+ <small>${escapeHtml(assertionMeta(assertion))}</small>
253
+ ${quantizedBar(
254
+ assertion.score,
255
+ assertion.passed ? "tone-positive" : "tone-negative",
256
+ `${assertion.name} scored ${percentage(
257
+ assertion.score,
258
+ )}; threshold ${percentage(assertion.threshold)}`,
259
+ assertion.threshold,
260
+ )}
261
+ ${assertionDiagnostics(assertion)}
262
+ </span>
263
+ <span class="assertion-score">${escapeHtml(
264
+ percentage(assertion.score),
265
+ )}</span>
266
+ </article>`,
267
+ )
268
+ .join("")
269
+ : '<div class="artifact"><span>No grading assertions recorded</span><small>ungraded</small></div>'
270
+ }
271
+ </div>
272
+ </section>
273
+ <section class="detail-section">
274
+ <h3>Trace <span>${events.length} events</span></h3>
275
+ <div class="trace-list">
276
+ ${
277
+ events.length
278
+ ? events
279
+ .map((event) => {
280
+ const [title, content] = eventContent(event);
281
+ return `<article class="trace-item">
282
+ <time>${escapeHtml(eventTime(event, metadata?.startedAt))}</time>
283
+ <div><strong>${escapeHtml(title)}</strong><p>${escapeHtml(content)}</p></div>
284
+ </article>`;
285
+ })
286
+ .join("")
287
+ : '<div class="artifact"><span>No trace events recorded</span><small>empty</small></div>'
288
+ }
289
+ </div>
290
+ </section>
291
+ <section class="detail-section">
292
+ <h3>Artifacts <span>${artifacts.length} files</span></h3>
293
+ <div class="artifact-list">
294
+ ${
295
+ artifacts.length
296
+ ? artifacts
297
+ .map(
298
+ (artifact) => `<button class="artifact artifact-button" type="button"
299
+ data-artifact="${escapeHtml(artifact)}">
300
+ <span>${escapeHtml(artifact)}</span><small>preview →</small>
301
+ </button>`,
302
+ )
303
+ .join("")
304
+ : '<div class="artifact"><span>No captured artifacts</span><small>empty</small></div>'
305
+ }
306
+ </div>
307
+ </section>
308
+ </div>`;
309
+ };
310
+
311
+ const suiteItemStatus = (item) => {
312
+ if (item.status !== "completed") return item.status;
313
+ if (item.exitReason !== "done" || item.casePassed === false) return "failed";
314
+ if (item.casePassed === true) return "passed";
315
+ return "ungraded";
316
+ };
317
+
318
+ const metricSummaryRows = (metrics) =>
319
+ Object.entries(metrics ?? {})
320
+ .map(
321
+ ([name, summary]) => `<tr>
322
+ <td>${escapeHtml(name)}</td>
323
+ <td>${escapeHtml(metricValue(summary.mean, inferredMetricUnit(name, summary.unit)))}</td>
324
+ <td>${escapeHtml(metricValue(summary.median, inferredMetricUnit(name, summary.unit)))}</td>
325
+ <td>${escapeHtml(metricValue(summary.p90, inferredMetricUnit(name, summary.unit)))}</td>
326
+ <td>${summary.n}</td>
327
+ </tr>`,
328
+ )
329
+ .join("");
330
+
331
+ export const renderSuiteDetail = (item, payload) => {
332
+ const summary = payload?.summary;
333
+ const totals = summary?.totals;
334
+ const metrics = metricSummaryRows(summary?.metrics);
335
+ return `
336
+ <header class="detail-header">
337
+ <div class="detail-kicker">${badge(item.status)}<span class="detail-path">${escapeHtml(
338
+ item.path,
339
+ )}</span></div>
340
+ <div class="detail-title-row">
341
+ <div><h2>${escapeHtml(item.suiteId)}</h2><p class="detail-subtitle">${escapeHtml(
342
+ `${item.profileId ?? "default profile"} · ${fullDate(item.startedAt)}`,
343
+ )}</p></div>
344
+ <div class="score-block"><strong>${escapeHtml(
345
+ percentage(totals?.passRate),
346
+ )}</strong><small>${totals?.passed ?? 0}/${totals?.attempts ?? item.items.length} cases passed</small></div>
347
+ </div>
348
+ </header>
349
+ <div class="detail-body">
350
+ <dl class="detail-stats">
351
+ <div><dt>Attempts</dt><dd>${totals?.attempts ?? item.items.length}</dd></div>
352
+ <div><dt>Concurrency</dt><dd>${item.concurrency}</dd></div>
353
+ <div><dt>Failed</dt><dd>${totals?.failed ?? 0}</dd></div>
354
+ <div><dt>Pending</dt><dd>${totals?.pending ?? 0}</dd></div>
355
+ </dl>
356
+ ${compositionFigure(
357
+ "Suite outcomes",
358
+ [
359
+ { key: "passed", label: "Passed", value: totals?.passed ?? 0 },
360
+ { key: "regressed", label: "Failed", value: totals?.failed ?? 0 },
361
+ { key: "ungraded", label: "Ungraded", value: totals?.ungraded ?? 0 },
362
+ { key: "pending", label: "Pending", value: totals?.pending ?? 0 },
363
+ ],
364
+ `${totals?.passed ?? 0} passed, ${totals?.failed ?? 0} failed, ${
365
+ totals?.ungraded ?? 0
366
+ } ungraded, ${totals?.pending ?? 0} pending`,
367
+ )}
368
+ ${
369
+ metrics
370
+ ? `<section class="detail-section">
371
+ <h3>Aggregate metrics <span>mean / median / p90</span></h3>
372
+ <div class="matrix-wrap"><table class="matrix">
373
+ <thead><tr><th>Metric</th><th>Mean</th><th>Median</th><th>P90</th><th>N</th></tr></thead>
374
+ <tbody>${metrics}</tbody>
375
+ </table></div>
376
+ </section>`
377
+ : ""
378
+ }
379
+ <section class="detail-section">
380
+ <h3>Case attempts <span>${item.items.length} total</span></h3>
381
+ <div class="suite-list">
382
+ ${item.items
383
+ .map((entry) => {
384
+ const content = `<span>${escapeHtml(entry.caseId)}</span>
385
+ <small>${escapeHtml(statusLabel(suiteItemStatus(entry)))}</small>`;
386
+ return entry.runId
387
+ ? `<button class="suite-item suite-button" type="button" data-open-run="${escapeHtml(
388
+ entry.runId,
389
+ )}">${content}</button>`
390
+ : `<div class="suite-item">${content}</div>`;
391
+ })
392
+ .join("")}
393
+ </div>
394
+ </section>
395
+ </div>`;
396
+ };
397
+
398
+ const candidateById = (payload, id) => payload.candidates.find((candidate) => candidate.id === id);
399
+
400
+ const baselineCandidate = (payload) =>
401
+ candidateById(payload, "baseline") ??
402
+ payload.candidates.find((candidate) => candidate.parentIds.length === 0) ??
403
+ payload.candidates[0] ??
404
+ null;
405
+
406
+ const evaluationKey = (evaluation) => `${evaluation.profileId}/${evaluation.split}`;
407
+
408
+ const caseDeltaRows = (baseline, target) => {
409
+ const cases = new Set([
410
+ ...Object.keys(baseline?.casePassRates ?? {}),
411
+ ...Object.keys(target?.casePassRates ?? {}),
412
+ ]);
413
+ return [...cases]
414
+ .sort()
415
+ .map((caseId) => {
416
+ const before = baseline?.casePassRates?.[caseId];
417
+ const after = target?.casePassRates?.[caseId];
418
+ const delta = typeof before === "number" && typeof after === "number" ? after - before : null;
419
+ return `<tr>
420
+ <td>${escapeHtml(caseId)}</td>
421
+ <td class="bar-table-cell">${quantizedBar(
422
+ before,
423
+ "tone-before",
424
+ `${caseId} baseline pass rate ${percentage(before)}`,
425
+ )}<small>${escapeHtml(percentage(before))}</small></td>
426
+ <td class="bar-table-cell">${quantizedBar(
427
+ after,
428
+ "tone-after",
429
+ `${caseId} candidate pass rate ${percentage(after)}`,
430
+ )}<small>${escapeHtml(percentage(after))}</small></td>
431
+ <td class="${toneForDelta(delta)}">${escapeHtml(signedPercentage(delta))}</td>
432
+ </tr>`;
433
+ })
434
+ .join("");
435
+ };
436
+
437
+ const evaluationMetricComparisons = (before, after) => {
438
+ const definitions = { ...(before?.metricDefinitions ?? {}), ...(after?.metricDefinitions ?? {}) };
439
+ return Object.keys(definitions)
440
+ .sort()
441
+ .map((name) => {
442
+ const definition = definitions[name];
443
+ const a = before?.metrics?.[name];
444
+ const b = after?.metrics?.[name];
445
+ const delta = typeof a === "number" && typeof b === "number" ? b - a : null;
446
+ const tone =
447
+ definition.better === "neutral"
448
+ ? "metric-neutral"
449
+ : toneForDelta(delta, definition.better === "lower");
450
+ return comparisonBars({
451
+ title: metricName(name, definition.label),
452
+ before: a,
453
+ after: b,
454
+ beforeText: metricValue(a, definition.unit),
455
+ afterText: metricValue(b, definition.unit),
456
+ deltaText: signedMetricValue(delta, definition.unit),
457
+ deltaTone: tone,
458
+ });
459
+ })
460
+ .join("");
461
+ };
462
+
463
+ const experimentScopes = (baselineEvaluations, targetEvaluations) => {
464
+ const baselineByKey = new Map(baselineEvaluations.map((entry) => [evaluationKey(entry), entry]));
465
+ const targetByKey = new Map(targetEvaluations.map((entry) => [evaluationKey(entry), entry]));
466
+ const keys = new Set([...baselineByKey.keys(), ...targetByKey.keys()]);
467
+ return [...keys]
468
+ .sort()
469
+ .map((key) => {
470
+ const before = baselineByKey.get(key);
471
+ const after = targetByKey.get(key);
472
+ const delta =
473
+ typeof before?.passRate === "number" && typeof after?.passRate === "number"
474
+ ? after.passRate - before.passRate
475
+ : null;
476
+ const metrics = evaluationMetricComparisons(before, after);
477
+ return `<article class="comparison-scope">
478
+ <header>
479
+ <strong>${escapeHtml(key)}</strong>
480
+ <span class="${toneForDelta(delta)}">${escapeHtml(signedPercentage(delta))}</span>
481
+ </header>
482
+ <div class="matrix-wrap"><table class="matrix">
483
+ <thead><tr><th>Case</th><th>Baseline</th><th>Candidate</th><th>Δ</th></tr></thead>
484
+ <tbody>${caseDeltaRows(before, after) || '<tr><td colspan="4">No per-case evidence.</td></tr>'}</tbody>
485
+ </table></div>
486
+ ${metrics ? `<div class="comparison-plot-grid experiment-metrics">${metrics}</div>` : ""}
487
+ </article>`;
488
+ })
489
+ .join("");
490
+ };
491
+
492
+ const candidateLandscape = (evaluations, baselineId, targetId) => {
493
+ const rows = evaluations
494
+ .slice()
495
+ .sort((left, right) =>
496
+ `${left.profileId}/${left.split}/${left.candidateId}`.localeCompare(
497
+ `${right.profileId}/${right.split}/${right.candidateId}`,
498
+ ),
499
+ )
500
+ .map((evaluation) => {
501
+ let role = "peer";
502
+ let tone = "tone-neutral";
503
+ if (evaluation.candidateId === baselineId) {
504
+ role = "baseline";
505
+ tone = "tone-before";
506
+ } else if (evaluation.candidateId === targetId) {
507
+ role = "target";
508
+ tone = "tone-after";
509
+ }
510
+ return `<div class="landscape-row ${role}">
511
+ <span><strong>${escapeHtml(evaluation.candidateId)}</strong><small>${escapeHtml(
512
+ `${evaluation.profileId} / ${evaluation.split}`,
513
+ )}</small></span>
514
+ ${quantizedBar(
515
+ evaluation.passRate,
516
+ tone,
517
+ `${evaluation.candidateId} ${evaluation.profileId} ${evaluation.split} pass rate ${percentage(
518
+ evaluation.passRate,
519
+ )}`,
520
+ )}
521
+ <b>${escapeHtml(percentage(evaluation.passRate))}</b>
522
+ </div>`;
523
+ })
524
+ .join("");
525
+ return `<figure class="candidate-landscape">
526
+ <figcaption><span>Candidate pass-rate landscape</span><small>shared 0–100% scale</small></figcaption>
527
+ <div class="landscape-axis"><span>0</span><span>50</span><span>100%</span></div>
528
+ ${rows || '<div class="artifact"><span>No completed candidate evaluations</span><small>empty</small></div>'}
529
+ </figure>`;
530
+ };
531
+
532
+ const budgetUtilization = (decision) => {
533
+ if (!decision) return "";
534
+ const entries = [
535
+ ["Candidates", decision.spend.candidates, decision.budget.maxCandidates, "count"],
536
+ ["Metric calls", decision.spend.metricCalls, decision.budget.maxMetricCalls, "count"],
537
+ ["Judge calls", decision.spend.judgeCalls, decision.budget.maxJudgeCalls, "count"],
538
+ ["Proposal calls", decision.spend.proposalCalls, decision.budget.maxProposalCalls, "count"],
539
+ ["Tokens", decision.spend.totalTokens, decision.budget.maxTokens, "compact"],
540
+ ["Cost", decision.spend.costUsd, decision.budget.maxCostUsd, "currency"],
541
+ ["Active time", decision.spend.activeTimeMs, decision.budget.maxWallTimeMs, "duration"],
542
+ ];
543
+ const formatValue = (value, kind) => {
544
+ if (kind === "compact") return compactNumber(value);
545
+ if (kind === "currency") return `$${finite(value)}`;
546
+ if (kind === "duration") return duration(value);
547
+ return finite(value, 0);
548
+ };
549
+ return `<figure class="budget-visual">
550
+ <figcaption><span>Budget utilization</span><small>observed / ceiling</small></figcaption>
551
+ ${entries
552
+ .map(([label, value, limit, kind]) => {
553
+ const ratio = limit > 0 ? value / limit : 0;
554
+ return `<div class="budget-row">
555
+ <span>${escapeHtml(label)}</span>
556
+ ${quantizedBar(
557
+ ratio,
558
+ ratio > 0.9 ? "tone-negative" : "tone-neutral",
559
+ `${label} ${formatValue(value, kind)} of ${formatValue(limit, kind)}`,
560
+ )}
561
+ <b>${escapeHtml(`${formatValue(value, kind)} / ${formatValue(limit, kind)}`)}</b>
562
+ </div>`;
563
+ })
564
+ .join("")}
565
+ </figure>`;
566
+ };
567
+
568
+ const failedEvidence = (evaluations) =>
569
+ evaluations
570
+ .flatMap((evaluation) =>
571
+ evaluation.failedCases.map((failure) => ({
572
+ ...failure,
573
+ scope: evaluationKey(evaluation),
574
+ })),
575
+ )
576
+ .map(
577
+ (failure) => `<article class="failure-card">
578
+ <header><strong>${escapeHtml(failure.caseId)}</strong><small>${escapeHtml(
579
+ failure.scope,
580
+ )} · ${escapeHtml(percentage(failure.score))}</small></header>
581
+ ${
582
+ failure.feedback.length
583
+ ? `<ul>${failure.feedback.map((entry) => `<li>${escapeHtml(entry)}</li>`).join("")}</ul>`
584
+ : "<p>No evaluator feedback recorded.</p>"
585
+ }
586
+ </article>`,
587
+ )
588
+ .join("");
589
+
590
+ export const experimentTargetId = (payload, requested) => {
591
+ const baseline = baselineCandidate(payload);
592
+ const ids = new Set(payload.experiment.evaluations.map((entry) => entry.candidateId));
593
+ if (requested && ids.has(requested)) return requested;
594
+ const champion = payload.experiment.championCandidateId;
595
+ if (champion && ids.has(champion)) return champion;
596
+ return [...ids].find((id) => id !== baseline?.id) ?? [...ids][0] ?? null;
597
+ };
598
+
599
+ export const renderExperimentDetail = (item, payload, requestedTargetId) => {
600
+ const experiment = payload.experiment;
601
+ const decision = payload.decision;
602
+ const baseline = baselineCandidate(payload);
603
+ const targetId = experimentTargetId(payload, requestedTargetId);
604
+ const target = targetId ? candidateById(payload, targetId) : null;
605
+ const baselineEvaluations = experiment.evaluations.filter(
606
+ (evaluation) => evaluation.candidateId === baseline?.id,
607
+ );
608
+ const targetEvaluations = experiment.evaluations.filter(
609
+ (evaluation) => evaluation.candidateId === targetId,
610
+ );
611
+ const options = [...new Set(experiment.evaluations.map((entry) => entry.candidateId))]
612
+ .filter((id) => id !== baseline?.id || id === targetId)
613
+ .map(
614
+ (id) =>
615
+ `<option value="${escapeHtml(id)}"${id === targetId ? " selected" : ""}>${escapeHtml(
616
+ id,
617
+ )}</option>`,
618
+ )
619
+ .join("");
620
+ const gates = (decision?.gates ?? [])
621
+ .map(
622
+ (gate) => `<article class="gate ${gate.passed ? "passed" : "failed"}">
623
+ <span>${gate.passed ? "✓" : "×"}</span>
624
+ <div><strong>${escapeHtml(gate.metric)}</strong><small>${escapeHtml(
625
+ [gate.profile, gate.split, gate.interval, gate.note].filter(Boolean).join(" · "),
626
+ )}</small></div>
627
+ <b>${gate.values.map((value) => finite(value)).join(", ")}</b>
628
+ </article>`,
629
+ )
630
+ .join("");
631
+ const lineage = target
632
+ ? `<article class="lineage-card">
633
+ <span>${escapeHtml(target.id)}</span>
634
+ <small>from ${escapeHtml(target.parentIds.join(" + ") || "root")} · ${escapeHtml(
635
+ target.proposer,
636
+ )} · ${compactNumber(target.sourceBytes)} bytes</small>
637
+ ${target.rationale ? `<p>${escapeHtml(target.rationale)}</p>` : ""}
638
+ <div>${target.components
639
+ .map((component) => `<code>${escapeHtml(component)}</code>`)
640
+ .join("")}</div>
641
+ </article>`
642
+ : "";
643
+ return `
644
+ <header class="detail-header">
645
+ <div class="detail-kicker">${badge(item.status)}<span class="detail-path">${escapeHtml(
646
+ item.path,
647
+ )}</span></div>
648
+ <div class="detail-title-row">
649
+ <div><h2>${escapeHtml(item.campaignId)}</h2><p class="detail-subtitle">${escapeHtml(
650
+ `${item.mode} · ${fullDate(item.startedAt)} · ${item.experimentId}`,
651
+ )}</p></div>
652
+ <div class="score-block"><strong>${escapeHtml(
653
+ decision?.outcome ? statusLabel(decision.outcome) : item.candidateCount,
654
+ )}</strong><small>${decision?.outcome ? "verified decision" : "candidates"}</small></div>
655
+ </div>
656
+ </header>
657
+ <div class="detail-body">
658
+ ${
659
+ payload.integrity.verified
660
+ ? '<div class="integrity-banner">✓ Candidate ledger and accounting verified</div>'
661
+ : `<div class="attention-banner">Integrity unavailable: ${escapeHtml(
662
+ payload.integrity.errors.join(" · "),
663
+ )}</div>`
664
+ }
665
+ <dl class="detail-stats">
666
+ <div><dt>Champion</dt><dd>${escapeHtml(experiment.championCandidateId ?? "—")}</dd></div>
667
+ <div><dt>Active time</dt><dd>${escapeHtml(duration(experiment.activeTimeMs))}</dd></div>
668
+ <div><dt>Judge calls</dt><dd>${experiment.budgetUsage.judgeCalls}</dd></div>
669
+ <div><dt>Spend</dt><dd>$${experiment.budgetUsage.costUsd.toFixed(2)}</dd></div>
670
+ </dl>
671
+ <div class="experiment-visuals">
672
+ ${candidateLandscape(experiment.evaluations, baseline?.id, targetId)}
673
+ ${budgetUtilization(decision)}
674
+ </div>
675
+ ${
676
+ decision
677
+ ? `<section class="detail-section">
678
+ <h3>Promotion gates <span>${decision.gates.filter((gate) => gate.passed).length}/${decision.gates.length} passed</span></h3>
679
+ <div class="gate-list">${gates || '<div class="artifact"><span>No completed gates</span><small>incomplete</small></div>'}</div>
680
+ <div class="decision-meta">
681
+ <span>Ranking</span><strong>${escapeHtml(decision.ranking.join(" → ") || "—")}</strong>
682
+ <span>Eligible</span><strong>${escapeHtml(
683
+ decision.eligibleCandidateIds.join(", ") || "none",
684
+ )}</strong>
685
+ </div>
686
+ </section>`
687
+ : ""
688
+ }
689
+ <section class="detail-section">
690
+ <div class="section-heading">
691
+ <h3>Candidate comparison <span>baseline → target</span></h3>
692
+ ${
693
+ options
694
+ ? `<label class="candidate-picker">Target <select data-candidate-target>${options}</select></label>`
695
+ : ""
696
+ }
697
+ </div>
698
+ ${lineage}
699
+ <div class="comparison-scopes">
700
+ ${experimentScopes(baselineEvaluations, targetEvaluations) || '<div class="artifact"><span>No comparable evaluations</span><small>empty</small></div>'}
701
+ </div>
702
+ </section>
703
+ ${
704
+ failedEvidence(targetEvaluations)
705
+ ? `<section class="detail-section"><h3>Failed evidence <span>judge feedback</span></h3><div class="failure-list">${failedEvidence(
706
+ targetEvaluations,
707
+ )}</div></section>`
708
+ : ""
709
+ }
710
+ </div>`;
711
+ };
712
+
713
+ const detailAssertionMap = (detail) =>
714
+ new Map(
715
+ (detail.grading?.assertions ?? []).map((assertion) => [
716
+ assertion.id ?? assertion.name,
717
+ assertion,
718
+ ]),
719
+ );
720
+
721
+ const assertionDeltaRows = (payload) => {
722
+ const before = detailAssertionMap(payload.before);
723
+ const after = detailAssertionMap(payload.after);
724
+ return payload.comparison.grading.deltas
725
+ .map((delta) => {
726
+ const beforeAssertion = before.get(delta.id);
727
+ const afterAssertion = after.get(delta.id);
728
+ return `<tr>
729
+ <td><strong>${escapeHtml(delta.name)}</strong><small>${escapeHtml(delta.type)}</small></td>
730
+ <td>${escapeHtml(percentage(delta.before?.score))}</td>
731
+ <td>${escapeHtml(percentage(delta.after?.score))}</td>
732
+ <td><span class="delta-badge ${escapeHtml(delta.status)}">${escapeHtml(
733
+ delta.status,
734
+ )}</span></td>
735
+ <td class="rationale-cell">
736
+ <span>${escapeHtml(beforeAssertion?.comment ?? "—")}</span>
737
+ <b>→</b>
738
+ <span>${escapeHtml(afterAssertion?.comment ?? "—")}</span>
739
+ </td>
740
+ </tr>`;
741
+ })
742
+ .join("");
743
+ };
744
+
745
+ const artifactDelta = (payload) => {
746
+ const groups = [
747
+ ["changed", payload.comparison.artifactsDelta.changed],
748
+ ["added", payload.comparison.artifactsDelta.added],
749
+ ["removed", payload.comparison.artifactsDelta.removed],
750
+ ];
751
+ return groups
752
+ .flatMap(([status, paths]) =>
753
+ paths.map((path) => {
754
+ const clickable = status === "changed" || status === "added" || status === "removed";
755
+ const content = `<span>${escapeHtml(path)}</span><small>${escapeHtml(status)}${
756
+ clickable ? " →" : ""
757
+ }</small>`;
758
+ return clickable
759
+ ? `<button class="artifact artifact-button ${escapeHtml(
760
+ status,
761
+ )}" type="button" data-compare-artifact="${escapeHtml(path)}">${content}</button>`
762
+ : `<div class="artifact">${content}</div>`;
763
+ }),
764
+ )
765
+ .join("");
766
+ };
767
+
768
+ const signedMetricValue = (value, unit) => {
769
+ if (typeof value !== "number") return "—";
770
+ if (unit === "milliseconds") return signedDuration(value);
771
+ if (unit === "ratio" || unit === "score") return signedPercentage(value);
772
+ return `${value > 0 ? "+" : ""}${metricValue(value, unit)}`;
773
+ };
774
+
775
+ const driverMetricComparisons = (runDelta) => {
776
+ const before = runDelta?.driverMetrics?.a ?? {};
777
+ const after = runDelta?.driverMetrics?.b ?? {};
778
+ const names = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
779
+ return names
780
+ .map((name) => {
781
+ const a = before[name];
782
+ const b = after[name];
783
+ const unit = a?.unit ?? b?.unit ?? "number";
784
+ const comparable = a && b && a.unit === b.unit;
785
+ const delta = comparable ? b.value - a.value : null;
786
+ const better = b?.better ?? a?.better ?? "neutral";
787
+ const tone =
788
+ better === "neutral" ? "metric-neutral" : toneForDelta(delta, better === "lower");
789
+ return comparisonBars({
790
+ title: metricName(name, b?.label ?? a?.label),
791
+ before: a?.value,
792
+ after: b?.value,
793
+ beforeText: metricValue(a?.value, unit),
794
+ afterText: metricValue(b?.value, unit),
795
+ deltaText: comparable ? signedMetricValue(delta, unit) : "not paired",
796
+ deltaTone: tone,
797
+ });
798
+ })
799
+ .join("");
800
+ };
801
+
802
+ export const renderRunComparison = (payload, labels) => {
803
+ const comparison = payload.comparison;
804
+ const runDelta = comparison.runDelta;
805
+ const durationDelta =
806
+ typeof runDelta?.durationMsA === "number" && typeof runDelta?.durationMsB === "number"
807
+ ? runDelta.durationMsB - runDelta.durationMsA
808
+ : null;
809
+ const mutationA = runDelta?.actionTiming?.a?.timeToFirstMutationMs;
810
+ const mutationB = runDelta?.actionTiming?.b?.timeToFirstMutationMs;
811
+ const mutationDelta =
812
+ typeof mutationA === "number" && typeof mutationB === "number" ? mutationB - mutationA : null;
813
+ const tokenA = totalTokens(runDelta?.usage?.a?.agent);
814
+ const tokenB = totalTokens(runDelta?.usage?.b?.agent);
815
+ const tokenDelta = tokenB - tokenA;
816
+ const scoreDelta =
817
+ typeof comparison.grading.caseScoreA === "number" &&
818
+ typeof comparison.grading.caseScoreB === "number"
819
+ ? comparison.grading.caseScoreB - comparison.grading.caseScoreA
820
+ : null;
821
+ const driverMetrics = driverMetricComparisons(runDelta);
822
+ return `
823
+ <header class="detail-header comparison-header">
824
+ <div class="detail-kicker">${badge(
825
+ comparison.grading.regressions ? "failed" : "passed",
826
+ )}<span>run comparison</span></div>
827
+ <div class="detail-title-row">
828
+ <div><h2>${escapeHtml(labels.before)} → ${escapeHtml(
829
+ labels.after,
830
+ )}</h2><p class="detail-subtitle">Baseline A compared with candidate B</p></div>
831
+ <button class="secondary-button" type="button" data-close-compare>Done</button>
832
+ </div>
833
+ </header>
834
+ <div class="detail-body">
835
+ ${
836
+ comparison.caseMismatch.length
837
+ ? `<div class="attention-banner">Evidence identity differs: ${escapeHtml(
838
+ comparison.caseMismatch.map((entry) => entry.field).join(", "),
839
+ )}. Treat deltas as potentially incomparable.</div>`
840
+ : '<div class="integrity-banner">✓ Case, evaluator, runtime, and driver identities are comparable</div>'
841
+ }
842
+ <dl class="comparison-stats">
843
+ <div><dt>Case score</dt><dd>${escapeHtml(
844
+ `${percentage(comparison.grading.caseScoreA)} → ${percentage(
845
+ comparison.grading.caseScoreB,
846
+ )}`,
847
+ )}</dd><small class="${toneForDelta(scoreDelta)}">${escapeHtml(
848
+ signedPercentage(scoreDelta),
849
+ )}</small></div>
850
+ <div><dt>Duration</dt><dd>${escapeHtml(
851
+ `${duration(runDelta?.durationMsA)} → ${duration(runDelta?.durationMsB)}`,
852
+ )}</dd><small class="${toneForDelta(durationDelta, true)}">${escapeHtml(
853
+ signedDuration(durationDelta),
854
+ )}</small></div>
855
+ <div><dt>First mutation</dt><dd>${escapeHtml(
856
+ `${duration(mutationA)} → ${duration(mutationB)}`,
857
+ )}</dd><small class="${toneForDelta(mutationDelta, true)}">${escapeHtml(
858
+ signedDuration(mutationDelta),
859
+ )}</small></div>
860
+ <div><dt>Agent tokens</dt><dd>${escapeHtml(
861
+ `${compactNumber(tokenA)} → ${compactNumber(tokenB)}`,
862
+ )}</dd><small class="${toneForDelta(tokenDelta, true)}">${tokenDelta > 0 ? "+" : ""}${escapeHtml(
863
+ compactNumber(tokenDelta),
864
+ )}</small></div>
865
+ <div><dt>Assertions</dt><dd>${comparison.grading.improvements} ↑ / ${comparison.grading.regressions} ↓</dd><small>${comparison.grading.deltas.length} compared</small></div>
866
+ </dl>
867
+ <section class="detail-section">
868
+ <h3>Comparison profile <span>shared scales within each measure</span></h3>
869
+ <div class="comparison-plot-grid">
870
+ ${comparisonBars({
871
+ title: "Case score",
872
+ before: comparison.grading.caseScoreA,
873
+ after: comparison.grading.caseScoreB,
874
+ maximum: 1,
875
+ beforeText: percentage(comparison.grading.caseScoreA),
876
+ afterText: percentage(comparison.grading.caseScoreB),
877
+ deltaText: signedPercentage(scoreDelta),
878
+ deltaTone: toneForDelta(scoreDelta),
879
+ })}
880
+ ${comparisonBars({
881
+ title: "Duration",
882
+ before: runDelta?.durationMsA,
883
+ after: runDelta?.durationMsB,
884
+ beforeText: duration(runDelta?.durationMsA),
885
+ afterText: duration(runDelta?.durationMsB),
886
+ deltaText: signedDuration(durationDelta),
887
+ deltaTone: toneForDelta(durationDelta, true),
888
+ })}
889
+ ${comparisonBars({
890
+ title: "Time to first mutation",
891
+ before: mutationA,
892
+ after: mutationB,
893
+ beforeText: duration(mutationA),
894
+ afterText: duration(mutationB),
895
+ deltaText: signedDuration(mutationDelta),
896
+ deltaTone: toneForDelta(mutationDelta, true),
897
+ })}
898
+ ${comparisonBars({
899
+ title: "Agent tokens",
900
+ before: tokenA,
901
+ after: tokenB,
902
+ beforeText: compactNumber(tokenA),
903
+ afterText: compactNumber(tokenB),
904
+ deltaText: `${tokenDelta > 0 ? "+" : ""}${compactNumber(tokenDelta)}`,
905
+ deltaTone: toneForDelta(tokenDelta, true),
906
+ })}
907
+ </div>
908
+ <div class="comparison-distributions">
909
+ ${compositionFigure(
910
+ "Assertion movement",
911
+ [
912
+ { key: "improved", label: "Improved", value: comparison.grading.improvements },
913
+ { key: "regressed", label: "Regressed", value: comparison.grading.regressions },
914
+ {
915
+ key: "added",
916
+ label: "Added",
917
+ value: comparison.grading.deltas.filter((delta) => delta.status === "added").length,
918
+ },
919
+ {
920
+ key: "removed",
921
+ label: "Removed",
922
+ value: comparison.grading.deltas.filter((delta) => delta.status === "removed")
923
+ .length,
924
+ },
925
+ {
926
+ key: "unchanged",
927
+ label: "Unchanged",
928
+ value: comparison.grading.deltas.filter((delta) => delta.status === "unchanged")
929
+ .length,
930
+ },
931
+ ],
932
+ "Distribution of assertion changes",
933
+ )}
934
+ ${compositionFigure(
935
+ "Artifact movement",
936
+ [
937
+ {
938
+ key: "improved",
939
+ label: "Added",
940
+ value: comparison.artifactsDelta.added.length,
941
+ },
942
+ {
943
+ key: "regressed",
944
+ label: "Removed",
945
+ value: comparison.artifactsDelta.removed.length,
946
+ },
947
+ {
948
+ key: "changed",
949
+ label: "Changed",
950
+ value: comparison.artifactsDelta.changed.length,
951
+ },
952
+ {
953
+ key: "unchanged",
954
+ label: "Unchanged",
955
+ value: comparison.artifactsDelta.unchanged.length,
956
+ },
957
+ ],
958
+ "Distribution of artifact changes",
959
+ )}
960
+ </div>
961
+ </section>
962
+ ${
963
+ driverMetrics
964
+ ? `<section class="detail-section"><h3>Driver metric comparison <span>shared scale per metric</span></h3><div class="comparison-plot-grid">${driverMetrics}</div></section>`
965
+ : ""
966
+ }
967
+ <section class="detail-section">
968
+ <h3>Judgment delta <span>rationale side by side</span></h3>
969
+ <div class="matrix-wrap"><table class="matrix comparison-matrix">
970
+ <thead><tr><th>Assertion</th><th>A</th><th>B</th><th>Change</th><th>Judge rationale A → B</th></tr></thead>
971
+ <tbody>${assertionDeltaRows(payload) || '<tr><td colspan="5">No assertions.</td></tr>'}</tbody>
972
+ </table></div>
973
+ </section>
974
+ <section class="detail-section">
975
+ <h3>Artifacts <span>${comparison.artifactsDelta.changed.length} changed · ${comparison.artifactsDelta.added.length} added · ${comparison.artifactsDelta.removed.length} removed</span></h3>
976
+ <div class="artifact-list">${artifactDelta(payload) || '<div class="artifact"><span>No artifact changes</span><small>unchanged</small></div>'}</div>
977
+ </section>
978
+ </div>`;
979
+ };
980
+
981
+ const metricDeltaSummary = (metrics) =>
982
+ Object.entries(metrics)
983
+ .map(
984
+ ([name, summary]) =>
985
+ `<span><b>${escapeHtml(name)}</b> ${escapeHtml(
986
+ metricValue(summary.mean, inferredMetricUnit(name, summary.unit)),
987
+ )}</span>`,
988
+ )
989
+ .join("");
990
+
991
+ export const renderSuiteComparison = (payload, labels) => {
992
+ const comparison = payload.comparison;
993
+ const movement = { improved: 0, regressed: 0, unchanged: 0, missing: 0 };
994
+ for (const entry of comparison.cases) {
995
+ if (!entry.before || !entry.after) movement.missing++;
996
+ else if (entry.regressed) movement.regressed++;
997
+ else if ((entry.passRateDelta ?? 0) > 0) movement.improved++;
998
+ else movement.unchanged++;
999
+ }
1000
+ return `
1001
+ <header class="detail-header comparison-header">
1002
+ <div class="detail-kicker">${badge(
1003
+ comparison.regressions || comparison.identityMismatches.length ? "failed" : "passed",
1004
+ )}<span>suite comparison</span></div>
1005
+ <div class="detail-title-row">
1006
+ <div><h2>${escapeHtml(labels.before)} → ${escapeHtml(
1007
+ labels.after,
1008
+ )}</h2><p class="detail-subtitle">Repeated evidence with paired case metrics</p></div>
1009
+ <button class="secondary-button" type="button" data-close-compare>Done</button>
1010
+ </div>
1011
+ </header>
1012
+ <div class="detail-body">
1013
+ ${
1014
+ comparison.identityMismatches.length
1015
+ ? `<div class="attention-banner">${comparison.identityMismatches.length} repetition identities differ; those pairs are not directly comparable.</div>`
1016
+ : '<div class="integrity-banner">✓ All paired repetition identities match</div>'
1017
+ }
1018
+ <dl class="comparison-stats">
1019
+ <div><dt>Pass rate</dt><dd>${escapeHtml(
1020
+ `${percentage(comparison.before.passRate)} → ${percentage(comparison.after.passRate)}`,
1021
+ )}</dd><small class="${toneForDelta(comparison.passRateDelta)}">${escapeHtml(
1022
+ signedPercentage(comparison.passRateDelta),
1023
+ )}</small></div>
1024
+ <div><dt>Attempts</dt><dd>${comparison.before.attempts} → ${comparison.after.attempts}</dd><small>paired populations</small></div>
1025
+ <div><dt>Regressions</dt><dd>${comparison.regressions}</dd><small>case-level</small></div>
1026
+ <div><dt>Identity drift</dt><dd>${comparison.identityMismatches.length}</dd><small>repetitions</small></div>
1027
+ </dl>
1028
+ <section class="detail-section">
1029
+ <h3>Outcome movement <span>shared 0–100% scale</span></h3>
1030
+ <div class="suite-outcome-visual">
1031
+ ${comparisonBars({
1032
+ title: "Suite pass rate",
1033
+ before: comparison.before.passRate,
1034
+ after: comparison.after.passRate,
1035
+ maximum: 1,
1036
+ beforeText: percentage(comparison.before.passRate),
1037
+ afterText: percentage(comparison.after.passRate),
1038
+ deltaText: signedPercentage(comparison.passRateDelta),
1039
+ deltaTone: toneForDelta(comparison.passRateDelta),
1040
+ })}
1041
+ ${compositionFigure(
1042
+ "Case movement",
1043
+ [
1044
+ {
1045
+ key: "improved",
1046
+ label: "Improved",
1047
+ value: movement.improved,
1048
+ },
1049
+ {
1050
+ key: "regressed",
1051
+ label: "Regressed",
1052
+ value: movement.regressed,
1053
+ },
1054
+ {
1055
+ key: "unchanged",
1056
+ label: "Unchanged",
1057
+ value: movement.unchanged,
1058
+ },
1059
+ {
1060
+ key: "pending",
1061
+ label: "Missing side",
1062
+ value: movement.missing,
1063
+ },
1064
+ ],
1065
+ "Distribution of per-case suite movement",
1066
+ )}
1067
+ </div>
1068
+ </section>
1069
+ <section class="detail-section">
1070
+ <h3>Per-case delta <span>after − before</span></h3>
1071
+ <div class="matrix-wrap"><table class="matrix comparison-matrix">
1072
+ <thead><tr><th>Case</th><th>Before</th><th>After</th><th>Δ</th><th>Paired metric means</th></tr></thead>
1073
+ <tbody>${comparison.cases
1074
+ .map(
1075
+ (entry) => `<tr class="${entry.regressed ? "row-regressed" : ""}">
1076
+ <td>${escapeHtml(entry.canonicalCaseId)}</td>
1077
+ <td class="bar-table-cell">${quantizedBar(
1078
+ entry.before?.passRate,
1079
+ "tone-before",
1080
+ `${entry.canonicalCaseId} baseline pass rate ${percentage(
1081
+ entry.before?.passRate,
1082
+ )}`,
1083
+ )}<small>${escapeHtml(percentage(entry.before?.passRate))} · n=${
1084
+ entry.before?.attempts ?? 0
1085
+ }</small></td>
1086
+ <td class="bar-table-cell">${quantizedBar(
1087
+ entry.after?.passRate,
1088
+ "tone-after",
1089
+ `${entry.canonicalCaseId} candidate pass rate ${percentage(
1090
+ entry.after?.passRate,
1091
+ )}`,
1092
+ )}<small>${escapeHtml(percentage(entry.after?.passRate))} · n=${
1093
+ entry.after?.attempts ?? 0
1094
+ }</small></td>
1095
+ <td class="${toneForDelta(entry.passRateDelta)}">${escapeHtml(
1096
+ signedPercentage(entry.passRateDelta),
1097
+ )}</td>
1098
+ <td class="metric-deltas">${metricDeltaSummary(entry.pairedMetricDeltas)}</td>
1099
+ </tr>`,
1100
+ )
1101
+ .join("")}</tbody>
1102
+ </table></div>
1103
+ </section>
1104
+ </div>`;
1105
+ };
1106
+
1107
+ export { renderArtifactComparison, renderArtifactPreview } from "./artifact-view.js";
1108
+ export { detailEmpty };