@feigi/fleet-ctl 3.93.6 → 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 +1 -1
- package/scripts/cell-readout.mjs +56 -26
- package/scripts/member-outcomes.mjs +14 -2
- package/scripts/pr-cost.mjs +52 -30
- package/scripts/pull-attribution.mjs +132 -43
- package/scripts/ticket-router.mjs +45 -27
package/package.json
CHANGED
package/scripts/cell-readout.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
37
|
-
// model by name, or `mixed (<model-a> n=…, <model-b> n=…)` when
|
|
38
|
-
// than one, because a role's target is operator config that
|
|
39
|
-
// cell's history. A cell below the gate prints nothing on
|
|
40
|
-
// `below the gate` count on stderr. Neither the gate line nor
|
|
41
|
-
// a session, ticket or member: the gate forbids reading a
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
if (
|
|
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)
|
|
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
|
-
|
|
243
|
+
const order = generations(admitted);
|
|
244
|
+
const tallied = (list = []) => {
|
|
220
245
|
list.sort((a, b) => Number(a.ticket) - Number(b.ticket));
|
|
221
|
-
|
|
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
|
-
|
|
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`
|
package/scripts/pr-cost.mjs
CHANGED
|
@@ -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
|
|
43
|
-
//
|
|
44
|
-
// (merged PRs
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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.
|
|
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,
|
|
133
|
-
* ruling, pendingPr, claims, falseClaims, rows, spend }
|
|
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
|
|
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 =
|
|
152
|
-
const
|
|
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
|
-
|
|
156
|
-
|
|
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 (
|
|
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
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
if (u.merged)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
} else
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
344
|
-
//
|
|
345
|
-
// left-out and unpriced Pull counts.
|
|
346
|
-
|
|
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
|
|
353
|
-
.
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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.
|
|
375
|
-
//
|
|
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]) =>
|
|
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
|
|