@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.
- package/package.json +1 -1
- package/scripts/cell-readout.mjs +3 -7
- package/scripts/closing-refs.mjs +37 -0
- package/scripts/drop-merged-label.sh +23 -3
- package/scripts/fleet-tick.mjs +28 -14
- package/scripts/ledger.mjs +7 -22
- package/scripts/member-outcomes.mjs +1 -1
- package/scripts/metrics-refresh.mjs +1 -1
- package/scripts/phase0.mjs +15 -2
- package/scripts/pr-cost.mjs +88 -230
- package/scripts/pull-attribution.mjs +260 -0
- package/scripts/ticket-router.mjs +62 -148
- package/scripts/tier-outcomes.mjs +19 -17
package/scripts/pr-cost.mjs
CHANGED
|
@@ -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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// is
|
|
38
|
-
//
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
|
299
|
-
if (
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
343
|
-
if (
|
|
344
|
-
const a = arms[arm(
|
|
345
|
-
a.merged++;
|
|
346
|
-
|
|
347
|
-
a.
|
|
348
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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.
|
|
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 = [
|
|
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"}
|
|
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) {
|
|
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();
|