@bglocation/tune-context 2.4.0 → 2.5.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,689 @@
1
+ #!/usr/bin/env node
2
+ // analyze — per-prompt rows (TASK-085/086, arm-annotated by TASK-088) → one
3
+ // aggregate per ARM. See tasks/TASK-089.
4
+ //
5
+ // This file walks the distance between "rows exist" and "this arm cost less",
6
+ // and stops one step short of the second: it aggregates and it discards, it
7
+ // never rules. The verdict, and every threshold behind one, is TASK-090.
8
+ //
9
+ // Two things it must make impossible rather than merely discourage:
10
+ //
11
+ // • **Adding the two populations together.** The paired measurement (four
12
+ // scripted sessions, §2a) and ordinary daily work are not two samples of
13
+ // one thing — one holds model, effort, task and prompt text constant, the
14
+ // other holds nothing constant. So there is no collection of pools to fold
15
+ // over here, and no cost field above them: `paired` and `natural` are two
16
+ // named objects with different shapes (only `paired` has `pairs`), and a
17
+ // reader who wants a combined number has to write out both names and mean
18
+ // it. Criterion 3.
19
+ // • **A discard that does not show up.** Every row that goes in comes out
20
+ // either inside an arm or inside a counter, and `rows_in === rows_used +
21
+ // rows_rejected` is asserted by a test rather than by hope. The failure
22
+ // this guards against is the one earlier epics kept finding: a sample that
23
+ // quietly shrank to the rows that agreed with the conclusion.
24
+ //
25
+ // The arm is a property of the SESSION, not of the prompt: MCP servers and
26
+ // settings.json are read once at startup, so a session whose rows disagree
27
+ // about the arm was never in one arm at all. It is rejected whole (error class
28
+ // 6), never split down the middle.
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { parseArgs } from 'node:util';
31
+ import { claudeBaseDir } from '../hooks/config-dir.mjs';
32
+ import { experimentPaths, readLedger } from '../cli/experiment.mjs';
33
+ import { ledgerArmAt, parseArmBranch, rowTimestamp } from './arm.mjs';
34
+ import { TOKEN_CLASS_FIELDS, formatUsd, summarizeCost } from './pricing.mjs';
35
+
36
+ /** The two arms, always both present in the output — see armsOf(). */
37
+ export const ARMS = ['off', 'on'];
38
+
39
+ const USAGE = `
40
+ analyze — per-arm aggregate from prompt-metrics rows (TASK-089).
41
+
42
+ Usage:
43
+ analyze [--rows <path>]...
44
+
45
+ Options:
46
+ --rows <path> A JSONL file written by prompt-metrics. Repeatable.
47
+ (default: read from stdin)
48
+ -h, --help Show this help
49
+
50
+ Writes the aggregate as JSON to stdout and a short summary to stderr. It does
51
+ NOT rule on which arm is cheaper — that is TASK-090.
52
+ `.trim();
53
+
54
+ // ─── numbers ────────────────────────────────────────────────────────────────
55
+
56
+ /** Micro-dollar resolution, the same as pricing.mjs — one rounding rule. */
57
+ function round6(n) {
58
+ return Math.round(n * 1e6) / 1e6;
59
+ }
60
+
61
+ /** Milliseconds, for the two quantities measured in seconds. */
62
+ function round3(n) {
63
+ return Math.round(n * 1000) / 1000;
64
+ }
65
+
66
+ /**
67
+ * Quantile by linear interpolation between closest ranks (the R-7 definition
68
+ * numpy and most spreadsheets use). Chosen over nearest-rank because these
69
+ * pools hold single-digit prompt counts, where nearest-rank makes p25 and the
70
+ * minimum the same number and the spread looks narrower than it is.
71
+ */
72
+ function quantile(sorted, q) {
73
+ if (sorted.length === 1) return sorted[0];
74
+ const pos = (sorted.length - 1) * q;
75
+ const lo = Math.floor(pos);
76
+ const hi = Math.ceil(pos);
77
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
78
+ }
79
+
80
+ /**
81
+ * Distribution of a per-prompt quantity: **median and spread, never the mean
82
+ * alone** (criterion 7).
83
+ *
84
+ * The mean rides along rather than being dropped, because the gap between the
85
+ * two is itself the finding — one 44-hour turn (measured: `work_s` 158999) or
86
+ * one prompt that read a 200k-token file moves a mean of six and leaves a
87
+ * median untouched. A single figure would have to pick which of those two
88
+ * stories to tell.
89
+ *
90
+ * Non-finite values are excluded and counted: `work_s` is null whenever a turn
91
+ * has no assistant reply, and a null coerced to 0 is a prompt that took no
92
+ * time.
93
+ */
94
+ function stats(values, { round = round6 } = {}) {
95
+ const xs = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
96
+ const missing = values.length - xs.length;
97
+ if (xs.length === 0) {
98
+ return { n: 0, missing, total: null, mean: null, median: null, p25: null, p75: null, iqr: null, min: null, max: null };
99
+ }
100
+ const total = xs.reduce((a, b) => a + b, 0);
101
+ const p25 = quantile(xs, 0.25);
102
+ const p75 = quantile(xs, 0.75);
103
+ return {
104
+ n: xs.length,
105
+ missing,
106
+ total: round(total),
107
+ mean: round(total / xs.length),
108
+ median: round(quantile(xs, 0.5)),
109
+ p25: round(p25),
110
+ p75: round(p75),
111
+ iqr: round(p75 - p25),
112
+ min: round(xs[0]),
113
+ max: round(xs[xs.length - 1]),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Tokens for a set of rows: a total that is never published without the five
119
+ * classes it is made of (criterion 4).
120
+ *
121
+ * `cache_write_unsplit` is reported apart from the five rather than folded into
122
+ * them — the 5m and 1h write multipliers differ by 60%, so a class we cannot
123
+ * place is a number we cannot price, and it is exactly why the rows carrying it
124
+ * come back unpriced.
125
+ */
126
+ function tokenTotals(rows) {
127
+ const by_class = Object.fromEntries(TOKEN_CLASS_FIELDS.map(([name]) => [name, 0]));
128
+ let unsplit = 0;
129
+ for (const row of rows) {
130
+ for (const [name, field] of TOKEN_CLASS_FIELDS) by_class[name] += row[field] || 0;
131
+ unsplit += row.cache_write_unsplit || 0;
132
+ }
133
+ return {
134
+ total: Object.values(by_class).reduce((a, b) => a + b, 0) + unsplit,
135
+ by_class,
136
+ cache_write_unsplit: unsplit,
137
+ };
138
+ }
139
+
140
+ /** Counts of a categorical field, e.g. `{ 'claude-opus-5': 7, mixed: 1 }`. */
141
+ function distribution(rows, field) {
142
+ // Null-prototype: the keys are model ids and effort strings from a
143
+ // transcript, so `constructor` is a value this can legitimately be handed.
144
+ const out = Object.create(null);
145
+ for (const row of rows) {
146
+ const key = row[field] === null || row[field] === undefined ? '(none)' : String(row[field]);
147
+ out[key] = (out[key] ?? 0) + 1;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function sumField(rows, field) {
153
+ return rows.reduce((n, row) => n + (row[field] || 0), 0);
154
+ }
155
+
156
+ function distinct(rows, field) {
157
+ return new Set(rows.map((row) => row[field]).filter((v) => typeof v === 'string' && v !== ''));
158
+ }
159
+
160
+ // ─── classification ─────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Is this branch name TRYING to be an experiment branch and failing?
164
+ *
165
+ * The distinction the aggregate has to draw is not "valid or invalid" but
166
+ * "which population": `main` is not a broken `exp/task-089/on`, it is ordinary
167
+ * work and belongs to the natural pool by way of the ledger. `exp/task-089`,
168
+ * `exp/` and `exp/task-089/on/extra` are different — they claim the convention
169
+ * and miss it, so they go to a counter and to NO arm (criterion 1). Guessing
170
+ * which arm was meant is the silent mis-assignment TASK-088 exists to prevent.
171
+ */
172
+ function isOffConventionBranch(branch) {
173
+ if (typeof branch !== 'string' || branch === '') return false;
174
+ const b = branch.trim();
175
+ return (b === 'exp' || b.startsWith('exp/')) && parseArmBranch(b) === null;
176
+ }
177
+
178
+ /**
179
+ * One row → the pool and arm it belongs to, or the reason it belongs to
180
+ * neither. Never throws and never returns "probably".
181
+ *
182
+ * Order matters and is the order of decreasing certainty:
183
+ *
184
+ * 1. `gitBranchChanged` — the arm marker moved INSIDE one prompt, so it
185
+ * describes two states and therefore neither. Same family as a mixed
186
+ * session, one level down.
187
+ * 2. An `exp/<task>/<arm>` branch → the paired pool, with the arm annotateRows
188
+ * already reconciled against the ledger. An unresolved arm there carries
189
+ * its own reason (`conflict:ledger`, `conflict:no-ledger-entry`, …) and
190
+ * that reason is what gets counted — "12 rejected" says nothing, "12
191
+ * rejected: conflict:ledger" says which run to repeat.
192
+ * 3. A branch that IS the convention on a row annotated as ordinary work, or a
193
+ * branch that merely mimics the convention → counter only. Two different
194
+ * disagreements, two different reasons, neither of them an arm.
195
+ * 4. Everything else is ordinary work, and its arm comes from the ledger,
196
+ * which is the only record of what was switched on at the time.
197
+ *
198
+ * "Never throws" has one exception, and it is not about data: a row that never
199
+ * went through annotateRows is a wiring mistake in the caller, and the reading
200
+ * that would let it pass silently is the one this whole file exists to prevent.
201
+ */
202
+ export function classifyRow(row, { ledger = [], ledgerAvailable = false } = {}) {
203
+ if (row.exp_arm_source === undefined) {
204
+ // The layer boundary arm.mjs warns about. An unannotated row has `undefined`
205
+ // where the contract promises `''`, and `undefined !== ''` would let it pass
206
+ // as ordinary work with a straight face. Fail loudly instead: this is a
207
+ // wiring mistake in the caller, not a property of the data.
208
+ throw new TypeError('analyze: rows must be annotated with annotateRows() from eval/arm.mjs first');
209
+ }
210
+ if (row.gitBranchChanged === true) return { pool: null, reject: 'branch-changed' };
211
+ if (row.exp_task) {
212
+ if (!row.exp_arm) return { pool: null, reject: row.exp_arm_source || 'unresolved-arm' };
213
+ return { pool: 'paired', task: row.exp_task, arm: row.exp_arm };
214
+ }
215
+ // The branch says "experiment" and the annotation says "ordinary work". One of
216
+ // the two is wrong and there is no way to tell which, so the row goes to
217
+ // neither pool. Unreachable through annotateRows today — it is here because
218
+ // the alternative reading is the silent one: without this the row would be
219
+ // attributed from the LEDGER as if it were daily work, which is how an
220
+ // experimental prompt ends up in the background pool with a straight face.
221
+ if (parseArmBranch(row.gitBranch) !== null) return { pool: null, reject: 'branch-annotation-mismatch' };
222
+ if (isOffConventionBranch(row.gitBranch)) return { pool: null, reject: 'off-convention-branch' };
223
+ // No ledger on this machine is an ABSENT control, not a negative one: the row
224
+ // is ordinary work whose arm nobody recorded, which is not the same as work
225
+ // done with the doctrine off. Refused either way, but under its own name.
226
+ if (!ledgerAvailable) return { pool: null, reject: 'no-ledger' };
227
+ const arm = ledgerArmAt(ledger, rowTimestamp(row));
228
+ if (arm === null) return { pool: null, reject: 'no-arm-in-force' };
229
+ // `mixed` is half the doctrine and half the RAG — a state the comparison has
230
+ // no name for, so it is refused rather than rounded up to "on".
231
+ if (!ARMS.includes(arm)) return { pool: null, reject: `unusable-arm:${arm}` };
232
+ return { pool: 'natural', arm };
233
+ }
234
+
235
+ /**
236
+ * Sessions whose rows do not agree on one arm, and are therefore thrown away
237
+ * whole (criterion 2).
238
+ *
239
+ * The key is pool AND arm, so a session that started on an `exp/` branch and
240
+ * finished on `main` is caught as well as one that flipped `off` to `on`: both
241
+ * mean the session's rows were produced under two different setups, and the
242
+ * setup is what the measurement is about.
243
+ *
244
+ * **A row that resolved to nothing counts as its own key**, not as an absence.
245
+ * Without that, a session half of whose rows resolved and half did not stayed in
246
+ * the sample with its resolved half — and that is the commonest form of the
247
+ * switch, not an exotic one: a ledger window closed by `restore` mid-session
248
+ * leaves the earlier prompts with an arm and the later ones with none. Half a
249
+ * session is not a smaller sample, it is a sample whose setup we cannot state.
250
+ *
251
+ * A session with a null id is skipped rather than lumped in with every other
252
+ * unnamed session; it is counted on its own so the omission is on the record.
253
+ */
254
+ function mixedArmSessions(classified) {
255
+ const keysBySession = new Map();
256
+ for (const { row, verdict } of classified) {
257
+ const session = row.session;
258
+ if (typeof session !== 'string' || session === '') continue;
259
+ if (!keysBySession.has(session)) keysBySession.set(session, new Set());
260
+ keysBySession.get(session).add(verdict.pool === null ? 'unresolved' : `${verdict.pool}:${verdict.arm}`);
261
+ }
262
+ return new Set([...keysBySession.entries()].filter(([, keys]) => keys.size > 1).map(([session]) => session));
263
+ }
264
+
265
+ // ─── delegations ────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * The delegation records of a set of rows, **deduplicated by `agentId`**
269
+ * (criterion 8a).
270
+ *
271
+ * Three passes guard one number, at three different scopes, because they see
272
+ * different copies: the collector drops entries repeating a uuid within a file,
273
+ * then deduplicates by `agentId` within a prompt, and this pass closes the case
274
+ * neither can see — the same delegation landing on two ROWS, which is what
275
+ * happens when the copies carry different promptIds (measured) or come from two
276
+ * transcript files (the uuid index is per file, deliberately). Without the last
277
+ * one the only figure we have for subagent cost can still come out doubled, and
278
+ * it is a figure the verdict gate in §11a reads directly.
279
+ */
280
+ function dedupeDelegations(rows) {
281
+ const byId = new Map();
282
+ let duplicates = 0;
283
+ for (const row of rows) {
284
+ for (const d of row.delegations ?? []) {
285
+ if (byId.has(d.agent_id)) {
286
+ duplicates++;
287
+ continue;
288
+ }
289
+ byId.set(d.agent_id, d);
290
+ }
291
+ }
292
+ return { list: [...byId.values()], duplicates };
293
+ }
294
+
295
+ /**
296
+ * What the subagents of one arm cost, **from below**.
297
+ *
298
+ * Three properties this shape enforces, all from COST_EVALUATION_PROCEDURE
299
+ * §11a:
300
+ *
301
+ * • It is its own field. It is never added to the arm's cost, because a lower
302
+ * bound folded into a total silently becomes the total.
303
+ * • `bound: 'lower'` travels WITH the number. The evidence is that a
304
+ * subagent's `cache_read` grows linearly with its tool-call count, which is
305
+ * one turn re-reading its context, not a sum over turns — so the true cost
306
+ * is this figure times an unknown factor ≥ 1.
307
+ * • `spawns` and `measured` are both reported. Their difference is the part
308
+ * of the delegation cost for which there is no figure at all; a report that
309
+ * showed only `measured` would look complete.
310
+ */
311
+ function delegationSummary(rows) {
312
+ const { list, duplicates } = dedupeDelegations(rows);
313
+ const spawns = sumField(rows, 'agent_spawns');
314
+ return {
315
+ bound: 'lower',
316
+ spawns,
317
+ measured: list.length,
318
+ unmeasured_spawns: Math.max(0, spawns - list.length),
319
+ duplicates_dropped: duplicates + sumField(rows, 'delegation_duplicates_dropped'),
320
+ unidentified_dropped: sumField(rows, 'delegations_unidentified'),
321
+ cost: summarizeCost(list),
322
+ tokens: tokenTotals(list),
323
+ by_agent_type: distribution(list, 'agent_type'),
324
+ };
325
+ }
326
+
327
+ // ─── the aggregate ──────────────────────────────────────────────────────────
328
+
329
+ /**
330
+ * Everything the comparison needs about one arm — and nothing that decides it.
331
+ *
332
+ * `cost` comes from summarizeCost() rather than a sum written here, which is
333
+ * what keeps criterion 5 true by construction: an unpriced row is outside the
334
+ * total and inside `unpriced.rows`, with the model that caused it named. A pool
335
+ * whose cost is unknown for some of its rows says so through `cost_complete`
336
+ * instead of averaging over the rest.
337
+ */
338
+ function armAggregate(arm, rows, { n_pairs = 0 } = {}) {
339
+ const cost = summarizeCost(rows);
340
+ return {
341
+ arm,
342
+ n_prompts: rows.length,
343
+ n_sessions: distinct(rows, 'session').size,
344
+ // Passed in rather than patched in afterwards: only the pool level knows
345
+ // what a pair is, and a field with two writers is a field whose value
346
+ // depends on the order they ran in. Zero in the natural pool, where the
347
+ // concept does not apply, but present so the two arm objects stay readable
348
+ // by one consumer.
349
+ n_pairs,
350
+ cost,
351
+ // Per-prompt spread alongside the arm's total: two arms can share a total
352
+ // and differ entirely in how it was spent (criterion 7).
353
+ cost_per_prompt: stats(rows.map((r) => r.list_cost_usd)),
354
+ cost_complete: cost.total_rows > 0 && cost.unpriced.rows === 0,
355
+ // work_s, not a duration measured from submission: the wait between sending
356
+ // a prompt and the first assistant token is queue time, which belongs to the
357
+ // user and to the machine's load, not to the arm.
358
+ work_s: stats(rows.map((r) => r.work_s), { round: round3 }),
359
+ // The one queue-time figure kept, because §8 lists it as a thing to check
360
+ // before believing a difference: a large gap inside a turn is a tool call
361
+ // that sat waiting for the operator to approve it.
362
+ max_gap_s: stats(rows.map((r) => r.max_gap_s), { round: round3 }),
363
+ tokens: tokenTotals(rows),
364
+ tools: {
365
+ calls_total: sumField(rows, 'tool_calls_total'),
366
+ agent_spawns: sumField(rows, 'agent_spawns'),
367
+ by_name: rows.reduce((acc, row) => {
368
+ for (const [name, n] of Object.entries(row.tools ?? {})) acc[name] = (acc[name] ?? 0) + n;
369
+ return acc;
370
+ }, Object.create(null)),
371
+ },
372
+ delegations: delegationSummary(rows),
373
+ // Held-constant variables, reported rather than assumed (§2). A pair whose
374
+ // arms ran on different models or efforts is not a pair; TASK-090 reads
375
+ // these two distributions to say so.
376
+ models: distribution(rows, 'model'),
377
+ efforts: distribution(rows, 'effort'),
378
+ // The honesty counters that belong to an arm rather than to the input as a
379
+ // whole. `unpriced` sits in `cost` above, where the reason and the model
380
+ // that caused it live too.
381
+ counters: {
382
+ synthetic_turns: sumField(rows, 'synthetic_turns'),
383
+ understated_turns: sumField(rows, 'understated_turns'),
384
+ multi_attempt_turns: sumField(rows, 'multi_attempt_turns'),
385
+ sidechain_turns: sumField(rows, 'sidechain_turns'),
386
+ api_turns: sumField(rows, 'api_turns'),
387
+ },
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Both arms, always — an arm with no rows is emitted as zeros rather than
393
+ * omitted, so `agg.paired.arms.on` is never undefined and a missing arm reads
394
+ * as `n_prompts: 0` instead of crashing three layers up (criterion 6).
395
+ */
396
+ function armsOf(rows, pairsPerArm = {}) {
397
+ const out = {};
398
+ for (const arm of ARMS) {
399
+ out[arm] = armAggregate(arm, rows.filter((r) => r.arm === arm), { n_pairs: pairsPerArm[arm] ?? 0 });
400
+ }
401
+ return out;
402
+ }
403
+
404
+ /**
405
+ * One task measured twice. This is the unit §11b does arithmetic on: the arm
406
+ * effect and the order effect are both means over pairs, and neither can be
407
+ * computed from pool totals alone — which is why the per-pair, per-arm cost is
408
+ * carried here rather than left to be re-derived.
409
+ *
410
+ * `complete` is false for a task with only one arm. Such a task is NOT dropped:
411
+ * a half-finished pair is a fact about the run (§7 says to repeat it), and a
412
+ * pair that vanishes from the aggregate cannot be repeated because nobody sees
413
+ * it is missing.
414
+ */
415
+ function pairsOf(rows) {
416
+ const tasks = [...new Set(rows.map((r) => r.task))].sort();
417
+ return tasks.map((task) => {
418
+ const arms = {};
419
+ for (const arm of ARMS) {
420
+ const armRows = rows.filter((r) => r.task === task && r.arm === arm);
421
+ if (armRows.length === 0) continue;
422
+ const cost = summarizeCost(armRows);
423
+ const passOrders = [...new Set(armRows.map((r) => r.pass_order).filter((p) => p !== null && p !== undefined))];
424
+ arms[arm] = {
425
+ // One value or none. Two different pass_orders for one (task, arm) means
426
+ // the rows came from two different runs of the same arm, and picking one
427
+ // would be inventing which run this is.
428
+ pass_order: passOrders.length === 1 ? passOrders[0] : null,
429
+ n_prompts: armRows.length,
430
+ n_sessions: distinct(armRows, 'session').size,
431
+ list_cost_usd: cost.list_cost_usd,
432
+ cost_complete: cost.total_rows > 0 && cost.unpriced.rows === 0,
433
+ work_s_total: stats(armRows.map((r) => r.work_s)).total,
434
+ agent_spawns: sumField(armRows, 'agent_spawns'),
435
+ delegations_measured: dedupeDelegations(armRows).list.length,
436
+ };
437
+ }
438
+ return {
439
+ task,
440
+ complete: ARMS.every((arm) => arms[arm] !== undefined),
441
+ arms,
442
+ };
443
+ });
444
+ }
445
+
446
+ /**
447
+ * The paired measurement: four scripted sessions per §2a, everything held
448
+ * constant except the arm.
449
+ *
450
+ * The only pool with `pairs`, and that asymmetry is deliberate — it is what
451
+ * makes the two pools un-addable by shape rather than by comment (criterion 3).
452
+ */
453
+ function pairedPool(rows) {
454
+ const pairs = pairsOf(rows);
455
+ const complete = pairs.filter((p) => p.complete);
456
+ const pairsPerArm = Object.fromEntries(
457
+ ARMS.map((arm) => [arm, complete.filter((p) => p.arms[arm] !== undefined).length]),
458
+ );
459
+ return {
460
+ pool: 'paired',
461
+ source: 'branch exp/<task>/<arm> (TASK-088), reconciled against the TASK-087 ledger',
462
+ arms: armsOf(rows, pairsPerArm),
463
+ pairs,
464
+ n_pairs_complete: complete.length,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Ordinary work, attributed by the ledger alone.
470
+ *
471
+ * Nothing is held constant here: different tasks, different days, different
472
+ * prompt text. It is background — useful for asking whether the paired result
473
+ * points the same way, useless as evidence on its own, and dangerous the moment
474
+ * its prompts are counted alongside the scripted ones.
475
+ */
476
+ function naturalPool(rows) {
477
+ return {
478
+ pool: 'natural',
479
+ source: 'TASK-087 ledger, by timestamp',
480
+ arms: armsOf(rows),
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Rows in, aggregate out. Pure: no disk, no clock, no ambient state.
486
+ *
487
+ * @param rows prompt-metrics rows that have been through annotateRows().
488
+ * @param ledger / ledgerAvailable the TASK-087 ledger, for natural work only.
489
+ * Paired rows carry their arm already; ordinary work has no marker but
490
+ * this one.
491
+ * @param unattributed the collector's count of transcript entries that landed
492
+ * in no row at all. It is not derivable from rows — that is the point of
493
+ * it — so it is passed through, and stays **null** when the caller did
494
+ * not supply it. Null means "not reported", never zero.
495
+ */
496
+ export function analyze(rows, { ledger = [], ledgerAvailable = false, unattributed = null } = {}) {
497
+ const classified = rows.map((row) => ({ row, verdict: classifyRow(row, { ledger, ledgerAvailable }) }));
498
+ const mixed = mixedArmSessions(classified);
499
+
500
+ const pools = { paired: [], natural: [] };
501
+ const rejected = Object.create(null);
502
+ const offConventionNames = new Set();
503
+ const noArmSessions = new Set();
504
+ let rowsWithoutSession = 0;
505
+
506
+ const reject = (reason) => {
507
+ rejected[reason] = (rejected[reason] ?? 0) + 1;
508
+ };
509
+
510
+ for (const { row, verdict } of classified) {
511
+ if (typeof row.session !== 'string' || row.session === '') rowsWithoutSession++;
512
+ if (verdict.reject === 'off-convention-branch') offConventionNames.add(row.gitBranch);
513
+ if (verdict.reject === 'no-arm-in-force' || verdict.reject === 'no-ledger') {
514
+ if (typeof row.session === 'string' && row.session !== '') noArmSessions.add(row.session);
515
+ }
516
+ if (verdict.pool === null) {
517
+ reject(verdict.reject);
518
+ continue;
519
+ }
520
+ // The session-level refusal runs LAST and overrides a row that looked fine
521
+ // on its own: the arm is the session's property, so a well-formed row from a
522
+ // session that switched arms mid-flight is well-formed about the wrong
523
+ // thing (criterion 2).
524
+ if (mixed.has(row.session)) {
525
+ reject('mixed-arm-session');
526
+ continue;
527
+ }
528
+ // A copy of the row carrying its resolved `arm` and `task`, so the
529
+ // aggregators below read one flat object and never re-derive the
530
+ // classification. Neither key exists on a prompt-metrics row (it spells them
531
+ // `exp_arm`/`exp_task`), so nothing is being overwritten here.
532
+ pools[verdict.pool].push({ ...row, arm: verdict.arm, task: verdict.task ?? '' });
533
+ }
534
+
535
+ const used = pools.paired.length + pools.natural.length;
536
+ return {
537
+ schema: 'tune-context/analyze@1',
538
+ // No cost field at this level, on purpose. There is nothing here to add the
539
+ // pools into, and no array of pools to fold over — see the file header.
540
+ paired: pairedPool(pools.paired),
541
+ natural: naturalPool(pools.natural),
542
+ counters: {
543
+ rows_in: rows.length,
544
+ rows_used: used,
545
+ // rows_in === rows_used + rows_rejected, asserted in the tests. Every row
546
+ // is in exactly one of the two.
547
+ rows_rejected: rows.length - used,
548
+ rejected_by_reason: rejected,
549
+ mixed_arm_sessions: mixed.size,
550
+ mixed_arm_session_ids: [...mixed].sort(),
551
+ sessions_without_arm: noArmSessions.size,
552
+ off_convention_branches: rejected['off-convention-branch'] ?? 0,
553
+ off_convention_branch_names: [...offConventionNames].sort(),
554
+ rows_without_session: rowsWithoutSession,
555
+ unattributed,
556
+ },
557
+ };
558
+ }
559
+
560
+ // ─── reporting ──────────────────────────────────────────────────────────────
561
+
562
+ function armLine(a) {
563
+ // An arm with no rows says so instead of printing $0.0000: a zero next to an
564
+ // arm that was never measured is the one number this whole file exists to
565
+ // keep off the page.
566
+ if (a.n_prompts === 0) return ` ${a.arm.padEnd(3)} no rows`;
567
+ const cost = a.cost_complete ? formatUsd(a.cost.list_cost_usd) : `${formatUsd(a.cost.list_cost_usd)} INCOMPLETE`;
568
+ return (
569
+ ` ${a.arm.padEnd(3)} ${a.n_prompts} prompt(s), ${a.n_sessions} session(s), ${a.n_pairs} pair(s)` +
570
+ ` | ${cost} (median/prompt ${formatUsd(a.cost_per_prompt.median)}, IQR ${formatUsd(a.cost_per_prompt.iqr)})` +
571
+ ` | work ${a.work_s.median ?? '—'}s median` +
572
+ ` | ${a.tools.agent_spawns} spawn(s), delegation floor ${formatUsd(a.delegations.cost.list_cost_usd)}`
573
+ );
574
+ }
575
+
576
+ /**
577
+ * A summary for stderr. Prints what was thrown away next to what was kept, and
578
+ * says outright that it is not a verdict — a per-arm table with no such line
579
+ * reads as one whatever the surrounding prose claims.
580
+ */
581
+ export function formatAnalysis(agg) {
582
+ const L = [`analyze: ${agg.counters.rows_in} row(s) in, ${agg.counters.rows_used} used, ${agg.counters.rows_rejected} rejected`];
583
+ L.push(' paired measurement (TASK-088 — the only pool a verdict may be built on):');
584
+ for (const arm of ARMS) L.push(armLine(agg.paired.arms[arm]));
585
+ for (const pair of agg.paired.pairs) {
586
+ const orders = ARMS.filter((a) => pair.arms[a]).map((a) => `${a}=pass${pair.arms[a].pass_order ?? '?'}`);
587
+ L.push(` ${pair.task}: ${orders.join(' ')}${pair.complete ? '' : ' — INCOMPLETE PAIR'}`);
588
+ }
589
+ L.push(' natural work (background — never added to the pool above):');
590
+ for (const arm of ARMS) L.push(armLine(agg.natural.arms[arm]));
591
+
592
+ const reasons = Object.entries(agg.counters.rejected_by_reason).sort((a, b) => b[1] - a[1]);
593
+ if (reasons.length > 0) {
594
+ L.push(` rejected: ${reasons.map(([r, n]) => `${r} ${n}`).join(', ')}`);
595
+ }
596
+ if (agg.counters.mixed_arm_sessions > 0) {
597
+ L.push(
598
+ ` ${agg.counters.mixed_arm_sessions} session(s) changed arm mid-session and were dropped WHOLE` +
599
+ ' — the arm is a property of the session (EPIC-009 class 6), so that run is repeated, not absorbed',
600
+ );
601
+ }
602
+ if (agg.counters.off_convention_branch_names.length > 0) {
603
+ L.push(` branches outside exp/<task>/<arm>: ${agg.counters.off_convention_branch_names.join(', ')} — counted, never assigned`);
604
+ }
605
+ if (agg.counters.unattributed === null) {
606
+ L.push(' unattributed transcript entries: NOT REPORTED (the caller did not pass them) — this is not a zero');
607
+ } else {
608
+ L.push(` unattributed transcript entries: ${agg.counters.unattributed.entries ?? agg.counters.unattributed}`);
609
+ }
610
+ L.push(' this is an aggregate, NOT a verdict — thresholds and gates are TASK-090.');
611
+ return L.join('\n');
612
+ }
613
+
614
+ // ─── CLI ────────────────────────────────────────────────────────────────────
615
+
616
+ async function readStdin() {
617
+ const chunks = [];
618
+ for await (const chunk of process.stdin) chunks.push(chunk);
619
+ return Buffer.concat(chunks).toString('utf8');
620
+ }
621
+
622
+ /** JSONL → rows, malformed lines counted rather than fatal — the same rule the
623
+ * collector applies to transcripts, for the same reason: the file may be being
624
+ * appended to while we read it. */
625
+ export function parseRows(text) {
626
+ const rows = [];
627
+ let malformed = 0;
628
+ for (const line of text.split('\n')) {
629
+ if (line.trim() === '') continue;
630
+ try {
631
+ const row = JSON.parse(line);
632
+ if (row && typeof row === 'object' && !Array.isArray(row)) rows.push(row);
633
+ else malformed++;
634
+ } catch {
635
+ malformed++;
636
+ }
637
+ }
638
+ return { rows, malformed };
639
+ }
640
+
641
+ async function main() {
642
+ let text;
643
+ try {
644
+ const { values } = parseArgs({
645
+ args: process.argv.slice(2),
646
+ options: {
647
+ rows: { type: 'string', multiple: true },
648
+ help: { type: 'boolean', short: 'h', default: false },
649
+ },
650
+ strict: true,
651
+ });
652
+ if (values.help) {
653
+ process.stdout.write(`${USAGE}\n`);
654
+ return;
655
+ }
656
+ const paths = values.rows ?? [];
657
+ const missing = paths.filter((p) => !existsSync(p));
658
+ if (missing.length > 0) {
659
+ for (const p of missing) process.stderr.write(`analyze: no rows file at ${p}\n`);
660
+ process.exitCode = 1;
661
+ return;
662
+ }
663
+ text = paths.length > 0 ? paths.map((p) => readFileSync(p, 'utf8')).join('\n') : await readStdin();
664
+ } catch (e) {
665
+ process.stderr.write(`analyze: ${e.message}\n`);
666
+ process.exitCode = 1;
667
+ return;
668
+ }
669
+
670
+ const { rows, malformed } = parseRows(text);
671
+ if (malformed > 0) process.stderr.write(`analyze: ${malformed} malformed line(s) skipped\n`);
672
+
673
+ // The ledger is read here rather than inside analyze() for the same reason
674
+ // prompt-metrics reads it in its CLI: it is machine state, and a pure function
675
+ // that reaches for the disk cannot be tested against someone else's run.
676
+ const ledgerPath = experimentPaths(claudeBaseDir()).ledgerPath;
677
+ const ledgerAvailable = existsSync(ledgerPath);
678
+ const agg = analyze(rows, {
679
+ ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
680
+ ledgerAvailable,
681
+ });
682
+
683
+ process.stdout.write(`${JSON.stringify(agg, null, 2)}\n`);
684
+ process.stderr.write(`${formatAnalysis(agg)}\n`);
685
+ }
686
+
687
+ if (process.argv[1]?.endsWith('analyze.mjs')) {
688
+ await main();
689
+ }