@bglocation/tune-context 2.3.1 → 2.4.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 +78 -0
- package/README.md +111 -6
- package/bin/tune-context.mjs +241 -15
- package/cli/experiment.mjs +674 -0
- package/cli/managed-block.mjs +24 -0
- package/cli/verify.mjs +51 -4
- package/eval/adoption-report.mjs +8 -6
- package/eval/arm.mjs +242 -0
- package/eval/pricing.mjs +430 -0
- package/eval/prompt-metrics.mjs +718 -0
- package/package.json +1 -1
package/cli/managed-block.mjs
CHANGED
|
@@ -24,3 +24,27 @@ export function upsertManagedBlock(existing, innerBlock) {
|
|
|
24
24
|
const next = `${existing}${sep}\n${block}\n`;
|
|
25
25
|
return { content: next, changed: true };
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The inverse of upsertManagedBlock: drop the block AND its markers, keep every
|
|
30
|
+
* byte outside them. Used only by the experiment switch (TASK-087) to reach the
|
|
31
|
+
* `tc off` arm — init never removes the block, it replaces it.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately conservative about what it deletes: only the marker span, plus
|
|
34
|
+
* the blank lines the removal would otherwise leave stacked at the seam. What
|
|
35
|
+
* the file's owner wrote below the closing marker is what an off arm has to
|
|
36
|
+
* keep — losing it would be the TASK-082 defect with a different trigger.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|null} existing - current file content, or null if there is no file
|
|
39
|
+
* @returns {{content: string|null, changed: boolean}} content unchanged when there is no block
|
|
40
|
+
*/
|
|
41
|
+
export function removeManagedBlock(existing) {
|
|
42
|
+
if (existing == null) return { content: null, changed: false };
|
|
43
|
+
const startIdx = existing.indexOf(START_MARKER);
|
|
44
|
+
const endIdx = existing.indexOf(END_MARKER);
|
|
45
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return { content: existing, changed: false };
|
|
46
|
+
const head = existing.slice(0, startIdx);
|
|
47
|
+
const tail = existing.slice(endIdx + END_MARKER.length).replace(/^\n+/, '');
|
|
48
|
+
const next = head.trim() === '' ? tail : `${head.trimEnd()}\n\n${tail}`;
|
|
49
|
+
return { content: next, changed: next !== existing };
|
|
50
|
+
}
|
package/cli/verify.mjs
CHANGED
|
@@ -158,10 +158,19 @@ function freshnessChecks(claudeDir, pkgRoot) {
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* @param {string} [pkgRoot] - installed package root, for the staleness check
|
|
161
|
+
* @param {{tc: 'on'|'off', rag: 'on'|'off'}} [arm] - the experiment arm currently set
|
|
162
|
+
* through `tune-context experiment` (TASK-087), read from the ledger state — NOT
|
|
163
|
+
* detected here. Under `tc: 'off'` the doctrine is *supposed* to be absent, so the
|
|
164
|
+
* expectations below are inverted rather than dropped: a missing registration is a
|
|
165
|
+
* tick, and one that is still there is a RED, because an off arm that is not off
|
|
166
|
+
* invalidates the measurement just as surely as a broken install invalidates a
|
|
167
|
+
* session. Reporting "broken install" for a correctly disabled doctrine would be
|
|
168
|
+
* the EPIC-006 false-red regression, only pointing the other way.
|
|
161
169
|
* @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string, unknown?: boolean}[]}}
|
|
162
170
|
*/
|
|
163
|
-
export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions, pkgRoot }) {
|
|
171
|
+
export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions, pkgRoot, arm }) {
|
|
164
172
|
const checks = [];
|
|
173
|
+
const tcOff = arm?.tc === 'off';
|
|
165
174
|
|
|
166
175
|
// Cross-check, not two independent presence checks. The old code asked
|
|
167
176
|
// "does some command contain the script name?" and, separately, "does the
|
|
@@ -175,6 +184,19 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
|
|
|
175
184
|
const allCommands = entries.flatMap((g) => (g.hooks || []).map((h) => h.command)).filter(Boolean);
|
|
176
185
|
const installedPath = resolve(join(claudeDir, 'hooks', scriptName));
|
|
177
186
|
|
|
187
|
+
if (tcOff) {
|
|
188
|
+
// Same looseness as the switch's own removal rule: any command naming the
|
|
189
|
+
// script counts, whatever dir it points at. A stricter test here would let
|
|
190
|
+
// a stray registration keep firing the hook while verify called the arm clean.
|
|
191
|
+
const still = allCommands.filter((c) => c.includes(scriptName));
|
|
192
|
+
checks.push({
|
|
193
|
+
name: `hooks.${event}: ${scriptName} unregistered (experiment arm tc=off)`,
|
|
194
|
+
ok: still.length === 0,
|
|
195
|
+
detail: still.length ? `still registered — the off arm is NOT in effect: ${still.join(', ')}` : undefined,
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
178
200
|
// Classify by DESTINATION, not by spelling: a command that resolves to
|
|
179
201
|
// the very file we installed is the CLI registration, whatever its path
|
|
180
202
|
// happens to look like. Without this, PLUGIN_PATH_HINT's loose
|
|
@@ -254,7 +276,24 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
|
|
|
254
276
|
const scriptPath = join(claudeDir, 'hooks', scriptName);
|
|
255
277
|
const exists = existsSync(scriptPath);
|
|
256
278
|
const executable = exists && Boolean(statSync(scriptPath).mode & 0o111);
|
|
257
|
-
|
|
279
|
+
// Under an off arm a missing script is not a broken install: the switch
|
|
280
|
+
// parks REGISTRATIONS, never the scripts, so their absence just means the
|
|
281
|
+
// doctrine was never installed on this machine — which is what the arm asks
|
|
282
|
+
// for anyway. Reported as `?`, so it neither passes nor turns the arm red
|
|
283
|
+
// (epic criterion 4). Present-but-not-executable stays red in both arms: the
|
|
284
|
+
// arm did not cause it, and it breaks the moment the doctrine goes back on.
|
|
285
|
+
checks.push({
|
|
286
|
+
name: `${scriptName} installed + executable`,
|
|
287
|
+
ok: exists && executable,
|
|
288
|
+
unknown: tcOff && !exists,
|
|
289
|
+
detail: !exists
|
|
290
|
+
? tcOff
|
|
291
|
+
? 'not installed — this arm parks hook registrations, not the scripts themselves'
|
|
292
|
+
: 'missing'
|
|
293
|
+
: !executable
|
|
294
|
+
? 'not executable'
|
|
295
|
+
: undefined,
|
|
296
|
+
});
|
|
258
297
|
}
|
|
259
298
|
|
|
260
299
|
if (projectSettingsPath && expectedPermissions?.length) {
|
|
@@ -268,8 +307,16 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
|
|
|
268
307
|
|
|
269
308
|
const skillsDir = join(claudeDir, 'skills');
|
|
270
309
|
for (const skill of ['token-efficiency', 'caveman', 'cdd', 'phase-workflow']) {
|
|
271
|
-
const
|
|
272
|
-
|
|
310
|
+
const present = existsSync(join(skillsDir, skill, 'SKILL.md'));
|
|
311
|
+
if (tcOff) {
|
|
312
|
+
checks.push({
|
|
313
|
+
name: `skill ${skill} parked (experiment arm tc=off)`,
|
|
314
|
+
ok: !present,
|
|
315
|
+
detail: present ? `still installed at ${join(skillsDir, skill)} — the off arm is NOT in effect` : undefined,
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
checks.push({ name: `skill ${skill} installed`, ok: present, detail: present ? undefined : `missing ${join(skillsDir, skill, 'SKILL.md')}` });
|
|
273
320
|
}
|
|
274
321
|
|
|
275
322
|
checks.push(...freshnessChecks(claudeDir, pkgRoot));
|
package/eval/adoption-report.mjs
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// levers show adoption, which don't. See tasks/TASK-062.
|
|
7
7
|
//
|
|
8
8
|
// Honest scope (printed in the report too):
|
|
9
|
-
// • Cost is NOT here.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// • Cost is NOT here. This report reads the hook log, which carries no
|
|
10
|
+
// tokens — it measures *behavior* (a proxy), not dollars. For dollars see
|
|
11
|
+
// eval/prompt-metrics.mjs + eval/pricing.mjs (TASK-085/086), which price the
|
|
12
|
+
// transcript. OTEL stays the independent route to Anthropic's own figure.
|
|
12
13
|
// • caveman can't be measured: it's an output style, not a tool call.
|
|
13
14
|
// • RAG availability (the `~` line in the verdict) rests on a project's
|
|
14
15
|
// `.mcp.json` declaring a semantic-search server that predates the sessions
|
|
@@ -75,7 +76,7 @@ Options:
|
|
|
75
76
|
-h, --help Show this help
|
|
76
77
|
|
|
77
78
|
The log is written by the adoption-log.mjs PostToolUse hook (installed by
|
|
78
|
-
tune-context). Cost in dollars is NOT here —
|
|
79
|
+
tune-context). Cost in dollars is NOT here — see eval/prompt-metrics.mjs.
|
|
79
80
|
`.trim();
|
|
80
81
|
|
|
81
82
|
export function parseLog(text) {
|
|
@@ -494,8 +495,9 @@ export function formatReport(a) {
|
|
|
494
495
|
}
|
|
495
496
|
}
|
|
496
497
|
L.push('');
|
|
497
|
-
L.push('Cost (dollars) is not
|
|
498
|
-
L.push('
|
|
498
|
+
L.push('Cost (dollars) is not in the hook log. Two independent routes to it:');
|
|
499
|
+
L.push(' eval/prompt-metrics.mjs — prices the transcript, works with hooks OFF too.');
|
|
500
|
+
L.push(' OTEL: CLAUDE_CODE_ENABLE_TELEMETRY=1 + an OTLP collector →');
|
|
499
501
|
L.push(' metric `claude_code.cost.usage` (direct) / `claude_code.token.usage`.');
|
|
500
502
|
L.push(' caveman (output style) leaves no tool call — not measured here.');
|
|
501
503
|
|
package/eval/arm.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// arm — assigns a metrics row to an experiment arm (TASK-088).
|
|
2
|
+
//
|
|
3
|
+
// Two independent descriptions of the same fact, and the rule for when they
|
|
4
|
+
// disagree:
|
|
5
|
+
//
|
|
6
|
+
// • The BRANCH (`exp/<task>/<arm>`) carries the arm the operator INTENDED. It
|
|
7
|
+
// is on every transcript entry, works retroactively, and does not pollute
|
|
8
|
+
// the prompt text — which is why TASK-088 chose it as the marker.
|
|
9
|
+
// • The LEDGER (TASK-087) carries the arm actually SET before the session.
|
|
10
|
+
//
|
|
11
|
+
// These are not two competing truths; they are a measurement and its control
|
|
12
|
+
// (COST_EVALUATION_PROCEDURE §8). A disagreement means the arm is UNKNOWN: the
|
|
13
|
+
// row gets `exp_arm: ''` plus the reason in `exp_arm_source`, and the run goes
|
|
14
|
+
// to §7. Nothing here guesses and nothing defaults to "on" — a silently
|
|
15
|
+
// assigned arm is precisely the false saving TASK-088 exists to prevent.
|
|
16
|
+
//
|
|
17
|
+
// The module is pure: it never touches the disk. The caller (the CLI) loads the
|
|
18
|
+
// ledger, so the same rules can be tested against in-memory entries and applied
|
|
19
|
+
// to rows collected on another machine.
|
|
20
|
+
|
|
21
|
+
/** The arms the procedure knows. Anything else is not an arm. */
|
|
22
|
+
const ARMS = new Set(['on', 'off']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `exp/<task>/<arm>` → `{task, arm}`; **null** for everything else.
|
|
26
|
+
*
|
|
27
|
+
* The strictness is a function, not fussiness: TASK-088 criterion 1 says a
|
|
28
|
+
* branch outside the convention must not fall SILENTLY into an arm. So names
|
|
29
|
+
* close to valid ones are rejected too — `exp/task-089/off-by-one`,
|
|
30
|
+
* `feat/exp/task-089/on`, `exp/task-089/OFF` — rather than being stretched onto
|
|
31
|
+
* the nearest matching arm.
|
|
32
|
+
*
|
|
33
|
+
* The `<task>` segment stays free (non-empty, no whitespace): §9 of the
|
|
34
|
+
* procedure decides which tasks count; the parser only has to avoid lying about
|
|
35
|
+
* the shape of the name.
|
|
36
|
+
*/
|
|
37
|
+
export function parseArmBranch(branch) {
|
|
38
|
+
if (typeof branch !== 'string') return null;
|
|
39
|
+
const parts = branch.trim().split('/');
|
|
40
|
+
if (parts.length !== 3) return null;
|
|
41
|
+
const [prefix, task, arm] = parts;
|
|
42
|
+
if (prefix !== 'exp') return null;
|
|
43
|
+
if (task === '' || /\s/.test(task)) return null;
|
|
44
|
+
if (!ARMS.has(arm)) return null;
|
|
45
|
+
return { task, arm };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `{tc, rag}` → one label. Half and half is `mixed`, never rounded up to "on". */
|
|
49
|
+
function armLabel(tc, rag) {
|
|
50
|
+
if (tc === 'on' && rag === 'on') return 'on';
|
|
51
|
+
if (tc === 'off' && rag === 'off') return 'off';
|
|
52
|
+
return 'mixed';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function msOf(value) {
|
|
56
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
57
|
+
const ms = Date.parse(value);
|
|
58
|
+
return Number.isNaN(ms) ? null : ms;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The arm in force at `tsIso` according to the ledger: `'on'`, `'off'`,
|
|
63
|
+
* `'mixed'`, or **null**.
|
|
64
|
+
*
|
|
65
|
+
* Null means "no arm was in force" and covers two cases that are the same thing
|
|
66
|
+
* to the caller: there was no `set` entry before the session, or the covering
|
|
67
|
+
* entry is a `restore` — the measurement window was closed. That is exactly why
|
|
68
|
+
* TASK-087 appends `restore` to the ledger: without it the last arm would apply
|
|
69
|
+
* forever and every later piece of ordinary work would fall into the sample.
|
|
70
|
+
*
|
|
71
|
+
* Entries without a readable `ts` are skipped — they cannot be placed in time,
|
|
72
|
+
* and guessing their order would be inventing data.
|
|
73
|
+
*/
|
|
74
|
+
export function ledgerArmAt(entries, tsIso) {
|
|
75
|
+
const at = msOf(tsIso);
|
|
76
|
+
if (at === null || !Array.isArray(entries)) return null;
|
|
77
|
+
let covering = null;
|
|
78
|
+
let coveringMs = -Infinity;
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
81
|
+
const ms = msOf(entry.ts);
|
|
82
|
+
if (ms === null || ms > at) continue;
|
|
83
|
+
// `>=`, not `>`: on equal timestamps the later line in the file wins,
|
|
84
|
+
// because the ledger is append-only and write order is event order.
|
|
85
|
+
if (ms >= coveringMs) {
|
|
86
|
+
covering = entry;
|
|
87
|
+
coveringMs = ms;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (covering === null) return null;
|
|
91
|
+
if (covering.event === 'restore') return null;
|
|
92
|
+
return armLabel(covering.tc, covering.rag);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The row's timestamp: when the prompt was submitted, or the first **readable**
|
|
97
|
+
* one of the remaining fields.
|
|
98
|
+
*
|
|
99
|
+
* One definition for both readers (the ledger window and `pass_order`), because
|
|
100
|
+
* two of them already drifted apart once: `??` only falls through on null, so it
|
|
101
|
+
* stopped at a `ts_submit` that was a string but unparseable — and a row the
|
|
102
|
+
* ledger DID cover came out as `conflict:no-ledger-entry`, i.e. a run to be
|
|
103
|
+
* repeated (§7) because of a cosmetic defect in the data.
|
|
104
|
+
*/
|
|
105
|
+
function rowTs(row) {
|
|
106
|
+
for (const value of [row.ts_submit, row.ts_first_assistant, row.ts_end]) {
|
|
107
|
+
if (msOf(value) !== null) return value;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rowMs(row) {
|
|
113
|
+
return msOf(rowTs(row));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function armFields(row, { ledger, ledgerAvailable }) {
|
|
117
|
+
const none = { exp_task: '', exp_arm: '', exp_arm_source: 'none', exp_ledger_arm: '' };
|
|
118
|
+
const parsed = parseArmBranch(row.gitBranch);
|
|
119
|
+
if (parsed === null) return none;
|
|
120
|
+
|
|
121
|
+
// The branch changed inside a single prompt — the arm marker then describes
|
|
122
|
+
// two states at once and therefore describes neither. That is a §8 signal, not
|
|
123
|
+
// a detail to average away.
|
|
124
|
+
if (row.gitBranchChanged === true) {
|
|
125
|
+
return { exp_task: parsed.task, exp_arm: '', exp_arm_source: 'conflict:branch-changed', exp_ledger_arm: '' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// No ledger on this machine: the control is ABSENT, not negative. The row
|
|
129
|
+
// keeps the arm from the branch and says outright that nobody confirmed it —
|
|
130
|
+
// the same rule as `unknown` in `verify` (TASK-070): a thing that cannot be
|
|
131
|
+
// observed is neither verified nor broken.
|
|
132
|
+
if (!ledgerAvailable) {
|
|
133
|
+
return { exp_task: parsed.task, exp_arm: parsed.arm, exp_arm_source: 'branch', exp_ledger_arm: '' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fromLedger = ledgerArmAt(ledger, rowTs(row));
|
|
137
|
+
if (fromLedger === null) {
|
|
138
|
+
return { exp_task: parsed.task, exp_arm: '', exp_arm_source: 'conflict:no-ledger-entry', exp_ledger_arm: '' };
|
|
139
|
+
}
|
|
140
|
+
if (fromLedger !== parsed.arm) {
|
|
141
|
+
return { exp_task: parsed.task, exp_arm: '', exp_arm_source: 'conflict:ledger', exp_ledger_arm: fromLedger };
|
|
142
|
+
}
|
|
143
|
+
return { exp_task: parsed.task, exp_arm: parsed.arm, exp_arm_source: 'branch+ledger', exp_ledger_arm: fromLedger };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `pass_order` — 1 for the arm that ran first within a task, 2 for the second.
|
|
148
|
+
* Derived from the start time, because the ledger does not know which task an
|
|
149
|
+
* entry belongs to (`--note` is prose for a human, not a field).
|
|
150
|
+
*
|
|
151
|
+
* The order is a covariate, not decoration: the second implementation of the
|
|
152
|
+
* same task is cheaper for a reason that has nothing to do with the arm, and
|
|
153
|
+
* that effect runs in the same direction as the one being measured (§11b).
|
|
154
|
+
* Without `pass_order` on the row the two cannot be separated.
|
|
155
|
+
*
|
|
156
|
+
* A row with no resolved arm gets `null` — ranking an unknown arm would assign
|
|
157
|
+
* it a place in an order we do not know.
|
|
158
|
+
*/
|
|
159
|
+
function assignPassOrder(rows) {
|
|
160
|
+
// Nested maps rather than a joined key: one structure instead of a
|
|
161
|
+
// build-key/split-key pair, so there is no separator to drift and nothing to
|
|
162
|
+
// misread in a diff.
|
|
163
|
+
const firstMs = new Map(); // task -> Map(arm -> earliest start)
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
if (!row.exp_task || !row.exp_arm) continue;
|
|
166
|
+
const ms = rowMs(row);
|
|
167
|
+
if (ms === null) continue;
|
|
168
|
+
if (!firstMs.has(row.exp_task)) firstMs.set(row.exp_task, new Map());
|
|
169
|
+
const arms = firstMs.get(row.exp_task);
|
|
170
|
+
const seen = arms.get(row.exp_arm);
|
|
171
|
+
if (seen === undefined || ms < seen) arms.set(row.exp_arm, ms);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const order = new Map(); // task -> Map(arm -> 1 | 2 | ...)
|
|
175
|
+
for (const [task, arms] of firstMs) {
|
|
176
|
+
// Ties broken by arm name, so two runs of the same analyzer cannot produce
|
|
177
|
+
// two different answers.
|
|
178
|
+
const ranked = [...arms.entries()].sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
|
|
179
|
+
order.set(task, new Map(ranked.map(([arm], i) => [arm, i + 1])));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return rows.map((row) => ({
|
|
183
|
+
...row,
|
|
184
|
+
pass_order: order.get(row.exp_task)?.get(row.exp_arm) ?? null,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Adds five fields to every row: `exp_task`, `exp_arm`, `exp_arm_source`,
|
|
190
|
+
* `exp_ledger_arm`, `pass_order`. The fields are **always** present, empty ones
|
|
191
|
+
* included — a row that omits them is indistinguishable from a row the parser
|
|
192
|
+
* failed on (the same rule as `gitBranch: ''` in TASK-085).
|
|
193
|
+
*
|
|
194
|
+
* **This is a layer boundary, not an optional step.** A row straight from the
|
|
195
|
+
* collector (`aggregate`, `collectFiles(...).result()`) does NOT carry these
|
|
196
|
+
* fields: the collector never reads the ledger and has no business knowing about
|
|
197
|
+
* the experiment. A consumer reading rows through the module rather than through
|
|
198
|
+
* the CLI's JSONL (e.g. `analyze.mjs` in TASK-089) must call this itself —
|
|
199
|
+
* otherwise it gets `undefined` where the contract promises `''`, and then
|
|
200
|
+
* `undefined !== ''` lets a non-experiment row pass as an experimental one.
|
|
201
|
+
*/
|
|
202
|
+
export function annotateRows(rows, { ledger = [], ledgerAvailable = false } = {}) {
|
|
203
|
+
return assignPassOrder(rows.map((row) => ({ ...row, ...armFields(row, { ledger, ledgerAvailable }) })));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Arm summary for stderr — the §8 check ("are both arms in the data at all")
|
|
208
|
+
* computed by the tool instead of by a one-liner pasted next to it. Returns null
|
|
209
|
+
* when the data holds no row from an `exp/*` branch: an ordinary run has no
|
|
210
|
+
* reason to read a section about the experiment.
|
|
211
|
+
*/
|
|
212
|
+
export function formatArmSummary(rows) {
|
|
213
|
+
// Truthiness, not `!== ''`: a row that was never annotated has `undefined`
|
|
214
|
+
// here, and `undefined !== ''` would let ordinary work into the experiment
|
|
215
|
+
// section. Costs the same and does not depend on call order.
|
|
216
|
+
const experimental = rows.filter((row) => row.exp_task);
|
|
217
|
+
if (experimental.length === 0) return null;
|
|
218
|
+
|
|
219
|
+
const counts = new Map();
|
|
220
|
+
for (const row of experimental) {
|
|
221
|
+
const label = row.exp_arm === '' ? `${row.exp_task}/? (${row.exp_arm_source})` : `${row.exp_task}/${row.exp_arm}`;
|
|
222
|
+
const bucket = counts.get(label) ?? { rows: 0, passOrders: new Set() };
|
|
223
|
+
bucket.rows++;
|
|
224
|
+
if (row.pass_order !== null) bucket.passOrders.add(row.pass_order);
|
|
225
|
+
counts.set(label, bucket);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const lines = ['arms (branch × ledger):'];
|
|
229
|
+
for (const [label, bucket] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
230
|
+
const pass = bucket.passOrders.size ? ` pass_order=${[...bucket.passOrders].sort().join(',')}` : '';
|
|
231
|
+
lines.push(` ${label}: ${bucket.rows} row(s)${pass}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const unresolved = experimental.filter((row) => row.exp_arm === '').length;
|
|
235
|
+
if (unresolved > 0) {
|
|
236
|
+
lines.push(
|
|
237
|
+
` ${unresolved} row(s) on an exp/ branch could NOT be assigned to an arm — that run is invalid,`,
|
|
238
|
+
' not averageable (COST_EVALUATION_PROCEDURE §7).',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return lines.join('\n');
|
|
242
|
+
}
|