@feigi/fleet-ctl 3.92.2 → 3.93.1

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.
@@ -25,55 +25,42 @@
25
25
  // fleet-tick's `router` row prints it today; a missing or unparseable file
26
26
  // means the default cell only, so deleting it cannot evade the guard.
27
27
  //
28
- // BOOKING. A Pull is a ticket-features.tsv row; its $ is the cost of the
29
- // member-outcomes.tsv row with the same `session` + `agent`, plus every member
30
- // that one dispatched (an `agent` path nested under it). A PR's $ is every
31
- // Pull of its ticket(s) up to the ruling — superseded re-dispatches included —
32
- // plus every member whose NAME carries the PR (`review-pr-<n>`, `fix-pr-<n>`,
33
- // the finisher spellings, the review fan-out labels; member-record.mjs's
34
- // parseMemberName decides), with their own nested members. `merge-bot-*`,
35
- // `memory` and `__advisor` are booked nowhere: their cost does not vary with
36
- // the implementer's cell. Only omp rows are read; a blank `cost` books 0 and
37
- // is counted as unpriced, and a Pull with no member row at all books 0 and is
38
- // listed as unbooked.
28
+ // BOOKING is pull-attribution.mjs's, the one the router's fit reads too: each
29
+ // Pull in the window is a unit booked as routed to its `chosen_cell`, with
30
+ // every implementer under it, its PR's review, fix and finisher members and
31
+ // their nested members, and its merge through its Ruling — merged when that
32
+ // PR's state is MERGED. A Pull is counted, or excluded (no Admissible row),
33
+ // pending (its PR still open, or not in the list), left out (never merged, no
34
+ // `fixed` bail, and every ending the ticket's or neither's) or unpriced (a
35
+ // booked row with a blank `cost`, or a Ruling whose PR none of its
36
+ // implementers carries); only counted Pulls enter a cell's figures. A `cost`
37
+ // that is neither blank nor a number is an input error (exit 2), as is a
38
+ // ticket-features `run_date` that is neither blank nor YYYY-MM-DD, or a ruling
39
+ // of a windowed Pull's ticket whose run_date is not YYYY-MM-DD or whose verdict
40
+ // is not yes or no.
39
41
  //
40
- // A ticket's RULING is its last tier-outcomes.tsv row in file order dated on or
41
- // after its first Pull in the window, skipping rows with both verdict columns
42
- // blank, which were never ruled; that row names the PR and carries the quality
43
- // verdict. A PR ruling several tickets takes its verdict from the later of
44
- // their rulings in file order, whatever their dates. A ruling whose run_date is
45
- // not YYYY-MM-DD is an input error (exit 2), as is one whose verdict is not
46
- // yes or no. The PR's CELL is the `chosen_cell` of the last Pull booked to it
47
- // — tickets in the order of their first Pull, each ticket's Pulls in file
48
- // order, those dated on or before the ticket's ruling — cross-checked against
49
- // that Pull's member row: a `subagent_type` other than
50
- // `fleet-implementer-<cell>`, or an `effort` other than the cell's level,
51
- // lists the PR under `mismatch` and books it to no cell at all. A PR still
52
- // OPEN is pending and booked nowhere yet. A Pull with no ruling books to its
53
- // own `chosen_cell` as unmerged spend — unless its member opened a PR that is
54
- // still open, which is pending too.
55
- //
56
- // PER CELL: `n_pulls`; `n_merged`; `n_pass` (merged PRs passing the quality
57
- // floor — failure is `minted_false_claim=yes` or `closed_own_ticket=no`);
58
- // `fail_rate` = failed / merged; `mean_usd` = every $ booked to the cell —
59
- // merged, unmerged and superseded — / merged, so a cell that abandons work
60
- // carries that spend; `median_usd` over merged PRs alone, descriptive;
61
- // `router_usd`, the Pulls' own routing cost, summed. `usd_diff_ci95` is a
62
- // bootstrap 95% interval on `mean_usd` minus the baseline's — printed to be
63
- // read, never judged on.
42
+ // PER CELL, over its counted Pulls: `n_pulls`; `n_merged`; `n_claims` (merged
43
+ // PRs plus every `bail:fixed` or `bail:fixed-refuted` ending); `n_failed`
44
+ // (merged PRs ruled `minted_false_claim=yes` or `closed_own_ticket=no`, plus
45
+ // every `bail:fixed-refuted`); `fail_rate` = failed / claims; `mean_usd` = the
46
+ // Pulls' spend — merged or not — / merged, so a cell that abandons work carries
47
+ // that spend; `median_usd` over the merged Pulls alone, descriptive;
48
+ // `router_usd`, the Pulls' own routing cost, summed and never in their spend.
49
+ // `usd_diff_ci95` is a bootstrap 95% interval on `mean_usd` minus the
50
+ // baseline's — printed to be read, never judged on. Then the cell's
51
+ // `excluded`, `left_out` and `unpriced` Pull counts.
64
52
 
65
53
  import { execFileSync } from "node:child_process";
66
54
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
67
55
  import { dirname, join } from "node:path";
68
56
  import { fileURLToPath } from "node:url";
69
- import { makeDie, defineFlags, isDigits } from "./arg.mjs";
57
+ import { makeDie, defineFlags } from "./arg.mjs";
70
58
  import { gitEnv } from "./git-env.mjs";
71
59
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
72
60
  import { isCLI } from "./is-cli.mjs";
73
- import { CELL } from "./ledger-grammar.mjs";
74
- import { parseMemberName } from "./member-record.mjs";
75
61
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
76
- import { RulingError, parseTierOutcomes, rulingFor, rulingsByTicket } from "./tier-outcomes.mjs";
62
+ import { AttributionError, attribute, parseFeatures, tally } from "./pull-attribution.mjs";
63
+ import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
77
64
 
78
65
  const NAME = "pr-cost";
79
66
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
@@ -91,38 +78,7 @@ export const TRIP_POINTS = 15;
91
78
  export const EXIT = { ok: 0, tripped: 3, none: 4 };
92
79
  export const GH_PR_LIMIT = 1000;
93
80
 
94
- export const FEATURE_COLUMNS = [
95
- "run_date", "session", "agent", "ticket", "policy_cell", "chosen_cell", "exploration_draw",
96
- "sizing_src", "sizing_pre", "router_usd", "brief_chars", "criteria", "comments", "age_days",
97
- "paths", "test_paths", "xrefs", "kind",
98
- ];
99
-
100
- // ticket-features.tsv: one bare header line naming the columns, then rows.
101
- // Read by header name; a row whose width differs from the header's is refused.
102
- export function parseFeatures(text) {
103
- const lines = String(text ?? "").split("\n").filter((l) => l.trim() && !l.startsWith("#"));
104
- if (lines.length === 0) throw new Error("ticket-features.tsv has no header line");
105
- const header = lines[0].split("\t");
106
- const missing = FEATURE_COLUMNS.filter((c) => !header.includes(c));
107
- if (missing.length) throw new Error(`ticket-features.tsv header lacks ${missing.join(", ")}`);
108
- return lines.slice(1).map((l, i) => {
109
- const cells = l.split("\t");
110
- if (cells.length !== header.length) {
111
- throw new Error(`ticket-features.tsv row ${i + 1}: ${cells.length} fields, expected ${header.length} — ${l.slice(0, 60)}`);
112
- }
113
- const row = Object.fromEntries(header.map((c, j) => [c, cells[j]]));
114
- if (!isDigits(row.ticket)) throw new Error(`ticket-features.tsv row ${i + 1}: ticket ${JSON.stringify(row.ticket)} is not a number`);
115
- if (!CELL.test(row.chosen_cell)) throw new Error(`ticket-features.tsv row ${i + 1}: chosen_cell ${JSON.stringify(row.chosen_cell)} is not a cell`);
116
- return row;
117
- });
118
- }
119
-
120
- export const EXCLUDED_MEMBER = /^(?:merge-?bot|memory$|__advisor$)/i;
121
- // omp nests a member's id as `<parent>/<parent>.<child>`: the child is the last segment after either separator.
122
- export const lastSegment = (agent) => String(agent).split(/[/.]/).at(-1);
123
- const usd = (r) => (r.cost === "" || r.cost === undefined ? 0 : Number(r.cost));
124
- const isMerged = (pr) => pr?.state === "MERGED";
125
- const failed = (tier) => tier.minted_false_claim === "yes" || tier.closed_own_ticket === "no";
81
+ const routerUsd = (pull) => (pull.router_usd === "" ? 0 : Number(pull.router_usd));
126
82
  const round = (x, d = 4) => (x === null || !Number.isFinite(x) ? null : Number(x.toFixed(d)));
127
83
 
128
84
  function median(xs) {
@@ -167,7 +123,8 @@ export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed
167
123
  }
168
124
 
169
125
  // The trip rule, in integer counts so a 3-of-20 difference is exactly 15
170
- // points rather than 14.999… in floating point.
126
+ // points rather than 14.999… in floating point. `n` is a cell's claims and
127
+ // `n_pass` the claims among them that held.
171
128
  export function trips(cell, base) {
172
129
  const failC = cell.n - cell.n_pass, failB = base.n - base.n_pass;
173
130
  const failRise = (failC * base.n - failB * cell.n) * 100 >= TRIP_POINTS * cell.n * base.n;
@@ -175,142 +132,46 @@ export function trips(cell, base) {
175
132
  }
176
133
 
177
134
  export function computeReport({ members, tiers, features, prs, routerTable = null, pricing = null, windowStart = WINDOW_START }) {
178
- const omp = members.filter((r) => r.harness === "omp");
179
- const memberKey = (session, agent) => `${session}\0${agent}`;
180
- const prState = new Map(prs.map((p) => [String(p.number), p]));
181
-
182
- // Direct bookings, keyed by the member row they name.
183
- const pulls = features.filter((f) => f.run_date >= windowStart);
184
- const pullByKey = new Map(pulls.map((p) => [memberKey(p.session, p.agent), p]));
185
- const prMemberKeys = new Map();
186
- for (const r of omp) {
187
- if (EXCLUDED_MEMBER.test(lastSegment(r.member))) continue;
188
- if (pullByKey.has(memberKey(r.session, r.agent))) continue;
189
- const { pr } = parseMemberName(r.member);
190
- if (pr) prMemberKeys.set(memberKey(r.session, r.agent), pr);
191
- }
192
- // Every omp row books to its nearest booked ancestor (itself included).
193
- const pullUsd = new Map(), prMemberUsd = new Map();
194
- const memberOfPull = new Map();
195
- const booked = []; // [row, pull | null, pr | null]
196
- for (const r of omp) {
197
- if (EXCLUDED_MEMBER.test(lastSegment(r.member))) continue;
198
- const segs = String(r.agent).split("/");
199
- for (let i = segs.length; i > 0; i--) {
200
- const k = memberKey(r.session, segs.slice(0, i).join("/"));
201
- const pull = pullByKey.get(k);
202
- const pr = prMemberKeys.get(k);
203
- if (!pull && !pr) continue;
204
- booked.push([r, pull ?? null, pull ? null : pr]);
205
- if (pull) {
206
- pullUsd.set(pull, (pullUsd.get(pull) ?? 0) + usd(r));
207
- if (i === segs.length) memberOfPull.set(pull, r);
208
- } else prMemberUsd.set(pr, (prMemberUsd.get(pr) ?? 0) + usd(r));
209
- break;
210
- }
211
- }
212
-
213
- // Rulings: per ticket, `rulingFor` of the ticket's first Pull — the last
214
- // ruling row in file order dated on or after it, both-blank rows skipped.
215
- const rulings = rulingsByTicket(tiers);
216
- const fileOrder = new Map(tiers.map((t, i) => [t, i]));
217
- const pullsByTicket = new Map();
218
- for (const p of pulls) {
219
- if (!pullsByTicket.has(p.ticket)) pullsByTicket.set(p.ticket, []);
220
- pullsByTicket.get(p.ticket).push(p);
221
- }
222
- const groups = new Map(); // pr -> { pr, tier, pulls[] }
223
- const unruled = [];
224
- for (const [ticket, ps] of pullsByTicket) {
225
- const ruling = rulingFor(rulings, ticket, ps[0].run_date);
226
- if (!ruling) { unruled.push(...ps); continue; }
227
- const g = groups.get(ruling.pr) ?? { pr: ruling.pr, tier: ruling, pulls: [] };
228
- if (fileOrder.get(ruling) > fileOrder.get(g.tier)) g.tier = ruling;
229
- for (const p of ps) (p.run_date <= ruling.run_date ? g.pulls : unruled).push(p);
230
- groups.set(ruling.pr, g);
231
- }
232
-
233
- const cells = new Map();
234
- const cellOf = (c) => {
235
- if (!cells.has(c)) {
236
- cells.set(c, { cell: c, n_pulls: 0, n_merged: 0, n_pass: 0, usd: 0, router_usd: 0, merged_usd: [], units: [] });
237
- }
238
- return cells.get(c);
239
- };
240
- const routerUsd = (p) => (p.router_usd === "" ? 0 : Number(p.router_usd));
241
- const mismatch = [], pending = [];
242
-
243
- for (const g of groups.values()) {
244
- if (g.pulls.length === 0) continue;
245
- const carrier = g.pulls.at(-1);
246
- const cell = carrier.chosen_cell;
247
- const m = memberOfPull.get(carrier);
248
- const level = cell.split("-").slice(1).join("-");
249
- if (m && (m.subagentType !== `fleet-implementer-${cell}` || m.effort !== level)) {
250
- mismatch.push({ pr: g.pr, cell, subagent_type: m.subagentType, effort: m.effort });
251
- continue;
252
- }
253
- const state = prState.get(g.pr);
254
- if (!state || state.state === "OPEN") { pending.push(g.pr); continue; }
255
- const total = g.pulls.reduce((s, p) => s + (pullUsd.get(p) ?? 0), 0) + (prMemberUsd.get(g.pr) ?? 0);
256
- const c = cellOf(cell);
257
- for (const p of g.pulls) { cellOf(p.chosen_cell).n_pulls++; c.router_usd += routerUsd(p); }
258
- c.usd += total;
259
- const merged = isMerged(state) ? 1 : 0;
260
- c.units.push({ usd: total, merged });
261
- if (merged) {
262
- c.n_merged++;
263
- c.merged_usd.push(total);
264
- if (!failed(g.tier)) c.n_pass++;
265
- }
266
- }
267
- for (const p of unruled) {
268
- const opened = memberOfPull.get(p)?.pr;
269
- if (opened && prState.get(opened)?.state === "OPEN") { pending.push(opened); continue; }
270
- const c = cellOf(p.chosen_cell);
271
- c.n_pulls++;
272
- c.router_usd += routerUsd(p);
273
- const spent = pullUsd.get(p) ?? 0;
274
- c.usd += spent;
275
- c.units.push({ usd: spent, merged: 0 });
276
- }
277
-
278
- const base = cells.get(BASELINE_CELL) ?? null;
279
- const meanUsd = (c) => (c.n_merged ? c.usd / c.n_merged : null);
280
- const figures = [...cells.values()].sort((a, b) => a.cell.localeCompare(b.cell)).map((c) => ({
281
- cell: c.cell,
282
- n_pulls: c.n_pulls,
283
- n_merged: c.n_merged,
284
- n_pass: c.n_pass,
285
- fail_rate: c.n_merged ? round((c.n_merged - c.n_pass) / c.n_merged) : null,
286
- mean_usd: round(meanUsd(c), 2),
287
- median_usd: round(median(c.merged_usd), 2),
288
- router_usd: round(c.router_usd, 4),
289
- usd_diff_ci95: c.cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(c.units, base.units),
135
+ const units = attribute({ features, members, tiers, prs, windowStart });
136
+ const counted = units.filter((u) => u.status === "counted");
137
+ const tallies = new Map(tally(units).map((t) => [t.cell, t]));
138
+ const countedIn = (cell) => counted.filter((u) => u.cell === cell);
139
+ const bootUnits = (cell) => countedIn(cell).map((u) => ({ usd: u.spend, merged: u.merged ? 1 : 0 }));
140
+ const meanUsd = (t) => (t.merged ? t.spend / t.merged : null);
141
+ const base = tallies.get(BASELINE_CELL) ?? null;
142
+ const figures = [...tallies.values()].map((t) => ({
143
+ cell: t.cell,
144
+ n_pulls: t.n,
145
+ n_merged: t.merged,
146
+ n_claims: t.claims,
147
+ n_failed: t.failed,
148
+ fail_rate: t.claims ? round(t.failed / t.claims) : null,
149
+ mean_usd: round(meanUsd(t), 2),
150
+ median_usd: round(median(countedIn(t.cell).filter((u) => u.merged).map((u) => u.spend)), 2),
151
+ router_usd: round(countedIn(t.cell).reduce((s, u) => s + routerUsd(u.pull), 0), 4),
152
+ usd_diff_ci95: t.cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(bootUnits(t.cell), bootUnits(BASELINE_CELL)),
153
+ excluded: t.excluded,
154
+ left_out: t.left_out,
155
+ unpriced: t.unpriced,
290
156
  }));
291
157
 
292
158
  // The guard.
293
- const byCell = new Map(figures.map((f) => [f.cell, f]));
294
- const b = byCell.get(BASELINE_CELL) ?? { n_merged: 0, n_pass: 0, mean_usd: null, fail_rate: null };
159
+ const b = figures.find((f) => f.cell === BASELINE_CELL) ?? { n_merged: 0, mean_usd: null, fail_rate: null };
295
160
  const baseline = { cell: BASELINE_CELL, n: b.n_merged, mean_usd: b.mean_usd, fail_rate: b.fail_rate };
161
+ // The $ leg takes the unrounded means: the cent-rounded figures are for
162
+ // display, and a trip is sticky, so a cell cheaper by under half a cent
163
+ // must not trip on rounding alone. The fail leg reads the claims.
164
+ const quality = (t) => ({ n: t.claims, n_pass: t.claims - t.failed, mean_usd: meanUsd(t) });
296
165
  const tripped = [];
297
166
  if (baseline.n >= MIN_N) {
298
- for (const f of figures) {
299
- if (f.cell === BASELINE_CELL || f.n_merged < MIN_N) continue;
300
- // The $ leg takes the unrounded means: the cent-rounded figures are for
301
- // display, and a trip is sticky, so a cell cheaper by under half a cent
302
- // must not trip on rounding alone.
303
- const c = cells.get(f.cell);
304
- if (trips({ n: f.n_merged, n_pass: f.n_pass, mean_usd: meanUsd(c) }, { n: b.n_merged, n_pass: b.n_pass, mean_usd: meanUsd(base) })) tripped.push(f.cell);
167
+ for (const t of tallies.values()) {
168
+ if (t.cell !== BASELINE_CELL && t.merged >= MIN_N && trips(quality(t), quality(base))) tripped.push(t.cell);
305
169
  }
306
170
  }
307
171
  const verdict = baseline.n < MIN_N ? "none" : tripped.length ? "tripped" : "ok";
308
172
  // Retire: every non-default stage-1 cell tripped before any stratum adopted one.
309
173
  const adopted = Object.values(routerTable?.rows ?? {}).some((c) => c !== BASELINE_CELL);
310
174
  const retire = !adopted && STAGE_1.filter((c) => c !== BASELINE_CELL).every((c) => tripped.includes(c));
311
- // The member rows this report books: every windowed Pull's, and those of
312
- // PR-named members whose PR a windowed ticket was ruled on.
313
- const inReport = booked.filter(([, pull, pr]) => pull || groups.has(pr)).map(([r]) => r);
314
175
 
315
176
  return {
316
177
  window_start: windowStart,
@@ -319,44 +180,40 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
319
180
  tripped,
320
181
  verdict,
321
182
  retire,
322
- mismatch,
323
- pending: [...new Set(pending)].sort((x, y) => Number(x) - Number(y)),
324
- unpriced: inReport.filter((r) => r.cost === "").length,
325
- // A Pull whose member row never reached member-outcomes.tsv books $0 into
326
- // its cell's mean, which reads as a cheap run unless it is listed here.
327
- unbooked_pulls: pulls.filter((p) => !pullUsd.has(p)).map((p) => p.agent),
328
- ab: abReport(groups, pulls, prState, pullUsd, prMemberUsd),
329
- cross_check: crossCheck(inReport, pricing),
183
+ pending: [...new Set(units.filter((u) => u.status === "pending").map((u) => u.pendingPr))].sort((x, y) => Number(x) - Number(y)),
184
+ ab: abReport(units),
185
+ cross_check: crossCheck(units.flatMap((u) => u.rows), pricing),
330
186
  };
331
187
  }
332
188
 
333
189
  // The reasoning test: arm A on an even ticket, B on an odd one. B has run only
334
- // if some odd-ticket Pull carries a `sizing_pre`.
335
- export function abReport(groups, pulls, prState, pullUsd, prMemberUsd) {
190
+ // if some odd-ticket Pull carries a `sizing_pre`. An arm's $ is its counted
191
+ // Pulls' spend plus their own `router_usd`, since the arms compare policies,
192
+ // not cells.
193
+ export function abReport(units) {
336
194
  const arm = (ticket) => (Number(ticket) % 2 === 0 ? "A" : "B");
337
- const bPulls = pulls.filter((p) => arm(p.ticket) === "B");
195
+ const bPulls = units.map((u) => u.pull).filter((p) => arm(p.ticket) === "B");
338
196
  if (!bPulls.some((p) => p.sizing_pre !== "")) {
339
197
  return { status: "not-run", reason: "B not run — the table has no row a better classifier could change" };
340
198
  }
341
- const arms = { A: { merged: 0, failed: 0, usd: 0 }, B: { merged: 0, failed: 0, usd: 0 } };
342
- for (const g of groups.values()) {
343
- if (!g.pulls.length || !isMerged(prState.get(g.pr))) continue;
344
- const a = arms[arm(g.pulls.at(-1).ticket)];
345
- a.merged++;
346
- if (failed(g.tier)) a.failed++;
347
- a.usd += g.pulls.reduce((s, p) => s + (pullUsd.get(p) ?? 0) + (p.router_usd === "" ? 0 : Number(p.router_usd)), 0)
348
- + (prMemberUsd.get(g.pr) ?? 0);
199
+ const arms = { A: { merged: 0, claims: 0, failed: 0, usd: 0 }, B: { merged: 0, claims: 0, failed: 0, usd: 0 } };
200
+ for (const u of units) {
201
+ if (u.status !== "counted") continue;
202
+ const a = arms[arm(u.pull.ticket)];
203
+ if (u.merged) a.merged++;
204
+ a.claims += u.claims;
205
+ a.failed += u.falseClaims;
206
+ a.usd += u.spend + routerUsd(u.pull);
349
207
  }
350
208
  const disagree = bPulls.filter((p) => p.policy_cell !== p.chosen_cell).length;
351
- const out = {
352
- A: { n_merged: arms.A.merged, fail_rate: arms.A.merged ? round(arms.A.failed / arms.A.merged) : null, mean_usd: arms.A.merged ? round(arms.A.usd / arms.A.merged, 2) : null },
353
- B: { n_merged: arms.B.merged, fail_rate: arms.B.merged ? round(arms.B.failed / arms.B.merged) : null, mean_usd: arms.B.merged ? round(arms.B.usd / arms.B.merged, 2) : null },
354
- disagreement: bPulls.length ? round(disagree / bPulls.length) : null,
355
- };
209
+ const side = (a) => ({
210
+ n_merged: a.merged, fail_rate: a.claims ? round(a.failed / a.claims) : null, mean_usd: a.merged ? round(a.usd / a.merged, 2) : null,
211
+ });
212
+ const out = { A: side(arms.A), B: side(arms.B), disagreement: bPulls.length ? round(disagree / bPulls.length) : null };
356
213
  // B can only show an effect through the Pulls where it chose differently.
357
214
  if (arms.A.merged < MIN_N || arms.B.merged < MIN_N) return { status: "insufficient", ...out };
358
215
  if (disagree < MIN_N) return { status: "underpowered", ...out };
359
- const bWins = out.B.mean_usd < out.A.mean_usd && arms.B.failed * arms.A.merged <= arms.A.failed * arms.B.merged;
216
+ const bWins = out.B.mean_usd < out.A.mean_usd && arms.B.failed * arms.A.claims <= arms.A.failed * arms.B.claims;
360
217
  return { status: bWins ? "B-wins" : "B-off", ...out };
361
218
  }
362
219
 
@@ -381,7 +238,10 @@ export function crossCheck(rows, pricing) {
381
238
  return { rows: n, skipped, ratio: expected > 0 ? round(recorded / expected) : null };
382
239
  }
383
240
 
384
- const TSV_COLUMNS = ["cell", "n_pulls", "n_merged", "n_pass", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95", "guard"];
241
+ const TSV_COLUMNS = [
242
+ "cell", "n_pulls", "n_merged", "n_claims", "n_failed", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95",
243
+ "excluded", "left_out", "unpriced", "guard",
244
+ ];
385
245
 
386
246
  export function formatReport(report) {
387
247
  const guardOf = (f) => {
@@ -394,13 +254,7 @@ export function formatReport(report) {
394
254
  for (const f of report.cells) lines.push(TSV_COLUMNS.map((c) => (c === "guard" ? guardOf(f) : cell(f[c]))).join("\t"));
395
255
  const b = report.baseline;
396
256
  lines.push(`# verdict: ${report.verdict} (baseline ${b.cell} n=${b.n}/${MIN_N}${report.tripped.length ? `; tripped ${report.tripped.join(" ")}` : ""})${report.retire ? " — retire: every non-default stage-1 cell tripped" : ""}`);
397
- lines.push(`# window_start ${report.window_start}; pending PRs: ${report.pending.length ? report.pending.join(" ") : "none"}; unpriced member rows: ${report.unpriced}`);
398
- if (report.mismatch.length) {
399
- lines.push(`# mismatch (excluded from every cell): ${report.mismatch.map((m) => `PR#${m.pr} ${m.cell} vs ${m.subagent_type || "(none)"}/${m.effort || "(none)"}`).join("; ")}`);
400
- }
401
- if (report.unbooked_pulls.length) {
402
- lines.push(`# unbooked Pulls (no member row, so $0 in the mean): ${report.unbooked_pulls.join(" ")}`);
403
- }
257
+ lines.push(`# window_start ${report.window_start}; pending PRs: ${report.pending.length ? report.pending.join(" ") : "none"}`);
404
258
  const ab = report.ab;
405
259
  lines.push(ab.status === "not-run"
406
260
  ? `# A/B: ${ab.reason}`
@@ -493,7 +347,11 @@ function main() {
493
347
 
494
348
  let report;
495
349
  try { report = computeReport({ members, tiers, features, prs, routerTable, pricing }); }
496
- catch (e) { if (e instanceof RulingError) die(`${tiersPath}: ${e.message}`); throw e; }
350
+ catch (e) {
351
+ if (e instanceof RulingError) die(`${tiersPath}: ${e.message}`);
352
+ if (e instanceof AttributionError) die(e.message);
353
+ throw e;
354
+ }
497
355
  process.stdout.write(has("json") ? JSON.stringify(report, null, 2) + "\n" : formatReport(report));
498
356
  if (!has("guard")) return;
499
357
  const file = arg("out") ?? defaultGuardPath();