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