@bglocation/tune-context 2.3.1 → 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.
package/eval/arm.mjs ADDED
@@ -0,0 +1,246 @@
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 every reader — the ledger window, `pass_order`, and the
100
+ * natural-work attribution in analyze.mjs (TASK-089), which asks the same ledger
101
+ * the same question about a row that is NOT on an `exp/` branch. Exported for
102
+ * that reason: a second "when did this row happen" would be a second answer.
103
+ *
104
+ * Two of these readers already drifted apart once: `??` only falls through on null, so it
105
+ * stopped at a `ts_submit` that was a string but unparseable — and a row the
106
+ * ledger DID cover came out as `conflict:no-ledger-entry`, i.e. a run to be
107
+ * repeated (§7) because of a cosmetic defect in the data.
108
+ */
109
+ export function rowTimestamp(row) {
110
+ for (const value of [row.ts_submit, row.ts_first_assistant, row.ts_end]) {
111
+ if (msOf(value) !== null) return value;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function rowMs(row) {
117
+ return msOf(rowTimestamp(row));
118
+ }
119
+
120
+ function armFields(row, { ledger, ledgerAvailable }) {
121
+ const none = { exp_task: '', exp_arm: '', exp_arm_source: 'none', exp_ledger_arm: '' };
122
+ const parsed = parseArmBranch(row.gitBranch);
123
+ if (parsed === null) return none;
124
+
125
+ // The branch changed inside a single prompt — the arm marker then describes
126
+ // two states at once and therefore describes neither. That is a §8 signal, not
127
+ // a detail to average away.
128
+ if (row.gitBranchChanged === true) {
129
+ return { exp_task: parsed.task, exp_arm: '', exp_arm_source: 'conflict:branch-changed', exp_ledger_arm: '' };
130
+ }
131
+
132
+ // No ledger on this machine: the control is ABSENT, not negative. The row
133
+ // keeps the arm from the branch and says outright that nobody confirmed it —
134
+ // the same rule as `unknown` in `verify` (TASK-070): a thing that cannot be
135
+ // observed is neither verified nor broken.
136
+ if (!ledgerAvailable) {
137
+ return { exp_task: parsed.task, exp_arm: parsed.arm, exp_arm_source: 'branch', exp_ledger_arm: '' };
138
+ }
139
+
140
+ const fromLedger = ledgerArmAt(ledger, rowTimestamp(row));
141
+ if (fromLedger === null) {
142
+ return { exp_task: parsed.task, exp_arm: '', exp_arm_source: 'conflict:no-ledger-entry', exp_ledger_arm: '' };
143
+ }
144
+ if (fromLedger !== parsed.arm) {
145
+ return { exp_task: parsed.task, exp_arm: '', exp_arm_source: 'conflict:ledger', exp_ledger_arm: fromLedger };
146
+ }
147
+ return { exp_task: parsed.task, exp_arm: parsed.arm, exp_arm_source: 'branch+ledger', exp_ledger_arm: fromLedger };
148
+ }
149
+
150
+ /**
151
+ * `pass_order` — 1 for the arm that ran first within a task, 2 for the second.
152
+ * Derived from the start time, because the ledger does not know which task an
153
+ * entry belongs to (`--note` is prose for a human, not a field).
154
+ *
155
+ * The order is a covariate, not decoration: the second implementation of the
156
+ * same task is cheaper for a reason that has nothing to do with the arm, and
157
+ * that effect runs in the same direction as the one being measured (§11b).
158
+ * Without `pass_order` on the row the two cannot be separated.
159
+ *
160
+ * A row with no resolved arm gets `null` — ranking an unknown arm would assign
161
+ * it a place in an order we do not know.
162
+ */
163
+ function assignPassOrder(rows) {
164
+ // Nested maps rather than a joined key: one structure instead of a
165
+ // build-key/split-key pair, so there is no separator to drift and nothing to
166
+ // misread in a diff.
167
+ const firstMs = new Map(); // task -> Map(arm -> earliest start)
168
+ for (const row of rows) {
169
+ if (!row.exp_task || !row.exp_arm) continue;
170
+ const ms = rowMs(row);
171
+ if (ms === null) continue;
172
+ if (!firstMs.has(row.exp_task)) firstMs.set(row.exp_task, new Map());
173
+ const arms = firstMs.get(row.exp_task);
174
+ const seen = arms.get(row.exp_arm);
175
+ if (seen === undefined || ms < seen) arms.set(row.exp_arm, ms);
176
+ }
177
+
178
+ const order = new Map(); // task -> Map(arm -> 1 | 2 | ...)
179
+ for (const [task, arms] of firstMs) {
180
+ // Ties broken by arm name, so two runs of the same analyzer cannot produce
181
+ // two different answers.
182
+ const ranked = [...arms.entries()].sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
183
+ order.set(task, new Map(ranked.map(([arm], i) => [arm, i + 1])));
184
+ }
185
+
186
+ return rows.map((row) => ({
187
+ ...row,
188
+ pass_order: order.get(row.exp_task)?.get(row.exp_arm) ?? null,
189
+ }));
190
+ }
191
+
192
+ /**
193
+ * Adds five fields to every row: `exp_task`, `exp_arm`, `exp_arm_source`,
194
+ * `exp_ledger_arm`, `pass_order`. The fields are **always** present, empty ones
195
+ * included — a row that omits them is indistinguishable from a row the parser
196
+ * failed on (the same rule as `gitBranch: ''` in TASK-085).
197
+ *
198
+ * **This is a layer boundary, not an optional step.** A row straight from the
199
+ * collector (`aggregate`, `collectFiles(...).result()`) does NOT carry these
200
+ * fields: the collector never reads the ledger and has no business knowing about
201
+ * the experiment. A consumer reading rows through the module rather than through
202
+ * the CLI's JSONL (e.g. `analyze.mjs` in TASK-089) must call this itself —
203
+ * otherwise it gets `undefined` where the contract promises `''`, and then
204
+ * `undefined !== ''` lets a non-experiment row pass as an experimental one.
205
+ */
206
+ export function annotateRows(rows, { ledger = [], ledgerAvailable = false } = {}) {
207
+ return assignPassOrder(rows.map((row) => ({ ...row, ...armFields(row, { ledger, ledgerAvailable }) })));
208
+ }
209
+
210
+ /**
211
+ * Arm summary for stderr — the §8 check ("are both arms in the data at all")
212
+ * computed by the tool instead of by a one-liner pasted next to it. Returns null
213
+ * when the data holds no row from an `exp/*` branch: an ordinary run has no
214
+ * reason to read a section about the experiment.
215
+ */
216
+ export function formatArmSummary(rows) {
217
+ // Truthiness, not `!== ''`: a row that was never annotated has `undefined`
218
+ // here, and `undefined !== ''` would let ordinary work into the experiment
219
+ // section. Costs the same and does not depend on call order.
220
+ const experimental = rows.filter((row) => row.exp_task);
221
+ if (experimental.length === 0) return null;
222
+
223
+ const counts = new Map();
224
+ for (const row of experimental) {
225
+ const label = row.exp_arm === '' ? `${row.exp_task}/? (${row.exp_arm_source})` : `${row.exp_task}/${row.exp_arm}`;
226
+ const bucket = counts.get(label) ?? { rows: 0, passOrders: new Set() };
227
+ bucket.rows++;
228
+ if (row.pass_order !== null) bucket.passOrders.add(row.pass_order);
229
+ counts.set(label, bucket);
230
+ }
231
+
232
+ const lines = ['arms (branch × ledger):'];
233
+ for (const [label, bucket] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
234
+ const pass = bucket.passOrders.size ? ` pass_order=${[...bucket.passOrders].sort().join(',')}` : '';
235
+ lines.push(` ${label}: ${bucket.rows} row(s)${pass}`);
236
+ }
237
+
238
+ const unresolved = experimental.filter((row) => row.exp_arm === '').length;
239
+ if (unresolved > 0) {
240
+ lines.push(
241
+ ` ${unresolved} row(s) on an exp/ branch could NOT be assigned to an arm — that run is invalid,`,
242
+ ' not averageable (COST_EVALUATION_PROCEDURE §7).',
243
+ );
244
+ }
245
+ return lines.join('\n');
246
+ }