@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,963 @@
1
+ #!/usr/bin/env node
2
+ // verdict — the step analyze.mjs deliberately stops before: may this run say
3
+ // "one arm is cheaper"? See tasks/TASK-090.
4
+ //
5
+ // Pure predicates over the TASK-089 aggregate. No disk, no clock, no stdout —
6
+ // an aggregate in, a decision out. TASK-091 renders it as HTML; formatVerdict()
7
+ // renders it as text for a terminal. The CLI section at the bottom of this file
8
+ // (TASK-096) is the ONLY place in it that reads a file, stdin or the process,
9
+ // or writes anything: everything above it stays a function of its arguments.
10
+ //
11
+ // The failure this file exists to prevent is not a wrong number; it is a right
12
+ // number about the wrong thing. An aggregate that adds up perfectly still
13
+ // supports "the doctrine is 30% cheaper" when the two arms ran on different
14
+ // models, when the cheaper arm hid two subagent sessions from the transcript, or
15
+ // when the whole difference is the operator already knowing the answer the
16
+ // second time. None of those show up in a chart. So the gates are their own
17
+ // module with their own tests, and the only proof they work is a test where the
18
+ // verdict is WITHHELD on data that looks encouraging.
19
+ //
20
+ // Three properties hold everywhere below:
21
+ //
22
+ // • **A held gate is never a bare `false`.** It carries the reason, the value
23
+ // measured, the threshold it was measured against, and what is missing for a
24
+ // verdict to become possible ("one more complete pair in arm off"). A
25
+ // refusal without those is indistinguishable from a crash.
26
+ // • **Every gate is evaluated, always.** The reader has to learn how many
27
+ // things are wrong, not just the first one — a run that fixes the sample
28
+ // size and then trips on model homogeneity has cost two runs to learn what
29
+ // one report could have said.
30
+ // • **The thresholds are data, frozen by the PO, in one table apart from the
31
+ // logic** — the same anti-bias rule as MIN_SESSIONS_FOR_VERDICT in
32
+ // eval/adoption-report.mjs and the same shape as the price table in
33
+ // eval/pricing.mjs. The model does not get to pick the n at which its own
34
+ // tool comes out ahead. Nothing below re-states a threshold as a literal:
35
+ // every gate reads this table, so changing a number here changes the
36
+ // outcome, which is what makes the freeze meaningful rather than decorative.
37
+ //
38
+ // The verdict is built on the PAIRED pool only. Natural work holds nothing
39
+ // constant — it is background for asking whether the paired result points the
40
+ // same way, and it is never evidence (analyze.mjs keeps the two un-addable by
41
+ // shape; this file simply never reaches for the second one).
42
+ import { readFileSync } from 'node:fs';
43
+ import { parseArgs } from 'node:util';
44
+ import { endQuietlyOnClosedPipe, isEntryPoint, readStdin } from '../cli/entry-point.mjs';
45
+ import { meterStamp, stampProblem } from '../cli/version.mjs';
46
+ import { ARMS } from './analyze.mjs';
47
+ import { formatUsd } from './pricing.mjs';
48
+
49
+ /**
50
+ * An input this module refuses: an aggregate that is not one, a run journal of
51
+ * the wrong shape, and — in the CLI — a file that is missing or is not JSON.
52
+ * One class for all of them, so a CLI can answer exactly these with a message
53
+ * and exit 1, and let anything else go up as the bug it is.
54
+ *
55
+ * A TypeError, because the pure functions threw TypeError at this boundary
56
+ * before the class existed, and a caller matching on that keeps working.
57
+ */
58
+ export class VerdictInputError extends TypeError {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = 'VerdictInputError';
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Frozen with the PO on 2026-07-30 (COST_EVALUATION_PROCEDURE §10, §11); the
67
+ * seventh gate, meter_mixed, frozen by the PO on 2026-10-15 (EPIC-010).
68
+ *
69
+ * Each entry carries its own `frozen` date next to its value, not just the
70
+ * table: a later change to one threshold must be visible as a change to THAT
71
+ * line. A threshold moved after seeing the data is a threshold fitted to the
72
+ * result — if it has to happen, it lands in `amended` with a new date and the
73
+ * admission that it happened after the run. A gate ADDED before the next run is
74
+ * not that, which is why meter_mixed carries its own date and `amended` stays
75
+ * empty; the top-level `frozen` stays the date the table was first frozen.
76
+ */
77
+ export const VERDICT_THRESHOLDS = {
78
+ schema: 'tune-context/verdict-thresholds@2',
79
+ frozen: '2026-07-30',
80
+ frozen_by: 'PO',
81
+ source: 'COST_EVALUATION_PROCEDURE.md §10 and §11 (tasks/TASK-090)',
82
+ // Empty by construction. Every entry here is a threshold that moved AFTER a
83
+ // run — the record of that, not a place to quietly rewrite the table.
84
+ amended: [],
85
+ gates: {
86
+ sample: {
87
+ min_pairs: 2,
88
+ frozen: '2026-07-30',
89
+ note:
90
+ 'Deliberately the minimum: the first run asks whether anything happens at all, it does not rule.' +
91
+ ' At n = 2 the report says "observation", never "proof". The same number is the floor for sessions' +
92
+ ' per arm — §2a runs one scripted session per arm per pair, so an arm with fewer sessions than pairs' +
93
+ ' measured two tasks in one session and is a smaller sample than the pair count suggests. That is the' +
94
+ ' same frozen number read twice, not a second threshold chosen here.',
95
+ },
96
+ homogeneity: {
97
+ requires: 'identical',
98
+ frozen: '2026-07-30',
99
+ note:
100
+ 'Zero tolerance, so no number: measured opus/sonnet input price differs by 1.7x, larger than any effect' +
101
+ ' being looked for (error class 4). A run whose arms differ in model or effort is withheld and repeated,' +
102
+ ' NEVER averaged across models.',
103
+ },
104
+ delegation: {
105
+ max_diff: 2,
106
+ directional: true,
107
+ frozen: '2026-07-30',
108
+ note:
109
+ 'Withhold when the CHEAPER arm made more than 2 more Agent calls than the other. One delegation is a whole' +
110
+ ' subagent session (~62k tokens measured) whose turns never reach the transcript, so the cheaper arm is' +
111
+ ' cheaper by an unknown remainder (§11a). Directional on purpose: the reverse difference makes the verdict' +
112
+ ' cautious rather than inflated, and a gate that fires on it would refuse a result that was already conservative.',
113
+ },
114
+ order_effect: {
115
+ min_arm_over_order: 2,
116
+ frozen: '2026-07-30',
117
+ note:
118
+ 'A verdict requires |arm effect| >= 2 x |order effect| (§11b). The second implementation of a task is cheaper' +
119
+ ' because the solution is already known, and that runs in the same direction as the effect being measured.' +
120
+ ' At 1x a tie would pass as a result. Below the threshold the report prints both numbers and rules on nothing.',
121
+ },
122
+ unpriced: {
123
+ max_rows: 0,
124
+ frozen: '2026-07-30',
125
+ note:
126
+ 'Not a PO judgment call — it follows from precondition §1: a run does not start with UNPRICED rows. A cost' +
127
+ ' computed over some of the rows is not the cost of the pool.',
128
+ },
129
+ rejected: {
130
+ max_share: 0.1,
131
+ frozen: '2026-07-30',
132
+ note:
133
+ 'Share of experiment rows thrown away. 2 pairs = 4 sessions, and 10% of 4 is less than one — so a single' +
134
+ ' rejected run exceeds it. That is the point of the threshold, not a flaw in it: at four sessions there is' +
135
+ ' nothing to spare. A broken run is repeated (§7), not absorbed.',
136
+ },
137
+ meter_mixed: {
138
+ requires: 'single identifiable stamp across the chain',
139
+ frozen: '2026-10-15',
140
+ note:
141
+ 'Chosen by the PO (EPIC-010 decision 2), not by the model — the TASK-062 anti-bias rule. The meter is the whole' +
142
+ ' chain: rows (prompt-metrics), the aggregate (analyze) and this verdict each carry the stamp of the code that' +
143
+ ' computed them, and in the EPIC-009 series the measured tasks changed analyze and verdict themselves. So' +
144
+ ' exactly one stamp, and one that names code: `.dirty` was computed from uncommitted changes and `+unknown`' +
145
+ ' from a checkout git could not read — neither can be rebuilt, so neither can be checked. EPIC-009 was voided' +
146
+ ' because its rows came from a meter nobody could name.',
147
+ },
148
+ },
149
+ };
150
+
151
+ /**
152
+ * §7 rejections live in the run journal, not in the rows.
153
+ *
154
+ * A run the OPERATOR threw away — a skipped prompt, a prompt typed out of order,
155
+ * a prompt that differed between the arms — leaves nothing analyze.mjs can see.
156
+ * The procedure drops that session from the pool BEFORE the rows are aggregated
157
+ * (§8), by session id, because the alternative — deleting the transcript — is
158
+ * the failure class this epic exists to prevent. The aggregate's own counters
159
+ * therefore read zero, and gate 6 passed a series that had thrown away a third
160
+ * of its sessions. That happened, in the run this file was built by.
161
+ *
162
+ * Hence this input. It carries what the journal recorded, in the unit the gate
163
+ * already counts: rows, i.e. scripted prompts, because a rejected run takes all
164
+ * four of its own.
165
+ *
166
+ * `undefined` and `[]` are DIFFERENT answers and must never collapse:
167
+ *
168
+ * [] the journal was read and says nothing was thrown away.
169
+ * undefined nobody asked the journal.
170
+ *
171
+ * The second may not produce a ruling. A share whose denominator is unknown is
172
+ * the arithmetic form of a quietly dropped sample — the same error as a
173
+ * fabricated verdict, wearing a passing gate.
174
+ */
175
+ function normalizeJournalRejects(input) {
176
+ if (input === undefined || input === null) return { reported: false, rows: 0, runs: [] };
177
+ const problem = journalShapeProblem(input);
178
+ if (problem !== null) throw new VerdictInputError(`verdict: ${problem}`);
179
+ const runs = input.map((run) => ({
180
+ session: run.session ?? null,
181
+ task: run.task ?? null,
182
+ arm: run.arm ?? null,
183
+ prompts: run.prompts,
184
+ reason: run.reason ?? null,
185
+ }));
186
+ return { reported: true, rows: runs.reduce((n, r) => n + r.prompts, 0), runs };
187
+ }
188
+
189
+ /**
190
+ * What is wrong with a supplied journal, or null when nothing is. A message
191
+ * rather than a throw, because two layers report it in their own words — the
192
+ * pure function above, and the CLI reading a `--rejected-runs` file — and the
193
+ * check itself must exist once.
194
+ */
195
+ function journalShapeProblem(input) {
196
+ if (!Array.isArray(input)) return 'rejectedRuns must be an array of journal entries, or omitted to say nobody asked';
197
+ const i = input.findIndex((run) => !Number.isInteger(run?.prompts) || run.prompts < 0);
198
+ if (i === -1) return null;
199
+ return (
200
+ `rejectedRuns[${i}].prompts must be a non-negative integer — this gate counts rows, and a` +
201
+ ' rejected run whose prompt count is a guess cannot be counted into a threshold'
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Reject reasons that mean "a run that was TRYING to be part of the paired
207
+ * measurement failed" — the §7 invalidations, counted in rows because that is
208
+ * the unit analyze.mjs counts rejects in. A rejected session takes all four of
209
+ * its scripted prompts with it, so the row share reproduces the session
210
+ * arithmetic §10 does (one lost run of a two-pair series = 20% of the rows).
211
+ */
212
+ const EXPERIMENT_REJECTS = new Set([
213
+ 'branch-changed',
214
+ 'branch-annotation-mismatch',
215
+ 'off-convention-branch',
216
+ 'unresolved-arm',
217
+ 'mixed-arm-session',
218
+ ]);
219
+
220
+ /**
221
+ * Reject reasons that are NOT the paired measurement failing: ordinary daily
222
+ * work whose arm nobody recorded, on a machine that also does ordinary daily
223
+ * work. prompt-metrics reads every transcript under ~/.claude/projects (§8), so
224
+ * these outnumber the scripted rows by orders of magnitude on any real run.
225
+ * Counting them in gate 6 would make it a gate nobody can ever pass — as
226
+ * useless as no gate at all — while saying nothing about the experiment. They
227
+ * are reported inside the gate's measurement instead of being silently dropped.
228
+ */
229
+ function isBackgroundReject(reason) {
230
+ return reason === 'no-ledger' || reason === 'no-arm-in-force' || reason.startsWith('unusable-arm:');
231
+ }
232
+
233
+ function isExperimentReject(reason) {
234
+ return EXPERIMENT_REJECTS.has(reason) || reason.startsWith('conflict:');
235
+ }
236
+
237
+ /** Micro-dollar resolution, the same rounding rule as pricing.mjs and analyze.mjs. */
238
+ function round6(n) {
239
+ return Math.round(n * 1e6) / 1e6;
240
+ }
241
+
242
+ function mean(values) {
243
+ if (values.length === 0) return null;
244
+ return round6(values.reduce((a, b) => a + b, 0) / values.length);
245
+ }
246
+
247
+ /** The other arm of the pair. Derived from ARMS rather than written out, so the
248
+ * two-arm assumption lives in exactly one place. */
249
+ function other(arm) {
250
+ return ARMS.find((a) => a !== arm);
251
+ }
252
+
253
+ /**
254
+ * The arithmetic of §11b, per pair, over the four numbers a two-pair run
255
+ * produces:
256
+ *
257
+ * arm effect = mean over pairs of (cost off - cost on) > 0 means "on" cheaper
258
+ * order effect = mean over pairs of (cost pass1 - cost pass2) > 0 means the second pass cheaper
259
+ *
260
+ * Both are means over PAIRS, not over pool totals: the rotation (§9) is what
261
+ * lets the two be separated at all, and it only exists inside the pairs.
262
+ *
263
+ * A pair contributes only when all four inputs are real. Anything else is
264
+ * skipped WITH its reason rather than defaulted — a pair whose costs are
265
+ * incomplete, or whose two arms carry the same `pass_order`, cannot tell arm
266
+ * from order, and a zero standing in for it would quietly pull both effects
267
+ * toward the middle. The skipped list is what makes gate 4's refusal legible:
268
+ * "0 usable pairs" plus why beats a null.
269
+ */
270
+ function pairEffects(pairs) {
271
+ const used = [];
272
+ const skipped = [];
273
+ for (const pair of pairs) {
274
+ const skip = (why) => skipped.push({ task: pair.task, why });
275
+ if (!pair.complete) {
276
+ skip('incomplete-pair');
277
+ continue;
278
+ }
279
+ const [off, on] = [pair.arms.off, pair.arms.on];
280
+ // One check, not two: `cost_complete` and a finite total are the same fact
281
+ // read from two fields (summarizeCost cannot produce one without the other),
282
+ // and a second skip reason for a state that cannot occur would be a branch
283
+ // no test can reach.
284
+ if (![off, on].every((a) => a.cost_complete && Number.isFinite(a.list_cost_usd))) {
285
+ skip('cost-incomplete');
286
+ continue;
287
+ }
288
+ // Exactly one of the arms ran first. Two arms sharing a pass_order (or
289
+ // carrying none) is a pair the rotation did not actually rotate, and the
290
+ // order effect computed from it would be an assumption dressed as a
291
+ // measurement.
292
+ const orders = [off.pass_order, on.pass_order];
293
+ if (!(orders.includes(1) && orders.includes(2))) {
294
+ skip('pass-order-unusable');
295
+ continue;
296
+ }
297
+ const first = off.pass_order === 1 ? off : on;
298
+ const second = off.pass_order === 1 ? on : off;
299
+ used.push({
300
+ task: pair.task,
301
+ off_usd: off.list_cost_usd,
302
+ on_usd: on.list_cost_usd,
303
+ arm_delta_usd: round6(off.list_cost_usd - on.list_cost_usd),
304
+ order_delta_usd: round6(first.list_cost_usd - second.list_cost_usd),
305
+ });
306
+ }
307
+
308
+ const arm = mean(used.map((p) => p.arm_delta_usd));
309
+ const order = mean(used.map((p) => p.order_delta_usd));
310
+ return {
311
+ // Named `*_usd` and left as list price, the same qualifier pricing.mjs
312
+ // attaches: this is what the work would have cost through the API, never
313
+ // money spent.
314
+ arm_effect_usd: arm,
315
+ order_effect_usd: order,
316
+ // Which arm the arithmetic points at — always reported, even when a gate
317
+ // refuses to let it be published. Null on an exact tie and when there is
318
+ // nothing to compute from; a tie is not a cheaper arm.
319
+ cheaper_arm: arm === null || arm === 0 ? null : arm > 0 ? 'on' : 'off',
320
+ pairs_used: used,
321
+ pairs_skipped: skipped,
322
+ };
323
+ }
324
+
325
+ function gate(id, question, passed, measured, threshold, { reason = null, missing = null } = {}) {
326
+ return { id, question, passed, measured, threshold, reason, missing };
327
+ }
328
+
329
+ // ─── the gates ──────────────────────────────────────────────────────────────
330
+ //
331
+ // Each one holds the verdict on its own and for its own reason. They are
332
+ // written as independent functions over the same context rather than one
333
+ // chain of ifs, because a gate that can only fire when the previous one passed
334
+ // cannot report a run's second problem — and then the run gets repeated to
335
+ // discover it.
336
+
337
+ /** 1. Too small a sample — in pairs and in sessions per arm (§10). */
338
+ function sampleGate({ arms, paired, t }) {
339
+ const min = t.sample.min_pairs;
340
+ const q = 'Is the sample big enough to rule on?';
341
+ const sessions = Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].n_sessions]));
342
+ const measured = { n_pairs_complete: paired.n_pairs_complete, sessions_per_arm: sessions };
343
+ const short = ARMS.filter((arm) => sessions[arm] < min);
344
+ const pairsShort = paired.n_pairs_complete < min;
345
+ if (!pairsShort && short.length === 0) return gate('sample', q, true, measured, t.sample);
346
+
347
+ const reasons = [];
348
+ const missing = [];
349
+ if (pairsShort) {
350
+ reasons.push(`${paired.n_pairs_complete} complete pair(s), the frozen floor is ${min}`);
351
+ missing.push(`${min - paired.n_pairs_complete} more complete pair(s)`);
352
+ }
353
+ for (const arm of short) {
354
+ reasons.push(`arm ${arm} has ${sessions[arm]} session(s), the frozen floor is ${min}`);
355
+ missing.push(`${min - sessions[arm]} more session(s) in arm ${arm}`);
356
+ }
357
+ return gate('sample', q, false, measured, t.sample, {
358
+ reason: `sample below the PO floor: ${reasons.join('; ')}`,
359
+ missing: missing.join(', '),
360
+ });
361
+ }
362
+
363
+ /**
364
+ * 2. Model or effort differs between the arms (§10, error class 4).
365
+ *
366
+ * Read as a set union across both arms: one distinct value in the pool or the
367
+ * pair is not a pair. An arm with no rows contributes nothing — its emptiness is
368
+ * gate 1's business, and failing here as well would report one problem twice
369
+ * under two names.
370
+ */
371
+ function homogeneityGate({ arms, t }) {
372
+ const seen = (field) => [...new Set(ARMS.flatMap((arm) => Object.keys(arms[arm][field])))].sort();
373
+ const models = seen('models');
374
+ const efforts = seen('efforts');
375
+ const measured = {
376
+ models_by_arm: Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].models])),
377
+ efforts_by_arm: Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].efforts])),
378
+ distinct_models: models,
379
+ distinct_efforts: efforts,
380
+ };
381
+ const q = 'Did both arms run on one model and one effort?';
382
+ const broken = [];
383
+ if (models.length > 1) broken.push(`model: ${models.join(', ')}`);
384
+ if (efforts.length > 1) broken.push(`effort: ${efforts.join(', ')}`);
385
+ if (broken.length === 0) return gate('homogeneity', q, true, measured, t.homogeneity);
386
+
387
+ return gate('homogeneity', q, false, measured, t.homogeneity, {
388
+ reason:
389
+ `the arms are not comparable — ${broken.join('; ')}; the threshold is "identical", with no tolerance` +
390
+ ' (measured opus/sonnet input price differs by 1.7x, more than any effect being looked for)',
391
+ // Deliberately not "collect more data": more rows from a heterogeneous run
392
+ // make a bigger heterogeneous run.
393
+ missing: 'nothing collectable — the run is invalid under §7 and must be repeated with one model and one effort in both arms',
394
+ });
395
+ }
396
+
397
+ /**
398
+ * 3. The cheaper arm delegated more (§11a).
399
+ *
400
+ * Counts, not costs, and that is the whole point: the cost of a delegation is
401
+ * known only from below (subagent turns never reach the transcript), so the
402
+ * number that is uncertain is exactly the one this gate must not depend on.
403
+ * `spawns` rather than `measured` for the same reason — a delegation with no
404
+ * usable record is the worst case, not an absent one.
405
+ */
406
+ function delegationGate({ arms, effects, t }) {
407
+ const spawns = Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].delegations.spawns]));
408
+ const cheaper = effects.cheaper_arm;
409
+ // One shape on every path, `direction_known` included: a field present in one
410
+ // branch and absent in another makes `undefined` and `false` mean the same
411
+ // thing to the reader downstream, and they do not.
412
+ const measured = { spawns_by_arm: spawns, cheaper_arm: cheaper, direction_known: cheaper !== null, diff: null };
413
+ const q = 'Did the cheaper arm win by hiding work in subagents?';
414
+ const max = t.delegation.max_diff;
415
+
416
+ if (cheaper === null) {
417
+ // No cheaper arm means no dangerous direction to fire in, and §11a is
418
+ // explicit that this gate is not symmetric. Holding here would report the
419
+ // order gate's refusal a second time under a different name. The counts are
420
+ // published either way.
421
+ return gate('delegation', q, true, measured, t.delegation);
422
+ }
423
+
424
+ const diff = spawns[cheaper] - spawns[other(cheaper)];
425
+ measured.diff = diff;
426
+ if (diff <= max) {
427
+ return gate('delegation', q, true, measured, t.delegation);
428
+ }
429
+ return gate('delegation', q, false, measured, t.delegation, {
430
+ reason:
431
+ `the cheaper arm (${cheaper}) made ${diff} more Agent call(s) than ${other(cheaper)}` +
432
+ ` (${spawns[cheaper]} vs ${spawns[other(cheaper)]}), above the frozen maximum of ${max};` +
433
+ ' a subagent\'s turns never reach the transcript, so that arm is cheaper by an unknown remainder',
434
+ missing: `a repeat run in which arm ${cheaper} delegates no more than ${max} times above ${other(cheaper)}`,
435
+ });
436
+ }
437
+
438
+ /**
439
+ * 4. The order effect swamps the arm effect (§11b).
440
+ *
441
+ * The one gate whose held state is the likeliest real outcome of a two-pair run,
442
+ * and the one whose refusal is most tempting to talk past: both numbers are
443
+ * reported so the report can print them side by side and rule on neither.
444
+ */
445
+ function orderGate({ effects, t }) {
446
+ const ratio = t.order_effect.min_arm_over_order;
447
+ const arm = effects.arm_effect_usd;
448
+ const order = effects.order_effect_usd;
449
+ const q = 'Is the arm effect large enough next to the known order effect?';
450
+ const measured = {
451
+ arm_effect_usd: arm,
452
+ order_effect_usd: order,
453
+ // The observed ratio, for a report that wants to say how far off it was.
454
+ // Null when it cannot be formed (no pairs, or an order effect of exactly
455
+ // zero next to which every arm effect is infinitely large).
456
+ ratio: arm === null || order === null || order === 0 ? null : round6(Math.abs(arm) / Math.abs(order)),
457
+ pairs_used: effects.pairs_used.length,
458
+ pairs_skipped: effects.pairs_skipped,
459
+ };
460
+ if (arm === null || order === null) {
461
+ return gate('order_effect', q, false, measured, t.order_effect, {
462
+ reason:
463
+ 'neither effect can be computed: 0 usable pair(s)' +
464
+ (effects.pairs_skipped.length > 0
465
+ ? ` (skipped: ${effects.pairs_skipped.map((p) => `${p.task} ${p.why}`).join(', ')})`
466
+ : ''),
467
+ missing: 'at least one pair with both arms priced and one arm at pass_order 1, the other at 2',
468
+ });
469
+ }
470
+ if (Math.abs(arm) >= ratio * Math.abs(order)) {
471
+ return gate('order_effect', q, true, measured, t.order_effect);
472
+ }
473
+ // Past both returns above, `ratio` is always a number: an order effect of
474
+ // exactly zero passes (every arm effect clears 2 x 0), so the only paths that
475
+ // reach here have a divisor.
476
+ return gate('order_effect', q, false, measured, t.order_effect, {
477
+ reason:
478
+ `the arm effect ${formatUsd(arm)} does not reach ${ratio}x the order effect ${formatUsd(order)}` +
479
+ ` (measured ratio ${measured.ratio}x);` +
480
+ ' the second implementation of a task is cheaper because the solution is already known, and that runs in the' +
481
+ ' same direction as the effect being measured',
482
+ missing:
483
+ `pairs in which |arm effect| reaches ${ratio}x |order effect| — with the rotation of §9 kept, since without it` +
484
+ ' the two effects cannot be separated at all',
485
+ });
486
+ }
487
+
488
+ /** 5. Part of the pool has no price (§1 precondition). */
489
+ function unpricedGate({ arms, t }) {
490
+ const rows = Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].cost.unpriced.rows]));
491
+ const total = ARMS.reduce((n, arm) => n + rows[arm], 0);
492
+ const measured = {
493
+ unpriced_rows: total,
494
+ unpriced_rows_by_arm: rows,
495
+ reasons_by_arm: Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].cost.unpriced.reasons])),
496
+ models_by_arm: Object.fromEntries(ARMS.map((arm) => [arm, arms[arm].cost.unpriced.models])),
497
+ };
498
+ const q = 'Does the cost cover the whole pool?';
499
+ if (total <= t.unpriced.max_rows) return gate('unpriced', q, true, measured, t.unpriced);
500
+
501
+ const models = [...new Set(ARMS.flatMap((arm) => Object.keys(arms[arm].cost.unpriced.models)))].sort();
502
+ return gate('unpriced', q, false, measured, t.unpriced, {
503
+ reason:
504
+ `${total} row(s) carry no price (off ${rows.off}, on ${rows.on}) against a frozen maximum of` +
505
+ ` ${t.unpriced.max_rows}; a cost computed over part of the rows is not the cost of the pool`,
506
+ missing:
507
+ models.length > 0
508
+ ? `prices for ${models.join(', ')} in eval/pricing.mjs, then a recount (§8)`
509
+ : 'the price table completed for the models named in the unpriced reasons, then a recount (§8)',
510
+ });
511
+ }
512
+
513
+ /**
514
+ * 6. More of the measurement was thrown away than the threshold allows (§10).
515
+ *
516
+ * Rows, not sessions, because rows are what the aggregate counts rejects in —
517
+ * and because a rejected run loses all of its scripted prompts, the two units
518
+ * give the same answer at the sizes this runs at.
519
+ *
520
+ * One deliberate over-count remains: a `mixed-arm-session` in ordinary daily
521
+ * work (a session left open across a ledger `set`) is indistinguishable, in the
522
+ * aggregate, from a scripted session that switched arms — the counter carries no
523
+ * pool. It is counted, and the direction of that choice is the one to be wrong
524
+ * in: a false hold names the session ids and asks for a look, while the opposite
525
+ * error drops a genuinely switched experiment session out of sight, which is the
526
+ * failure class this epic exists to prevent.
527
+ */
528
+ function rejectedGate({ agg, arms, t, journal }) {
529
+ const byReason = agg.counters.rejected_by_reason;
530
+ let rejected = 0;
531
+ let background = 0;
532
+ const reasons = {};
533
+ // Rows this gate cannot place: a reject reason analyze.mjs learned to emit
534
+ // after this file was written. Neither counted nor discarded — a discard that
535
+ // does not show up is the failure class the epic is built around, and a new
536
+ // reason silently missing from the share is exactly that. It surfaces in the
537
+ // measurement so the next reader classifies it deliberately.
538
+ const unclassified = {};
539
+ for (const [reason, n] of Object.entries(byReason)) {
540
+ if (isBackgroundReject(reason)) {
541
+ background += n;
542
+ continue;
543
+ }
544
+ if (!isExperimentReject(reason)) {
545
+ unclassified[reason] = n;
546
+ continue;
547
+ }
548
+ rejected += n;
549
+ reasons[reason] = n;
550
+ }
551
+ const used = ARMS.reduce((n, arm) => n + arms[arm].n_prompts, 0);
552
+ // The journal's rejects sit on the same side of the ratio as the parser's:
553
+ // both are measurement that was thrown away, and §10 freezes ONE share over
554
+ // the two. Keeping them in separate counters would let a series pass the
555
+ // threshold twice on half the evidence each time.
556
+ rejected += journal.rows;
557
+ const pool = used + rejected;
558
+ const share = pool === 0 ? 0 : round6(rejected / pool);
559
+ const measured = {
560
+ rejected_rows: rejected,
561
+ rows_in_pool: pool,
562
+ share,
563
+ rejected_by_reason: reasons,
564
+ // 'NOT REPORTED' rather than 0, for the same reason analyze.mjs prints it
565
+ // for unattributed entries: a zero the caller never supplied is a claim
566
+ // nobody made.
567
+ operator_rejects: journal.reported ? journal.rows : 'NOT REPORTED',
568
+ operator_reject_runs: journal.runs,
569
+ mixed_arm_sessions: agg.counters.mixed_arm_sessions,
570
+ mixed_arm_session_ids: agg.counters.mixed_arm_session_ids,
571
+ // Reported, never counted above — see isBackgroundReject().
572
+ background_rows_without_arm: background,
573
+ unclassified_rejects: unclassified,
574
+ };
575
+ const q = 'Did enough of the measurement survive to be about the measurement?';
576
+ // Not told, and there is a scripted pool to be told about. The share computed
577
+ // above is a floor, not a share, so it cannot clear a maximum — and passing on
578
+ // a floor is how this gate reported 0% for a series that had discarded 33%.
579
+ // A pool with no paired rows has no journal to read, so it is not held.
580
+ if (!journal.reported && used > 0) {
581
+ return gate('rejected', q, false, measured, t.rejected, {
582
+ reason:
583
+ 'the journal count of §7 rejections was not supplied, so the share above counts only what the parser could' +
584
+ ` see (${rejected} of ${pool} row(s)); an operator-rejected run leaves no trace in the rows by design`,
585
+ missing:
586
+ 'rejectedRuns from the run journal — pass [] to state that the journal was read and nothing was thrown away',
587
+ });
588
+ }
589
+ if (share <= t.rejected.max_share) return gate('rejected', q, true, measured, t.rejected);
590
+
591
+ return gate('rejected', q, false, measured, t.rejected, {
592
+ reason:
593
+ `${rejected} of ${pool} experiment row(s) rejected — ${(share * 100).toFixed(1)}% against a frozen maximum of` +
594
+ ` ${(t.rejected.max_share * 100).toFixed(1)}% (${Object.entries(reasons).map(([r, n]) => `${r} ${n}`).join(', ')});` +
595
+ ' a broken run is repeated, not absorbed',
596
+ missing: 'the rejected run(s) repeated under §7 — at four sessions there is nothing to spare',
597
+ });
598
+ }
599
+
600
+ /**
601
+ * 7. More than one meter computed this chain, or one that names no code
602
+ * (EPIC-010, PO decision 2, frozen 2026-10-15).
603
+ *
604
+ * The stamps read are the paired pool's rows (`paired.meter_versions`), the
605
+ * aggregate's `analyzed_by` and this verdict's own `judged_by` — the whole
606
+ * chain, because in the EPIC-009 series the measured tasks changed analyze and
607
+ * verdict themselves, and rows from one version judged by another is the same
608
+ * error one floor up. Natural work never reaches the verdict, so its stamps are
609
+ * not read.
610
+ *
611
+ * analyze does not refuse mixed stamps (decision 2): the per-pair costs must
612
+ * stay visible to plan a repeat. So the refusal lives here, and it says which
613
+ * step carried which stamp — "two meters" alone does not say what to recompute.
614
+ */
615
+ function meterGate({ agg, paired, judgedBy, t }) {
616
+ const q = 'Did one identifiable meter compute the whole chain, from the rows to this verdict?';
617
+ const carriers = new Map();
618
+ const carry = (stamp, what) => {
619
+ if (!carriers.has(stamp)) carriers.set(stamp, []);
620
+ carriers.get(stamp).push(what);
621
+ };
622
+ const rows = paired.meter_versions ?? {};
623
+ for (const [stamp, n] of Object.entries(rows)) carry(stamp, `rows ×${n}`);
624
+ // A step with no stamp is not a stamp of its own: it is a step nobody can
625
+ // name, and it holds the gate under that description.
626
+ const unstamped = [];
627
+ if (agg.analyzed_by) carry(agg.analyzed_by, 'analyze');
628
+ else unstamped.push('analyze');
629
+ if (judgedBy) carry(judgedBy, 'verdict');
630
+ else unstamped.push('verdict');
631
+
632
+ const stamps = [...carriers.keys()].sort();
633
+ const unidentifiable = Object.fromEntries(
634
+ stamps.map((stamp) => [stamp, stampProblem(stamp)]).filter(([, problem]) => problem !== null),
635
+ );
636
+ const measured = {
637
+ stamps,
638
+ carried_by: Object.fromEntries(stamps.map((stamp) => [stamp, carriers.get(stamp)])),
639
+ rows_by_stamp: { ...rows },
640
+ analyzed_by: agg.analyzed_by ?? null,
641
+ judged_by: judgedBy ?? null,
642
+ unidentifiable,
643
+ unstamped_steps: unstamped,
644
+ };
645
+ if (stamps.length === 1 && Object.keys(unidentifiable).length === 0 && unstamped.length === 0) {
646
+ return gate('meter_mixed', q, true, measured, t.meter_mixed);
647
+ }
648
+
649
+ const where = (stamp) => `${stamp} (${carriers.get(stamp).join(', ')})`;
650
+ const reasons = [];
651
+ if (stamps.length > 1) reasons.push(`the chain was computed by ${stamps.length} meters: ${stamps.map(where).join('; ')}`);
652
+ for (const [stamp, problem] of Object.entries(unidentifiable)) reasons.push(`${where(stamp)} names no code: ${problem}`);
653
+ if (unstamped.length > 0) reasons.push(`${unstamped.join(' and ')} left no stamp`);
654
+ return gate('meter_mixed', q, false, measured, t.meter_mixed, {
655
+ reason: reasons.join('; '),
656
+ missing:
657
+ 'the whole chain from one meter that names its code — recompute the rows from the transcripts or from an' +
658
+ ' archive (`tune-context experiment archive`) with the current version, or run every step with the version' +
659
+ ' that computed the rows (`npx -p @bglocation/tune-context@<version> tune-context eval …`); a series runs' +
660
+ ' from a release or a clean commit, never from a .dirty tree',
661
+ });
662
+ }
663
+
664
+ const GATES = [sampleGate, homogeneityGate, delegationGate, orderGate, unpricedGate, rejectedGate, meterGate];
665
+
666
+ /**
667
+ * Aggregate in, decision out.
668
+ *
669
+ * @param agg the object analyze() returned (schema tune-context/analyze@2).
670
+ * @param thresholds the frozen table. Injectable so a test can prove a gate
671
+ * READS it rather than repeating the number in code — which
672
+ * is the only way to tell a frozen threshold from a
673
+ * coincidence. Callers in production pass nothing.
674
+ * @param rejectedRuns the §7 rejections the run journal recorded, as
675
+ * `{ session, task, arm, prompts, reason }`. Omitting it is
676
+ * not the same as passing `[]` — see
677
+ * normalizeJournalRejects(). A paired pool cannot be ruled
678
+ * on while it is omitted.
679
+ * @param judgedBy the stamp of the code running this function, from the
680
+ * caller — the same arrangement as report's `generatedAt`,
681
+ * so this stays free of ambient state. Omitted, the
682
+ * meter_mixed gate holds: a verdict nobody can trace to code
683
+ * is the thing EPIC-010 exists to stop.
684
+ *
685
+ * Throws on an input that is not an analyze aggregate: the same layer-boundary
686
+ * rule classifyRow() applies to unannotated rows. A shape this does not
687
+ * recognise would otherwise read as a run with no pairs, no cost and no
688
+ * rejects — i.e. as a withheld verdict, which is the one wrong answer nobody
689
+ * would look at twice.
690
+ */
691
+ export function verdict(agg, { thresholds = VERDICT_THRESHOLDS, rejectedRuns, judgedBy = null } = {}) {
692
+ if (!agg || typeof agg !== 'object' || agg.schema !== 'tune-context/analyze@2') {
693
+ // An @1 aggregate is refused, not upgraded: its rows carry no stamp, and the
694
+ // only way back to that data is through the transcripts (EPIC-010 decision 4).
695
+ const got = agg && typeof agg === 'object' && typeof agg.schema === 'string' ? ` — got ${agg.schema}` : '';
696
+ throw new VerdictInputError(
697
+ `verdict: expected an aggregate from analyze() (schema tune-context/analyze@2)${got}; recompute it with` +
698
+ ' `tune-context eval analyze` from stamped rows (EPIC-010: unstamped data is not used)',
699
+ );
700
+ }
701
+ const paired = agg.paired;
702
+ const arms = paired.arms;
703
+ const t = thresholds.gates;
704
+ const effects = pairEffects(paired.pairs);
705
+ const journal = normalizeJournalRejects(rejectedRuns);
706
+
707
+ const ctx = { agg, paired, arms, effects, t, journal, judgedBy };
708
+ const gates = GATES.map((fn) => fn(ctx));
709
+ const holds = gates.filter((g) => !g.passed);
710
+ // Three outcomes, named, because two of them are not "cheaper arm" and
711
+ // collapsing either into a bare `false` produces a refusal with no reason
712
+ // attached — the one thing this file may never emit.
713
+ //
714
+ // withheld a gate held; `holds` says which and what is missing.
715
+ // no-difference every gate passed and the arms cost the same on average.
716
+ // That is a finding, not a failure: at this sample size "no
717
+ // difference measured" is exactly what the run may publish,
718
+ // and reporting it as a withheld verdict would hide a clean
719
+ // measurement behind a word that means "something is wrong".
720
+ // cheaper-arm every gate passed and the arithmetic points at one arm.
721
+ const finding = holds.length > 0 ? 'withheld' : effects.cheaper_arm === null ? 'no-difference' : 'cheaper-arm';
722
+ const ruled = finding !== 'withheld';
723
+
724
+ return {
725
+ schema: 'tune-context/verdict@2',
726
+ pool: 'paired',
727
+ // Which code judged — read by the meter_mixed gate above, and kept here so
728
+ // a verdict read a year later still names it.
729
+ judged_by: judgedBy,
730
+ // The table travels with the decision: a verdict read six months later has
731
+ // to show which thresholds it was judged against, without anyone trusting
732
+ // that the file still says the same thing.
733
+ thresholds,
734
+ effects,
735
+ gates,
736
+ // The same objects as above, filtered — not a second copy that could
737
+ // disagree. All of them, because a reader who fixes one problem and reruns
738
+ // has paid a run to learn about the second.
739
+ holds,
740
+ finding,
741
+ // True for both `cheaper-arm` and `no-difference`: the gates licensed a
742
+ // statement, and which statement it is, is `finding`. A consumer that reads
743
+ // `ruled` alone and reaches for `cheaper_arm` gets null rather than a name
744
+ // it could print.
745
+ ruled,
746
+ // Null unless every gate passed. `effects.cheaper_arm` above stays whatever
747
+ // the arithmetic said: the arithmetic is a measurement, this is a licence to
748
+ // publish it, and collapsing the two is exactly how a withheld verdict gets
749
+ // printed as a result anyway.
750
+ cheaper_arm: ruled ? effects.cheaper_arm : null,
751
+ n_pairs_used: effects.pairs_used.length,
752
+ // "Observation", never "proof" (§10) — and unconditionally so. The PO froze
753
+ // a MINIMUM and nothing above it, so there is no sample size at which this
754
+ // may flip to "proof". A comparison computed here (n > min => proof) would
755
+ // be the model choosing the point at which its own tool stops being
756
+ // qualified — the same anti-bias rule that keeps the thresholds in the
757
+ // table. Promoting a run past "observation" takes a PO decision with its
758
+ // own frozen date, which lands in the table, not in this expression.
759
+ observation_only: true,
760
+ };
761
+ }
762
+
763
+ // ─── text ───────────────────────────────────────────────────────────────────
764
+
765
+ const pctText = (x) => (Number.isFinite(x) ? `${(x * 100).toFixed(1)}%` : '—');
766
+
767
+ /**
768
+ * Per gate: what was measured, against what. One entry per gate in GATES — a
769
+ * test holds the two lists equal, so a new gate cannot reach the terminal as a
770
+ * bare id. Kept here rather than in the gates, because a gate's job is to
771
+ * decide and this is only how a decision reads at 80 columns.
772
+ */
773
+ const GATE_SUMMARIES = {
774
+ sample: (m, t) =>
775
+ `${m.n_pairs_complete} complete pair(s), sessions off ${m.sessions_per_arm.off} / on ${m.sessions_per_arm.on}; floor ${t.min_pairs}`,
776
+ homogeneity: (m) => `model ${m.distinct_models.join(', ') || '—'}, effort ${m.distinct_efforts.join(', ') || '—'}; must be identical`,
777
+ delegation: (m, t) =>
778
+ `spawns off ${m.spawns_by_arm.off} / on ${m.spawns_by_arm.on}` +
779
+ (m.direction_known ? `, cheaper arm ${m.cheaper_arm} +${m.diff}; max +${t.max_diff}` : ', no cheaper arm'),
780
+ order_effect: (m, t) =>
781
+ m.arm_effect_usd === null || m.order_effect_usd === null
782
+ ? `no usable pair; needs |arm| >= ${t.min_arm_over_order}x |order|`
783
+ : `arm ${formatUsd(m.arm_effect_usd)}, order ${formatUsd(m.order_effect_usd)}, ratio ${m.ratio ?? '—'}x; needs >= ${t.min_arm_over_order}x`,
784
+ unpriced: (m, t) => `${m.unpriced_rows} unpriced row(s); max ${t.max_rows}`,
785
+ rejected: (m, t) =>
786
+ `${pctText(m.share)} rejected (${m.rejected_rows} of ${m.rows_in_pool} row(s)), journal ` +
787
+ `${m.operator_rejects === 'NOT REPORTED' ? 'NOT REPORTED' : `${m.operator_rejects} row(s)`}; max ${pctText(t.max_share)}`,
788
+ meter_mixed: (m) =>
789
+ `${m.stamps.map((stamp) => `${stamp} (${m.carried_by[stamp].join(', ')})`).join('; ') || 'no stamp'}` +
790
+ `${m.unstamped_steps.length > 0 ? `, unstamped: ${m.unstamped_steps.join(', ')}` : ''}; one identifiable stamp`,
791
+ };
792
+
793
+ /**
794
+ * The verdict as text, for stderr: one line per gate — passed or held, what it
795
+ * measured against its threshold, and why it holds — then the finding in one
796
+ * word. Every gate, always, in the order verdict() evaluated them, for the same
797
+ * reason verdict() evaluates them all: a reader who fixes one problem and
798
+ * reruns has paid a run to learn about the second.
799
+ *
800
+ * Pure: a verdict in, a string out.
801
+ */
802
+ export function formatVerdict(v) {
803
+ const L = [`verdict: paired pool, ${v.n_pairs_used} usable pair(s) — an observation, never proof`];
804
+ for (const g of v.gates) {
805
+ const summary = GATE_SUMMARIES[g.id]?.(g.measured, g.threshold) ?? '';
806
+ L.push(` ${g.passed ? '✓' : '✗'} ${g.id.padEnd(12)} ${summary}${g.passed ? '' : ` — ${g.reason}`}`);
807
+ }
808
+ // Said on its own line, because it is the one hold the operator causes by
809
+ // omission: the flag was not passed, so nobody asked the journal.
810
+ const journal = v.gates.find((g) => g.id === 'rejected');
811
+ if (journal && !journal.passed && journal.measured.operator_rejects === 'NOT REPORTED') {
812
+ L.push(
813
+ ' ! no --rejected-runs: nobody asked the run journal, so gate `rejected` holds by construction —' +
814
+ ' pass its §7 rejections, or a file containing [] once the journal has been read',
815
+ );
816
+ }
817
+ L.push(
818
+ `finding: ${
819
+ v.finding === 'withheld'
820
+ ? `withheld: ${v.holds.length} gate(s) hold — ${v.holds.map((g) => g.id).join(', ')}`
821
+ : v.finding === 'no-difference'
822
+ ? 'no-difference'
823
+ : `cheaper-arm: ${v.cheaper_arm}`
824
+ }`,
825
+ );
826
+ return L.join('\n');
827
+ }
828
+
829
+ // ─── CLI ────────────────────────────────────────────────────────────────────
830
+ //
831
+ // The only I/O in this file (see the header). `eval verdict` and `eval report`
832
+ // both judge, so both read their input through readVerdictInput() below.
833
+
834
+ const USAGE = `
835
+ verdict — may this run say "one arm is cheaper"? The gates over an analyze aggregate (TASK-090).
836
+
837
+ Usage:
838
+ verdict [--aggregate <path>] [--rejected-runs <path>]
839
+
840
+ Options:
841
+ --aggregate <path> JSON written by analyze (default: read stdin)
842
+ --rejected-runs <path> JSON array of the run journal's §7 rejections,
843
+ [{ session, task, arm, prompts, reason }]. OMITTING it
844
+ is not the same as passing a file containing []: the
845
+ first says nobody asked the journal, and gate 6 holds
846
+ the verdict for it. Same flag, same meaning as report.
847
+ -h, --help Show this help
848
+
849
+ Writes the verdict as JSON to stdout — the same object report embeds in its
850
+ HTML — and one line per gate to stderr. Exit 0 for every verdict, a withheld one
851
+ included: withholding is an answer, not a failure. Exit 1 only when the input
852
+ cannot be read.
853
+ `.trim();
854
+
855
+ /**
856
+ * A file the CLI was pointed at, as text — or the VerdictInputError naming the
857
+ * flag: missing, a directory, unreadable. Asking existsSync first let a
858
+ * directory through to readFileSync, and up as an EISDIR stacktrace.
859
+ */
860
+ function readInputFile(path, flag) {
861
+ try {
862
+ return readFileSync(path, 'utf8');
863
+ } catch (e) {
864
+ throw new VerdictInputError(e.code === 'ENOENT' ? `no file at ${path} (${flag})` : `cannot read ${path} (${flag}): ${e.message}`);
865
+ }
866
+ }
867
+
868
+ function parseJson(text, what) {
869
+ try {
870
+ return JSON.parse(text);
871
+ } catch (e) {
872
+ throw new VerdictInputError(`${what} is not JSON: ${e.message}`);
873
+ }
874
+ }
875
+
876
+ /**
877
+ * The aggregate and the run journal, read and checked in ONE place for both
878
+ * steps that judge. Two copies would drift, and the drift nobody notices is the
879
+ * report accepting an input the verdict refuses.
880
+ *
881
+ * @param aggregate a path, or undefined to read stdin
882
+ * @param rejectedRuns a path, or undefined — and undefined STAYS undefined. It
883
+ * means nobody asked the journal, which gate 6 holds on;
884
+ * defaulting it to [] here would pass that gate on a share
885
+ * whose denominator nobody supplied.
886
+ * @returns {Promise<{agg: object, rejectedRuns: object[]|undefined}>}
887
+ * @throws {VerdictInputError} a file that is missing, a directory or
888
+ * unreadable, a file that is not JSON, or a journal of the wrong shape —
889
+ * the message names the flag and the path.
890
+ * Whether the aggregate IS an aggregate is verdict()'s check, the same
891
+ * one a module caller gets.
892
+ */
893
+ export async function readVerdictInput({ aggregate, rejectedRuns }) {
894
+ // Both files before stdin: a mistyped --rejected-runs is reported at once,
895
+ // not after the aggregate has been piped in.
896
+ const aggText = aggregate === undefined ? undefined : readInputFile(aggregate, '--aggregate');
897
+ const runsText = rejectedRuns === undefined ? undefined : readInputFile(rejectedRuns, '--rejected-runs');
898
+ const agg =
899
+ aggText === undefined ? parseJson(await readStdin(), 'the aggregate on stdin') : parseJson(aggText, `--aggregate ${aggregate}`);
900
+ if (runsText === undefined) return { agg, rejectedRuns: undefined };
901
+ const runs = parseJson(runsText, `--rejected-runs ${rejectedRuns}`);
902
+ const problem = journalShapeProblem(runs);
903
+ if (problem !== null) throw new VerdictInputError(`--rejected-runs ${rejectedRuns}: ${problem}`);
904
+ return { agg, rejectedRuns: runs };
905
+ }
906
+
907
+ /**
908
+ * The line an input error prints: the step's name, then the message — unless
909
+ * the message already names this step (verdict()'s own errors start with
910
+ * `verdict:`), so `eval verdict` never stutters while `eval report` still says
911
+ * which layer refused.
912
+ */
913
+ export function inputErrorLine(step, error) {
914
+ return error.message.startsWith(`${step}: `) ? error.message : `${step}: ${error.message}`;
915
+ }
916
+
917
+ /**
918
+ * The CLI. `args` is everything after the script name, or after
919
+ * `tune-context eval verdict` — one parser behind both doors (EPIC-010).
920
+ */
921
+ export async function main(args = process.argv.slice(2)) {
922
+ endQuietlyOnClosedPipe();
923
+ let values;
924
+ try {
925
+ ({ values } = parseArgs({
926
+ args,
927
+ options: {
928
+ aggregate: { type: 'string' },
929
+ 'rejected-runs': { type: 'string' },
930
+ help: { type: 'boolean', short: 'h', default: false },
931
+ },
932
+ strict: true,
933
+ }));
934
+ } catch (e) {
935
+ process.stderr.write(`verdict: ${e.message}\n`);
936
+ process.exitCode = 1;
937
+ return;
938
+ }
939
+ if (values.help) {
940
+ process.stdout.write(`${USAGE}\n`);
941
+ return;
942
+ }
943
+
944
+ let v;
945
+ try {
946
+ const { agg, rejectedRuns } = await readVerdictInput({ aggregate: values.aggregate, rejectedRuns: values['rejected-runs'] });
947
+ v = verdict(agg, { rejectedRuns, judgedBy: meterStamp() });
948
+ } catch (e) {
949
+ // Exactly the input boundary. A TypeError from inside a gate is a bug and
950
+ // keeps its stacktrace.
951
+ if (!(e instanceof VerdictInputError)) throw e;
952
+ process.stderr.write(`${inputErrorLine('verdict', e)}\n`);
953
+ process.exitCode = 1;
954
+ return;
955
+ }
956
+
957
+ process.stdout.write(`${JSON.stringify(v, null, 2)}\n`);
958
+ process.stderr.write(`${formatVerdict(v)}\n`);
959
+ }
960
+
961
+ if (isEntryPoint(import.meta.url)) {
962
+ await main();
963
+ }