@bglocation/tune-context 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,881 @@
1
+ #!/usr/bin/env node
2
+ // report — the TASK-089 aggregate and the TASK-090 verdict rendered as ONE
3
+ // self-contained HTML file. See tasks/TASK-091.
4
+ //
5
+ // The failure this file exists to prevent is not a wrong number — analyze.mjs
6
+ // and verdict.mjs already refuse to produce one. It is a page that AGREES with
7
+ // every number on it and still leaves the reader believing something the run did
8
+ // not measure. A chart persuades harder than the figure it draws and much harder
9
+ // than the caveat underneath it: two bars side by side assert "this is a
10
+ // comparison" even when the paragraph beside them says the verdict is withheld.
11
+ // Presentation can undo all of TASK-090's caution without touching a digit.
12
+ //
13
+ // So the rules below are structural, not editorial:
14
+ //
15
+ // • **A withheld verdict is the page's main content, not a footnote.** When a
16
+ // gate holds, the arm-comparison chart is NOT DRAWN AT ALL — not drawn and
17
+ // annotated, not greyed out. The per-pair costs stay in a table, because they
18
+ // are facts the operator needs to repeat the run; what disappears is the
19
+ // picture that says "compare these".
20
+ // • **The two pools are rendered by two calls that share no accumulator.**
21
+ // There is no code path here that adds paired to natural, and none that could
22
+ // be introduced by editing one number — the same shape rule analyze.mjs uses.
23
+ // • **A token count never appears without its five classes.** One helper emits
24
+ // both or neither, so "total tokens" cannot be printed alone by accident.
25
+ // • **The honesty counters are a section, not a footer.** They are the first
26
+ // thing a reader would cut "for clarity", so they sit above the fold of the
27
+ // natural-work detail, with the rejected rows and the unmeasured delegation
28
+ // cost among them.
29
+ // • **Zero network, zero build step.** No src, no href, no @import, no webfont:
30
+ // the file opens from file:// on a machine with no internet, which is where
31
+ // it will actually be read. Data rides inside a
32
+ // <script type="application/json"> block so the numbers behind the page can
33
+ // be re-read without trusting the prose.
34
+ //
35
+ // Everything visual follows the `dataviz` skill: form chosen before color,
36
+ // categorical hues assigned in fixed order (arms take slots 1-2, token classes
37
+ // slots 3-7), palette validated with the skill's own script in both modes
38
+ // (light: contrast WARN on aqua/yellow/magenta, so both charts ship direct
39
+ // labels AND a table twin — the documented relief), zero-baseline axes,
40
+ // hairline chrome, 2px surface gaps instead of borders.
41
+ import { writeFileSync } from 'node:fs';
42
+ import { parseArgs } from 'node:util';
43
+ import { endQuietlyOnClosedPipe, isEntryPoint } from '../cli/entry-point.mjs';
44
+ import { meterStamp } from '../cli/version.mjs';
45
+ import { ARMS } from './analyze.mjs';
46
+ import { TOKEN_CLASS_FIELDS, formatUsd } from './pricing.mjs';
47
+ import { VERDICT_THRESHOLDS, VerdictInputError, verdict as computeVerdict, inputErrorLine, readVerdictInput } from './verdict.mjs';
48
+
49
+ const USAGE = `
50
+ report — one self-contained HTML file from an analyze aggregate (TASK-091).
51
+
52
+ Usage:
53
+ report [--aggregate <path>] [--rejected-runs <path>] [--out <path>]
54
+
55
+ Options:
56
+ --aggregate <path> JSON written by analyze (default: read stdin)
57
+ --rejected-runs <path> JSON array of the run journal's §7 rejections,
58
+ [{ session, task, arm, prompts, reason }]. OMITTING it
59
+ is not the same as passing a file containing []: the
60
+ first says nobody asked the journal, and gate 6 holds
61
+ the verdict for it. That is deliberate.
62
+ --out <path> Where to write the HTML (default: stdout)
63
+ -h, --help Show this help
64
+
65
+ The file it writes has no external references: it opens from file:// with no
66
+ network and no build step.
67
+ `.trim();
68
+
69
+ const TOKEN_CLASSES = TOKEN_CLASS_FIELDS.map(([name]) => name);
70
+
71
+ /** Fixed hue order (dataviz: assign in order, never cycle). Arms take slots 1-2,
72
+ * token classes 3-7, so a colour never means "off" in one chart and "input" in
73
+ * the next. Both subsets were run through the skill's validator in light AND
74
+ * dark mode; see the file header. */
75
+ const ARM_SERIES = { off: 'var(--s1)', on: 'var(--s2)' };
76
+ const CLASS_SERIES = {
77
+ input: 'var(--s3)',
78
+ cache_read: 'var(--s4)',
79
+ cache_write_5m: 'var(--s5)',
80
+ cache_write_1h: 'var(--s6)',
81
+ output: 'var(--s7)',
82
+ };
83
+
84
+ /**
85
+ * Ink for a label sitting INSIDE a coloured fill — the one place dataviz allows
86
+ * text over a series colour, and only "white or ink by the fill's luminance so
87
+ * it always clears contrast".
88
+ *
89
+ * A CSS variable rather than a value chosen here, because the fill differs by
90
+ * mode: light-mode yellow #eda100 takes near-black (9.1:1 against it, white is
91
+ * 2.2:1), while the same slot's dark step takes near-black too but slot 7 flips
92
+ * between the modes. The per-mode values are computed in STYLE, so a hue swapped
93
+ * there cannot leave the label behind at 2:1.
94
+ */
95
+ const CLASS_INK = {
96
+ input: 'var(--on-s3)',
97
+ cache_read: 'var(--on-s4)',
98
+ cache_write_5m: 'var(--on-s5)',
99
+ cache_write_1h: 'var(--on-s6)',
100
+ output: 'var(--on-s7)',
101
+ };
102
+
103
+ // ─── text ───────────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * HTML escaping for every string that reaches the page.
107
+ *
108
+ * Applied without exception, including to strings this repo produced itself:
109
+ * task names, branch names and reject reasons all come from a transcript or a
110
+ * branch name, i.e. from outside. A `&` in a branch name that silently ate the
111
+ * next word would be a rendering bug nobody would think to look for.
112
+ */
113
+ function esc(s) {
114
+ return String(s)
115
+ .replace(/&/g, '&amp;')
116
+ .replace(/</g, '&lt;')
117
+ .replace(/>/g, '&gt;')
118
+ .replace(/"/g, '&quot;')
119
+ .replace(/'/g, '&#39;');
120
+ }
121
+
122
+ /**
123
+ * JSON for a <script type="application/json"> block.
124
+ *
125
+ * `</script` inside a string would end the block early and spill the rest of the
126
+ * data into the document as markup; U+2028/U+2029 are legal in JSON and illegal
127
+ * in a JavaScript string literal, and a reader who copies the block into a
128
+ * console would hit a syntax error with no visible cause.
129
+ */
130
+ function escJson(value) {
131
+ return JSON.stringify(value, null, 2)
132
+ .replace(/</g, '\\u003c')
133
+ .replace(/\u2028/g, '\\u2028')
134
+ .replace(/\u2029/g, '\\u2029');
135
+ }
136
+
137
+ /** Thousands-separated integer, or an em dash for a number that does not exist.
138
+ * Never "0": a count nobody measured and a count of zero are different claims. */
139
+ function num(n) {
140
+ return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : '—';
141
+ }
142
+
143
+ /** Seconds with one decimal. Same em-dash rule as num(). */
144
+ function secs(n) {
145
+ return Number.isFinite(n) ? `${n.toFixed(1)}s` : '—';
146
+ }
147
+
148
+ function pct(x) {
149
+ return Number.isFinite(x) ? `${(x * 100).toFixed(1)}%` : '—';
150
+ }
151
+
152
+ /** A `{ key: count }` distribution as text, in descending count order. */
153
+ function distText(dist) {
154
+ const entries = Object.entries(dist ?? {}).sort((a, b) => b[1] - a[1]);
155
+ if (entries.length === 0) return '—';
156
+ return entries.map(([k, n]) => `${esc(k)} ×${num(n)}`).join(', ');
157
+ }
158
+
159
+ // ─── charts ─────────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * A horizontal bar with a 4px rounded data-end and a square baseline end
163
+ * (dataviz mark spec). A rect with `rx` would round the baseline too, which
164
+ * detaches the mark from the axis it is measured against.
165
+ */
166
+ function barPath(x, y, w, h, r = 4) {
167
+ if (!(w > r)) return `M${x},${y} h${Math.max(w, 0)} v${h} h${-Math.max(w, 0)} Z`;
168
+ return `M${x},${y} H${x + w - r} Q${x + w},${y} ${x + w},${y + r} V${y + h - r} Q${x + w},${y + h} ${x + w - r},${y + h} H${x} Z`;
169
+ }
170
+
171
+ /**
172
+ * Roughly how wide a label will render, so a label is never placed where it does
173
+ * not fit (dataviz: "a label that won't fit doesn't get clipped — measure
174
+ * first"). Deliberately an over-estimate: the failure mode of guessing high is a
175
+ * label that moves outside the bar, and of guessing low is clipped text.
176
+ */
177
+ function textWidth(s, size) {
178
+ return String(s).length * size * 0.62;
179
+ }
180
+
181
+ /**
182
+ * Per-pair cost, one bar per arm, **zero baseline**.
183
+ *
184
+ * Drawn ONLY when the verdict is not withheld — the caller enforces that, and
185
+ * the enforcement is a test (criterion 2). A truncated axis is the cheapest way
186
+ * to manufacture a difference, so the scale runs 0 → max over every bar in the
187
+ * chart rather than per pair.
188
+ *
189
+ * Returns null rather than an empty frame when there is nothing to draw: a chart
190
+ * with axes and no marks reads as "measured, and it was zero".
191
+ */
192
+ function pairChart(pairs) {
193
+ const usable = pairs.filter((p) => p.complete && ARMS.every((a) => Number.isFinite(p.arms[a]?.list_cost_usd)));
194
+ if (usable.length === 0) return null;
195
+
196
+ const max = Math.max(...usable.flatMap((p) => ARMS.map((a) => p.arms[a].list_cost_usd)));
197
+ if (!(max > 0)) return null;
198
+
199
+ const padL = 96;
200
+ // Room for the widest tip label, measured rather than assumed: at a fixed
201
+ // padding the longest amount ran off the right edge and lost its last
202
+ // characters — a clipped value is worse than no label (dataviz).
203
+ const padR = Math.ceil(Math.max(...usable.flatMap((p) => ARMS.map((a) => textWidth(formatUsd(p.arms[a].list_cost_usd), 11))))) + 24;
204
+ const rowH = 62;
205
+ const barH = 20;
206
+ const gap = 2; // the surface gap between adjacent bars — never a border
207
+ const top = 28;
208
+ const width = 720;
209
+ const height = top + usable.length * rowH + 34;
210
+ const plotW = width - padL - padR;
211
+ const scale = (v) => (v / max) * plotW;
212
+
213
+ const parts = [];
214
+ // Gridlines: hairline, solid, recessive; zero and max only, so the ink stays
215
+ // on the data (dataviz chrome spec).
216
+ for (const t of [0, 0.5, 1]) {
217
+ const x = padL + plotW * t;
218
+ parts.push(`<line class="grid" x1="${x}" y1="${top - 8}" x2="${x}" y2="${top + usable.length * rowH - 8}"/>`);
219
+ parts.push(`<text class="tick" x="${x}" y="${top + usable.length * rowH + 12}" text-anchor="middle">${esc(formatUsd(max * t))}</text>`);
220
+ }
221
+ parts.push(`<line class="axis" x1="${padL}" y1="${top - 8}" x2="${padL}" y2="${top + usable.length * rowH - 8}"/>`);
222
+
223
+ usable.forEach((pair, i) => {
224
+ const y0 = top + i * rowH;
225
+ parts.push(`<text class="cat" x="${padL - 12}" y="${y0 + barH + 4}" text-anchor="end">${esc(pair.task)}</text>`);
226
+ ARMS.forEach((arm, j) => {
227
+ const a = pair.arms[arm];
228
+ const y = y0 + j * (barH + gap);
229
+ const w = scale(a.list_cost_usd);
230
+ parts.push(
231
+ `<path fill="${ARM_SERIES[arm]}" d="${barPath(padL, y, w, barH)}">` +
232
+ `<title>${esc(`${pair.task} — arm ${arm}, pass_order ${a.pass_order ?? 'unknown'}, ${formatUsd(a.list_cost_usd)}, ${a.n_prompts} prompt(s)`)}</title></path>`,
233
+ );
234
+ // The amount at the tip, outside the bar — and only the amount. The arm
235
+ // comes from the legend and the pass order from the table below: a tip
236
+ // label carrying all three ran into the edge, and identity that needs the
237
+ // label to fit is identity that disappears at the widest value.
238
+ parts.push(`<text class="val" x="${padL + w + 8}" y="${y + barH - 5}">${esc(formatUsd(a.list_cost_usd))}</text>`);
239
+ });
240
+ });
241
+
242
+ // Legend, always, because there are two series (dataviz): identity never rests
243
+ // on colour-matching against a table further down the page.
244
+ const legend = ARMS.map(
245
+ (arm) => `<li><span class="dot" style="background:${ARM_SERIES[arm]}"></span>arm ${esc(arm)}</li>`,
246
+ ).join('');
247
+
248
+ return (
249
+ `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" ` +
250
+ `aria-label="Cost per pair, one bar per arm, axis from zero">${parts.join('')}</svg>` +
251
+ `<ul class="legend">${legend}</ul>`
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Cost composition by token class, as one 100% stacked bar.
257
+ *
258
+ * Pool-level and never split by arm — on purpose. A per-arm split of this chart
259
+ * would be an arm comparison by another name, and criterion 2 forbids drawing
260
+ * one while a gate holds. The per-arm class figures are in the tables below,
261
+ * where they are numbers rather than a picture.
262
+ *
263
+ * Its subject is the lever the doctrine talks about most: in the 29-transcript
264
+ * baseline, cache read + write were 99.8% of the bill and plain input 0.07%.
265
+ */
266
+ function classShareChart(byClass) {
267
+ if (!byClass) return null;
268
+ const entries = TOKEN_CLASSES.map((c) => [c, byClass[c] ?? 0]).filter(([, v]) => v > 0);
269
+ const total = entries.reduce((a, [, v]) => a + v, 0);
270
+ if (!(total > 0)) return null;
271
+
272
+ const width = 720;
273
+ const barY = 16;
274
+ const barH = 24;
275
+ const gap = 2;
276
+ const parts = [];
277
+ let x = 0;
278
+ entries.forEach(([name, value], i) => {
279
+ const share = value / total;
280
+ const w = Math.max(share * width - (i < entries.length - 1 ? gap : 0), 0);
281
+ const first = i === 0;
282
+ const last = i === entries.length - 1;
283
+ const r = 4;
284
+ // Rounded only on the two outer ends, so the stack reads as one bar and the
285
+ // interior joints stay square (the 2px surface gap does the separating).
286
+ const d = first || last
287
+ ? `M${x + (first ? r : 0)},${barY} H${x + w - (last ? r : 0)} ${last ? `Q${x + w},${barY} ${x + w},${barY + r} V${barY + barH - r} Q${x + w},${barY + barH} ${x + w - r},${barY + barH}` : `V${barY + barH}`} H${x + (first ? r : 0)} ${first ? `Q${x},${barY + barH} ${x},${barY + barH - r} V${barY + r} Q${x},${barY} ${x + r},${barY}` : ''} Z`
288
+ : `M${x},${barY} h${w} v${barH} h${-w} Z`;
289
+ parts.push(
290
+ `<path fill="${CLASS_SERIES[name]}" d="${d}"><title>${esc(`${name}: ${formatUsd(value)} (${pct(share)} of the pool's priced cost)`)}</title></path>`,
291
+ );
292
+ // Inside label only where it demonstrably fits — otherwise the legend and
293
+ // the table carry it (never clipped, never overflowing).
294
+ const label = pct(share);
295
+ if (textWidth(label, 11) + 16 < w) {
296
+ parts.push(
297
+ `<text class="inbar" fill="${CLASS_INK[name]}" x="${x + w / 2}" y="${barY + barH - 7}" text-anchor="middle">${esc(label)}</text>`,
298
+ );
299
+ }
300
+ x += share * width;
301
+ });
302
+
303
+ const legend = entries
304
+ .map(
305
+ ([name, value]) =>
306
+ `<li><span class="dot" style="background:${CLASS_SERIES[name]}"></span>${esc(name)} — ${esc(formatUsd(value))} (${esc(pct(value / total))})</li>`,
307
+ )
308
+ .join('');
309
+
310
+ return (
311
+ `<svg class="chart" viewBox="0 0 ${width} ${barY + barH + 8}" role="img" ` +
312
+ `aria-label="Share of the priced cost by token class">${parts.join('')}</svg>` +
313
+ `<ul class="legend">${legend}</ul>`
314
+ );
315
+ }
316
+
317
+ // ─── blocks ─────────────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * A token figure and its five classes, or neither (criterion 3).
321
+ *
322
+ * One helper for every token count on the page, so "total tokens" has no way of
323
+ * being printed on its own — the split is not a formatting choice made per call
324
+ * site, it is the only shape this function can return. `cache_write_unsplit`
325
+ * rides alongside rather than inside: it is the admission that a write's TTL is
326
+ * unknown, and folding it into a class would price it.
327
+ */
328
+ function tokenBlock(tokens) {
329
+ if (!tokens) return '<span class="dim">no tokens recorded</span>';
330
+ const classes = TOKEN_CLASSES.map(
331
+ (c) => `<li><span class="dot" style="background:${CLASS_SERIES[c]}"></span>${esc(c)} <b>${num(tokens.by_class?.[c] ?? 0)}</b></li>`,
332
+ ).join('');
333
+ return (
334
+ `<div class="tokens"><div class="tokens-total">${num(tokens.total)} tokens</div>` +
335
+ `<ul class="tokens-split">${classes}` +
336
+ `<li class="unsplit">cache_write_unsplit <b>${num(tokens.cache_write_unsplit ?? 0)}</b> — TTL unknown, so unpriced</li>` +
337
+ '</ul></div>'
338
+ );
339
+ }
340
+
341
+ /**
342
+ * One arm's column. Median and spread together wherever a per-prompt figure
343
+ * appears (criterion: never the mean alone where the spread is the finding); the
344
+ * mean rides along labelled, because the gap between the two IS the finding when
345
+ * one 44-hour turn sits in the pool.
346
+ */
347
+ function armColumn(a) {
348
+ if (a.n_prompts === 0) {
349
+ // A zero next to an arm that was never measured is the one number this whole
350
+ // epic exists to keep off the page.
351
+ return `<td><span class="dim">no rows — not $0.0000, no measurement</span></td>`;
352
+ }
353
+ const cost = a.cost_complete
354
+ ? esc(formatUsd(a.cost.list_cost_usd))
355
+ : `${esc(formatUsd(a.cost.list_cost_usd))} <span class="flag">INCOMPLETE — ${num(a.cost.unpriced.rows)} unpriced row(s)</span>`;
356
+ const d = a.delegations;
357
+ return `<td>
358
+ <div class="stat"><span class="stat-label">list cost</span><span class="stat-value">${cost}</span></div>
359
+ <dl>
360
+ <dt>prompts / sessions / pairs</dt><dd>${num(a.n_prompts)} / ${num(a.n_sessions)} / ${num(a.n_pairs)}</dd>
361
+ <dt>cost per prompt</dt><dd>median ${esc(formatUsd(a.cost_per_prompt.median))} · IQR ${esc(formatUsd(a.cost_per_prompt.iqr))} · range ${esc(formatUsd(a.cost_per_prompt.min))}–${esc(formatUsd(a.cost_per_prompt.max))}<br><span class="dim">mean ${esc(formatUsd(a.cost_per_prompt.mean))} — printed next to the median, never instead of it</span></dd>
362
+ <dt>work_s per prompt</dt><dd>median ${secs(a.work_s.median)} · IQR ${secs(a.work_s.iqr)} · max ${secs(a.work_s.max)}<br><span class="dim">${num(a.work_s.missing)} prompt(s) with no assistant reply, excluded rather than counted as 0s</span></dd>
363
+ <dt>max_gap_s</dt><dd>median ${secs(a.max_gap_s.median)} · max ${secs(a.max_gap_s.max)} <span class="dim">— a large gap is a tool call waiting for the operator, i.e. human time</span></dd>
364
+ <dt>tokens</dt><dd>${tokenBlock(a.tokens)}</dd>
365
+ <dt>tool calls</dt><dd>${num(a.tools.calls_total)} total, ${num(a.tools.agent_spawns)} Agent spawn(s)</dd>
366
+ <dt>delegations</dt><dd>${num(d.spawns)} spawn(s), ${num(d.measured)} with a usable record, ${num(d.unmeasured_spawns)} with none<br>
367
+ floor cost ${esc(formatUsd(d.cost.list_cost_usd))} <span class="flag">LOWER BOUND — not the cost of delegation, and never zero</span><br>
368
+ ${tokenBlock(d.tokens)}
369
+ <span class="dim">${num(d.duplicates_dropped)} duplicate record(s) dropped, ${num(d.unidentified_dropped)} unidentified</span></dd>
370
+ <dt>models</dt><dd>${distText(a.models)}</dd>
371
+ <dt>effort</dt><dd>${distText(a.efforts)}</dd>
372
+ </dl>
373
+ </td>`;
374
+ }
375
+
376
+ /**
377
+ * One pool, rendered on its own.
378
+ *
379
+ * Takes exactly one pool and holds no accumulator across calls, which is what
380
+ * makes criterion 4 true by construction rather than by discipline: there is no
381
+ * variable in this file that could hold paired + natural, so no edit adds them
382
+ * by accident.
383
+ */
384
+ function poolSection({ id, title, lede, pool, chart }) {
385
+ const empty = ARMS.every((arm) => pool.arms[arm].n_prompts === 0);
386
+ const body = empty
387
+ ? '<p class="empty">No rows in this pool. Nothing is drawn and nothing is totalled — an empty axis would say "measured, and it was zero".</p>'
388
+ : `<table class="arms"><thead><tr><th></th>${ARMS.map((a) => `<th><span class="dot" style="background:${ARM_SERIES[a]}"></span>arm ${esc(a)}</th>`).join('')}</tr></thead>` +
389
+ `<tbody><tr><th class="rowhead">per arm</th>${ARMS.map((a) => armColumn(pool.arms[a])).join('')}</tr></tbody></table>` +
390
+ (chart ?? '');
391
+ return `<section id="${id}"><h2>${esc(title)}</h2><p class="lede">${lede}</p><p class="src">source: ${esc(pool.source)}</p>${body}</section>`;
392
+ }
393
+
394
+ /** Status chip: colour never carries the meaning alone — icon plus word, per the
395
+ * dataviz status rule. */
396
+ function chip(state, text) {
397
+ const icon = state === 'good' ? '✓' : state === 'critical' ? '✕' : '!';
398
+ return `<span class="chip chip-${state}"><span class="chip-icon" aria-hidden="true">${icon}</span>${esc(text)}</span>`;
399
+ }
400
+
401
+ /**
402
+ * The verdict, as the page's main content.
403
+ *
404
+ * A held verdict gets the hero slot and the reason in full — the thing it must
405
+ * never become is a caption under a chart. Every gate is listed whether it held
406
+ * or passed, in verdict.mjs's own order, because a reader who fixes one problem
407
+ * and reruns has paid a whole run to discover the second.
408
+ */
409
+ function verdictSection(v) {
410
+ const withheld = v.finding === 'withheld';
411
+ const hero = withheld
412
+ ? 'VERDICT WITHHELD'
413
+ : v.finding === 'no-difference'
414
+ ? 'NO DIFFERENCE MEASURED'
415
+ : `CHEAPER ARM: ${String(v.cheaper_arm).toUpperCase()}`;
416
+ const heroNote = withheld
417
+ ? `${v.holds.length} of ${v.gates.length} gate(s) hold the verdict. The comparison chart is not drawn — not hidden, not greyed out. The per-pair numbers stay below so the run can be repeated.`
418
+ : v.finding === 'no-difference'
419
+ ? 'Every gate passed and the arms cost the same on average. That is a finding, not a failure.'
420
+ : 'Every gate passed. Still an observation at this sample size — never proof.';
421
+
422
+ const gates = v.gates
423
+ .map((g) => {
424
+ const state = g.passed ? 'good' : 'critical';
425
+ const detail = g.passed
426
+ ? ''
427
+ : `<p class="gate-reason">${esc(g.reason)}</p><p class="gate-missing"><b>Missing for a verdict:</b> ${esc(g.missing)}</p>`;
428
+ return (
429
+ `<li class="gate ${g.passed ? 'pass' : 'hold'}"><div class="gate-head">${chip(state, g.passed ? 'passed' : 'HOLDS')}` +
430
+ `<b>${esc(g.id)}</b><span class="gate-q">${esc(g.question)}</span></div>${detail}` +
431
+ `<details><summary>measured</summary><pre>${esc(JSON.stringify(g.measured, null, 2))}</pre></details></li>`
432
+ );
433
+ })
434
+ .join('');
435
+
436
+ return `<section id="verdict">
437
+ <div class="hero ${withheld ? 'hero-hold' : 'hero-ruled'}">
438
+ <div class="hero-figure">${esc(hero)}</div>
439
+ <p>${esc(heroNote)}</p>
440
+ <p class="dim">Pool: paired measurement only (${num(v.n_pairs_used)} usable pair(s)). Natural work is never evidence.
441
+ ${v.observation_only ? 'This report says "observation", never "proof" — the PO froze a minimum sample, not a level at which it becomes proof.' : ''}</p>
442
+ </div>
443
+ <h2>The ${v.gates.length} gates</h2>
444
+ <ul class="gates">${gates}</ul>
445
+ </section>`;
446
+ }
447
+
448
+ /**
449
+ * The two effects, side by side and always both (§11b).
450
+ *
451
+ * The order effect is reported at the same size and in the same place as the arm
452
+ * effect, because the whole point of §11b is that the second implementation of a
453
+ * task is cheaper for a reason that has nothing to do with the arm — and a page
454
+ * that prints the arm effect large and the order effect in a footnote has made
455
+ * exactly the claim the gate refuses.
456
+ */
457
+ function effectsSection(v) {
458
+ const e = v.effects;
459
+ const rows = e.pairs_used
460
+ .map(
461
+ (p) =>
462
+ `<tr><td>${esc(p.task)}</td><td>${esc(formatUsd(p.off_usd))}</td><td>${esc(formatUsd(p.on_usd))}</td>` +
463
+ `<td>${esc(formatUsd(p.arm_delta_usd))}</td><td>${esc(formatUsd(p.order_delta_usd))}</td></tr>`,
464
+ )
465
+ .join('');
466
+ const skipped = e.pairs_skipped
467
+ .map((p) => `<li>${esc(p.task)} — ${esc(p.why)}</li>`)
468
+ .join('');
469
+
470
+ return `<section id="effects">
471
+ <h2>Arm effect next to order effect</h2>
472
+ <p class="lede">Both are means over pairs, from the same four numbers. The rotation of §9 is the only thing that
473
+ separates them; at two pairs each of them stands on two numbers, which is arithmetic, not statistics.</p>
474
+ <div class="tiles">
475
+ <div class="tile"><span class="stat-label">arm effect (off − on)</span>
476
+ <span class="stat-value">${esc(formatUsd(e.arm_effect_usd))}</span>
477
+ <span class="dim">positive = arm "on" cheaper</span></div>
478
+ <div class="tile"><span class="stat-label">order effect (pass 1 − pass 2)</span>
479
+ <span class="stat-value">${esc(formatUsd(e.order_effect_usd))}</span>
480
+ <span class="dim">positive = the second implementation cheaper, whichever arm it was</span></div>
481
+ <div class="tile"><span class="stat-label">ratio |arm| / |order|</span>
482
+ <span class="stat-value">${e.arm_effect_usd === null || e.order_effect_usd === null ? '—' : esc(String(v.gates.find((g) => g.id === 'order_effect')?.measured.ratio ?? '—'))}</span>
483
+ <span class="dim">a verdict needs ≥ ${num(VERDICT_THRESHOLDS.gates.order_effect.min_arm_over_order)}×</span></div>
484
+ </div>
485
+ ${rows === '' ? '<p class="empty">No pair contributed to either effect.</p>' : `<table class="grid-table"><thead><tr><th>pair</th><th>off</th><th>on</th><th>arm Δ</th><th>order Δ</th></tr></thead><tbody>${rows}</tbody></table>`}
486
+ ${skipped === '' ? '' : `<p class="dim">Pairs skipped, with the reason rather than a default: </p><ul class="dim">${skipped}</ul>`}
487
+ </section>`;
488
+ }
489
+
490
+ /**
491
+ * Per-pair cost — the actual comparison, and the one thing the page withholds.
492
+ *
493
+ * The chart is drawn only when `verdict.ruled` is true. The table is always
494
+ * drawn: those numbers are what the operator needs to repeat a rejected run, and
495
+ * a table does not make the claim a chart makes. That asymmetry is criterion 2,
496
+ * and the test for it asserts the ABSENCE of the chart, not the presence of a
497
+ * caveat.
498
+ */
499
+ function pairsSection(agg, v) {
500
+ const pairs = agg.paired.pairs;
501
+ if (pairs.length === 0) {
502
+ return '<section id="pairs"><h2>Pairs</h2><p class="empty">No paired rows: no task was measured twice. Nothing to compare, so nothing is drawn.</p></section>';
503
+ }
504
+ const rows = pairs
505
+ .map((p) => {
506
+ const cells = ARMS.map((arm) => {
507
+ const a = p.arms[arm];
508
+ if (!a) return '<td><span class="dim">arm missing</span></td>';
509
+ return `<td>${esc(formatUsd(a.list_cost_usd))}${a.cost_complete ? '' : ' <span class="flag">INCOMPLETE</span>'}<br>
510
+ <span class="dim">pass ${a.pass_order ?? '?'} · ${num(a.n_prompts)} prompt(s) · ${num(a.n_sessions)} session(s) · ${num(a.agent_spawns)} spawn(s)</span></td>`;
511
+ }).join('');
512
+ return `<tr><td>${esc(p.task)}${p.complete ? '' : ' <span class="flag">INCOMPLETE PAIR</span>'}</td>${cells}</tr>`;
513
+ })
514
+ .join('');
515
+
516
+ const chart = v.ruled ? pairChart(pairs) : null;
517
+ const chartBlock = v.ruled
518
+ ? (chart ??
519
+ '<p class="empty">No pair has both arms priced, so there is nothing to draw. The numbers stay in the table.</p>')
520
+ : `<div class="notdrawn">${chip('critical', 'comparison chart not drawn')}
521
+ <p>A gate holds the verdict, so the arm comparison is not rendered at all. A chart asserts "these two are
522
+ comparable" more loudly than any caption can withdraw it. The costs below are still here — they are what a
523
+ repeat run is planned from.</p></div>`;
524
+
525
+ return `<section id="pairs">
526
+ <h2>Cost per pair</h2>
527
+ <p class="lede">One task, two arms, same base commit. This is the comparison; everything else on this page is context.</p>
528
+ ${chartBlock}
529
+ <table class="grid-table"><thead><tr><th>pair</th>${ARMS.map((a) => `<th><span class="dot" style="background:${ARM_SERIES[a]}"></span>${esc(a)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>
530
+ </section>`;
531
+ }
532
+
533
+ /**
534
+ * The honesty counters, as a section of their own.
535
+ *
536
+ * Everything here is a number about what the measurement does NOT contain:
537
+ * rows thrown away and why, sessions that changed arm, branches that missed the
538
+ * convention, delegation cost that exists but was never captured, turns whose
539
+ * usage is a known under-count. They are on the page rather than in a footer
540
+ * because they are the first thing an editor would cut, and cutting them is how
541
+ * a clean-looking report gets published over a sample that lost a third of its
542
+ * sessions (that happened — see verdict.mjs, gate 6).
543
+ */
544
+ function countersSection(agg, v) {
545
+ const c = agg.counters;
546
+ const rejected = Object.entries(c.rejected_by_reason ?? {}).sort((a, b) => b[1] - a[1]);
547
+ const rejectedGate = v.gates.find((g) => g.id === 'rejected');
548
+ const perArm = ARMS.map((arm) => {
549
+ const a = agg.paired.arms[arm];
550
+ const k = a.counters;
551
+ return `<tr><td>${esc(arm)}</td><td>${num(k.synthetic_turns)}</td><td>${num(k.understated_turns)}</td><td>${num(k.multi_attempt_turns)}</td><td>${num(k.sidechain_turns)}</td><td>${num(k.api_turns)}</td><td>${num(a.cost.unpriced.rows)}</td></tr>`;
552
+ }).join('');
553
+
554
+ return `<section id="counters">
555
+ <h2>Honesty counters</h2>
556
+ <p class="lede">What is missing from the numbers above, in numbers. A discard that does not show up is the same
557
+ class of error as a fabricated verdict, so none of this belongs in a footer.</p>
558
+ <div class="tiles">
559
+ <div class="tile"><span class="stat-label">rows in / used / rejected</span>
560
+ <span class="stat-value">${num(c.rows_in)} / ${num(c.rows_used)} / ${num(c.rows_rejected)}</span>
561
+ <span class="dim">every row is in exactly one of the last two</span></div>
562
+ <div class="tile"><span class="stat-label">operator rejections (§7 journal)</span>
563
+ <span class="stat-value">${esc(String(rejectedGate?.measured.operator_rejects ?? 'NOT REPORTED'))}</span>
564
+ <span class="dim">a run the operator threw away leaves no trace in the rows; NOT REPORTED is not zero</span></div>
565
+ <div class="tile"><span class="stat-label">unattributed transcript entries</span>
566
+ <span class="stat-value">${c.unattributed === null ? 'NOT REPORTED' : num(c.unattributed?.entries ?? c.unattributed)}</span>
567
+ <span class="dim">entries that landed in no row at all</span></div>
568
+ </div>
569
+ <table class="grid-table">
570
+ <thead><tr><th>arm</th><th>synthetic turns</th><th>understated turns</th><th>multi-attempt</th><th>sidechain</th><th>api turns</th><th>unpriced rows</th></tr></thead>
571
+ <tbody>${perArm}</tbody>
572
+ </table>
573
+ <ul class="counters-list">
574
+ <li>rejected by reason: ${rejected.length === 0 ? '<span class="dim">none</span>' : rejected.map(([r, n]) => `${esc(r)} ×${num(n)}`).join(', ')}</li>
575
+ <li>sessions dropped whole for changing arm mid-session: ${num(c.mixed_arm_sessions)}${c.mixed_arm_session_ids.length > 0 ? ` (${c.mixed_arm_session_ids.map(esc).join(', ')})` : ''}</li>
576
+ <li>branches outside <code>exp/&lt;task&gt;/&lt;arm&gt;</code>: ${num(c.off_convention_branches)}${c.off_convention_branch_names.length > 0 ? ` — ${c.off_convention_branch_names.map(esc).join(', ')}` : ''} <span class="dim">counted, never assigned to an arm</span></li>
577
+ <li>rows with no session id: ${num(c.rows_without_session)}</li>
578
+ <li>delegation cost: measured <b>from below</b> in both arms. The subagent's own turns never reach the transcript,
579
+ so the figure in the tables is a floor multiplied by an unknown factor ≥ 1 — never zero, never a total.</li>
580
+ </ul>
581
+ </section>`;
582
+ }
583
+
584
+ /** The frozen thresholds, with their dates, travelling with the decision they
585
+ * judged — a verdict read six months from now has to show what it was measured
586
+ * against without anyone trusting that the file still says the same thing. */
587
+ function thresholdsSection(t) {
588
+ const rows = Object.entries(t.gates)
589
+ .map(
590
+ ([id, g]) =>
591
+ `<tr><td>${esc(id)}</td><td>${esc(
592
+ Object.entries(g)
593
+ .filter(([k]) => k !== 'note' && k !== 'frozen')
594
+ .map(([k, val]) => `${k}=${val}`)
595
+ .join(', '),
596
+ )}</td><td>${esc(g.frozen)}</td><td class="note">${esc(g.note)}</td></tr>`,
597
+ )
598
+ .join('');
599
+ return `<section id="thresholds">
600
+ <h2>Frozen thresholds</h2>
601
+ <p class="lede">Frozen by ${esc(t.frozen_by)} on ${esc(t.frozen)} — ${esc(t.source)}. Amended after a run:
602
+ ${t.amended.length === 0 ? 'never.' : esc(JSON.stringify(t.amended))}</p>
603
+ <table class="grid-table"><thead><tr><th>gate</th><th>threshold</th><th>frozen</th><th>why this number</th></tr></thead><tbody>${rows}</tbody></table>
604
+ </section>`;
605
+ }
606
+
607
+ /**
608
+ * Which code computed this page, step by step — rows, aggregate, verdict
609
+ * (EPIC-010). In the header rather than the footer, because EPIC-009's verdict
610
+ * was voided over exactly this question, asked of files that could not answer
611
+ * it. Whether the stamps agree is the meter_mixed gate's business; this line
612
+ * only names them, all of them.
613
+ */
614
+ function meterLine(agg, v) {
615
+ const rows = Object.entries(agg.meter_versions ?? {});
616
+ const stamp = (s) => (s ? `<code>${esc(s)}</code>` : '<span class="flag">no stamp</span>');
617
+ return (
618
+ `<p class="src">meter — rows: ${rows.length === 0 ? '—' : rows.map(([s, n]) => `${stamp(s)} ×${num(n)}`).join(', ')}` +
619
+ ` · analyze: ${stamp(agg.analyzed_by)} · verdict: ${stamp(v.judged_by)}</p>`
620
+ );
621
+ }
622
+
623
+ // ─── the page ───────────────────────────────────────────────────────────────
624
+
625
+ /** Palette and chrome from the `dataviz` reference instance, as CSS custom
626
+ * properties so the light/dark values swap in one place. Dark mode is selected,
627
+ * not an automatic inversion: its own steps from the same ramps, validated
628
+ * against the dark surface. */
629
+ const STYLE = `
630
+ :root {
631
+ color-scheme: light;
632
+ --surface-1:#fcfcfb; --plane:#f9f9f7; --ink:#0b0b0b; --ink-2:#52514e; --muted:#898781;
633
+ --grid:#e1e0d9; --axis:#c3c2b7; --border:rgba(11,11,11,0.10);
634
+ --s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; --s4:#eda100; --s5:#e87ba4; --s6:#008300; --s7:#4a3aa7;
635
+ /* Ink for a label inside the fill, picked by luminance (see CLASS_INK). */
636
+ --on-s3:#0b0b0b; --on-s4:#0b0b0b; --on-s5:#0b0b0b; --on-s6:#fff; --on-s7:#fff;
637
+ --good:#0ca30c; --warning:#fab219; --critical:#d03b3b;
638
+ }
639
+ @media (prefers-color-scheme: dark) {
640
+ :root:where(:not([data-theme="light"])) {
641
+ color-scheme: dark;
642
+ --surface-1:#1a1a19; --plane:#0d0d0d; --ink:#fff; --ink-2:#c3c2b7; --muted:#898781;
643
+ --grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
644
+ --s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500; --s5:#d55181; --s6:#008300; --s7:#9085e9;
645
+ --on-s3:#0b0b0b; --on-s4:#0b0b0b; --on-s5:#0b0b0b; --on-s6:#fff; --on-s7:#0b0b0b;
646
+ }
647
+ }
648
+ :root[data-theme="dark"] {
649
+ color-scheme: dark;
650
+ --surface-1:#1a1a19; --plane:#0d0d0d; --ink:#fff; --ink-2:#c3c2b7; --muted:#898781;
651
+ --grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
652
+ --s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500; --s5:#d55181; --s6:#008300; --s7:#9085e9;
653
+ --on-s3:#0b0b0b; --on-s4:#0b0b0b; --on-s5:#0b0b0b; --on-s6:#fff; --on-s7:#0b0b0b;
654
+ }
655
+ * { box-sizing: border-box; }
656
+ body { margin:0; padding:32px 20px 96px; background:var(--plane); color:var(--ink);
657
+ font:15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
658
+ main { max-width: 980px; margin: 0 auto; }
659
+ h1 { font-size:28px; margin:0 0 6px; }
660
+ h2 { font-size:19px; margin:0 0 8px; }
661
+ section { background:var(--surface-1); border:1px solid var(--border); border-radius:10px;
662
+ padding:20px 22px; margin:18px 0; }
663
+ .lede { color:var(--ink-2); margin:0 0 14px; max-width:72ch; }
664
+ .src, .dim { color:var(--muted); font-size:13px; }
665
+ .dim b { color:var(--ink-2); }
666
+ .empty { color:var(--ink-2); background:var(--plane); border:1px solid var(--border);
667
+ border-radius:8px; padding:14px 16px; margin:8px 0; }
668
+ .hero { border:1px solid var(--border); border-radius:10px; padding:20px 22px; background:var(--plane); }
669
+ .hero-figure { font-size:48px; font-weight:650; letter-spacing:-0.02em; line-height:1.1; margin-bottom:8px; }
670
+ .hero-hold .hero-figure { color:var(--critical); }
671
+ .hero-ruled .hero-figure { color:var(--good); }
672
+ .hero p { margin:6px 0 0; max-width:80ch; }
673
+ .gates { list-style:none; padding:0; margin:12px 0 0; }
674
+ .gate { border-top:1px solid var(--border); padding:12px 0; }
675
+ .gate-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
676
+ .gate-q { color:var(--ink-2); }
677
+ .gate-reason { margin:8px 0 4px; max-width:80ch; }
678
+ .gate-missing { margin:0; color:var(--ink-2); max-width:80ch; }
679
+ .chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600;
680
+ padding:2px 9px; border-radius:999px; border:1px solid var(--border); color:var(--ink); }
681
+ .chip-icon { font-weight:700; }
682
+ .chip-good { background:color-mix(in srgb, var(--good) 16%, transparent); }
683
+ .chip-critical { background:color-mix(in srgb, var(--critical) 16%, transparent); }
684
+ .notdrawn { background:var(--plane); border:1px solid var(--border); border-radius:8px; padding:14px 16px; margin:8px 0 14px; }
685
+ .notdrawn p { margin:8px 0 0; color:var(--ink-2); max-width:80ch; }
686
+ .tiles { display:flex; flex-wrap:wrap; gap:12px; margin:12px 0; }
687
+ .tile { flex:1 1 210px; background:var(--plane); border:1px solid var(--border); border-radius:8px; padding:12px 14px; }
688
+ .stat-label, .stat-value { display:block; }
689
+ .stat-label { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
690
+ .stat-value { font-size:22px; font-weight:620; margin:2px 0 4px; }
691
+ .stat { margin-bottom:10px; }
692
+ table { border-collapse:collapse; width:100%; margin:10px 0; font-size:14px; }
693
+ th, td { text-align:left; vertical-align:top; padding:8px 10px; border-top:1px solid var(--border); }
694
+ th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
695
+ td { font-variant-numeric: tabular-nums; }
696
+ .rowhead { width:80px; }
697
+ .arms td { width:44%; }
698
+ .note { color:var(--ink-2); font-size:12px; font-variant-numeric:normal; }
699
+ dl { margin:0; }
700
+ dt { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; margin-top:10px; }
701
+ dd { margin:2px 0 0; }
702
+ .dot { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:6px; vertical-align:middle; }
703
+ .flag { color:var(--critical); font-weight:600; font-size:12px; }
704
+ .tokens-total { font-weight:600; }
705
+ .tokens-split, .legend, .counters-list { list-style:none; padding:0; margin:4px 0 0; }
706
+ .tokens-split li { font-size:13px; color:var(--ink-2); }
707
+ .legend { display:flex; flex-wrap:wrap; gap:14px; font-size:13px; color:var(--ink-2); }
708
+ .counters-list li { margin:6px 0; max-width:80ch; }
709
+ .unsplit { color:var(--muted); }
710
+ .chart { display:block; width:100%; height:auto; margin:14px 0 6px; overflow:visible; }
711
+ .chart .grid { stroke:var(--grid); stroke-width:1; }
712
+ .chart .axis { stroke:var(--axis); stroke-width:1; }
713
+ .chart .tick, .chart .cat, .chart .val { fill:var(--muted); font-size:11px;
714
+ font-family:system-ui, sans-serif; font-variant-numeric:tabular-nums; }
715
+ .chart .cat, .chart .val { fill:var(--ink-2); }
716
+ .chart .inbar { font-size:11px; font-family:system-ui, sans-serif; font-weight:600; }
717
+ details { margin-top:8px; }
718
+ summary { cursor:pointer; color:var(--muted); font-size:12px; }
719
+ pre { background:var(--plane); border:1px solid var(--border); border-radius:6px;
720
+ padding:10px 12px; overflow-x:auto; font-size:12px; }
721
+ code { font-size:13px; }
722
+ .scroll { overflow-x:auto; }
723
+ footer { max-width:980px; margin:0 auto; color:var(--muted); font-size:12px; }
724
+ `.trim();
725
+
726
+ /**
727
+ * Aggregate + verdict → one HTML document. Pure: no disk, no clock (the caller
728
+ * passes `generatedAt`), no ambient state — the same rule analyze() and
729
+ * verdict() follow, and the reason a test can render a fabricated run without a
730
+ * fixture directory.
731
+ *
732
+ * @param agg an aggregate from analyze() (schema tune-context/analyze@2)
733
+ * @param verdict the decision from verdict(). Computed by the caller rather
734
+ * than here, because the journal of §7 rejections is an input this layer
735
+ * has no way to read — and a verdict computed without it holds gate 6,
736
+ * which is the correct answer, not a bug to route around.
737
+ */
738
+ export function renderReport(agg, { verdict: v, generatedAt = null } = {}) {
739
+ if (!agg || typeof agg !== 'object' || agg.schema !== 'tune-context/analyze@2') {
740
+ throw new TypeError('report: expected an aggregate from analyze() (schema tune-context/analyze@2)');
741
+ }
742
+ if (v?.schema !== 'tune-context/verdict@2') {
743
+ throw new TypeError('report: expected a verdict from verdict() (schema tune-context/verdict@2)');
744
+ }
745
+
746
+ // Pool-level composition: the two arms of ONE pool added together. The pools
747
+ // themselves are never added — that is the line, and it is drawn here rather
748
+ // than left to a comment because this is the only place on the page where any
749
+ // addition happens at all.
750
+ const pooledClasses = (pool) => {
751
+ const out = Object.fromEntries(TOKEN_CLASSES.map((c) => [c, 0]));
752
+ for (const arm of ARMS) {
753
+ const byClass = pool.arms[arm].cost.list_cost_by_class;
754
+ if (!byClass) continue;
755
+ for (const c of TOKEN_CLASSES) out[c] += byClass[c] ?? 0;
756
+ }
757
+ return out;
758
+ };
759
+
760
+ const pairedChart = classShareChart(pooledClasses(agg.paired));
761
+ const naturalChart = classShareChart(pooledClasses(agg.natural));
762
+ const cacheNote =
763
+ '<h3>Where the money goes</h3><p class="lede">Share of this pool\'s priced cost by token class. In the ' +
764
+ '29-transcript baseline, cache read plus cache write were 99.8% of the bill and plain input 0.07% — which is why ' +
765
+ '<code>/clear</code> before each arm is a validity condition rather than hygiene. Split by class, never by arm: ' +
766
+ 'a per-arm version of this chart would be an arm comparison wearing a different label.</p>';
767
+
768
+ return `<!doctype html>
769
+ <html lang="en"><head><meta charset="utf-8">
770
+ <meta name="viewport" content="width=device-width, initial-scale=1">
771
+ <title>EPIC-009 — cost measurement report</title>
772
+ <style>${STYLE}</style>
773
+ </head><body>
774
+ <main>
775
+ <header>
776
+ <h1>EPIC-009 — paired cost measurement</h1>
777
+ <p class="lede">The doctrine measured against its own absence: one task implemented twice from the same commit,
778
+ once with tune-context and RAG on, once with both parked. Generated${generatedAt ? ` ${esc(generatedAt)}` : ''} from
779
+ an aggregate of ${num(agg.counters.rows_in)} prompt row(s). Costs are <b>list price through the API</b> — what the
780
+ work would have cost, never money spent.</p>
781
+ ${meterLine(agg, v)}
782
+ </header>
783
+ ${verdictSection(v)}
784
+ ${pairsSection(agg, v)}
785
+ ${effectsSection(v)}
786
+ ${poolSection({
787
+ id: 'paired',
788
+ title: 'Paired measurement — the only pool a verdict may be built on',
789
+ lede: 'Four scripted sessions (§2a): model, effort, task and prompt text held constant, the arm the only difference.',
790
+ pool: agg.paired,
791
+ chart: pairedChart ? cacheNote + pairedChart : '',
792
+ })}
793
+ ${countersSection(agg, v)}
794
+ ${poolSection({
795
+ id: 'natural',
796
+ title: 'Natural work — background, never evidence',
797
+ lede: 'Ordinary daily work attributed by the ledger alone. Nothing is held constant here: different tasks, different days, different prompts. It is useful for asking whether the paired result points the same way, and it is never added to the pool above — not on this page, and not in the aggregate behind it.',
798
+ pool: agg.natural,
799
+ chart: naturalChart ? cacheNote + naturalChart : '',
800
+ })}
801
+ ${thresholdsSection(v.thresholds)}
802
+ </main>
803
+ <footer>tune-context · EPIC-009 · this page is an observation, not proof — see the frozen thresholds above.</footer>
804
+ <script type="application/json" id="report-data">${escJson({
805
+ schema: 'tune-context/report@1',
806
+ generated_at: generatedAt,
807
+ aggregate: agg,
808
+ verdict: v,
809
+ })}</script>
810
+ </body></html>
811
+ `;
812
+ }
813
+
814
+ // ─── CLI ────────────────────────────────────────────────────────────────────
815
+
816
+ /**
817
+ * The CLI. `args` is everything after the script name, or after
818
+ * `tune-context eval report` — one parser behind both doors (EPIC-010).
819
+ */
820
+ export async function main(args = process.argv.slice(2)) {
821
+ endQuietlyOnClosedPipe();
822
+ let values;
823
+ try {
824
+ ({ values } = parseArgs({
825
+ args,
826
+ options: {
827
+ aggregate: { type: 'string' },
828
+ 'rejected-runs': { type: 'string' },
829
+ out: { type: 'string' },
830
+ help: { type: 'boolean', short: 'h', default: false },
831
+ },
832
+ strict: true,
833
+ }));
834
+ } catch (e) {
835
+ process.stderr.write(`report: ${e.message}\n`);
836
+ process.exitCode = 1;
837
+ return;
838
+ }
839
+ if (values.help) {
840
+ process.stdout.write(`${USAGE}\n`);
841
+ return;
842
+ }
843
+
844
+ // The same reader and the same boundary as `eval verdict` (TASK-096): one
845
+ // place decides what a readable aggregate and journal are, so this page can
846
+ // never accept an input the verdict refuses — and only those refusals become
847
+ // a message. A TypeError from inside a gate or the page is a bug and keeps its
848
+ // stacktrace, here as there. `rejectedRuns` stays undefined when the flag is
849
+ // absent — that is what makes gate 6 hold instead of passing on a share whose
850
+ // denominator nobody supplied.
851
+ let html;
852
+ try {
853
+ const { agg, rejectedRuns } = await readVerdictInput({ aggregate: values.aggregate, rejectedRuns: values['rejected-runs'] });
854
+ const v = computeVerdict(agg, { rejectedRuns, judgedBy: meterStamp() });
855
+ html = renderReport(agg, { verdict: v, generatedAt: new Date().toISOString().slice(0, 10) });
856
+ } catch (e) {
857
+ if (!(e instanceof VerdictInputError)) throw e;
858
+ process.stderr.write(`${inputErrorLine('report', e)}\n`);
859
+ process.exitCode = 1;
860
+ return;
861
+ }
862
+
863
+ if (values.out) {
864
+ try {
865
+ writeFileSync(values.out, html);
866
+ } catch (e) {
867
+ // Where to write is the operator's input too: a missing directory is a
868
+ // message, not a stacktrace.
869
+ process.stderr.write(`report: cannot write ${values.out}: ${e.message}\n`);
870
+ process.exitCode = 1;
871
+ return;
872
+ }
873
+ process.stderr.write(`report: wrote ${values.out}\n`);
874
+ } else {
875
+ process.stdout.write(html);
876
+ }
877
+ }
878
+
879
+ if (isEntryPoint(import.meta.url)) {
880
+ await main();
881
+ }