@feigi/fleet-ctl 3.93.5 → 3.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.93.5",
3
+ "version": "3.94.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": {
@@ -14,7 +14,7 @@
14
14
  // ADMISSIBLE ROW. A Pull counts only when its member row exists, was
15
15
  // dispatched as the drawn cell's own definition (`subagent_type` =
16
16
  // `fleet-implementer-<chosen_cell>`), ran at the cell's level (`effort` = the
17
- // cell's `<level>`) and has a resolved `model` on record. So a generic `task`
17
+ // cell's `<level>`) and has its `dispatch_model` on record. So a generic `task`
18
18
  // dispatch, a clamped effort and every pre-cutover `fleet-implementer` /
19
19
  // `fleet-implementer-alt` row are never admissible. Whether the resolved model
20
20
  // was the role's target at dispatch is recorded in neither file; that half of
@@ -22,7 +22,7 @@
22
22
  //
23
23
  // COMPARISON. For a cell X other than slow-high, a comparison is one session
24
24
  // holding at least one admissible X row and at least one admissible slow-high
25
- // row whose resolved (`model`, `effort`) differ from that X row's. A session
25
+ // row whose (`dispatch_model`, `effort`) differ from that X row's. A session
26
26
  // counts once however many rows it holds, and its date is its member-outcomes
27
27
  // `run_date`; a blank `run_date` means unknown, so that session is still a
28
28
  // comparison but adds no date to the distinct-date count.
@@ -33,12 +33,13 @@
33
33
  //
34
34
  // <cell> <comparisons> <run_dates> <resolved models>
35
35
  //
36
- // `<resolved models>` is the model of every admissible row at the cell — one
37
- // model by name, or `mixed (<model-a> n=…, <model-b> n=…)` when they span more
38
- // than one, because a role's target is operator config that can move under a
39
- // cell's history. A cell below the gate prints nothing on stdout and one
40
- // `below the gate` count on stderr. Neither the gate line nor that count names
41
- // a session, ticket or member: the gate forbids reading a comparison early.
36
+ // `<resolved models>` is the `dispatch_model` of every admissible row at the
37
+ // cell — one model by name, or `mixed (<model-a> n=…, <model-b> n=…)` when
38
+ // they span more than one, because a role's target is operator config that
39
+ // can move under a cell's history. A cell below the gate prints nothing on
40
+ // stdout and one `below the gate` count on stderr. Neither the gate line nor
41
+ // that count names a session, ticket or member: the gate forbids reading a
42
+ // comparison early.
42
43
  // Only the refusal of conflicting ticket-features rows names a session and an
43
44
  // agent, and it prints nothing on stdout.
44
45
  //
@@ -49,7 +50,11 @@
49
50
  // the ticket's last Pull in file order, at any cell, slow-high included,
50
51
  // dated on or before the ruling. Dates only set that floor: a ruling counts
51
52
  // for a Pull only when dated on or after it, and neither pick is the
52
- // latest-dated row. A Pull at X is
53
+ // latest-dated row. A Pull's GENERATION is pull-attribution.mjs's
54
+ // (`pullGenerations`): the `dispatch_model` of its first Admissible
55
+ // implementer row in dispatch order, over every implementer row booked to
56
+ // the Pull, its retries included, so the guard and this rule never name two
57
+ // generations for one Pull. A Pull is admissible here when it has one. A Pull at X is
53
58
  // COUNTABLE when X is a cell other than slow-high whose definition is live
54
59
  // and the Pull is admissible and dated on or after the day X's
55
60
  // `fleet-implementer-<cell>` definition was most recently added. The ruling
@@ -67,8 +72,12 @@
67
72
  // could move the verdict to another cell or uncount it. So is an admissible
68
73
  // Pull at X whose `run_date` is not `YYYY-MM-DD`: it cannot be placed against
69
74
  // the day X's definition was added, and dropping it would uncount its ruling.
70
- // X is to be withdrawn once it has at least STOP.verdicts verdicts and
71
- // floor failures ÷ verdicts is at least STOP.failRate.
75
+ // X is to be withdrawn once its newest generation has at least STOP.verdicts
76
+ // verdicts and floor failures ÷ verdicts is at least STOP.failRate. A ruling
77
+ // counts for its carrier's generation; a cell's newest generation is that of
78
+ // its latest admissible Pull, by session id, then file order. An older
79
+ // generation's verdicts are returned beside the newest's and never withdraw
80
+ // the cell.
72
81
  //
73
82
  // Exit 0 whatever the counts; 2 on a usage error or an unreadable or
74
83
  // malformed input, with nothing on stdout.
@@ -78,7 +87,7 @@ import { makeDie, defineFlags } from "./arg.mjs";
78
87
  import { isCLI } from "./is-cli.mjs";
79
88
  import { POLICY_CELL } from "./ledger-grammar.mjs";
80
89
  import { formatTsv as formatMemberTsv, parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
81
- import { admissibleRow, parseFeatures } from "./pull-attribution.mjs";
90
+ import { admissibleRow, generations, parseFeatures, pullGenerations } from "./pull-attribution.mjs";
82
91
  import { DATE, rulingFor, rulingsByTicket } from "./tier-outcomes.mjs";
83
92
 
84
93
  const NAME = "cell-readout";
@@ -128,7 +137,7 @@ function indexMembers(members) {
128
137
  * `cells`: one entry per cell other than the policy cell that has a
129
138
  * ticket-features row, `{ cell, comparisons, runDates, dates, models, gated }`,
130
139
  * sorted by cell. `dates` is the comparisons' distinct run_dates, sorted;
131
- * `models` maps each resolved model of the cell's admissible rows to its row
140
+ * `models` maps each `dispatch_model` of the cell's admissible rows to its row
132
141
  * count, largest first. `unjoined` counts Pulls with no member row. Throws on
133
142
  * two ticket-features rows of one session+agent naming different cells, and on
134
143
  * a member-outcomes session+agent repeated with different fields.
@@ -144,9 +153,9 @@ export function readout({ features, members }) {
144
153
  if (!byKey.has(key(p.session, p.agent))) unjoined++;
145
154
  const m = admissibleMember(p, byKey);
146
155
  if (!m) continue;
147
- if (p.chosen_cell !== POLICY_CELL) cells.get(p.chosen_cell).set(m.model, (cells.get(p.chosen_cell).get(m.model) ?? 0) + 1);
156
+ if (p.chosen_cell !== POLICY_CELL) cells.get(p.chosen_cell).set(m.dispatchModel, (cells.get(p.chosen_cell).get(m.dispatchModel) ?? 0) + 1);
148
157
  if (!sessions.has(p.session)) sessions.set(p.session, { runDate: m.run_date, rows: [] });
149
- sessions.get(p.session).rows.push({ cell: p.chosen_cell, model: m.model, effort: m.effort });
158
+ sessions.get(p.session).rows.push({ cell: p.chosen_cell, model: m.dispatchModel, effort: m.effort });
150
159
  }
151
160
 
152
161
  const out = [...cells].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, modelCounts]) => {
@@ -169,15 +178,22 @@ export function readout({ features, members }) {
169
178
  /**
170
179
  * One entry per cell named in `added` (cell → the `YYYY-MM-DD` its definition
171
180
  * was most recently added) other than the policy cell, sorted by cell:
172
- * `{ cell, since, verdicts, failures, stop }`. `verdicts` is
181
+ * `{ cell, since, generation, verdicts, failures, stop, older }`. `verdicts` is
173
182
  * `[{ ticket, pr, run_date, closed_own_ticket, minted_false_claim, failed }]`
174
183
  * sorted by ticket, `run_date` the ruling's; `stop` is whether the stopping
175
184
  * rule withdraws the cell. `verdicts` are parsed tier-outcomes.tsv rows.
176
185
  * A ticket's ruling is its last ruling row in file order, charged to the cell
177
186
  * of its carrier, the ticket's last Pull in file order at any cell dated on or
178
187
  * before the ruling, and only when that Pull is
179
- * countable: admissible, at a cell named in `added` other than the policy
188
+ * countable: admissible (it has a generation, pull-attribution.mjs's
189
+ * `pullGenerations`), at a cell named in `added` other than the policy
180
190
  * cell, and dated on or after the day that cell's definition was added.
191
+ * A ruling is charged to its carrier's generation too, and `generation`,
192
+ * `verdicts`, `failures` and `stop` are the cell's newest generation's alone
193
+ * (pull-attribution.mjs's `generations`, over the cell's admissible Pulls;
194
+ * null when it has none). `older` holds `{ generation, verdicts, failures }`
195
+ * for each other generation with at least one verdict, newest first, and
196
+ * never a stop.
181
197
  * Throws on a non-blank ruling row of a ticket with a countable Pull whose
182
198
  * `closed_own_ticket` or `minted_false_claim` is not `yes` or `no` or whose
183
199
  * `run_date` is not `YYYY-MM-DD`,
@@ -187,19 +203,25 @@ export function readout({ features, members }) {
187
203
  * repeated with different fields.
188
204
  */
189
205
  export function stoppingRule({ features, members, verdicts, added }) {
190
- const byKey = indexMembers(members);
206
+ indexMembers(members); // refuses a session+agent repeated with different fields
191
207
  const rulings = rulingsByTicket(verdicts);
192
- const charged = new Map(Object.keys(added).filter((c) => c !== POLICY_CELL).map((c) => [c, []]));
208
+ const charged = new Map(Object.keys(added).filter((c) => c !== POLICY_CELL).map((c) => [c, new Map()]));
193
209
  const dated = (p) => {
194
210
  if (!DATE.test(p.run_date)) throw new Error(`ticket #${p.ticket} (Pull ${p.agent} at ${p.chosen_cell}): run_date is '${p.run_date}', expected YYYY-MM-DD`);
195
211
  return p.run_date;
196
212
  };
197
- const countable = new Set();
213
+ // A Pull's generation is pull-attribution's, read over all of its implementer
214
+ // rows; a Pull with none is not admitted.
215
+ const generationOf = pullGenerations(features, members);
216
+ const countable = new Map(); // Pull -> its generation
217
+ const admitted = []; // every Pull with a generation at a judged cell, as generations() reads one
198
218
  const pullsByTicket = new Map();
199
219
  for (const p of features) {
200
220
  (pullsByTicket.get(p.ticket) ?? pullsByTicket.set(p.ticket, []).get(p.ticket)).push(p);
201
- if (!charged.has(p.chosen_cell) || !admissibleMember(p, byKey)) continue;
202
- if (dated(p) >= added[p.chosen_cell]) countable.add(p);
221
+ const g = charged.has(p.chosen_cell) ? generationOf.get(p) : null;
222
+ if (!g) continue;
223
+ admitted.push({ cell: p.chosen_cell, generation: g, pull: p });
224
+ if (dated(p) >= added[p.chosen_cell]) countable.set(p, g);
203
225
  }
204
226
  for (const [ticket, pulls] of pullsByTicket) {
205
227
  if (!pulls.some((p) => countable.has(p))) continue;
@@ -210,17 +232,25 @@ export function stoppingRule({ features, members, verdicts, added }) {
210
232
  let carrier = null;
211
233
  for (const p of pulls) if (dated(p) <= v.run_date) carrier = p;
212
234
  if (!carrier || !countable.has(carrier)) continue;
213
- charged.get(carrier.chosen_cell).push({
235
+ const byGeneration = charged.get(carrier.chosen_cell);
236
+ const g = countable.get(carrier);
237
+ (byGeneration.get(g) ?? byGeneration.set(g, []).get(g)).push({
214
238
  ticket: carrier.ticket, pr: v.pr, run_date: v.run_date,
215
239
  closed_own_ticket: v.closed_own_ticket, minted_false_claim: v.minted_false_claim,
216
240
  failed: v.minted_false_claim === "yes" || v.closed_own_ticket === "no",
217
241
  });
218
242
  }
219
- return [...charged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, list]) => {
243
+ const order = generations(admitted);
244
+ const tallied = (list = []) => {
220
245
  list.sort((a, b) => Number(a.ticket) - Number(b.ticket));
221
- const failures = list.filter((v) => v.failed).length;
246
+ return { verdicts: list, failures: list.filter((v) => v.failed).length };
247
+ };
248
+ return [...charged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, byGeneration]) => {
249
+ const [generation = null, ...rest] = order.get(cell) ?? [];
250
+ const { verdicts: list, failures } = tallied(byGeneration.get(generation));
222
251
  const stop = list.length >= STOP.verdicts && failures / list.length >= STOP.failRate;
223
- return { cell, since: added[cell], verdicts: list, failures, stop };
252
+ const older = rest.filter((g) => byGeneration.has(g)).map((g) => ({ generation: g, ...tallied(byGeneration.get(g)) }));
253
+ return { cell, since: added[cell], generation, verdicts: list, failures, stop, older };
224
254
  });
225
255
  }
226
256
 
@@ -13,6 +13,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
 
15
15
  import { readOmpSession, normalizeModel, parseMemberName, isOmpSessionDirName } from "./member-record.mjs";
16
+ import { stripLevel } from "./tier-roles.mjs";
16
17
  import { reportFamily, reportStatus, defaultScratch } from "./report-files.mjs";
17
18
  import { FleetDirUnresolvable } from "./fleet-dir.mjs";
18
19
  import { spawnSync } from "node:child_process";
@@ -91,9 +92,20 @@ export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMain
91
92
  tokensIn: r.tokens_in, tokensCacheRead: r.tokens_cache_read,
92
93
  tokensCacheWrite1h: r.tokens_cache_write_1h ?? "", cost: r.cost == null ? "" : Number(r.cost.toFixed(6)),
93
94
  ending: endings[i],
95
+ dispatchModel: dispatchModel(r.resolvedModelIdentity),
94
96
  }));
95
97
  }
96
98
 
99
+ // The `dispatch_model` column: the model fixed at dispatch, `session_init`'s
100
+ // own model (the record's `resolvedModelIdentity`), with its provider prefix
101
+ // and `:<level>` suffix stripped. `model` is the member's last turn, which a
102
+ // provider fallback moves; this one does not move. Blank when the transcript
103
+ // carries no `session_init` model: blank means unknown. Operator config is
104
+ // never read for it.
105
+ export function dispatchModel(identity) {
106
+ return stripLevel(String(identity ?? "").trim()).split("/").at(-1);
107
+ }
108
+
97
109
  // A column is APPENDED, never inserted, as a rule: a read-out that indexes by
98
110
  // position (`$5`, in the file's header) silently repoints when one is added
99
111
  // ahead of it. `report` is placed after `subagent_type` regardless, which
@@ -108,7 +120,7 @@ export const COLUMNS = [
108
120
  "session", "run_date", "role", "member", "model", "effort", "ticket", "pr",
109
121
  "tokens_cache_create", "tokens_out", "wall_s", "turns", "agent", "harness",
110
122
  "subagent_type", "report", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost",
111
- "ending",
123
+ "ending", "dispatch_model",
112
124
  ];
113
125
 
114
126
  // `report`: what reportStatus() says of the row's member under `scratch` — a
@@ -221,7 +233,7 @@ function verifyMainWithGit(sha) {
221
233
  const FIELD = {
222
234
  tokens_cache_create: "tokensCacheCreate", tokens_out: "tokensOut", wall_s: "wallS",
223
235
  subagent_type: "subagentType", tokens_in: "tokensIn", tokens_cache_read: "tokensCacheRead",
224
- tokens_cache_write_1h: "tokensCacheWrite1h",
236
+ tokens_cache_write_1h: "tokensCacheWrite1h", dispatch_model: "dispatchModel",
225
237
  };
226
238
  const field = (c) => FIELD[c] ?? c;
227
239
  // Keyed on the transcript's path-relative stem, not the member name: `member`
@@ -39,16 +39,22 @@
39
39
  // of a windowed Pull's ticket whose run_date is not YYYY-MM-DD or whose verdict
40
40
  // is not yes or no.
41
41
  //
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.
42
+ // PER CELL GENERATION (pull-attribution.mjs's GENERATION), over its counted
43
+ // Pulls: `generation`, the dispatch model; `n_pulls`; `n_merged`; `n_claims`
44
+ // (merged PRs plus every `bail:fixed` or `bail:fixed-refuted` ending);
45
+ // `n_failed` (merged PRs ruled `minted_false_claim=yes` or
46
+ // `closed_own_ticket=no`, plus every `bail:fixed-refuted`); `fail_rate` =
47
+ // failed / claims; `mean_usd` = the Pulls' spend — merged or not — / merged,
48
+ // so a cell that abandons work carries that spend; `median_usd` over the
49
+ // merged Pulls alone, descriptive; `router_usd`, the Pulls' own routing cost,
50
+ // summed and never in their spend. `usd_diff_ci95` is a bootstrap 95% interval
51
+ // on `mean_usd` minus the baseline's — printed to be read, never judged on.
52
+ // Then the `left_out` and `unpriced` Pull counts, and the cell's `excluded`
53
+ // count, which no generation owns. The baseline, the guard file's cells and
54
+ // every trip are each cell's newest generation alone, so a cell whose model
55
+ // changed is judged afresh once its new generation reaches MIN_N merged PRs.
56
+ // Each older generation prints on its own line beside the newest, with no
57
+ // verdict and no interval.
52
58
 
53
59
  import { execFileSync } from "node:child_process";
54
60
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -135,29 +141,39 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
135
141
  const units = attribute({ features, members, tiers, prs, windowStart });
136
142
  const counted = units.filter((u) => u.status === "counted");
137
143
  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 }));
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 }));
140
146
  const meanUsd = (t) => (t.merged ? t.spend / t.merged : null);
141
147
  const base = tallies.get(BASELINE_CELL) ?? null;
148
+ // One generation's figures. The interval is the newest generation's against
149
+ // 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
+ };
142
168
  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)),
169
+ ...figuresOf(t.cell, t, true),
153
170
  excluded: t.excluded,
154
- left_out: t.left_out,
155
- unpriced: t.unpriced,
171
+ older: t.older.map((o) => figuresOf(t.cell, o, false)),
156
172
  }));
157
173
 
158
- // The guard.
159
- const b = figures.find((f) => f.cell === BASELINE_CELL) ?? { n_merged: 0, mean_usd: null, fail_rate: null };
160
- const baseline = { cell: BASELINE_CELL, n: b.n_merged, mean_usd: b.mean_usd, fail_rate: b.fail_rate };
174
+ // The guard, over each cell's newest generation alone.
175
+ const b = figures.find((f) => f.cell === BASELINE_CELL) ?? { generation: null, n_merged: 0, mean_usd: null, fail_rate: null };
176
+ const baseline = { cell: BASELINE_CELL, generation: b.generation, n: b.n_merged, mean_usd: b.mean_usd, fail_rate: b.fail_rate };
161
177
  // The $ leg takes the unrounded means: the cent-rounded figures are for
162
178
  // display, and a trip is sticky, so a cell cheaper by under half a cent
163
179
  // must not trip on rounding alone. The fail leg reads the claims.
@@ -239,10 +255,12 @@ export function crossCheck(rows, pricing) {
239
255
  }
240
256
 
241
257
  const TSV_COLUMNS = [
242
- "cell", "n_pulls", "n_merged", "n_claims", "n_failed", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95",
258
+ "cell", "generation", "n_pulls", "n_merged", "n_claims", "n_failed", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95",
243
259
  "excluded", "left_out", "unpriced", "guard",
244
260
  ];
245
261
 
262
+ // One line per cell's newest generation, carrying the guard's verdict on it,
263
+ // then one per older generation of that cell, which carries none.
246
264
  export function formatReport(report) {
247
265
  const guardOf = (f) => {
248
266
  if (f.cell === BASELINE_CELL) return "baseline";
@@ -250,8 +268,12 @@ export function formatReport(report) {
250
268
  return f.n_merged >= MIN_N && report.verdict !== "none" ? "ok" : `n<${MIN_N}`;
251
269
  };
252
270
  const cell = (v) => (v === null || v === undefined ? "" : Array.isArray(v) ? `[${v.join(", ")}]` : String(v));
271
+ const line = (f, guard) => TSV_COLUMNS.map((c) => (c === "guard" ? guard : cell(f[c]))).join("\t");
253
272
  const lines = [TSV_COLUMNS.join("\t")];
254
- for (const f of report.cells) lines.push(TSV_COLUMNS.map((c) => (c === "guard" ? guardOf(f) : cell(f[c]))).join("\t"));
273
+ for (const f of report.cells) {
274
+ lines.push(line(f, guardOf(f)));
275
+ for (const o of f.older) lines.push(line(o, "older"));
276
+ }
255
277
  const b = report.baseline;
256
278
  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" : ""}`);
257
279
  lines.push(`# window_start ${report.window_start}; pending PRs: ${report.pending.length ? report.pending.join(" ") : "none"}`);
@@ -269,7 +291,7 @@ export function guardFile(report, computedAt) {
269
291
  computed_at: computedAt,
270
292
  window_start: report.window_start,
271
293
  baseline: report.baseline,
272
- cells: report.cells.map((f) => ({ cell: f.cell, n: f.n_merged, mean_usd: f.mean_usd, fail_rate: f.fail_rate })),
294
+ cells: report.cells.map((f) => ({ cell: f.cell, generation: f.generation, n: f.n_merged, mean_usd: f.mean_usd, fail_rate: f.fail_rate })),
273
295
  tripped: report.tripped,
274
296
  verdict: report.verdict,
275
297
  retire: report.retire,
@@ -48,6 +48,17 @@
48
48
  //
49
49
  // SPEND is the recorded `cost` of every row booked to the Pull. A `cost` that
50
50
  // is neither blank nor a number is refused, naming the row.
51
+ //
52
+ // GENERATION. A cell's figures are per Cell generation: the cell on one
53
+ // dispatch model, member-outcomes' `dispatch_model`. A Pull's generation is
54
+ // the dispatch model of its first Admissible implementer row in dispatch
55
+ // order: the session, then `impl-<N>` before its retries `-b`, `-c`, ….
56
+ // A row at another cell (a rescue) is booked to the Pull and never changes
57
+ // its generation; an excluded Pull has none. A cell's NEWEST generation is
58
+ // that of its latest Pull that has one, by session id, then file order.
59
+ // Generations are told apart by model alone, so a cell that returns to an
60
+ // earlier model rejoins that generation's Pulls. Only the newest generation's
61
+ // figures are judged; older ones are reported beside them, never pooled.
51
62
 
52
63
  import { isDigits } from "./arg.mjs";
53
64
  import { CELL } from "./ledger-grammar.mjs";
@@ -87,10 +98,11 @@ export const EXCLUDED_MEMBER = /^(?:merge-?bot|memory$|__advisor$)/i;
87
98
  export const lastSegment = (agent) => String(agent).split(/[/.]/).at(-1);
88
99
 
89
100
  // An Admissible row for `cell`: dispatched as the cell's own definition, run at
90
- // the cell's level, with a resolved model on record.
101
+ // the cell's level, with its dispatch model on record. The last turn's `model`
102
+ // is not read: a member that fell back to another provider is still admitted.
91
103
  export function admissibleRow(row, cell) {
92
104
  const level = CELL.exec(cell)?.[2] ?? null;
93
- return level !== null && row.subagentType === `fleet-implementer-${cell}` && row.effort === level && !!row.model;
105
+ return level !== null && row.subagentType === `fleet-implementer-${cell}` && row.effort === level && !!row.dispatchModel;
94
106
  }
95
107
 
96
108
  // A bad input row, as opposed to a bug: a caller that reports bad input catches this class alone.
@@ -118,6 +130,61 @@ const failsFloor = (ruling) => ruling.minted_false_claim === "yes" || ruling.clo
118
130
  // An omp session id opens with its ISO start time, so two of them sort in time order.
119
131
  const STARTED = /^\d{4}-\d{2}-\d{2}T/;
120
132
  const startedBefore = (pull, row) => (STARTED.test(pull.session) && STARTED.test(row.session) ? pull.session < row.session : pull.run_date <= row.run_date);
133
+ // An implementer leaf's retry suffix: "" for `impl-<N>`, "b" for `impl-<N>-b`.
134
+ const retryOf = (row) => /^impl-\d+(?:-([a-z0-9]+))?$/.exec(String(row.member).split("/").pop())?.[1] ?? "";
135
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
136
+ // Implementer rows in dispatch order: by session, then the bare name before
137
+ // its retries, a shorter suffix before a longer one (`-z` before `-aa`).
138
+ const dispatchOrder = (rows) => [...rows].sort((a, b) => {
139
+ const [x, y] = [retryOf(a), retryOf(b)];
140
+ return cmp(a.session, b.session) || x.length - y.length || cmp(x, y);
141
+ });
142
+ const key = (session, agent) => `${session}\0${agent}`;
143
+ // The omp rows a Pull can book: every member but the merge bot, memory and the advisor.
144
+ const ompRows = (members) => members.filter((r) => r.harness === "omp" && !EXCLUDED_MEMBER.test(lastSegment(r.member)));
145
+
146
+ /**
147
+ * Each implementer row of `omp` to its Pull among `pulls`: the same session's
148
+ * Pull of the row's ticket and agent, else that session's last Pull of the
149
+ * ticket, else the ticket's last Pull started before the row (a retry in a
150
+ * later session). `impls` maps every Pull to its rows, `implPull` each booked
151
+ * row's session+agent to its Pull. A row with no ticket, or no such Pull, is
152
+ * booked to none.
153
+ */
154
+ function bookImplementers(pulls, omp) {
155
+ const bySessionTicket = new Map(), byTicket = new Map();
156
+ for (const p of pulls) {
157
+ const k = key(p.session, p.ticket);
158
+ (bySessionTicket.get(k) ?? bySessionTicket.set(k, []).get(k)).push(p);
159
+ (byTicket.get(p.ticket) ?? byTicket.set(p.ticket, []).get(p.ticket)).push(p);
160
+ }
161
+ const implPull = new Map();
162
+ const impls = new Map(pulls.map((p) => [p, []]));
163
+ for (const r of omp) {
164
+ if (!isImplementer(r.member) || !r.ticket) continue;
165
+ const own = bySessionTicket.get(key(r.session, r.ticket));
166
+ const pull = own ? (own.find((p) => p.agent === r.agent) ?? own.at(-1)) : byTicket.get(r.ticket)?.findLast((p) => startedBefore(p, r));
167
+ if (!pull) continue;
168
+ implPull.set(key(r.session, r.agent), pull);
169
+ impls.get(pull).push(r);
170
+ }
171
+ return { impls, implPull };
172
+ }
173
+
174
+ // A Pull's generation from its implementer rows: its first Admissible row's dispatch model, else null.
175
+ const generationOf = (own, cell) => dispatchOrder(own).find((m) => admissibleRow(m, cell))?.dispatchModel ?? null;
176
+
177
+ /**
178
+ * Each of `pulls` (ticket-features rows) to its generation, `attribute`'s
179
+ * rule over `members`: the dispatch model of the Pull's first Admissible
180
+ * implementer row in dispatch order, or null when it has none. Every caller
181
+ * that names a Pull's generation reads it here, so the guard, the fit and the
182
+ * stopping rule never derive two generations for one Pull.
183
+ */
184
+ export function pullGenerations(pulls, members) {
185
+ const { impls } = bookImplementers(pulls, ompRows(members));
186
+ return new Map(pulls.map((p) => [p, generationOf(impls.get(p), p.chosen_cell)]));
187
+ }
121
188
 
122
189
  function costOf(row) {
123
190
  const c = String(row.cost ?? "");
@@ -129,39 +196,24 @@ function costOf(row) {
129
196
 
130
197
  /**
131
198
  * One unit per ticket-features row dated on or after `windowStart` (every dated
132
- * row when null), in file order: `{ pull, cell, stratum, status, merged, pr,
133
- * ruling, pendingPr, claims, falseClaims, rows, spend }`. `prs` is `gh pr list`'s
199
+ * row when null), in file order: `{ pull, cell, stratum, generation, status,
200
+ * merged, pr, ruling, pendingPr, claims, falseClaims, rows, spend }`,
201
+ * `generation` null exactly when the Pull is excluded. `prs` is `gh pr list`'s
134
202
  * `{ number, state }` rows, or null when PR state is unknown (the fit). `stratum`
135
203
  * maps a features row to its stratum. Throws `AttributionError` on a features
136
204
  * row it cannot date or a booked `cost` it cannot read, and `RulingError` as
137
205
  * `rulingFor` does.
138
206
  */
139
207
  export function attribute({ features, members, tiers, prs = null, windowStart = null, stratum = () => null }) {
140
- const key = (session, agent) => `${session}\0${agent}`;
141
208
  const pulls = datedRows(features);
142
- const bySessionTicket = new Map(), byTicket = new Map();
143
- for (const p of pulls) {
144
- const k = key(p.session, p.ticket);
145
- (bySessionTicket.get(k) ?? bySessionTicket.set(k, []).get(k)).push(p);
146
- (byTicket.get(p.ticket) ?? byTicket.set(p.ticket, []).get(p.ticket)).push(p);
147
- }
148
-
149
- const omp = members.filter((r) => r.harness === "omp" && !EXCLUDED_MEMBER.test(lastSegment(r.member)));
209
+ const omp = ompRows(members);
150
210
  // Implementer rows to their Pull, and a PR-named member's key to its PR.
151
- const implPull = new Map(), prOfKey = new Map();
152
- const impls = new Map(pulls.map((p) => [p, []]));
211
+ const { impls, implPull } = bookImplementers(pulls, omp);
212
+ const prOfKey = new Map();
153
213
  for (const r of omp) {
154
- if (isImplementer(r.member)) {
155
- if (!r.ticket) continue;
156
- const own = bySessionTicket.get(key(r.session, r.ticket));
157
- const pull = own ? (own.find((p) => p.agent === r.agent) ?? own.at(-1)) : byTicket.get(r.ticket)?.findLast((p) => startedBefore(p, r));
158
- if (!pull) continue;
159
- implPull.set(key(r.session, r.agent), pull);
160
- impls.get(pull).push(r);
161
- } else {
162
- const { pr } = parseMemberName(r.member);
163
- if (pr) prOfKey.set(key(r.session, r.agent), pr);
164
- }
214
+ if (isImplementer(r.member)) continue;
215
+ const { pr } = parseMemberName(r.member);
216
+ if (pr) prOfKey.set(key(r.session, r.agent), pr);
165
217
  }
166
218
  // Each PR to the last Pull, in file order, whose implementer rows carry it.
167
219
  const carrier = new Map();
@@ -224,37 +276,74 @@ export function attribute({ features, members, tiers, prs = null, windowStart =
224
276
  if (c === null) blank = true;
225
277
  else spend += c;
226
278
  }
279
+ const generation = generationOf(own, pull.chosen_cell);
227
280
  let status = "counted";
228
- if (!own.some((m) => admissibleRow(m, pull.chosen_cell))) status = "excluded";
281
+ if (generation === null) status = "excluded";
229
282
  else if (pendingPr !== null) status = "pending";
230
283
  else if (claims === 0 && own.every((m) => ticketsEnding(m.ending) || NEITHER_ENDINGS.has(m.ending))) status = "left-out";
231
284
  else if (blank || (r && !r.carried)) status = "unpriced";
232
285
  return {
233
- pull, cell: pull.chosen_cell, stratum: stratum(pull), status, merged, pr: r?.pr ?? null, ruling: r?.ruling ?? null,
286
+ pull, cell: pull.chosen_cell, stratum: stratum(pull), generation, status, merged, pr: r?.pr ?? null, ruling: r?.ruling ?? null,
234
287
  pendingPr, claims, falseClaims, rows: booked, spend: blank ? null : spend,
235
288
  };
236
289
  });
237
290
  }
238
291
 
239
292
  /**
240
- * Per cell, in cell-name order: `{ cell, n, merged, claims, failed, spend,
241
- * excluded, left_out, unpriced, pending }`. `n`, `merged`, `claims`, `failed`
242
- * and `spend` are over counted Pulls alone; the last four count the Pulls of
243
- * each other status.
293
+ * Each cell's generations, newest first: cell -> [model, …]. A generation's
294
+ * place is that of its latest Pull, by session id (an omp session id opens
295
+ * with its start time), then by file order, the order of `units`.
296
+ */
297
+ export function generations(units) {
298
+ const latest = new Map(); // cell -> Map(generation -> { session, at })
299
+ units.forEach((u, at) => {
300
+ if (!u.generation) return;
301
+ const gens = latest.get(u.cell) ?? latest.set(u.cell, new Map()).get(u.cell);
302
+ const held = gens.get(u.generation);
303
+ if (!held || u.pull.session >= held.session) gens.set(u.generation, { session: u.pull.session, at });
304
+ });
305
+ return new Map([...latest].map(([cell, gens]) => [cell, [...gens]
306
+ .sort(([, a], [, b]) => cmp(b.session, a.session) || b.at - a.at)
307
+ .map(([g]) => g)]));
308
+ }
309
+
310
+ /**
311
+ * 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.
316
+ * `excluded` counts the cell's excluded Pulls, which have no generation.
317
+ * `older` is an array, newest first, of every other generation's figures in
318
+ * the same shape, each carrying its `generation`. `order` is `generations()`
319
+ * of the units that place each cell's generations; by default these same
320
+ * units, but a caller tallying a subset passes the whole set's, so a subset
321
+ * never moves a cell's newest generation.
244
322
  */
245
- export function tally(units) {
323
+ 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 });
246
325
  const cells = new Map();
247
326
  for (const u of units) {
248
- const c = cells.get(u.cell) ?? cells.set(u.cell, {
249
- cell: u.cell, n: 0, merged: 0, claims: 0, failed: 0, spend: 0, excluded: 0, left_out: 0, unpriced: 0, pending: 0,
250
- }).get(u.cell);
327
+ const c = cells.get(u.cell) ?? cells.set(u.cell, { excluded: 0, gens: new Map() }).get(u.cell);
328
+ if (u.status === "excluded") {
329
+ c.excluded++;
330
+ continue;
331
+ }
332
+ const g = u.generation ?? null;
333
+ const f = c.gens.get(g) ?? c.gens.set(g, figures(g)).get(g);
251
334
  if (u.status === "counted") {
252
- c.n++;
253
- if (u.merged) c.merged++;
254
- c.claims += u.claims;
255
- c.failed += u.falseClaims;
256
- c.spend += u.spend;
257
- } else c[u.status.replace("-", "_")]++;
335
+ f.n++;
336
+ if (u.merged) f.merged++;
337
+ f.claims += u.claims;
338
+ f.failed += u.falseClaims;
339
+ f.spend += u.spend;
340
+ } else f[u.status.replace("-", "_")]++;
258
341
  }
259
- return [...cells.values()].sort((a, b) => (a.cell < b.cell ? -1 : a.cell > b.cell ? 1 : 0));
342
+ return [...cells].sort(([a], [b]) => cmp(a, b)).map(([cell, c]) => {
343
+ const ranked = order.get(cell) ?? [];
344
+ const newest = ranked[0] ?? null;
345
+ const rank = (g) => (ranked.includes(g) ? ranked.indexOf(g) : ranked.length);
346
+ const older = [...c.gens.values()].filter((f) => f.generation !== newest).sort((a, b) => rank(a.generation) - rank(b.generation));
347
+ return { cell, ...(c.gens.get(newest) ?? figures(newest)), excluded: c.excluded, older };
348
+ });
260
349
  }
@@ -47,7 +47,7 @@ import { makeDie, defineFlags } from "./arg.mjs";
47
47
  import { CELL, POLICY_CELL, drawCell, parseMember } from "./ledger-grammar.mjs";
48
48
  import { sessionFromReceipt } from "./phase0-receipt.mjs";
49
49
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
50
- import { AttributionError, FEATURE_COLUMNS, attribute, parseFeatures, tally } from "./pull-attribution.mjs";
50
+ import { AttributionError, FEATURE_COLUMNS, attribute, generations, parseFeatures, tally } from "./pull-attribution.mjs";
51
51
  import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
52
52
 
53
53
  const NAME = "ticket-router";
@@ -333,36 +333,47 @@ const round4 = (x) => Math.round(x * 1e4) / 1e4;
333
333
  * the A/B's test set, not the fit's. The fit sees no PR state, so a Pull with
334
334
  * a Ruling is merged. A fit reads everything in the commit it is given: no row
335
335
  * is cut by date, because `--check` re-reads that same commit rather than
336
- * re-cutting the working tree's files.
336
+ * re-cutting the working tree's files. `order` ranks each cell's generations
337
+ * over every Pull in the window, the restriction aside, so the fit names the
338
+ * same newest generation the guard does.
337
339
  */
338
340
  function fitPulls({ features, members, verdicts, window }) {
339
- return attribute({ features, members, tiers: verdicts, windowStart: window || null, stratum: ruleStratum })
340
- .filter((u) => u.pull.exploration_draw !== "" || u.pull.sizing_src === "rule");
341
+ const all = attribute({ features, members, tiers: verdicts, windowStart: window || null, stratum: ruleStratum });
342
+ return { units: all.filter((u) => u.pull.exploration_draw !== "" || u.pull.sizing_src === "rule"), order: generations(all) };
341
343
  }
342
344
 
343
- // Per stratum and `*`, per cell: `n` counted Pulls, `merged`, $ per merged PR,
344
- // false claims over claims, fix rounds per merged PR, and the cell's excluded,
345
- // left-out and unpriced Pull counts.
346
- function estimate(units) {
345
+ // Per stratum and `*`, per cell, its newest generation's figures: `n` counted
346
+ // Pulls, `merged`, $ per merged PR, false claims over claims, fix rounds per
347
+ // merged PR, and its left-out and unpriced Pull counts. Beside them the cell's
348
+ // excluded Pull count, `generation` naming the newest generation's model, and
349
+ // `older`, each older generation's model to its figures, newest first.
350
+ function estimate(units, order) {
347
351
  const out = {};
348
352
  for (const s of units.length ? [...new Set([...units.map((u) => u.stratum), "*"])].sort() : []) {
349
353
  const mine = s === "*" ? units : units.filter((u) => u.stratum === s);
350
354
  out[s] = {};
351
- for (const t of tally(mine)) {
352
- const fix = mine.filter((u) => u.cell === t.cell && u.status === "counted" && u.merged)
353
- .reduce((n, u) => n + u.rows.filter((r) => parseMember(r.member)?.family === "fix-pr").length, 0);
355
+ for (const t of tally(mine, order)) {
356
+ const figures = (g) => {
357
+ const fix = mine.filter((u) => u.cell === t.cell && u.generation === g.generation && u.status === "counted" && u.merged)
358
+ .reduce((n, u) => n + u.rows.filter((r) => parseMember(r.member)?.family === "fix-pr").length, 0);
359
+ return {
360
+ n: g.n,
361
+ merged: g.merged,
362
+ claims: g.claims,
363
+ failed: g.failed,
364
+ usd_per_merged: g.merged ? round4(g.spend / g.merged) : null,
365
+ fail_rate: g.claims ? round4(g.failed / g.claims) : null,
366
+ fix_rounds: g.merged ? round4(fix / g.merged) : null,
367
+ review_findings: null,
368
+ left_out: g.left_out,
369
+ unpriced: g.unpriced,
370
+ };
371
+ };
354
372
  out[s][t.cell] = {
355
- n: t.n,
356
- merged: t.merged,
357
- claims: t.claims,
358
- failed: t.failed,
359
- usd_per_merged: t.merged ? round4(t.spend / t.merged) : null,
360
- fail_rate: t.claims ? round4(t.failed / t.claims) : null,
361
- fix_rounds: t.merged ? round4(fix / t.merged) : null,
362
- review_findings: null,
373
+ generation: t.generation,
374
+ ...figures(t),
363
375
  excluded: t.excluded,
364
- left_out: t.left_out,
365
- unpriced: t.unpriced,
376
+ older: Object.fromEntries(t.older.map((o) => [o.generation, figures(o)])),
366
377
  };
367
378
  }
368
379
  }
@@ -371,8 +382,9 @@ function estimate(units) {
371
382
 
372
383
  // Among cells with n >= MIN_N in a stratum and not tripped, the one with the
373
384
  // lowest $ per merged PR — adopted only at or under ADOPT_RATIO of the
374
- // default cell's. The quality floor is the guard's to enforce, never the
375
- // fit's. null when the stratum cannot decide (no default cell at n, or no
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
376
388
  // candidate at n).
377
389
  function adopt(est, cells, tripped) {
378
390
  const base = est?.[POLICY_CELL];
@@ -393,8 +405,8 @@ function adopt(est, cells, tripped) {
393
405
  */
394
406
  export function fitTable({ prior, features, members, verdicts, guard, commit = null }) {
395
407
  const window = guard.window_start ?? prior.window_start ?? null;
396
- const units = fitPulls({ features, members, verdicts, window });
397
- const estimates = estimate(units);
408
+ const { units, order } = fitPulls({ features, members, verdicts, window });
409
+ const estimates = estimate(units, order);
398
410
  const tripped = new Set(guard.tripped);
399
411
 
400
412
  let { stage, cells } = prior;
@@ -437,10 +449,16 @@ export function mergedSince({ table, features, members, verdicts, fitted }) {
437
449
  .filter((u) => u.status === "counted" && u.merged && !read.has(`${u.pull.session}\0${u.pull.agent}`)).length;
438
450
  }
439
451
 
440
- // Each cell's Pull counts over every stratum, as the fit prints them.
452
+ // Each cell's Pull counts over every stratum, as the fit prints them: its
453
+ // newest generation, then each older one.
441
454
  function cellCounts(estimates) {
455
+ const usd = (x) => (x === null ? "-" : x.toFixed(2));
456
+ const counts = (e) => `n=${e.n} merged=${e.merged} failed=${e.failed} usd_per_merged=${usd(e.usd_per_merged)}`;
442
457
  return Object.entries(estimates["*"] ?? {})
443
- .map(([c, e]) => `${c} n=${e.n} merged=${e.merged} failed=${e.failed} excluded=${e.excluded} left_out=${e.left_out} unpriced=${e.unpriced}`)
458
+ .map(([c, e]) => [
459
+ `${c} ${e.generation ?? "-"} ${counts(e)} excluded=${e.excluded} left_out=${e.left_out} unpriced=${e.unpriced}`,
460
+ ...Object.entries(e.older).map(([g, o]) => `${c} ${g} (older) ${counts(o)}`),
461
+ ].join(", "))
444
462
  .join(", ") || "none";
445
463
  }
446
464