@feigi/fleet-ctl 3.94.0 → 3.95.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.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.94.0",
3
+ "version": "3.95.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,6 +50,7 @@
50
50
  // opens with no sha, is a NEED and N is left untouched, claim and all.
51
51
  // The PR a `covered` detail names is never written to.
52
52
  // A `PR#M` outcome also sets the claim branch's upstream when it has none: `reap.sh` keeps a branch with no upstream, and only an upstream's `[gone]` state makes it reapable. It writes `branch.<b>.remote` and `branch.<b>.merge` only while the merge key is unset, and prints `NEED upstream <b> — <why>` when a write fails.
53
+ // A `blocked` outcome takes no action beyond the settle: no label, comment, release or upstream, so the ticket keeps `in-progress`.
53
54
  // fix-report <fix-pr-M[-x]>
54
55
  // `settle --from-report`, which records the filings and rulings the
55
56
  // report carries. Settled `applied:`/`no-op`, on a PR whose rows carry
@@ -2508,6 +2508,9 @@ function recordFiled(id, subject) {
2508
2508
  // own repository name N;
2509
2509
  // the Check-step record (below) holds for <head>
2510
2510
  // impl-N=bailed bailCause is not null; the receipt names it
2511
+ // impl-N=blocked nothing to check: report-schema.mjs already
2512
+ // refuses a block that names a bailCause, a pr or
2513
+ // a head, or gives no reason in bailDetail
2511
2514
  // fix-pr-M=applied:<head> origin's refs/heads/<branch> is <head>, fetched
2512
2515
  // by verify-sha.sh; the Check-step record (below)
2513
2516
  // holds for <head>
@@ -2670,6 +2673,8 @@ async function runSettleFromReport() {
2670
2673
  const { view: pr, numbers } = prViewClosing(report.pr, "state,closingIssuesReferences,url");
2671
2674
  check(`PR #${report.pr} reads OPEN`, pr.state === "OPEN");
2672
2675
  check(`PR #${report.pr} closes #${live.number}`, numbers.includes(live.number));
2676
+ } else if (report.outcome === "blocked") {
2677
+ outcome = "blocked";
2673
2678
  } else {
2674
2679
  outcome = "bailed";
2675
2680
  check(`bailCause: ${report.bailCause}`, report.bailCause !== null);
@@ -146,6 +146,7 @@ export function withReports(rows, scratch) {
146
146
  // opened one (more than one leaves the pr column blank), or
147
147
  // the last report claims a PR an earlier same-ticket
148
148
  // implementer opened in this session;
149
+ // blocked the last report blocks mid-implementation;
149
150
  // bail:<cause> the last report bails (`fixed` is checked against main);
150
151
  // provider a model fell back, or the last turn errored;
151
152
  // budget a budget notice reached the member;
@@ -162,6 +163,7 @@ export function endingOf({ member, pr, signals = null, earlierPrs = new Set(), v
162
163
  if (signals.openedPrs.length > 0) return "pr";
163
164
  const last = lastReport(signals.reportWrites, member.split("/").pop());
164
165
  if (last?.outcome === "pr" && earlierPrs.has(String(last.pr))) return "pr";
166
+ if (last?.outcome === "blocked") return "blocked";
165
167
  // lastReport passed the impl schema, whose bailCause is a BAILS key or null.
166
168
  if (last?.bailCause) return bailEnding(last, verifyMain);
167
169
  if (signals.providerFallback || signals.lastStopReason === "error") return "provider";
@@ -65,7 +65,7 @@ import { gitEnv } from "./git-env.mjs";
65
65
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
66
66
  import { isCLI } from "./is-cli.mjs";
67
67
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
68
- import { AttributionError, attribute, parseFeatures, tally } from "./pull-attribution.mjs";
68
+ import { AttributionError, attribute, bootUnit, bootstrapDiffCI, parseFeatures, tally, underFloor } from "./pull-attribution.mjs";
69
69
  import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
70
70
 
71
71
  const NAME = "pr-cost";
@@ -78,9 +78,6 @@ export const BASELINE_CELL = "slow-high";
78
78
  // Stage 1 is the model axis at fixed `high`.
79
79
  export const STAGE_1 = ["slow-high", "task-high", "smol-high"];
80
80
  export const MIN_N = 20;
81
- // A cell trips when its fail rate is at least this many points above the
82
- // baseline's, or its mean $ is at least the baseline's.
83
- export const TRIP_POINTS = 15;
84
81
  export const EXIT = { ok: 0, tripped: 3, none: 4 };
85
82
  export const GH_PR_LIMIT = 1000;
86
83
 
@@ -94,77 +91,36 @@ function median(xs) {
94
91
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
95
92
  }
96
93
 
97
- // Deterministic, so a re-run prints the same interval.
98
- function mulberry32(seed) {
99
- let a = seed >>> 0;
100
- return () => {
101
- a = (a + 0x6d2b79f5) >>> 0;
102
- let t = a;
103
- t = Math.imul(t ^ (t >>> 15), t | 1);
104
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
105
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
106
- };
107
- }
108
-
109
- // Each unit is one booked group: `{ usd, merged }`. mean_usd is Σusd / Σmerged.
110
- export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed = 1 } = {}) {
111
- if (!cellUnits.length || !baseUnits.length) return null;
112
- const rand = mulberry32(seed);
113
- const resample = (units) => {
114
- let u = 0, m = 0;
115
- for (let i = 0; i < units.length; i++) {
116
- const x = units[Math.floor(rand() * units.length)];
117
- u += x.usd; m += x.merged;
118
- }
119
- return m ? u / m : null;
120
- };
121
- const diffs = [];
122
- for (let i = 0; i < iterations; i++) {
123
- const a = resample(cellUnits), b = resample(baseUnits);
124
- if (a !== null && b !== null) diffs.push(a - b);
125
- }
126
- if (diffs.length < iterations / 2) return null;
127
- diffs.sort((x, y) => x - y);
128
- return [round(diffs[Math.floor(0.025 * (diffs.length - 1))], 2), round(diffs[Math.ceil(0.975 * (diffs.length - 1))], 2)];
129
- }
130
-
131
- // The trip rule, in integer counts so a 3-of-20 difference is exactly 15
132
- // points rather than 14.999… in floating point. `n` is a cell's claims and
133
- // `n_pass` the claims among them that held.
94
+ // The trip rule: the quality floor (pull-attribution.mjs's `underFloor`, in
95
+ // integer counts), or a mean $ at least the baseline's. Each side is
96
+ // `{ claims, failed, mean_usd }`.
134
97
  export function trips(cell, base) {
135
- const failC = cell.n - cell.n_pass, failB = base.n - base.n_pass;
136
- const failRise = (failC * base.n - failB * cell.n) * 100 >= TRIP_POINTS * cell.n * base.n;
137
- return failRise || cell.mean_usd >= base.mean_usd;
98
+ return underFloor(cell, base) || cell.mean_usd >= base.mean_usd;
138
99
  }
139
100
 
140
101
  export function computeReport({ members, tiers, features, prs, routerTable = null, pricing = null, windowStart = WINDOW_START }) {
141
102
  const units = attribute({ features, members, tiers, prs, windowStart });
142
- const counted = units.filter((u) => u.status === "counted");
143
103
  const tallies = new Map(tally(units).map((t) => [t.cell, t]));
144
- const countedIn = (cell, generation) => counted.filter((u) => u.cell === cell && u.generation === generation);
145
- const bootUnits = (us) => us.map((u) => ({ usd: u.spend, merged: u.merged ? 1 : 0 }));
104
+ const bootUnits = (g) => g.pulls.map(bootUnit);
146
105
  const meanUsd = (t) => (t.merged ? t.spend / t.merged : null);
147
106
  const base = tallies.get(BASELINE_CELL) ?? null;
148
107
  // One generation's figures. The interval is the newest generation's against
149
108
  // the baseline's newest; an older generation is printed, never compared.
150
- const figuresOf = (cell, g, newest) => {
151
- const mine = countedIn(cell, g.generation);
152
- return {
153
- cell,
154
- generation: g.generation,
155
- n_pulls: g.n,
156
- n_merged: g.merged,
157
- n_claims: g.claims,
158
- n_failed: g.failed,
159
- fail_rate: g.claims ? round(g.failed / g.claims) : null,
160
- mean_usd: round(meanUsd(g), 2),
161
- median_usd: round(median(mine.filter((u) => u.merged).map((u) => u.spend)), 2),
162
- router_usd: round(mine.reduce((s, u) => s + routerUsd(u.pull), 0), 4),
163
- usd_diff_ci95: !newest || cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(bootUnits(mine), bootUnits(countedIn(BASELINE_CELL, base.generation))),
164
- left_out: g.left_out,
165
- unpriced: g.unpriced,
166
- };
167
- };
109
+ const figuresOf = (cell, g, newest) => ({
110
+ cell,
111
+ generation: g.generation,
112
+ n_pulls: g.n,
113
+ n_merged: g.merged,
114
+ n_claims: g.claims,
115
+ n_failed: g.failed,
116
+ fail_rate: g.claims ? round(g.failed / g.claims) : null,
117
+ mean_usd: round(meanUsd(g), 2),
118
+ median_usd: round(median(g.pulls.filter((u) => u.merged).map((u) => u.spend)), 2),
119
+ router_usd: round(g.pulls.reduce((s, u) => s + routerUsd(u.pull), 0), 4),
120
+ usd_diff_ci95: !newest || cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(bootUnits(g), bootUnits(base)),
121
+ left_out: g.left_out,
122
+ unpriced: g.unpriced,
123
+ });
168
124
  const figures = [...tallies.values()].map((t) => ({
169
125
  ...figuresOf(t.cell, t, true),
170
126
  excluded: t.excluded,
@@ -177,7 +133,7 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
177
133
  // The $ leg takes the unrounded means: the cent-rounded figures are for
178
134
  // display, and a trip is sticky, so a cell cheaper by under half a cent
179
135
  // must not trip on rounding alone. The fail leg reads the claims.
180
- const quality = (t) => ({ n: t.claims, n_pass: t.claims - t.failed, mean_usd: meanUsd(t) });
136
+ const quality = (t) => ({ claims: t.claims, failed: t.failed, mean_usd: meanUsd(t) });
181
137
  const tripped = [];
182
138
  if (baseline.n >= MIN_N) {
183
139
  for (const t of tallies.values()) {
@@ -1,7 +1,8 @@
1
1
  // The one booking of member rows, merges and claims to Pulls, read by both the
2
2
  // cost guard (pr-cost.mjs) and the router's fit (ticket-router.mjs), so the two
3
3
  // print the same figures for a cell. Neither CLI imports the other; both import
4
- // this.
4
+ // this, and with it the two judgements they share: the quality floor (the
5
+ // guard's fail leg) and the bootstrap range of a $ per merged PR difference.
5
6
  //
6
7
  // THE UNIT is the Pull, a ticket-features.tsv row, booked AS ROUTED to its
7
8
  // `chosen_cell`: its merge, its Ruling and all its spend. A ticket Pulled twice
@@ -38,8 +39,8 @@
38
39
  // counted every other Pull: the only ones in a cell's figures.
39
40
  //
40
41
  // ENDINGS come from member-outcomes' `ending` column. The cell's: `hollow`,
41
- // `budget`, `bail:fixed-refuted`, `unknown`. The ticket's: every other
42
- // `bail:<cause>`. Neither's: `provider`, `crash`, `abort`.
42
+ // `budget`, `bail:fixed-refuted`, `unknown`. The ticket's: `blocked` and every
43
+ // other `bail:<cause>`. Neither's: `provider`, `crash`, `abort`.
43
44
  //
44
45
  // CLAIMS. A merged Pull is one claim, false when its Ruling fails the floor
45
46
  // (`minted_false_claim=yes` or `closed_own_ticket=no`). Each `bail:fixed` or
@@ -59,6 +60,10 @@
59
60
  // Generations are told apart by model alone, so a cell that returns to an
60
61
  // earlier model rejoins that generation's Pulls. Only the newest generation's
61
62
  // figures are judged; older ones are reported beside them, never pooled.
63
+ //
64
+ // THE FLOOR. A cell is under the floor against a baseline when its fail rate
65
+ // is TRIP_POINTS or more points above the baseline's, compared in integer
66
+ // counts. The guard holds it pooled; the fit holds it per row.
62
67
 
63
68
  import { isDigits } from "./arg.mjs";
64
69
  import { CELL } from "./ledger-grammar.mjs";
@@ -73,6 +78,57 @@ export const FEATURE_COLUMNS = Object.freeze([
73
78
  "paths", "test_paths", "xrefs", "kind",
74
79
  ]);
75
80
 
81
+ // The quality floor's margin, in points of fail rate.
82
+ export const TRIP_POINTS = 15;
83
+
84
+ // The floor, in integer counts so a 3-of-20 difference is exactly 15 points
85
+ // rather than 14.999… in floating point. Each side is `{ claims, failed }`.
86
+ export function underFloor(cell, base) {
87
+ return (cell.failed * base.claims - base.failed * cell.claims) * 100 >= TRIP_POINTS * cell.claims * base.claims;
88
+ }
89
+
90
+ const round2 = (x) => (Number.isFinite(x) ? Number(x.toFixed(2)) : null);
91
+
92
+ // Deterministic, so a re-run reads the same range.
93
+ function mulberry32(seed) {
94
+ let a = seed >>> 0;
95
+ return () => {
96
+ a = (a + 0x6d2b79f5) >>> 0;
97
+ let t = a;
98
+ t = Math.imul(t ^ (t >>> 15), t | 1);
99
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
100
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
101
+ };
102
+ }
103
+
104
+ // The bootstrap 95% range of (cell − base) $ per merged PR, its ends rounded
105
+ // to cents. Each unit is one counted Pull: `{ usd, merged }`, merged 0 or 1.
106
+ // $ per merged PR is Σusd / Σmerged. null when either side has no unit, or
107
+ // when under half the resamples have a merged PR on both sides.
108
+ export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed = 1 } = {}) {
109
+ if (!cellUnits.length || !baseUnits.length) return null;
110
+ const rand = mulberry32(seed);
111
+ const resample = (units) => {
112
+ let u = 0, m = 0;
113
+ for (let i = 0; i < units.length; i++) {
114
+ const x = units[Math.floor(rand() * units.length)];
115
+ u += x.usd; m += x.merged;
116
+ }
117
+ return m ? u / m : null;
118
+ };
119
+ const diffs = [];
120
+ for (let i = 0; i < iterations; i++) {
121
+ const a = resample(cellUnits), b = resample(baseUnits);
122
+ if (a !== null && b !== null) diffs.push(a - b);
123
+ }
124
+ if (diffs.length < iterations / 2) return null;
125
+ diffs.sort((x, y) => x - y);
126
+ return [round2(diffs[Math.floor(0.025 * (diffs.length - 1))]), round2(diffs[Math.ceil(0.975 * (diffs.length - 1))])];
127
+ }
128
+
129
+ // A counted Pull as a bootstrap unit.
130
+ export const bootUnit = (u) => ({ usd: u.spend, merged: u.merged ? 1 : 0 });
131
+
76
132
  // ticket-features.tsv: one bare header line naming the columns, then rows.
77
133
  // Read by header name; a row whose width differs from the header's is refused.
78
134
  export function parseFeatures(text) {
@@ -125,7 +181,7 @@ function datedRows(features) {
125
181
 
126
182
  const CELL_ENDINGS = new Set(["hollow", "budget", "bail:fixed-refuted", "unknown"]);
127
183
  const NEITHER_ENDINGS = new Set(["provider", "crash", "abort"]);
128
- const ticketsEnding = (e) => e.startsWith("bail:") && !CELL_ENDINGS.has(e);
184
+ const ticketsEnding = (e) => e === "blocked" || (e.startsWith("bail:") && !CELL_ENDINGS.has(e));
129
185
  const failsFloor = (ruling) => ruling.minted_false_claim === "yes" || ruling.closed_own_ticket === "no";
130
186
  // An omp session id opens with its ISO start time, so two of them sort in time order.
131
187
  const STARTED = /^\d{4}-\d{2}-\d{2}T/;
@@ -309,10 +365,11 @@ export function generations(units) {
309
365
 
310
366
  /**
311
367
  * Per cell, in cell-name order: `{ cell, generation, n, merged, claims, failed,
312
- * spend, excluded, left_out, unpriced, pending, older }`. `generation` is the
313
- * cell's newest (null when no Pull of it has one), and the figures beside it
314
- * are that generation's alone: `n`, `merged`, `claims`, `failed` and `spend`
315
- * over its counted Pulls, then its left-out, unpriced and pending Pull counts.
368
+ * spend, pulls, excluded, left_out, unpriced, pending, older }`. `generation`
369
+ * is the cell's newest (null when no Pull of it has one), and the figures
370
+ * beside it are that generation's alone: `n`, `merged`, `claims`, `failed` and
371
+ * `spend` over its counted Pulls, `pulls` those counted units themselves in
372
+ * the order of `units`, then its left-out, unpriced and pending Pull counts.
316
373
  * `excluded` counts the cell's excluded Pulls, which have no generation.
317
374
  * `older` is an array, newest first, of every other generation's figures in
318
375
  * the same shape, each carrying its `generation`. `order` is `generations()`
@@ -321,7 +378,10 @@ export function generations(units) {
321
378
  * never moves a cell's newest generation.
322
379
  */
323
380
  export function tally(units, order = generations(units)) {
324
- const figures = (generation) => ({ generation, n: 0, merged: 0, claims: 0, failed: 0, spend: 0, left_out: 0, unpriced: 0, pending: 0 });
381
+ // `n` is read off `pulls`, so the count and the list cannot drift apart.
382
+ const figures = (generation) => ({
383
+ generation, get n() { return this.pulls.length; }, merged: 0, claims: 0, failed: 0, spend: 0, pulls: [], left_out: 0, unpriced: 0, pending: 0,
384
+ });
325
385
  const cells = new Map();
326
386
  for (const u of units) {
327
387
  const c = cells.get(u.cell) ?? cells.set(u.cell, { excluded: 0, gens: new Map() }).get(u.cell);
@@ -332,11 +392,11 @@ export function tally(units, order = generations(units)) {
332
392
  const g = u.generation ?? null;
333
393
  const f = c.gens.get(g) ?? c.gens.set(g, figures(g)).get(g);
334
394
  if (u.status === "counted") {
335
- f.n++;
336
395
  if (u.merged) f.merged++;
337
396
  f.claims += u.claims;
338
397
  f.failed += u.falseClaims;
339
398
  f.spend += u.spend;
399
+ f.pulls.push(u);
340
400
  } else f[u.status.replace("-", "_")]++;
341
401
  }
342
402
  return [...cells].sort(([a], [b]) => cmp(a, b)).map(([cell, c]) => {
@@ -6,7 +6,7 @@
6
6
  // (flat: no per-run directory), `<member>` being its ledger name (`impl-412`,
7
7
  // `impl-412-b`, `fix-pr-88`, `finisher-pr-88`, `merge-bot-3`). The file is
8
8
  // checked against the schema of the member's family, report-schema.mjs's
9
- // SCHEMAS, and the rule reportViolation() adds to it. The review runner and
9
+ // SCHEMAS, and the rules reportViolation() adds to them. The review runner and
10
10
  // every member outside those four families have no schema, so no file to
11
11
  // look up.
12
12
  //
@@ -18,9 +18,14 @@
18
18
  // 0 print: the schema is on stdout. validate: `valid` on stdout
19
19
  // 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
20
20
  // a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
21
- // is one that repeats a key, as `$.merged: duplicate key "merged"`, and
22
- // a merge-bot report giving one PR two verdicts, as
23
- // `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`
21
+ // is one that repeats a key, as `$.merged: duplicate key "merged"`, a
22
+ // merge-bot report giving one PR two verdicts, as
23
+ // `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`,
24
+ // and an impl report whose `blocked` outcome names a `bailCause`, as
25
+ // `$.bailCause: "<cause>" is not null, with outcome "blocked" — a block
26
+ // is no bail`, gives no reason in `bailDetail`, or names a `pr` or a
27
+ // `head`, as `$.pr: <pr> is not null, with outcome "blocked" — a block
28
+ // reports no PR`
24
29
  // 2 usage: no or unknown subcommand, a wrong argument count, an unknown
25
30
  // family, any flag; or <file> could not be read; or print could not
26
31
  // write the schema to stdout. Refused on stderr, nothing on stdout
@@ -112,7 +117,9 @@ const issues = listOf({ issue: id, subject: str });
112
117
  export const SCHEMAS = {
113
118
  impl: report({
114
119
  member: str,
115
- outcome: { type: "string", enum: ["pr", "bailed"] },
120
+ // `bailed` is the pre-code outcome; `blocked` a block or ambiguity found
121
+ // after implementation began, its reason in `bailDetail`.
122
+ outcome: { type: "string", enum: ["pr", "bailed", "blocked"] },
116
123
  pr: { ...id, type: ["integer", "null"] },
117
124
  head: { type: ["string", "null"], pattern: HEX },
118
125
  branch: str,
@@ -174,14 +181,25 @@ export function mergeBotVerdicts(report) {
174
181
  ];
175
182
  }
176
183
 
177
- // The first violation of `family`'s schema by `value`, or null; for a
178
- // merge-bot report then the rule no schema keyword states: each PR gets one
179
- // verdict — merged, held, conflict-held or refused — the last it reached in
180
- // the pass, so a PR named twice, in one list or in two, is a violation at
181
- // its second entry.
184
+ // The first violation of `family`'s schema by `value`, or null; then the rules
185
+ // no schema keyword states. For an impl report: a `blocked` outcome names no
186
+ // `bailCause`, since a block is no bail, gives its reason in `bailDetail`,
187
+ // text that is not only whitespace, and names no `pr` or `head`, since a block
188
+ // reports no PR — a PR it opened is outcome `pr`, which settle checks. For a
189
+ // merge-bot report: each PR gets one verdict — merged, held, conflict-held or
190
+ // refused — the last it reached in the pass, so a PR named twice, in one list
191
+ // or in two, is a violation at its second entry.
182
192
  export function reportViolation(family, value) {
183
193
  const found = validate(SCHEMAS[family], value);
184
- if (found !== null || family !== "merge-bot") return found;
194
+ if (found !== null) return found;
195
+ if (family === "impl" && value.outcome === "blocked") {
196
+ if (value.bailCause !== null) return `$.bailCause: ${show(value.bailCause)} is not null, with outcome "blocked" — a block is no bail`;
197
+ if (!/\S/.test(value.bailDetail ?? "")) return `$.bailDetail: ${show(value.bailDetail)} is not a reason, with outcome "blocked"`;
198
+ for (const field of ["pr", "head"]) {
199
+ if (value[field] !== null) return `$.${field}: ${show(value[field])} is not null, with outcome "blocked" — a block reports no PR`;
200
+ }
201
+ }
202
+ if (family !== "merge-bot") return null;
185
203
  const first = new Map();
186
204
  for (const { pr, path } of mergeBotVerdicts(value)) {
187
205
  if (first.has(pr)) return `${path}: PR #${pr} already has a verdict at ${first.get(pr)}`;
@@ -17,6 +17,14 @@
17
17
  "tau": null
18
18
  },
19
19
  "estimates": {},
20
+ "reasons": {
21
+ "*": {
22
+ "cell": "slow-high",
23
+ "reason": "unjudged",
24
+ "cells": {},
25
+ "pairs": []
26
+ }
27
+ },
20
28
  "guard": {
21
29
  "tripped": [],
22
30
  "n": {}
@@ -20,8 +20,10 @@
20
20
  // --guard <cost-guard.json> --commit <rev> [--due]
21
21
  // Re-fits the table from the three TSVs as committed at `--commit` (read
22
22
  // with `git show`, never from the working tree) and the guard, and
23
- // rewrites `--table`, recording the commit's sha as `fitted_commit`. The
24
- // TSV flags name paths inside the repository that holds them. With
23
+ // rewrites `--table`, recording the commit's sha as `fitted_commit`, then
24
+ // prints the fit and one line per row: its cell and the reason, then each
25
+ // candidate's verdict and range. The TSV flags name paths inside the
26
+ // repository that holds them. With
25
27
  // `--due`, writes nothing and prints `DUE=yes|no MERGED=<n>/<N>`: whether
26
28
  // N merged PRs have landed since `fitted_commit`, counting the features
27
29
  // rows at `--commit` that the fitted commit's features file does not hold.
@@ -47,7 +49,9 @@ import { makeDie, defineFlags } from "./arg.mjs";
47
49
  import { CELL, POLICY_CELL, drawCell, parseMember } from "./ledger-grammar.mjs";
48
50
  import { sessionFromReceipt } from "./phase0-receipt.mjs";
49
51
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
50
- import { AttributionError, FEATURE_COLUMNS, attribute, generations, parseFeatures, tally } from "./pull-attribution.mjs";
52
+ import {
53
+ AttributionError, FEATURE_COLUMNS, TRIP_POINTS, attribute, bootUnit, bootstrapDiffCI, generations, parseFeatures, tally, underFloor,
54
+ } from "./pull-attribution.mjs";
51
55
  import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
52
56
 
53
57
  const NAME = "ticket-router";
@@ -61,7 +65,7 @@ const HEAVY_BRIEF_CHARS = 3483;
61
65
  const HEAVY_CRITERIA = 5;
62
66
  // Exploration cadence after burn-in: the Pull that creates `impl-` row 5k.
63
67
  const EXPLORE_EVERY = 5;
64
- // The fit's adoption floor (n per side), margin, and cadence.
68
+ // The fit's adoption floor (merged PRs per side), margin, and cadence.
65
69
  const MIN_N = 20;
66
70
  const ADOPT_RATIO = 0.75;
67
71
  export const FIT_EVERY_MERGED = 50;
@@ -346,15 +350,19 @@ function fitPulls({ features, members, verdicts, window }) {
346
350
  // Pulls, `merged`, $ per merged PR, false claims over claims, fix rounds per
347
351
  // merged PR, and its left-out and unpriced Pull counts. Beside them the cell's
348
352
  // excluded Pull count, `generation` naming the newest generation's model, and
349
- // `older`, each older generation's model to its figures, newest first.
353
+ // `older`, each older generation's model to its figures, newest first. Beside
354
+ // `estimates`, `pulls`: per stratum and `*`, per cell, the newest generation's
355
+ // counted Pulls those figures are over.
350
356
  function estimate(units, order) {
351
- const out = {};
357
+ const estimates = {};
358
+ const pulls = {};
352
359
  for (const s of units.length ? [...new Set([...units.map((u) => u.stratum), "*"])].sort() : []) {
353
360
  const mine = s === "*" ? units : units.filter((u) => u.stratum === s);
354
- out[s] = {};
361
+ estimates[s] = {};
362
+ pulls[s] = {};
355
363
  for (const t of tally(mine, order)) {
356
364
  const figures = (g) => {
357
- const fix = mine.filter((u) => u.cell === t.cell && u.generation === g.generation && u.status === "counted" && u.merged)
365
+ const fix = g.pulls.filter((u) => u.merged)
358
366
  .reduce((n, u) => n + u.rows.filter((r) => parseMember(r.member)?.family === "fix-pr").length, 0);
359
367
  return {
360
368
  n: g.n,
@@ -369,52 +377,123 @@ function estimate(units, order) {
369
377
  unpriced: g.unpriced,
370
378
  };
371
379
  };
372
- out[s][t.cell] = {
380
+ estimates[s][t.cell] = {
373
381
  generation: t.generation,
374
382
  ...figures(t),
375
383
  excluded: t.excluded,
376
384
  older: Object.fromEntries(t.older.map((o) => [o.generation, figures(o)])),
377
385
  };
386
+ pulls[s][t.cell] = t.pulls;
378
387
  }
379
388
  }
380
- return out;
389
+ return { estimates, pulls };
381
390
  }
382
391
 
383
- // Among cells with n >= MIN_N in a stratum and not tripped, the one with the
384
- // lowest $ per merged PR — adopted only at or under ADOPT_RATIO of the
385
- // default cell's. Every figure read is the cell's newest generation's, the
386
- // default cell's included. The quality floor is the guard's to enforce, never
387
- // the fit's. null when the stratum cannot decide (no default cell at n, or no
388
- // candidate at n).
389
- function adopt(est, cells, tripped) {
390
- const base = est?.[POLICY_CELL];
391
- if (!base || base.n < MIN_N || base.usd_per_merged === null) return null;
392
- const candidates = cells
393
- .filter((c) => c !== POLICY_CELL && !tripped.has(c))
394
- .map((c) => ({ c, e: est[c] }))
395
- .filter(({ e }) => e && e.n >= MIN_N && e.usd_per_merged !== null)
396
- .sort((a, b) => a.e.usd_per_merged - b.e.usd_per_merged || (a.c < b.c ? -1 : 1));
397
- if (candidates.length === 0) return null;
398
- return candidates[0].e.usd_per_merged <= ADOPT_RATIO * base.usd_per_merged ? candidates[0].c : POLICY_CELL;
392
+ /**
393
+ * One row's cell — the `*` row's or a stratum's — and why. The quality floor
394
+ * is held twice: pooled by the guard, whose trip takes a cell out of every row
395
+ * and out of the exploration draw, and per row by the fit, which refuses a
396
+ * cell under the floor that row alone. Every figure read is the cell's newest
397
+ * generation's, the default cell's included. `est` and `pulls` are the row's
398
+ * `estimate()` figures and counted Pulls.
399
+ *
400
+ * A candidate is a cell of `cells` other than the default cell. Its `verdict`,
401
+ * the first that applies: `tripped` by the guard; `too-few`, under MIN_N
402
+ * merged PRs in the row; `under-floor` against the default cell there;
403
+ * `too-dear`, above ADOPT_RATIO of the default cell's $ per merged PR;
404
+ * `not-reliably-cheaper`, its `range` does not lie wholly below zero (its
405
+ * upper end, as `bootstrapDiffCI` returns it, is not below zero) or there is
406
+ * none; else it `qualifies`. A candidate past `too-few` records whether it is
407
+ * `under_floor`, the floor's `bar` (the default cell's fail rate plus
408
+ * TRIP_POINTS, rounded as a fail rate is, for reading only: the floor compares
409
+ * counts) and its `range`, the bootstrap 95% range of (candidate − default
410
+ * cell) $ per merged PR over those Pulls; the others record null for each.
411
+ *
412
+ * Several qualifiers: each pair records the range of (the cheaper by $ per
413
+ * merged PR, else by name − the other) in `pairs`, and a qualifier another
414
+ * is reliably cheaper than is dropped. Among the rest the lower fail rate,
415
+ * compared in counts, wins, then the lower $ per merged PR, then the name.
416
+ *
417
+ * `reason`, why the row holds `cell`: `unjudged` (the default cell, or every
418
+ * untripped candidate, is under MIN_N merged PRs; `cell` is null and a
419
+ * stratum follows `*`), `no-qualifier` (the default cell), or, for the
420
+ * winner, `sole-qualifier`, `reliably-cheaper` (every other qualifier was
421
+ * dropped), or what put it ahead of the next of the rest: `lower-fail-rate`,
422
+ * `lower-usd` or `name`.
423
+ */
424
+ function judgeRow(est = {}, pulls, cells, tripped) {
425
+ const record = { cell: null, reason: "unjudged", cells: {}, pairs: [] };
426
+ const base = est[POLICY_CELL];
427
+ if (!base || base.merged < MIN_N) return record;
428
+ // JSON writes −0 as 0, so a recorded end is 0 either way; `--check` compares the two.
429
+ const range = (a, b) => bootstrapDiffCI(pulls[a].map(bootUnit), pulls[b].map(bootUnit))?.map((x) => x + 0) ?? null;
430
+ const reliablyBelow = (r) => r !== null && r[1] < 0;
431
+ const bar = round4(base.failed / base.claims + TRIP_POINTS / 100);
432
+ const qualifiers = [];
433
+ let judged = false;
434
+ for (const c of cells) {
435
+ if (c === POLICY_CELL) continue;
436
+ const e = est[c];
437
+ if (tripped.has(c) || !e || e.merged < MIN_N) {
438
+ record.cells[c] = { verdict: tripped.has(c) ? "tripped" : "too-few", under_floor: null, bar: null, range: null };
439
+ continue;
440
+ }
441
+ judged = true;
442
+ const under = underFloor(e, base);
443
+ const r = range(c, POLICY_CELL);
444
+ const verdict = under ? "under-floor"
445
+ : e.usd_per_merged > ADOPT_RATIO * base.usd_per_merged ? "too-dear"
446
+ : reliablyBelow(r) ? "qualifies" : "not-reliably-cheaper";
447
+ record.cells[c] = { verdict, under_floor: under, bar, range: r };
448
+ if (verdict === "qualifies") qualifiers.push(c);
449
+ }
450
+ if (!judged) return record;
451
+ if (qualifiers.length === 0) return { ...record, cell: POLICY_CELL, reason: "no-qualifier" };
452
+
453
+ const usd = (c) => est[c].usd_per_merged;
454
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
455
+ const failCmp = (a, b) => est[a].failed * est[b].claims - est[b].failed * est[a].claims;
456
+ const named = [...qualifiers].sort(byName);
457
+ const dropped = new Set();
458
+ for (let i = 0; i < named.length; i++) {
459
+ for (let j = i + 1; j < named.length; j++) {
460
+ const [cheaper, other] = usd(named[j]) < usd(named[i]) ? [named[j], named[i]] : [named[i], named[j]];
461
+ const r = range(cheaper, other);
462
+ record.pairs.push({ cheaper, other, range: r });
463
+ if (reliablyBelow(r)) dropped.add(other);
464
+ }
465
+ }
466
+ // The cheapest qualifier, by $ then name, is never dropped, so the rest is never empty.
467
+ const [win, next] = named.filter((c) => !dropped.has(c)).sort((a, b) => failCmp(a, b) || usd(a) - usd(b) || byName(a, b));
468
+ const reason = qualifiers.length === 1 ? "sole-qualifier"
469
+ : next === undefined ? "reliably-cheaper"
470
+ : failCmp(win, next) !== 0 ? "lower-fail-rate"
471
+ : usd(win) !== usd(next) ? "lower-usd" : "name";
472
+ return { ...record, cell: win, reason };
399
473
  }
400
474
 
401
475
  /**
402
476
  * The re-fit table. `guard` is `{ tripped, n, window_start }` (readGuard's
403
477
  * shape); `commit` is the sha of the commit the inputs were read from, or null
404
- * for the empty corpus before any fit.
478
+ * for the empty corpus before any fit. `reasons` holds `judgeRow()`'s record
479
+ * for `*` and for each stratum with Pulls; a stratum whose record has no cell
480
+ * gets no row and follows `*`, and an unjudged `*` is the default cell.
405
481
  */
406
482
  export function fitTable({ prior, features, members, verdicts, guard, commit = null }) {
407
483
  const window = guard.window_start ?? prior.window_start ?? null;
408
484
  const { units, order } = fitPulls({ features, members, verdicts, window });
409
- const estimates = estimate(units, order);
485
+ const { estimates, pulls } = estimate(units, order);
410
486
  const tripped = new Set(guard.tripped);
411
487
 
412
488
  let { stage, cells } = prior;
413
489
  cells = [...cells];
414
- const rows = { "*": adopt(estimates["*"], cells, tripped) ?? POLICY_CELL };
490
+ const star = judgeRow(estimates["*"], pulls["*"], cells, tripped);
491
+ const rows = { "*": star.cell ?? POLICY_CELL };
492
+ const reasons = { "*": { ...star, cell: rows["*"] } };
415
493
  for (const s of STRATA) {
416
- const a = adopt(estimates[s], cells, tripped);
417
- if (a !== null) rows[s] = a;
494
+ if (!estimates[s]) continue;
495
+ reasons[s] = judgeRow(estimates[s], pulls[s], cells, tripped);
496
+ if (reasons[s].cell !== null) rows[s] = reasons[s].cell;
418
497
  }
419
498
  const atN = (c) => (guard.n[c] ?? 0) >= MIN_N;
420
499
  if (stage === 1 && cells.every(atN) && (rows["*"] !== POLICY_CELL || cells.some((c) => tripped.has(c)))) {
@@ -433,6 +512,7 @@ export function fitTable({ prior, features, members, verdicts, guard, commit = n
433
512
  rows,
434
513
  classifier: prior.classifier,
435
514
  estimates,
515
+ reasons,
436
516
  guard: { tripped: [...tripped].sort(), n: Object.fromEntries(Object.keys(guard.n).sort().map((c) => [c, guard.n[c]])) },
437
517
  };
438
518
  }
@@ -462,6 +542,16 @@ function cellCounts(estimates) {
462
542
  .join(", ") || "none";
463
543
  }
464
544
 
545
+ // One line per row the fit judged or skipped: its cell (`-` for a stratum
546
+ // that follows `*`) and its reason, then each candidate's verdict and range.
547
+ function rowLines(reasons) {
548
+ const range = (r) => (r === null ? "" : ` [${r.map((x) => x.toFixed(2)).join(", ")}]`);
549
+ return Object.entries(reasons).map(([s, r]) => {
550
+ const cells = Object.entries(r.cells).map(([c, x]) => `${c} ${x.verdict}${range(x.range)}`).join(", ");
551
+ return `${NAME}: row ${s} ${r.cell ?? "-"} ${r.reason}${cells ? ` (${cells})` : ""}\n`;
552
+ }).join("");
553
+ }
554
+
465
555
  // ---------------------------------------------------------------------------
466
556
  // the commit a fit reads
467
557
  // ---------------------------------------------------------------------------
@@ -638,7 +728,7 @@ function main() {
638
728
  const next = joined(() => fitTable({ prior: table, features, members, verdicts, guard, commit: sha }));
639
729
  writeFileSync(`${tablePath}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
640
730
  renameSync(`${tablePath}.tmp`, tablePath);
641
- process.stdout.write(`${NAME}: fitted ${next.n_rows} Pulls at ${short(sha)}; rows ${JSON.stringify(next.rows)}; burn_in ${next.burn_in}; stage ${next.stage}; cells ${cellCounts(next.estimates)}\n`);
731
+ process.stdout.write(`${NAME}: fitted ${next.n_rows} Pulls at ${short(sha)}; rows ${JSON.stringify(next.rows)}; burn_in ${next.burn_in}; stage ${next.stage}; cells ${cellCounts(next.estimates)}\n${rowLines(next.reasons)}`);
642
732
  }
643
733
 
644
734
  if (isCLI(import.meta.url)) main();
@@ -792,7 +792,7 @@ Match the failure to its row in the table below and run that response; where the
792
792
  |---|---|
793
793
  | SHA not on expected branch | Flag, do not enqueue, report |
794
794
  | Implementer bails before implementing | Act by the report's `bailCause` as the **Relabel causes** table says, then refill (**What a wake owes beyond its call**) |
795
- | Implementer blocked or ambiguous *mid-implementation* | Free the slot, `ledger.mjs settle impl-<N> blocked`, leave `in-progress`, report — the ticket is not In flight and waits for a human; the row above is the pre-code bail, not this one |
795
+ | Implementer blocked or ambiguous *mid-implementation* | Free the slot. A `blocked` report settles on the **Implementer report** wake, no label moved; with no report, `ledger.mjs settle impl-<N> blocked`, its Ending still `hollow`. Leave `in-progress`, report — the ticket is not In flight and waits for a human; the row above is the pre-code bail |
796
796
  | `review-pr` workflow throws or returns no tree | Retry once under the next letter (`review-pr-<pr#>-b`); still failing, report and leave the PR at review-due. Never enqueue the PR as reviewed — the workflow is not a member, so no other row here covers it |
797
797
  | Reviewer, fix-applier or red-check fixer cannot reach green | Report, leave the PR unlabeled, free the slot — a fix-applier or red-check fixer settles `failed` |
798
798
  | Merge bot hits the hold rule | Report `held-behind-#<lower>`, PR stays queued |
@@ -841,9 +841,7 @@ enqueue, do not return the ticket to the shortlist until the maintainer rules.
841
841
  escalate if it repeats, and never read it as `not reachable` or record it as a
842
842
  stray commit. See references/failure-handling.md.
843
843
 
844
- **Never `--delete-branch` — the merge bot deletes the head branch itself.**
845
- `gh pr merge <n> --merge` alone: the flag's local cleanup errors on a `main`
846
- held by another worktree, or strands the feature worktree on `main`.
844
+ **Never `--delete-branch` — the merge bot deletes the head branch itself.** See references/failure-handling.md.
847
845
 
848
846
  **Never force a rebase to start.** No `git clean`, `git checkout .`,
849
847
  `git reset --hard`, `git stash`. Non-empty `git status --porcelain -unormal` →
@@ -66,6 +66,13 @@ escalate if it repeats. Never read exit 2 as `not reachable`, and never record i
66
66
  as a stray commit — the flag and the maintainer's ruling above are what exit 1
67
67
  earns, and spending them on a probe that never answered costs a member its
68
68
  ticket over a failure a re-run would have cleared.
69
+
70
+ ## Never `--delete-branch`
71
+
72
+ **Never `--delete-branch` — the merge bot deletes the head branch itself.**
73
+ `gh pr merge <n> --merge` alone: the flag's local cleanup errors on a `main`
74
+ held by another worktree, or strands the feature worktree on `main`.
75
+
69
76
  ## Never force a rebase to start
70
77
 
71
78
  **Never force a rebase to start.** No `git clean`, `git checkout .`,