@feigi/fleet-ctl 3.94.1 → 3.95.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.94.1",
3
+ "version": "3.95.1",
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": {
@@ -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
@@ -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) {
@@ -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]) => {
@@ -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();