@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +195 -0
- package/README.md +211 -18
- package/bin/tune-context +1 -1
- package/bin/tune-context.mjs +167 -51
- package/cli/archive.mjs +322 -0
- package/cli/entry-point.mjs +73 -0
- package/cli/experiment.mjs +35 -3
- package/cli/version.mjs +118 -0
- package/eval/adoption-report.mjs +11 -5
- package/eval/analyze.mjs +792 -0
- package/eval/arm.mjs +9 -5
- package/eval/pricing.mjs +32 -1
- package/eval/prompt-metrics.mjs +395 -46
- package/eval/report.mjs +881 -0
- package/eval/verdict.mjs +963 -0
- package/hooks/adoption-log.mjs +1 -1
- package/hooks/context-reminder.mjs +1 -1
- package/hooks/pre-compact-snapshot.mjs +4 -4
- package/hooks/session-start-reinject.mjs +1 -4
- package/package.json +7 -2
- package/skills/token-efficiency/SKILL.md +1 -1
- package/skills/tune-context/SKILL.md +4 -2
package/eval/report.mjs
ADDED
|
@@ -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, '&')
|
|
116
|
+
.replace(/</g, '<')
|
|
117
|
+
.replace(/>/g, '>')
|
|
118
|
+
.replace(/"/g, '"')
|
|
119
|
+
.replace(/'/g, ''');
|
|
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/<task>/<arm></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
|
+
}
|