@fede0089/skill-eval 3.2.0 → 3.3.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.
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { Logger } from '../utils/logger.js';
4
- import { formatTokens, formatDuration, hasFunctionalBaseline } from '../utils/table-renderer.js';
5
- import { computeAssertionPassRate } from '../core/statistics.js';
4
+ import { detectAnomalies } from '../core/anomalies.js';
5
+ import { hasFunctionalBaseline } from '../utils/table-renderer.js';
6
6
  export class HtmlReporter {
7
7
  generate(report, runDir) {
8
8
  const htmlPath = path.join(runDir, 'report.html');
@@ -10,384 +10,1030 @@ export class HtmlReporter {
10
10
  Logger.write(`\n Report: file://${htmlPath}\n`);
11
11
  }
12
12
  }
13
- // ---------------------------------------------------------------------------
14
- // HTML generation (module-private)
15
- // ---------------------------------------------------------------------------
16
- function escapeHtml(s) {
17
- return s
18
- .replace(/&/g, '&')
19
- .replace(/</g, '&lt;')
20
- .replace(/>/g, '&gt;')
21
- .replace(/"/g, '&quot;')
22
- .replace(/'/g, '&#39;');
13
+ function toRunTrial(trial, cohort) {
14
+ return {
15
+ id: trial.id,
16
+ passed: trial.trialPassed,
17
+ isError: trial.isError === true,
18
+ results: trial.assertionResults.map(r => (r.passed ? 1 : 0)),
19
+ reasons: trial.assertionResults.map(r => r.reason),
20
+ tokens: trial.tokenStats ? { totalTokens: trial.tokenStats.totalTokens } : undefined,
21
+ durationMs: trial.durationMs,
22
+ output: trial.summary?.output ?? '',
23
+ outputLen: trial.summary?.outputLen ?? 0,
24
+ toolCalls: trial.summary?.toolCalls ?? 0,
25
+ stopStatus: trial.summary?.stopStatus,
26
+ logFile: trial.summary?.logFile,
27
+ anomalies: detectAnomalies(trial, cohort),
28
+ };
23
29
  }
24
- function formatPercent(val) {
25
- return `${Math.round(val * 100)}%`;
26
- }
27
- function passColorClass(val) {
28
- if (val >= 0.8)
29
- return 'green';
30
- if (val >= 0.5)
31
- return 'amber';
32
- return 'red';
30
+ /** Variant order, baseline first so the comparison reads left to right. */
31
+ function variantsOf(result, functional) {
32
+ const skillVersions = Object.keys(result.skillTrials);
33
+ return functional && (result.baselineTrials?.length ?? 0) > 0
34
+ ? ['baseline', ...skillVersions]
35
+ : skillVersions;
33
36
  }
34
- function isFunctional(report) {
35
- return report.command === 'functional' || hasFunctionalBaseline(report);
37
+ function trialsFor(result, variant) {
38
+ return variant === 'baseline' ? result.baselineTrials : result.skillTrials[variant] ?? [];
36
39
  }
37
- // ---------------------------------------------------------------------------
38
- // Metrics Grid
39
- // ---------------------------------------------------------------------------
40
- function renderDeltaCell(base, target, format, cellClass) {
41
- if (base <= 0)
42
- return `<span class="metric-val muted">—</span>`;
43
- const delta = target - base;
44
- const sign = delta >= 0 ? '+' : '';
45
- const pct = Math.round((delta / base) * 100);
46
- const cls = delta > 0 ? 'amber' : delta < 0 ? 'green' : '';
47
- return `<span class="${cellClass} ${cls}">${sign}${pct}%</span>`;
48
- }
49
- function renderMetricsGrid(report) {
50
- const { metrics, results } = report;
40
+ export function buildRunData(report) {
51
41
  const functional = isFunctional(report);
52
- const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
53
- const allVersions = functional && hasFunctionalBaseline(report) ? ['baseline', ...skillVersions] : skillVersions;
54
- const headerCells = allVersions.map(v => `<th>${v}</th>`).join('');
55
- const successRows = `<tr>
56
- <td>Success Rate</td>
57
- ${allVersions.map(v => {
58
- const val = metrics.assertionPassRate[v] ?? metrics.passAtK[v] ?? 0;
59
- return `<td><span class="metric-val ${passColorClass(val)}">${formatPercent(val)}</span></td>`;
60
- }).join('')}
61
- </tr>`;
62
- const tokenRows = `<tr>
63
- <td>Tokens (avg)</td>
64
- ${allVersions.map(v => {
65
- const stats = metrics.tokenStats?.[v];
66
- return `<td>${stats ? `<span class="metric-val">${formatTokens(stats.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>`;
67
- }).join('')}
68
- </tr>`;
69
- const timeRows = `<tr>
70
- <td>Time (avg)</td>
71
- ${allVersions.map(v => {
72
- const stats = metrics.durationStats?.[v];
73
- return `<td>${stats ? `<span class="metric-val">${formatDuration(stats.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>`;
74
- }).join('')}
75
- </tr>`;
76
- return `<div class="metrics-grid">
77
- <table>
78
- <thead>
79
- <tr><th></th>${headerCells}</tr>
80
- </thead>
81
- <tbody>
82
- ${successRows}
83
- ${tokenRows}
84
- ${timeRows}
85
- </tbody>
86
- </table>
87
- </div>`;
42
+ return {
43
+ // Matches the run directory name, so a reviewer's exclusions stay tied to
44
+ // this run and not to any other report opened from the same browser.
45
+ runId: report.timestamp.replace(/[:.]/g, '-'),
46
+ command: report.command ?? (functional ? 'functional' : 'trigger'),
47
+ skill: report.skill_name,
48
+ agent: report.agent,
49
+ timestamp: report.timestamp,
50
+ tasks: report.results.map(result => {
51
+ const variants = variantsOf(result, functional);
52
+ let assertions = [];
53
+ for (const v of variants) {
54
+ const trials = trialsFor(result, v);
55
+ if (trials.length > 0 && trials[0].assertionResults.length > 0) {
56
+ assertions = trials[0].assertionResults.map(r => r.assertion);
57
+ break;
58
+ }
59
+ }
60
+ const byVariant = {};
61
+ for (const v of variants) {
62
+ const trials = trialsFor(result, v);
63
+ byVariant[v] = trials.map(t => toRunTrial(t, trials));
64
+ }
65
+ return {
66
+ taskId: result.taskId,
67
+ prompt: result.prompt,
68
+ shouldTrigger: result.shouldTrigger,
69
+ assertions,
70
+ variants: byVariant,
71
+ };
72
+ }),
73
+ };
88
74
  }
89
- // ---------------------------------------------------------------------------
90
- // Expectations × Variants table (per-task drill-down)
91
- // ---------------------------------------------------------------------------
92
- function renderExpectationCell(trials, expIdx, variant, taskId, clickable, totalCols) {
93
- const passed = trials.filter(t => t.assertionResults[expIdx]?.passed).length;
94
- const total = trials.length;
95
- const rate = total > 0 ? passed / total : 0;
96
- const colorCls = passColorClass(rate);
97
- const ratePct = formatPercent(rate);
98
- const frac = `${passed} / ${total}`;
99
- const detailId = `exp-detail-${taskId}-${expIdx}-${variant}`;
100
- const dataAttr = clickable ? ` data-detail="${escapeHtml(detailId)}"` : '';
101
- const cell = `<td class="pass-cell ${colorCls}"${dataAttr}><span class="rate">${ratePct}</span><span class="frac">${frac}</span></td>`;
102
- if (!clickable)
103
- return { cell, detail: '' };
104
- const lines = trials.map(t => {
105
- const r = t.assertionResults[expIdx];
106
- const ok = !!r?.passed;
107
- const reason = r?.reason ?? '';
108
- const icon = ok ? '✓' : '✗';
109
- const iconCls = ok ? 'pass' : 'fail';
110
- return `<div class="exp-trial-line"><div class="exp-trial-icon ${iconCls}">${icon}</div><div class="exp-trial-body"><span class="trial-label">Trial ${t.id}</span><span class="trial-reason">${escapeHtml(reason)}</span></div></div>`;
111
- }).join('');
112
- const detail = `<tr class="exp-detail-row" id="${escapeHtml(detailId)}"><td colspan="${totalCols}"><div class="exp-detail-inner"><div class="exp-detail-header">Judge per trial · <span class="variant-pill">${escapeHtml(variant)}</span></div>${lines}</div></td></tr>`;
113
- return { cell, detail };
75
+ function isFunctional(report) {
76
+ return report.command === 'functional' || hasFunctionalBaseline(report);
114
77
  }
115
- function renderExpectationsTable(result, isFunctionalEval) {
116
- const skillVersions = Object.keys(result.skillTrials);
117
- const allVersions = isFunctionalEval && (result.baselineTrials?.length ?? 0) > 0 ? ['baseline', ...skillVersions] : skillVersions;
118
- let canonical = [];
119
- for (const v of allVersions) {
120
- const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
121
- if (trials && trials.length > 0 && trials[0].assertionResults.length > 0) {
122
- canonical = trials[0].assertionResults;
123
- break;
124
- }
125
- }
126
- if (canonical.length === 0) {
127
- return '<p class="muted">No assertions recorded.</p>';
128
- }
129
- const totalCols = 1 + allVersions.length;
130
- const headerCells = allVersions.map(v => `<th class="variant-col">${escapeHtml(v)}</th>`).join('');
131
- const rows = canonical.map((a, expIdx) => {
132
- const cells = [];
133
- const details = [];
134
- for (const v of allVersions) {
135
- const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
136
- const { cell, detail } = renderExpectationCell(trials, expIdx, v, result.taskId, isFunctionalEval, totalCols);
137
- cells.push(cell);
138
- if (detail)
139
- details.push(detail);
140
- }
141
- return `<tr class="exp-row"><td class="exp-text">${escapeHtml(a.assertion)}</td>${cells.join('')}</tr>${details.join('')}`;
142
- }).join('');
143
- return `<div class="expectations-table-wrap">
144
- <table class="expectations-table">
145
- <thead><tr><th class="exp-col">Expectation</th>${headerCells}</tr></thead>
146
- <tbody>${rows}</tbody>
147
- </table>
148
- </div>`;
149
- }
150
- function avgTrialTokens(trials) {
151
- const withStats = trials.filter(t => t.tokenStats != null);
152
- if (withStats.length === 0)
153
- return null;
154
- return Math.round(withStats.reduce((s, t) => s + t.tokenStats.totalTokens, 0) / withStats.length);
155
- }
156
- function avgTrialDuration(trials) {
157
- const withDuration = trials.filter(t => t.durationMs != null);
158
- if (withDuration.length === 0)
159
- return null;
160
- return Math.round(withDuration.reduce((s, t) => s + t.durationMs, 0) / withDuration.length);
161
- }
162
- function renderTaskMiniGrid(result, isFunctionalEval) {
163
- const skillVersions = Object.keys(result.skillTrials);
164
- const allVersions = isFunctionalEval && (result.baselineTrials?.length ?? 0) > 0 ? ['baseline', ...skillVersions] : skillVersions;
165
- const headerCells = allVersions.map(v => `<th>${v}</th>`).join('');
166
- const successRows = `<tr>
167
- <td>Success Rate</td>
168
- ${allVersions.map(v => {
169
- const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
170
- const rate = trials.length ? Math.round(computeAssertionPassRate(trials) * 100) : 0;
171
- return `<td><span class="metric-val-sm ${passColorClass(rate / 100)}">${rate}%</span></td>`;
172
- }).join('')}
173
- </tr>`;
174
- const tokenRows = `<tr>
175
- <td>Tokens (avg)</td>
176
- ${allVersions.map(v => {
177
- const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
178
- const tokens = avgTrialTokens(trials);
179
- return `<td>${tokens != null ? `<span class="metric-val-sm">${formatTokens(tokens)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>`;
180
- }).join('')}
181
- </tr>`;
182
- const timeRows = `<tr>
183
- <td>Time (avg)</td>
184
- ${allVersions.map(v => {
185
- const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
186
- const ms = avgTrialDuration(trials);
187
- return `<td>${ms != null ? `<span class="metric-val-sm">${formatDuration(ms)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>`;
188
- }).join('')}
189
- </tr>`;
190
- return `<div class="metrics-grid-sm">
191
- <table>
192
- <thead>
193
- <tr><th></th>${headerCells}</tr>
194
- </thead>
195
- <tbody>
196
- ${successRows}
197
- ${tokenRows}
198
- ${timeRows}
199
- </tbody>
200
- </table>
201
- </div>`;
202
- }
203
- function renderTaskDetails(result, isFunctionalEval) {
204
- const sections = [];
205
- sections.push(renderTaskMiniGrid(result, isFunctionalEval));
206
- sections.push(renderExpectationsTable(result, isFunctionalEval));
207
- return `<div class="task-details" id="details-${result.taskId}">${sections.join('')}</div>`;
78
+ /**
79
+ * Serialises the run for embedding in a script tag. Agent output is arbitrary
80
+ * text and routinely contains markup, so every "<" is escaped: an unescaped
81
+ * "</script>" in a transcript would end the tag and break the page.
82
+ */
83
+ function serializeRunData(data) {
84
+ return JSON.stringify(data)
85
+ .replace(/</g, '\\u003c')
86
+ .replace(/\u2028/g, '\\u2028')
87
+ .replace(/\u2029/g, '\\u2029');
208
88
  }
209
- // ---------------------------------------------------------------------------
210
- // Eval results table
211
- // ---------------------------------------------------------------------------
212
- function renderTaskTable(report) {
213
- const { results } = report;
214
- const functional = isFunctional(report);
215
- const headerRow = `<tr><th>#</th><th>Prompt</th><th>Details</th></tr>`;
216
- const rows = results.map(result => {
217
- const prompt = escapeHtml(result.prompt);
218
- // Negative trigger evals assert the opposite outcome — flag them so the row is not misread.
219
- const badge = result.shouldTrigger === false ? '<span class="badge-neg">no-trigger</span>' : '';
220
- const detailsBtn = `<button class="details-btn" data-target="details-${result.taskId}">▶</button>`;
221
- const detailsRow = `<tr class="details-row"><td colspan="3">${renderTaskDetails(result, functional)}</td></tr>`;
222
- return `<tr><td>${result.taskId}</td><td class="prompt-cell">${badge}${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
223
- }).join('');
224
- return `<div class="table-wrap"><table><thead>${headerRow}</thead><tbody>${rows}</tbody></table></div>`;
89
+ function escapeHtml(s) {
90
+ return s
91
+ .replace(/&/g, '&amp;')
92
+ .replace(/</g, '&lt;')
93
+ .replace(/>/g, '&gt;')
94
+ .replace(/"/g, '&quot;')
95
+ .replace(/'/g, '&#39;');
225
96
  }
226
97
  // ---------------------------------------------------------------------------
227
- // Full document
98
+ // Document
228
99
  // ---------------------------------------------------------------------------
229
100
  export function generateHtml(report) {
230
- const { skill_name, agent, timestamp, metrics } = report;
231
- const functional = isFunctional(report);
232
- const evalType = functional ? 'Functional' : 'Trigger';
233
- const overallScore = metrics.passAtK['local'] ?? 0;
234
- const statusClass = passColorClass(overallScore);
235
- const formattedDate = new Date(timestamp).toLocaleString();
101
+ const data = buildRunData(report);
236
102
  return `<!DOCTYPE html>
237
103
  <html lang="en">
238
104
  <head>
239
105
  <meta charset="UTF-8">
240
106
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
241
- <title>Skill Eval — ${escapeHtml(skill_name)}</title>
107
+ <title>Skill Eval — ${escapeHtml(report.skill_name)}</title>
242
108
  <style>
243
109
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
244
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; font-size: 14px; }
245
- a { color: #3b82f6; }
246
-
247
- /* Layout */
248
- .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
249
-
250
- /* Header */
251
- .header { background: #1e293b; color: #f1f5f9; padding: 24px 28px; border-radius: 10px; margin-bottom: 24px; }
252
- .header h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
253
- .header-meta { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: #94a3b8; }
254
- .header-meta span b { color: #e2e8f0; }
255
- .status-bar { height: 4px; border-radius: 2px; margin-top: 16px; }
110
+
111
+ :root {
112
+ --ground: #f8fafc;
113
+ --surface: #ffffff;
114
+ --surface-sunk: #f8fafc;
115
+ --ink: #1e293b;
116
+ --ink-soft: #334155;
117
+ --muted: #64748b;
118
+ --muted-light: #94a3b8;
119
+ --border: #e2e8f0;
120
+ --border-soft: #f1f5f9;
121
+ --green: #16a34a;
122
+ --green-soft: #86efac;
123
+ --amber: #d97706;
124
+ --amber-bg: #fffbeb;
125
+ --amber-border: #fcd34d;
126
+ --amber-soft: #fcd34d;
127
+ --red: #dc2626;
128
+ --red-soft: #fca5a5;
129
+ --blue: #3b82f6;
130
+ --blue-bg: #e0f2fe;
131
+ --blue-ink: #0369a1;
132
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
133
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
134
+ }
135
+
136
+ body {
137
+ font-family: var(--sans);
138
+ background: var(--ground);
139
+ color: var(--ink);
140
+ font-size: 14px;
141
+ line-height: 1.5;
142
+ }
143
+
144
+ .container { max-width: 1000px; margin: 0 auto; padding: 20px 16px 64px; }
145
+
146
+ /* ── Header ─────────────────────────────────────────────────── */
147
+ .header { background: var(--ink); color: #f1f5f9; padding: 24px 28px; border-radius: 10px; margin-bottom: 20px; }
148
+ .header h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.01em; }
149
+ .header-meta { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: var(--muted-light); }
150
+ .header-meta span b { color: #e2e8f0; font-weight: 600; }
151
+ .status-bar { height: 4px; border-radius: 2px; margin-top: 16px; background: var(--red); transition: background 0.2s; }
256
152
  .status-bar.green { background: #22c55e; }
257
153
  .status-bar.amber { background: #f59e0b; }
258
- .status-bar.red { background: #ef4444; }
154
+ .status-bar.red { background: #ef4444; }
155
+
156
+ /* ── Exclusion bar ──────────────────────────────────────────── */
157
+ .excl-bar {
158
+ display: none; align-items: center; gap: 14px; flex-wrap: wrap;
159
+ background: var(--surface); border: 1px solid var(--border);
160
+ border-left: 3px solid var(--muted); border-radius: 8px;
161
+ padding: 12px 16px; margin-bottom: 20px;
162
+ }
163
+ .excl-bar.active { display: flex; }
164
+ .excl-bar.warn { border-left-color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); }
165
+ .excl-bar .excl-icon { font-size: 15px; color: var(--muted); }
166
+ .excl-bar.warn .excl-icon { color: var(--amber); }
167
+ .excl-headline { font-size: 13px; font-weight: 600; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
168
+ .excl-split { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
169
+ .excl-warn-msg { flex-basis: 100%; font-size: 12px; color: #92400e; line-height: 1.5; }
170
+ .excl-warn-msg:empty { display: none; }
171
+ .excl-actions { margin-left: auto; display: flex; gap: 8px; }
172
+
173
+ .btn {
174
+ font-family: inherit; font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
175
+ padding: 5px 11px; border-radius: 5px; border: 1px solid var(--border);
176
+ background: var(--surface); color: var(--muted); cursor: pointer; white-space: nowrap;
177
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
178
+ }
179
+ .btn:hover { background: var(--border-soft); color: var(--ink-soft); }
180
+ .btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
181
+ .btn.primary { background: var(--ink); border-color: var(--ink); color: #f1f5f9; }
182
+ .btn.primary:hover { background: #0f172a; color: #fff; }
183
+ .btn.danger { color: var(--red); border-color: #fecaca; }
184
+ .btn.danger:hover { background: #fef2f2; color: #b91c1c; }
259
185
 
260
- /* Metrics Grid */
261
- .metrics-grid { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; margin-bottom: 24px; }
186
+ /* ── Metrics grid ───────────────────────────────────────────── */
187
+ .metrics-grid { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 20px; }
262
188
  .metrics-grid table { width: 100%; border-collapse: collapse; }
263
- .metrics-grid thead th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; padding: 10px 24px; text-align: right; border-bottom: 2px solid #e2e8f0; white-space: nowrap; }
189
+ .metrics-grid thead th {
190
+ background: var(--surface-sunk); font-size: 11px; font-weight: 600; text-transform: uppercase;
191
+ letter-spacing: 0.06em; color: var(--muted); padding: 10px 24px; text-align: right;
192
+ border-bottom: 2px solid var(--border); white-space: nowrap;
193
+ }
264
194
  .metrics-grid thead th:first-child { text-align: left; min-width: 120px; }
265
- .metrics-grid tbody td { padding: 14px 24px; border-bottom: 1px solid #f1f5f9; text-align: right; vertical-align: middle; }
195
+ .metrics-grid tbody td { padding: 14px 24px; border-bottom: 1px solid var(--border-soft); text-align: right; vertical-align: middle; }
266
196
  .metrics-grid tbody tr:last-child td { border-bottom: none; }
267
- .metrics-grid tbody td:first-child { text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; white-space: nowrap; }
268
- .metric-val { font-size: 24px; font-weight: 700; line-height: 1; display: block; }
269
- .metric-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; }
270
-
271
- /* Metrics Grid compact variant (inside task details) */
272
- .metrics-grid-sm { border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; margin-bottom: 14px; }
273
- .metrics-grid-sm table { width: 100%; border-collapse: collapse; }
274
- .metrics-grid-sm thead th { background: #f8fafc; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; padding: 6px 14px; text-align: right; border-bottom: 1px solid #e2e8f0; white-space: nowrap; }
275
- .metrics-grid-sm thead th:first-child { text-align: left; }
276
- .metrics-grid-sm tbody td { padding: 8px 14px; border-bottom: 1px solid #f1f5f9; text-align: right; vertical-align: middle; }
277
- .metrics-grid-sm tbody tr:last-child td { border-bottom: none; }
278
- .metrics-grid-sm tbody td:first-child { text-align: left; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; white-space: nowrap; }
279
- .metric-val-sm { font-size: 15px; font-weight: 700; line-height: 1; display: block; }
280
-
281
- /* Color utilities */
282
- .green { color: #16a34a; }
283
- .amber { color: #d97706; }
284
- .red { color: #dc2626; }
197
+ .metrics-grid tbody td:first-child {
198
+ text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
199
+ letter-spacing: 0.06em; color: var(--muted); white-space: nowrap;
200
+ }
201
+ .metric-val { font-size: 24px; font-weight: 700; line-height: 1; display: block; font-variant-numeric: tabular-nums; }
202
+ .metric-sub { font-size: 11px; color: var(--muted-light); margin-top: 5px; font-variant-numeric: tabular-nums; }
203
+ .metric-sub s { text-decoration-color: #cbd5e1; }
204
+ .metric-sub .lowconf { color: var(--amber); font-weight: 600; }
285
205
 
286
- /* Section */
287
- .section { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 20px; overflow: hidden; }
288
- .section-title { font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
206
+ .green { color: var(--green); }
207
+ .amber { color: var(--amber); }
208
+ .red { color: var(--red); }
209
+ .muted { color: var(--muted-light); }
289
210
 
290
- /* Table */
211
+ /* ── Section + task table ───────────────────────────────────── */
212
+ .section { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 20px; overflow: hidden; }
213
+ .section-title {
214
+ font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em;
215
+ color: var(--muted); padding: 12px 16px; border-bottom: 1px solid var(--border-soft);
216
+ }
291
217
  .table-wrap { overflow-x: auto; }
292
- table { width: 100%; border-collapse: collapse; }
293
- th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
294
- td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
295
- tr:last-child td { border-bottom: none; }
296
- .prompt-cell { max-width: 480px; word-break: break-word; color: #334155; }
297
- .badge-neg { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 10px; background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; vertical-align: 1px; }
298
-
299
- /* Details */
300
- .details-btn { background: none; border: 1px solid #e2e8f0; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 11px; color: #64748b; transition: background 0.15s; }
301
- .details-btn:hover { background: #f1f5f9; }
302
- .details-btn.open { color: #3b82f6; border-color: #3b82f6; }
303
- .details-row > td { padding: 0; background: #f8fafc; }
304
- .task-details { display: none; padding: 12px 16px; }
218
+ .task-table { width: 100%; border-collapse: collapse; }
219
+ .task-table > thead th {
220
+ background: var(--surface-sunk); font-size: 11px; font-weight: 600; text-transform: uppercase;
221
+ letter-spacing: 0.05em; color: var(--muted); padding: 10px 12px; text-align: left;
222
+ border-bottom: 1px solid var(--border);
223
+ }
224
+ .task-table > tbody > tr > td { padding: 10px 12px; border-bottom: 1px solid var(--border-soft); vertical-align: top; }
225
+ .task-table > tbody > tr:last-child > td { border-bottom: none; }
226
+ .prompt-cell { max-width: 560px; word-break: break-word; color: var(--ink-soft); }
227
+
228
+ .details-btn {
229
+ background: none; border: 1px solid var(--border); border-radius: 4px; cursor: pointer;
230
+ padding: 2px 8px; font-size: 11px; color: var(--muted); font-family: inherit; transition: background 0.15s;
231
+ }
232
+ .details-btn:hover { background: var(--border-soft); }
233
+ .details-btn.open { color: var(--blue); border-color: var(--blue); }
234
+ .details-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
235
+ .details-row > td { padding: 0 !important; background: var(--surface-sunk); }
236
+ .task-details { display: none; padding: 16px; }
305
237
  .task-details.visible { display: block; }
306
238
 
307
- /* Expectations × Variants table */
308
- .expectations-table-wrap { border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; background: #fff; }
239
+ /* ── Subsections: Summary / Trials / Expectations ───────────── */
240
+ .subsection + .subsection { margin-top: 20px; }
241
+ .subsection-title {
242
+ display: flex; align-items: center; gap: 10px; margin-bottom: 9px;
243
+ font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
244
+ }
245
+ .subsection-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
246
+ .subsection-title .st-count {
247
+ font-size: 11.5px; font-weight: 500; text-transform: none; letter-spacing: 0;
248
+ color: var(--muted-light); font-variant-numeric: tabular-nums;
249
+ }
250
+ .subsection-title .st-count .flagged { color: var(--amber); font-weight: 600; }
251
+
252
+ /* ── Trials table ───────────────────────────────────────────── */
253
+ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
254
+ .trials-table { width: 100%; border-collapse: collapse; }
255
+
256
+ .trials-table thead th {
257
+ background: var(--border-soft); font-size: 10px; font-weight: 700; text-transform: uppercase;
258
+ letter-spacing: 0.06em; color: #475569; padding: 8px 12px; text-align: left;
259
+ border-bottom: 1px solid var(--border); white-space: nowrap;
260
+ }
261
+ .trials-table th.num, .trials-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
262
+ .trials-table th.act, .trials-table td.act { text-align: right; }
263
+
264
+ .variant-row > td {
265
+ background: var(--surface-sunk); padding: 7px 12px;
266
+ border-bottom: 1px solid var(--border); border-top: 1px solid var(--border);
267
+ }
268
+ .trials-table tbody tr.variant-row:first-child > td { border-top: none; }
269
+ .variant-row .variant-name {
270
+ font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
271
+ color: var(--blue-ink); background: var(--blue-bg); padding: 2px 8px; border-radius: 4px;
272
+ }
273
+ .variant-row .variant-meta { font-size: 11.5px; color: var(--muted); margin-left: 9px; font-variant-numeric: tabular-nums; }
274
+ .variant-row .variant-meta .off { color: var(--ink-soft); font-weight: 600; }
275
+
276
+ .trial-row > td { padding: 9px 12px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
277
+ .trial-row { cursor: pointer; transition: background 0.12s; }
278
+ .trial-row:hover > td { background: #fbfcfd; }
279
+ .trial-row.open > td { background: var(--blue-bg); }
280
+
281
+ .tr-toggle {
282
+ background: none; border: none; cursor: pointer; font-size: 9px; color: var(--muted-light);
283
+ font-family: inherit; padding: 2px 4px; line-height: 1;
284
+ }
285
+ .tr-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 3px; }
286
+ .trial-row.open .tr-toggle { color: var(--blue); }
287
+
288
+ .tr-name { font-size: 12.5px; font-weight: 600; color: var(--ink-soft); white-space: nowrap; }
289
+ .tr-score .frac { font-size: 13px; font-weight: 700; line-height: 1.15; display: block; font-variant-numeric: tabular-nums; }
290
+ .tr-score .bar { display: block; width: 62px; height: 3px; background: var(--border); border-radius: 2px; margin-top: 4px; overflow: hidden; }
291
+ .tr-score .bar i { display: block; height: 100%; border-radius: 2px; }
292
+ .tr-score .bar i.green { background: var(--green); }
293
+ .tr-score .bar i.amber { background: var(--amber-soft); }
294
+ .tr-score .bar i.red { background: var(--red-soft); }
295
+
296
+ .tr-flags { line-height: 1.9; }
297
+ .flag-chip {
298
+ display: inline-block; font-size: 10.5px; font-weight: 600; white-space: nowrap;
299
+ color: #92400e; background: var(--amber-bg); border: 1px solid var(--amber-border);
300
+ border-radius: 4px; padding: 1px 7px; margin-right: 5px;
301
+ }
302
+ .tr-flags .clean { font-size: 11.5px; color: var(--muted-light); }
303
+ .excl-note { font-size: 11.5px; color: var(--muted-light); font-style: italic; }
304
+ .tr-num { font-size: 12px; color: var(--muted); white-space: nowrap; }
305
+
306
+ /* Excluded state: absence, not failure — desaturate, don't recolor. */
307
+ .trial-row.excluded > td {
308
+ background: repeating-linear-gradient(135deg, #fbfcfd, #fbfcfd 6px, var(--surface-sunk) 6px, var(--surface-sunk) 12px);
309
+ }
310
+ .trial-row.excluded:hover > td { background: repeating-linear-gradient(135deg, #f8fafc, #f8fafc 6px, var(--border-soft) 6px, var(--border-soft) 12px); }
311
+ .trial-row.excluded .tr-name,
312
+ .trial-row.excluded .tr-num { color: var(--muted-light); }
313
+ .trial-row.excluded .tr-score .frac { color: var(--muted-light); text-decoration: line-through; text-decoration-thickness: 1.5px; }
314
+ .trial-row.excluded .tr-score .bar i { background: #cbd5e1; }
315
+ .trial-row.excluded .flag-chip { color: var(--muted); background: var(--border-soft); border-color: var(--border); }
316
+ .excl-chip {
317
+ display: inline-block; font-size: 10.5px; font-weight: 700; white-space: nowrap;
318
+ color: #475569; background: #e2e8f0; border-radius: 4px; padding: 1px 7px; margin-right: 5px;
319
+ }
320
+
321
+ /* ── Trial detail row ───────────────────────────────────────── */
322
+ .trial-detail > td { padding: 0; background: var(--surface-sunk); border-bottom: 1px solid var(--border); }
323
+ .trial-detail-inner { padding: 13px 16px 15px; border-left: 3px solid var(--blue); }
324
+
325
+ .anomaly-row { display: flex; gap: 9px; align-items: baseline; padding: 5px 0; }
326
+ .anomaly-row + .anomaly-row { border-top: 1px dashed var(--border); }
327
+ .anomaly-why { font-size: 12px; color: var(--muted); line-height: 1.5; }
328
+ .no-anomaly { font-size: 12px; color: var(--muted-light); }
329
+
330
+ .td-label {
331
+ font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em;
332
+ color: var(--muted); margin: 14px 0 6px;
333
+ }
334
+ .output-box {
335
+ font-family: var(--mono); font-size: 11.5px; line-height: 1.6; color: var(--ink-soft);
336
+ background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
337
+ padding: 10px 12px; max-height: 230px; overflow: auto; white-space: pre-wrap; word-break: break-word;
338
+ }
339
+ .output-box.degenerate { color: var(--red); background: #fef2f2; border-color: #fecaca; }
340
+ .output-trunc { font-size: 10.5px; color: var(--muted-light); margin-top: 5px; }
341
+ .td-stats { font-size: 11.5px; color: var(--muted); margin-top: 12px; font-variant-numeric: tabular-nums; }
342
+ .td-stats b { color: var(--ink-soft); font-weight: 600; }
343
+ .td-log { font-size: 11.5px; color: var(--muted); margin-top: 5px; }
344
+ .td-log a { color: var(--blue); font-family: var(--mono); font-size: 11px; }
345
+
346
+ .reason-bar {
347
+ display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
348
+ margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--border);
349
+ font-size: 11.5px; color: var(--muted);
350
+ }
351
+ select, input[type="text"] {
352
+ font-family: inherit; font-size: 11.5px; color: var(--ink-soft); background: var(--surface);
353
+ border: 1px solid var(--border); border-radius: 5px; padding: 4px 7px;
354
+ }
355
+ select:focus-visible, input:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
356
+ input[type="text"] { width: 190px; }
357
+
358
+ /* ── Expectations table ─────────────────────────────────────── */
309
359
  .expectations-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
310
- .expectations-table thead th { background: #f1f5f9; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #475569; padding: 10px 14px; text-align: left; border-bottom: 1px solid #e2e8f0; }
311
- .expectations-table thead th.exp-col { width: auto; }
312
- .expectations-table thead th.variant-col { width: 110px; text-align: center; }
313
- .expectations-table tbody td { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; font-size: 13px; }
360
+ .expectations-table thead th {
361
+ background: var(--border-soft); font-size: 10px; font-weight: 700; text-transform: uppercase;
362
+ letter-spacing: 0.06em; color: #475569; padding: 9px 14px; text-align: left;
363
+ border-bottom: 1px solid var(--border);
364
+ }
365
+ .expectations-table thead th.variant-col { width: 106px; text-align: center; }
366
+ .expectations-table tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; font-size: 12.5px; }
314
367
  .expectations-table tbody tr.exp-row:last-of-type > td { border-bottom: none; }
315
- .exp-text { color: #1e293b; line-height: 1.4; word-break: break-word; }
316
-
317
- .pass-cell { text-align: center; border-left: 1px solid #f1f5f9; }
318
- .pass-cell[data-detail] { cursor: pointer; user-select: none; transition: background 0.12s; }
319
- .pass-cell[data-detail]:hover { background: #f1f5f9; }
320
- .pass-cell.open { background: #e0f2fe; }
321
- .pass-cell .rate { display: block; font-size: 13px; font-weight: 700; line-height: 1.1; }
322
- .pass-cell .frac { display: block; font-size: 10px; font-weight: 600; color: #94a3b8; margin-top: 2px; letter-spacing: 0.03em; }
323
- .pass-cell.green .rate { color: #16a34a; }
324
- .pass-cell.amber .rate { color: #d97706; }
325
- .pass-cell.red .rate { color: #dc2626; }
326
-
327
- .exp-detail-row { display: none; }
328
- .exp-detail-row.visible { display: table-row; }
329
- .exp-detail-row > td { padding: 0; background: #f8fafc; border-bottom: 1px solid #f1f5f9; }
330
- .exp-detail-inner { padding: 12px 14px 14px; border-left: 3px solid #3b82f6; }
331
- .exp-detail-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin-bottom: 8px; }
332
- .exp-detail-header .variant-pill { display: inline-block; background: #e0f2fe; color: #0369a1; padding: 1px 7px; border-radius: 4px; margin-left: 4px; font-weight: 700; }
333
- .exp-trial-line { display: flex; gap: 8px; padding: 6px 0; border-top: 1px dashed #e2e8f0; }
368
+ .exp-text { color: var(--ink); line-height: 1.5; word-break: break-word; }
369
+ .exp-text code { font-family: var(--mono); font-size: 11px; background: var(--border-soft); padding: 1px 4px; border-radius: 3px; }
370
+
371
+ .pass-cell { text-align: center; border-left: 1px solid var(--border-soft); cursor: pointer; user-select: none; transition: background 0.12s; }
372
+ .pass-cell:hover { background: var(--border-soft); }
373
+ .pass-cell.open { background: var(--blue-bg); }
374
+ .pass-cell .rate { display: block; font-size: 13px; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
375
+ .pass-cell .frac { display: block; font-size: 10px; font-weight: 600; color: var(--muted-light); margin-top: 2px; font-variant-numeric: tabular-nums; }
376
+ .pass-cell.green .rate { color: var(--green); }
377
+ .pass-cell.amber .rate { color: var(--amber); }
378
+ .pass-cell.red .rate { color: var(--red); }
379
+
380
+ .exp-detail-row > td { padding: 0; background: var(--surface-sunk); border-bottom: 1px solid var(--border-soft); }
381
+ .exp-detail-inner { padding: 12px 14px 14px; border-left: 3px solid var(--blue); }
382
+ .exp-detail-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 8px; }
383
+ .exp-trial-line { display: flex; gap: 8px; padding: 6px 0; border-top: 1px dashed var(--border); }
334
384
  .exp-trial-line:first-of-type { border-top: none; }
335
- .exp-trial-icon { flex-shrink: 0; font-size: 13px; font-weight: 700; line-height: 1.4; width: 16px; }
336
- .exp-trial-icon.pass { color: #16a34a; }
337
- .exp-trial-icon.fail { color: #dc2626; }
338
- .exp-trial-body { flex: 1; min-width: 0; font-size: 12px; color: #334155; line-height: 1.45; }
385
+ .exp-trial-line.is-excluded { opacity: 0.5; }
386
+ .exp-trial-icon { flex-shrink: 0; font-size: 13px; font-weight: 700; line-height: 1.45; width: 16px; }
387
+ .exp-trial-icon.pass { color: var(--green); }
388
+ .exp-trial-icon.fail { color: var(--red); }
389
+ .exp-trial-icon.off { color: var(--muted-light); }
390
+ .exp-trial-body { flex: 1; min-width: 0; font-size: 12px; color: var(--ink-soft); line-height: 1.5; }
339
391
  .exp-trial-body .trial-label { font-weight: 700; color: #475569; margin-right: 4px; }
340
- .exp-trial-body .trial-reason { color: #64748b; }
392
+ .exp-trial-body .trial-reason { color: var(--muted); }
393
+ .exp-trial-line.is-excluded .trial-reason { text-decoration: line-through; text-decoration-color: #cbd5e1; }
394
+
395
+ /* ── Mini metrics (Summary subsection) ──────────────────────── */
396
+ .metrics-grid-sm table { width: 100%; border-collapse: collapse; }
397
+ .metrics-grid-sm thead th {
398
+ background: var(--border-soft); font-size: 10px; font-weight: 700; text-transform: uppercase;
399
+ letter-spacing: 0.06em; color: #475569; padding: 8px 14px; text-align: right;
400
+ border-bottom: 1px solid var(--border); white-space: nowrap;
401
+ }
402
+ .metrics-grid-sm thead th:first-child { text-align: left; }
403
+ .metrics-grid-sm tbody td { padding: 9px 14px; border-bottom: 1px solid var(--border-soft); text-align: right; }
404
+ .metrics-grid-sm tbody tr:last-child td { border-bottom: none; }
405
+ .metrics-grid-sm tbody td:first-child {
406
+ text-align: left; font-size: 10px; font-weight: 600; text-transform: uppercase;
407
+ letter-spacing: 0.06em; color: var(--muted-light); white-space: nowrap;
408
+ }
409
+ .metric-val-sm { font-size: 15px; font-weight: 700; line-height: 1.15; display: block; font-variant-numeric: tabular-nums; }
410
+ .metric-sub-sm { font-size: 10px; color: var(--muted-light); margin-top: 3px; font-variant-numeric: tabular-nums; }
341
411
 
342
- .muted { color: #94a3b8; font-size: 13px; }
412
+ /* ── Toast ──────────────────────────────────────────────────── */
413
+ .toast {
414
+ position: fixed; bottom: 20px; left: 50%; transform: translate(-50%, 12px);
415
+ background: var(--ink); color: #f1f5f9; font-size: 12.5px; padding: 10px 16px;
416
+ border-radius: 6px; opacity: 0; pointer-events: none; transition: opacity 0.2s, transform 0.2s;
417
+ max-width: min(540px, calc(100vw - 32px)); text-align: center; line-height: 1.5;
418
+ }
419
+ .toast.visible { opacity: 1; transform: translate(-50%, 0); }
420
+
421
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
343
422
  </style>
344
423
  </head>
345
424
  <body>
425
+
346
426
  <div class="container">
347
427
 
348
- <!-- Header -->
349
428
  <div class="header">
350
- <h1>${escapeHtml(skill_name)}</h1>
429
+ <h1 id="hdr-skill">—</h1>
351
430
  <div class="header-meta">
352
- <span><b>Agent</b> ${escapeHtml(agent)}</span>
353
- <span><b>Type</b> ${evalType}</span>
354
- <span><b>Date</b> ${escapeHtml(formattedDate)}</span>
431
+ <span><b>Agent</b> <span id="hdr-agent">—</span></span>
432
+ <span><b>Type</b> <span id="hdr-type">—</span></span>
433
+ <span><b>Date</b> <span id="hdr-date">—</span></span>
355
434
  </div>
356
- <div class="status-bar ${statusClass}"></div>
435
+ <div class="status-bar" id="hdr-status"></div>
436
+ </div>
437
+
438
+ <div class="excl-bar" id="excl-bar">
439
+ <span class="excl-icon" aria-hidden="true">⊘</span>
440
+ <span class="excl-headline" id="excl-headline"></span>
441
+ <span class="excl-split" id="excl-split"></span>
442
+ <span class="excl-actions">
443
+ <button class="btn" id="btn-reset" type="button">Clear</button>
444
+ <button class="btn primary" id="btn-download" type="button">Download reviewed copy</button>
445
+ </span>
446
+ <span class="excl-warn-msg" id="excl-warn"></span>
357
447
  </div>
358
448
 
359
- <!-- Metrics Grid -->
360
- ${renderMetricsGrid(report)}
449
+ <div class="metrics-grid" id="metrics-grid"></div>
361
450
 
362
- <!-- Eval Results Table -->
363
451
  <div class="section">
364
452
  <div class="section-title">Eval results</div>
365
- ${renderTaskTable(report)}
453
+ <div class="table-wrap">
454
+ <table class="task-table">
455
+ <thead><tr><th style="width:44px">#</th><th>Prompt</th><th style="width:80px">Details</th></tr></thead>
456
+ <tbody id="task-tbody"></tbody>
457
+ </table>
458
+ </div>
366
459
  </div>
367
460
 
368
461
  </div>
462
+
463
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
464
+
465
+
466
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
467
+
468
+ <script id="run-data" type="application/json">${serializeRunData(data)}</script>
369
469
  <script>
370
470
  (function () {
371
- document.querySelectorAll('.details-btn').forEach(function (btn) {
372
- btn.addEventListener('click', function () {
373
- const target = document.getElementById(btn.getAttribute('data-target'));
374
- if (!target) return;
375
- const isOpen = target.classList.contains('visible');
376
- target.classList.toggle('visible', !isOpen);
377
- btn.classList.toggle('open', !isOpen);
378
- btn.textContent = isOpen ? '▶' : '▼';
471
+ 'use strict';
472
+
473
+ var RUN = JSON.parse(document.getElementById('run-data').textContent);
474
+
475
+ /* ── Exclusion state ────────────────────────────────────────
476
+ key = "<taskId>:<variant>:<trialId>" → { reason, note, at }
477
+ In the real reporter this same shape is what gets baked into
478
+ the downloaded copy. Here it also round-trips to localStorage. */
479
+ var STORE_KEY = 'skill-eval:excl:' + RUN.runId;
480
+ var excluded = {};
481
+
482
+ var REASONS = [
483
+ ['degenerate-output', 'Degenerate output'],
484
+ ['zero-assertions', 'Zero assertions'],
485
+ ['premature-stop', 'Premature stop'],
486
+ ['resource-outlier', 'Resource outlier'],
487
+ ['environment', 'Environment problem'],
488
+ ['infrastructure', 'Infrastructure'],
489
+ ['other', 'Other']
490
+ ];
491
+ function reasonLabel(id) {
492
+ for (var i = 0; i < REASONS.length; i++) if (REASONS[i][0] === id) return REASONS[i][1];
493
+ return id;
494
+ }
495
+
496
+ function loadState() {
497
+ try {
498
+ var raw = localStorage.getItem(STORE_KEY);
499
+ if (raw) { excluded = JSON.parse(raw) || {}; return; }
500
+ } catch (e) { /* file:// or blocked storage — fall through to the baked state */ }
501
+
502
+ // Opened elsewhere: a reviewed copy carries the exclusions it was saved with.
503
+ var baked = document.getElementById('excl-data');
504
+ if (!baked) return;
505
+ try { excluded = JSON.parse(decodeURIComponent(baked.textContent)) || {}; }
506
+ catch (e) { /* corrupt payload — start clean rather than fail to render */ }
507
+ }
508
+ function saveState() {
509
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(excluded)); }
510
+ catch (e) { /* non-fatal: the download is the durable path */ }
511
+ }
512
+
513
+ function key(taskId, variant, trialId) { return taskId + ':' + variant + ':' + trialId; }
514
+ function isExcluded(taskId, variant, trial) { return !!excluded[key(taskId, variant, trial.id)]; }
515
+
516
+ /* ── Aggregation ────────────────────────────────────────────
517
+ One implementation, used for the initial render and every
518
+ recompute. A trial leaves the denominator when it is excluded
519
+ or when it is an infrastructure error. */
520
+ function counted(taskId, variant, trials) {
521
+ return trials.filter(function (t) { return !t.isError && !isExcluded(taskId, variant, t); });
522
+ }
523
+
524
+ function rateOf(trials) {
525
+ var total = 0, passed = 0;
526
+ trials.forEach(function (t) {
527
+ total += t.results.length;
528
+ passed += t.results.reduce(function (s, v) { return s + v; }, 0);
379
529
  });
380
- });
530
+ return total ? passed / total : 0;
531
+ }
532
+
533
+ function taskRate(taskId, variant, trials) {
534
+ var live = counted(taskId, variant, trials);
535
+ return { rate: rateOf(live), n: live.length, of: trials.length };
536
+ }
537
+
538
+ function avgOver(trials, field) {
539
+ var vals = [];
540
+ trials.forEach(function (t) {
541
+ if (field === 'tokens' && t.tokens) vals.push(t.tokens.totalTokens);
542
+ if (field === 'duration' && t.durationMs != null) vals.push(t.durationMs);
543
+ });
544
+ if (!vals.length) return null;
545
+ return Math.round(vals.reduce(function (s, v) { return s + v; }, 0) / vals.length);
546
+ }
547
+
548
+ /* Suite level: average of per-task rates, matching aggregateAssertionPassRate. */
549
+ function suiteRate(variant, useExclusions) {
550
+ var sum = 0, n = 0, live = 0, all = 0;
551
+ RUN.tasks.forEach(function (task) {
552
+ var trials = task.variants[variant];
553
+ if (!trials) return;
554
+ var relevant = trials.filter(function (t) {
555
+ return !t.isError && (!useExclusions || !isExcluded(task.taskId, variant, t));
556
+ });
557
+ sum += rateOf(relevant);
558
+ n += 1;
559
+ live += relevant.length;
560
+ all += trials.length;
561
+ });
562
+ return { rate: n ? sum / n : 0, n: live, of: all };
563
+ }
564
+
565
+ function suiteAvg(variant, field) {
566
+ var live = [];
567
+ RUN.tasks.forEach(function (task) {
568
+ counted(task.taskId, variant, task.variants[variant] || []).forEach(function (t) { live.push(t); });
569
+ });
570
+ return avgOver(live, field);
571
+ }
572
+
573
+ /* Anomalies are computed by core/anomalies.ts and embedded per trial;
574
+ the page only renders them. */
575
+ function anomaliesOf(trial) { return trial.anomalies || []; }
576
+
577
+ function score(trial) { return trial.results.reduce(function (s, v) { return s + v; }, 0); }
578
+
579
+ /* ── Formatting ─────────────────────────────────────────────── */
580
+ function fmtPct(v) { return Math.round(v * 100) + '%'; }
581
+ function fmtTokens(n) {
582
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
583
+ if (n >= 1000) return Math.round(n / 1000) + 'K';
584
+ return String(n);
585
+ }
586
+ function fmtDuration(ms) {
587
+ if (ms < 1000) return ms + 'ms';
588
+ var s = Math.round(ms / 1000);
589
+ if (s < 60) return s + 's';
590
+ var m = Math.floor(s / 60), rem = s % 60;
591
+ return rem > 0 ? m + 'm ' + rem + 's' : m + 'm';
592
+ }
593
+ function colorClass(v) { return v >= 0.8 ? 'green' : v >= 0.5 ? 'amber' : 'red'; }
594
+ function esc(s) {
595
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
596
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
597
+ }
598
+ /* Backticks in assertions are the skill's own symbol names — render them as code. */
599
+ function escTicks(s) {
600
+ return esc(s).replace(/\`([^\`]+)\`/g, function (_, inner) { return '<code>' + inner + '</code>'; });
601
+ }
602
+ function cssId(s) { return String(s).replace(/[^a-zA-Z0-9_-]+/g, '-'); }
603
+
604
+ function variantsOf(task) {
605
+ var names = Object.keys(task.variants);
606
+ names.sort(function (a, b) { return a === 'baseline' ? -1 : b === 'baseline' ? 1 : 0; });
607
+ return names;
608
+ }
609
+ var ALL_VARIANTS = variantsOf(RUN.tasks[0]);
610
+ var TRIAL_COLS = 7;
611
+
612
+ /* What is open, so a recompute never collapses what is being read.
613
+ One trial at a time per task keeps the page from turning into a wall. */
614
+ var openTrial = {}; /* taskId → "variant:trialId" */
615
+ var openExp = {}; /* detailId → bool */
616
+
617
+ /* ── Render: header + suite metrics ─────────────────────────── */
618
+ function renderHeader() {
619
+ document.getElementById('hdr-skill').textContent = RUN.skill;
620
+ document.getElementById('hdr-agent').textContent = RUN.agent;
621
+ document.getElementById('hdr-type').textContent = RUN.command === 'functional' ? 'Functional' : 'Trigger';
622
+ document.getElementById('hdr-date').textContent = new Date(RUN.timestamp).toLocaleString();
623
+ }
624
+
625
+ function renderSuiteMetrics() {
626
+ var head = ALL_VARIANTS.map(function (v) { return '<th>' + esc(v) + '</th>'; }).join('');
627
+
628
+ var successCells = ALL_VARIANTS.map(function (v) {
629
+ var adj = suiteRate(v, true), raw = suiteRate(v, false);
630
+ var sub = adj.n !== raw.n
631
+ ? '<div class="metric-sub">raw <s>' + fmtPct(raw.rate) + '</s> · n=' + adj.n + '/' + adj.of +
632
+ (adj.n < 3 ? ' · <span class="lowconf">low-confidence</span>' : '') + '</div>'
633
+ : '<div class="metric-sub">n=' + adj.n + '/' + adj.of + '</div>';
634
+ return '<td><span class="metric-val ' + colorClass(adj.rate) + '">' + fmtPct(adj.rate) + '</span>' + sub + '</td>';
635
+ }).join('');
636
+
637
+ var tokenCells = ALL_VARIANTS.map(function (v) {
638
+ var t = suiteAvg(v, 'tokens');
639
+ return '<td>' + (t != null
640
+ ? '<span class="metric-val">' + fmtTokens(t) + '</span><div class="metric-sub">avg total</div>'
641
+ : '<span class="metric-val muted">—</span>') + '</td>';
642
+ }).join('');
381
643
 
382
- document.querySelectorAll('.pass-cell[data-detail]').forEach(function (cell) {
383
- cell.addEventListener('click', function () {
384
- const detailRow = document.getElementById(cell.getAttribute('data-detail'));
385
- if (!detailRow) return;
386
- const isOpen = detailRow.classList.contains('visible');
387
- detailRow.classList.toggle('visible', !isOpen);
388
- cell.classList.toggle('open', !isOpen);
644
+ var timeCells = ALL_VARIANTS.map(function (v) {
645
+ var d = suiteAvg(v, 'duration');
646
+ return '<td>' + (d != null
647
+ ? '<span class="metric-val">' + fmtDuration(d) + '</span>'
648
+ : '<span class="metric-val muted">—</span>') + '</td>';
649
+ }).join('');
650
+
651
+ document.getElementById('metrics-grid').innerHTML =
652
+ '<table><thead><tr><th></th>' + head + '</tr></thead><tbody>' +
653
+ '<tr><td>Success Rate</td>' + successCells + '</tr>' +
654
+ '<tr><td>Tokens (avg)</td>' + tokenCells + '</tr>' +
655
+ '<tr><td>Time (avg)</td>' + timeCells + '</tr>' +
656
+ '</tbody></table>';
657
+
658
+ var target = ALL_VARIANTS.indexOf('local') >= 0 ? 'local' : ALL_VARIANTS[ALL_VARIANTS.length - 1];
659
+ document.getElementById('hdr-status').className = 'status-bar ' + colorClass(suiteRate(target, true).rate);
660
+ }
661
+
662
+ /* ── Render: exclusion bar ──────────────────────────────────── */
663
+ function renderExclusionBar() {
664
+ var perVariant = {}, total = 0, all = 0;
665
+ ALL_VARIANTS.forEach(function (v) { perVariant[v] = 0; });
666
+ RUN.tasks.forEach(function (task) {
667
+ ALL_VARIANTS.forEach(function (v) {
668
+ (task.variants[v] || []).forEach(function (t) {
669
+ all += 1;
670
+ if (isExcluded(task.taskId, v, t)) { perVariant[v] += 1; total += 1; }
671
+ });
672
+ });
673
+ });
674
+
675
+ var bar = document.getElementById('excl-bar');
676
+ if (!total) { bar.classList.remove('active', 'warn'); return; }
677
+ bar.classList.add('active');
678
+
679
+ document.getElementById('excl-headline').textContent =
680
+ total + ' of ' + all + ' trials excluded (' + Math.round((total / all) * 100) + '%)';
681
+ document.getElementById('excl-split').textContent =
682
+ ALL_VARIANTS.map(function (v) { return perVariant[v] + ' ' + v; }).join(' · ');
683
+
684
+ /* Asymmetric exclusions are the p-hacking failure mode: warn loudly. */
685
+ var counts = ALL_VARIANTS.map(function (v) { return perVariant[v]; });
686
+ var lopsided = ALL_VARIANTS.length > 1 && Math.max.apply(null, counts) !== Math.min.apply(null, counts);
687
+ bar.classList.toggle('warn', lopsided);
688
+ document.getElementById('excl-warn').textContent = lopsided
689
+ ? 'Exclusions are not balanced across variants. Check that you applied the same criterion to each — ' +
690
+ 'excluding more from one than another moves the delta you are measuring.'
691
+ : '';
692
+ }
693
+
694
+ /* ── Render: task rows ──────────────────────────────────────── */
695
+ function renderTasks() {
696
+ var tbody = document.getElementById('task-tbody');
697
+ tbody.innerHTML = RUN.tasks.map(function (task) {
698
+ return '<tr>' +
699
+ '<td>' + task.taskId + '</td>' +
700
+ '<td class="prompt-cell">' + esc(task.prompt) + '</td>' +
701
+ '<td><button class="details-btn" type="button" data-task="' + task.taskId + '" aria-expanded="false">▶</button></td>' +
702
+ '</tr>' +
703
+ '<tr class="details-row"><td colspan="3">' +
704
+ '<div class="task-details" id="details-' + task.taskId + '"></div></td></tr>';
705
+ }).join('');
706
+
707
+ tbody.querySelectorAll('.details-btn').forEach(function (btn) {
708
+ btn.addEventListener('click', function () {
709
+ var panel = document.getElementById('details-' + btn.getAttribute('data-task'));
710
+ var open = panel.classList.toggle('visible');
711
+ btn.classList.toggle('open', open);
712
+ btn.textContent = open ? '▼' : '▶';
713
+ btn.setAttribute('aria-expanded', String(open));
714
+ });
389
715
  });
716
+ }
717
+
718
+ /* ── Subsection: Summary ────────────────────────────────────── */
719
+ function renderSummary(task) {
720
+ var head = ALL_VARIANTS.map(function (v) { return '<th>' + esc(v) + '</th>'; }).join('');
721
+
722
+ var successCells = ALL_VARIANTS.map(function (v) {
723
+ var trials = task.variants[v] || [];
724
+ if (!trials.length) return '<td><span class="metric-val-sm muted">—</span></td>';
725
+ var adj = taskRate(task.taskId, v, trials);
726
+ var rawLive = trials.filter(function (t) { return !t.isError; });
727
+ var sub = adj.n !== rawLive.length
728
+ ? '<div class="metric-sub-sm">raw <s>' + fmtPct(rateOf(rawLive)) + '</s> · n=' + adj.n + '/' + adj.of + '</div>'
729
+ : '<div class="metric-sub-sm">n=' + adj.n + '/' + adj.of + '</div>';
730
+ return '<td><span class="metric-val-sm ' + colorClass(adj.rate) + '">' + fmtPct(adj.rate) + '</span>' + sub + '</td>';
731
+ }).join('');
732
+
733
+ function avgCells(field, fmt) {
734
+ return ALL_VARIANTS.map(function (v) {
735
+ var val = avgOver(counted(task.taskId, v, task.variants[v] || []), field);
736
+ return '<td><span class="metric-val-sm' + (val == null ? ' muted' : '') + '">' +
737
+ (val == null ? '—' : fmt(val)) + '</span></td>';
738
+ }).join('');
739
+ }
740
+
741
+ return '<div class="subsection"><div class="subsection-title">Summary</div>' +
742
+ '<div class="panel metrics-grid-sm"><table><thead><tr><th></th>' + head + '</tr></thead><tbody>' +
743
+ '<tr><td>Success Rate</td>' + successCells + '</tr>' +
744
+ '<tr><td>Tokens (avg)</td>' + avgCells('tokens', fmtTokens) + '</tr>' +
745
+ '<tr><td>Time (avg)</td>' + avgCells('duration', fmtDuration) + '</tr>' +
746
+ '</tbody></table></div></div>';
747
+ }
748
+
749
+ /* ── Subsection: Trials ─────────────────────────────────────── */
750
+ function renderTrials(task) {
751
+ var totalTrials = 0, totalFlagged = 0, totalExcluded = 0;
752
+ var body = '';
753
+
754
+ ALL_VARIANTS.forEach(function (variant) {
755
+ var trials = task.variants[variant] || [];
756
+ if (!trials.length) return;
757
+
758
+ var flagged = 0, off = 0;
759
+ trials.forEach(function (t) {
760
+ if (anomaliesOf(t).length) flagged += 1;
761
+ if (isExcluded(task.taskId, variant, t)) off += 1;
762
+ });
763
+ totalTrials += trials.length; totalFlagged += flagged; totalExcluded += off;
764
+
765
+ var meta = trials.length + ' trials';
766
+ if (flagged) meta += ' · ' + flagged + ' flagged';
767
+ if (off) meta += ' · <span class="off">' + off + ' excluded</span>';
768
+
769
+ body += '<tr class="variant-row"><td colspan="' + TRIAL_COLS + '">' +
770
+ '<span class="variant-name">' + esc(variant) + '</span>' +
771
+ '<span class="variant-meta">' + meta + '</span></td></tr>';
772
+
773
+ trials.forEach(function (t) {
774
+ body += renderTrialRow(task, variant, t, trials);
775
+ });
776
+ });
777
+
778
+ var count = totalTrials + ' trials';
779
+ if (totalFlagged) count += ' · <span class="flagged">' + totalFlagged + ' flagged</span>';
780
+ if (totalExcluded) count += ' · ' + totalExcluded + ' excluded';
781
+
782
+ return '<div class="subsection">' +
783
+ '<div class="subsection-title">Trials <span class="st-count">' + count + '</span></div>' +
784
+ '<div class="panel"><table class="trials-table">' +
785
+ '<thead><tr>' +
786
+ '<th style="width:30px"></th>' +
787
+ '<th style="width:78px">Trial</th>' +
788
+ '<th style="width:86px">Assertions</th>' +
789
+ '<th>Anomalies</th>' +
790
+ '<th class="num" style="width:74px">Tokens</th>' +
791
+ '<th class="num" style="width:70px">Time</th>' +
792
+ '<th class="act" style="width:104px">Status</th>' +
793
+ '</tr></thead><tbody>' + body + '</tbody></table></div></div>';
794
+ }
795
+
796
+ function renderTrialRow(task, variant, trial, cohort) {
797
+ var flags = anomaliesOf(trial);
798
+ var k = key(task.taskId, variant, trial.id);
799
+ var state = excluded[k];
800
+ var isOpen = openTrial[task.taskId] === variant + ':' + trial.id;
801
+ var sc = score(trial), tot = trial.results.length;
802
+ var rate = tot ? sc / tot : 0;
803
+
804
+ /* Once excluded, the reason replaces the flags — the anomalies stay in the detail row. */
805
+ var flagCells = state
806
+ ? '<span class="excl-chip">' + esc(reasonLabel(state.reason)) + '</span>' +
807
+ (state.note ? '<span class="excl-note">' + esc(state.note) + '</span>' : '')
808
+ : (flags.length
809
+ ? flags.map(function (f) { return '<span class="flag-chip">⚠ ' + esc(f.tag) + '</span>'; }).join('')
810
+ : '<span class="clean">—</span>');
811
+
812
+ var row = '<tr class="trial-row' + (state ? ' excluded' : '') + (isOpen ? ' open' : '') + '" ' +
813
+ 'data-task="' + task.taskId + '" data-variant="' + esc(variant) + '" data-trial="' + trial.id + '">' +
814
+ '<td><button class="tr-toggle" type="button" aria-expanded="' + isOpen + '" ' +
815
+ 'aria-label="Show detail for trial ' + trial.id + '">' + (isOpen ? '▼' : '▶') + '</button></td>' +
816
+ '<td class="tr-name">Trial ' + trial.id + '</td>' +
817
+ '<td class="tr-score">' +
818
+ '<span class="frac ' + (state ? '' : colorClass(rate)) + '">' + sc + '/' + tot + '</span>' +
819
+ '<span class="bar"><i class="' + colorClass(rate) + '" style="width:' + Math.round(rate * 1000) / 10 + '%"></i></span>' +
820
+ '</td>' +
821
+ '<td class="tr-flags">' + flagCells + '</td>' +
822
+ '<td class="num tr-num">' + (trial.tokens ? fmtTokens(trial.tokens.totalTokens) : '—') + '</td>' +
823
+ '<td class="num tr-num">' + (trial.durationMs != null ? fmtDuration(trial.durationMs) : '—') + '</td>' +
824
+ '<td class="act"><button class="btn ' + (state ? '' : 'danger') + '" type="button" ' +
825
+ 'data-act="' + (state ? 'include' : 'exclude') + '">' +
826
+ (state ? 'Re-include' : '⊘ Exclude') + '</button></td>' +
827
+ '</tr>';
828
+
829
+ return row + (isOpen ? renderTrialDetail(task, variant, trial, flags, state) : '');
830
+ }
831
+
832
+ function renderTrialDetail(task, variant, trial, flags, state) {
833
+ var anomalyHtml = flags.length
834
+ ? flags.map(function (f) {
835
+ return '<div class="anomaly-row"><span class="flag-chip">' + esc(f.tag) + '</span>' +
836
+ '<span class="anomaly-why">' + esc(f.reason) + '</span></div>';
837
+ }).join('')
838
+ : '<div class="no-anomaly">No anomalies detected — this reads as a legitimate attempt.</div>';
839
+
840
+ var degenerate = flags.some(function (f) { return f.tag === 'degenerate-output'; });
841
+ var truncated = trial.outputLen > trial.output.length;
842
+
843
+ /* The reason controls only exist once there is something to justify. */
844
+ var reasonBar = state
845
+ ? '<div class="reason-bar"><span>Reason</span>' +
846
+ '<select data-role="reason">' +
847
+ REASONS.map(function (r) {
848
+ return '<option value="' + r[0] + '"' + (r[0] === state.reason ? ' selected' : '') + '>' +
849
+ r[1] + '</option>';
850
+ }).join('') +
851
+ '</select>' +
852
+ '<input type="text" data-role="note" placeholder="Note (optional)" value="' +
853
+ esc(state.note || '') + '">' +
854
+ '</div>'
855
+ : '';
856
+
857
+ return '<tr class="trial-detail"><td colspan="' + TRIAL_COLS + '"><div class="trial-detail-inner">' +
858
+ anomalyHtml +
859
+ '<div class="td-label">Final agent output</div>' +
860
+ '<div class="output-box' + (degenerate ? ' degenerate' : '') + '">' +
861
+ esc(trial.output || '(empty)') + '</div>' +
862
+ (truncated ? '<div class="output-trunc">Truncated to ' + trial.output.length + ' of ' +
863
+ trial.outputLen + ' characters.</div>' : '') +
864
+ '<div class="td-stats"><b>' + trial.toolCalls + '</b> tool calls · stop: <b>' +
865
+ esc(trial.stopStatus || '—') + '</b> · <b>' +
866
+ (trial.tokens ? fmtTokens(trial.tokens.totalTokens) : '—') + '</b> tokens · <b>' +
867
+ (trial.durationMs != null ? fmtDuration(trial.durationMs) : '—') + '</b></div>' +
868
+ (trial.logFile
869
+ ? '<div class="td-log">Full transcript: <a href="' + esc(trial.logFile) + '">' +
870
+ esc(trial.logFile) + '</a></div>'
871
+ : '') +
872
+ reasonBar +
873
+ '</div></td></tr>';
874
+ }
875
+
876
+ /* ── Subsection: Expectations ───────────────────────────────── */
877
+ function renderExpectations(task) {
878
+ var totalCols = 1 + ALL_VARIANTS.length;
879
+ var head = ALL_VARIANTS.map(function (v) { return '<th class="variant-col">' + esc(v) + '</th>'; }).join('');
880
+
881
+ var rows = task.assertions.map(function (assertion, idx) {
882
+ var cells = '', details = '';
883
+ ALL_VARIANTS.forEach(function (v) {
884
+ var trials = task.variants[v] || [];
885
+ var live = counted(task.taskId, v, trials);
886
+ var passed = live.filter(function (t) { return t.results[idx] === 1; }).length;
887
+ var rate = live.length ? passed / live.length : 0;
888
+ var detailId = 'exp-' + task.taskId + '-' + idx + '-' + cssId(v);
889
+
890
+ cells += '<td class="pass-cell ' + colorClass(rate) + (openExp[detailId] ? ' open' : '') +
891
+ '" data-detail="' + detailId + '">' +
892
+ '<span class="rate">' + fmtPct(rate) + '</span>' +
893
+ '<span class="frac">' + passed + ' / ' + live.length + '</span></td>';
894
+
895
+ if (openExp[detailId]) {
896
+ var lines = trials.map(function (t) {
897
+ var off = isExcluded(task.taskId, v, t);
898
+ var ok = t.results[idx] === 1;
899
+ return '<div class="exp-trial-line' + (off ? ' is-excluded' : '') + '">' +
900
+ '<div class="exp-trial-icon ' + (off ? 'off' : ok ? 'pass' : 'fail') + '">' +
901
+ (off ? '⊘' : ok ? '✓' : '✗') + '</div>' +
902
+ '<div class="exp-trial-body"><span class="trial-label">Trial ' + t.id + '</span>' +
903
+ '<span class="trial-reason">' + esc(t.reasons[idx] || '') + '</span></div></div>';
904
+ }).join('');
905
+ details += '<tr class="exp-detail-row"><td colspan="' + totalCols + '">' +
906
+ '<div class="exp-detail-inner"><div class="exp-detail-header">Judge per trial · ' + esc(v) + '</div>' +
907
+ lines + '</div></td></tr>';
908
+ }
909
+ });
910
+ return '<tr class="exp-row"><td class="exp-text">' + escTicks(assertion) + '</td>' + cells + '</tr>' + details;
911
+ }).join('');
912
+
913
+ return '<div class="subsection">' +
914
+ '<div class="subsection-title">Expectations <span class="st-count">' +
915
+ task.assertions.length + ' expectations</span></div>' +
916
+ '<div class="panel"><table class="expectations-table">' +
917
+ '<thead><tr><th class="exp-col">Expectation</th>' + head + '</tr></thead>' +
918
+ '<tbody>' + rows + '</tbody></table></div></div>';
919
+ }
920
+
921
+ /* ── Wiring ─────────────────────────────────────────────────── */
922
+ function renderTaskDetails(task) {
923
+ var host = document.getElementById('details-' + task.taskId);
924
+ if (!host) return;
925
+ host.innerHTML = renderSummary(task) + renderTrials(task) + renderExpectations(task);
926
+ wire(task, host);
927
+ }
928
+
929
+ function wire(task, host) {
930
+ host.querySelectorAll('.trial-row').forEach(function (row) {
931
+ var variant = row.getAttribute('data-variant');
932
+ var trialId = parseInt(row.getAttribute('data-trial'), 10);
933
+ var k = key(task.taskId, variant, trialId);
934
+
935
+ row.addEventListener('click', function (ev) {
936
+ if (ev.target.closest('[data-act]')) return;
937
+ var slot = variant + ':' + trialId;
938
+ openTrial[task.taskId] = openTrial[task.taskId] === slot ? null : slot;
939
+ render();
940
+ });
941
+
942
+ var actBtn = row.querySelector('[data-act]');
943
+ if (!actBtn) return;
944
+ actBtn.addEventListener('click', function () {
945
+ if (actBtn.getAttribute('data-act') === 'include') {
946
+ delete excluded[k];
947
+ toast('Trial ' + trialId + " of '" + variant + "' re-included");
948
+ } else {
949
+ /* Pre-fill the reason from the strongest signal; refine it in the detail row. */
950
+ var trial = task.variants[variant].filter(function (t) { return t.id === trialId; })[0];
951
+ var flags = anomaliesOf(trial);
952
+ excluded[k] = {
953
+ reason: flags.length ? flags[0].tag : 'other',
954
+ note: '',
955
+ at: new Date().toISOString()
956
+ };
957
+ openTrial[task.taskId] = variant + ':' + trialId;
958
+ toast('Trial ' + trialId + " of '" + variant + "' excluded — " + reasonLabel(excluded[k].reason));
959
+ }
960
+ saveState();
961
+ render();
962
+ });
963
+ });
964
+
965
+ /* Reason + note live inside the open detail row; persist as they change. */
966
+ host.querySelectorAll('.trial-detail [data-role]').forEach(function (input) {
967
+ var row = input.closest('.trial-detail').previousElementSibling;
968
+ var k = key(task.taskId, row.getAttribute('data-variant'), parseInt(row.getAttribute('data-trial'), 10));
969
+ input.addEventListener('change', function () {
970
+ if (!excluded[k]) return;
971
+ if (input.getAttribute('data-role') === 'reason') excluded[k].reason = input.value;
972
+ else excluded[k].note = input.value.trim();
973
+ saveState();
974
+ render();
975
+ });
976
+ });
977
+
978
+ host.querySelectorAll('.pass-cell').forEach(function (cell) {
979
+ cell.addEventListener('click', function () {
980
+ var id = cell.getAttribute('data-detail');
981
+ openExp[id] = !openExp[id];
982
+ render();
983
+ });
984
+ });
985
+ }
986
+
987
+ function render() {
988
+ renderSuiteMetrics();
989
+ renderExclusionBar();
990
+ RUN.tasks.forEach(renderTaskDetails);
991
+ }
992
+
993
+ var toastTimer;
994
+ function toast(msg) {
995
+ var el = document.getElementById('toast');
996
+ el.textContent = msg;
997
+ el.classList.add('visible');
998
+ clearTimeout(toastTimer);
999
+ toastTimer = setTimeout(function () { el.classList.remove('visible'); }, 2600);
1000
+ }
1001
+
1002
+ document.getElementById('btn-reset').addEventListener('click', function () {
1003
+ excluded = {};
1004
+ saveState();
1005
+ render();
1006
+ toast('Exclusions cleared');
390
1007
  });
1008
+
1009
+ /* Saves a copy of this page with the current exclusions baked in — the
1010
+ durable, shareable artifact. URI-encoded so a reviewer's note can contain
1011
+ any character without breaking out of the script tag. */
1012
+ document.getElementById('btn-download').addEventListener('click', function () {
1013
+ var baked = document.getElementById('excl-data');
1014
+ if (!baked) {
1015
+ baked = document.createElement('script');
1016
+ baked.id = 'excl-data';
1017
+ baked.type = 'application/json';
1018
+ document.body.appendChild(baked);
1019
+ }
1020
+ baked.textContent = encodeURIComponent(JSON.stringify(excluded));
1021
+
1022
+ var link = document.createElement('a');
1023
+ var url = URL.createObjectURL(
1024
+ new Blob(['<!DOCTYPE html>\\n' + document.documentElement.outerHTML], { type: 'text/html' })
1025
+ );
1026
+ link.href = url;
1027
+ link.download = 'report-reviewed.html';
1028
+ link.click();
1029
+ URL.revokeObjectURL(url);
1030
+ toast('Saved report-reviewed.html with ' + Object.keys(excluded).length + ' exclusion(s)');
1031
+ });
1032
+
1033
+ loadState();
1034
+ renderHeader();
1035
+ renderTasks();
1036
+ render();
391
1037
  }());
392
1038
  </script>
393
1039
  </body>