@feigi/fleet-ctl 3.94.1 → 3.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/pr-cost.mjs +22 -66
- package/scripts/pull-attribution.mjs +67 -7
- package/scripts/router-table.json +8 -0
- package/scripts/ticket-router.mjs +122 -32
package/package.json
CHANGED
package/scripts/pr-cost.mjs
CHANGED
|
@@ -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
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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) => ({
|
|
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`
|
|
313
|
-
* cell's newest (null when no Pull of it has one), and the figures
|
|
314
|
-
* are that generation's alone: `n`, `merged`, `claims`, `failed` and
|
|
315
|
-
* over its counted Pulls,
|
|
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
|
-
|
|
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]) => {
|
|
@@ -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
|
|
24
|
-
//
|
|
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 {
|
|
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 (
|
|
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
|
|
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
|
-
|
|
361
|
+
estimates[s] = {};
|
|
362
|
+
pulls[s] = {};
|
|
355
363
|
for (const t of tally(mine, order)) {
|
|
356
364
|
const figures = (g) => {
|
|
357
|
-
const fix =
|
|
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
|
-
|
|
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
|
|
389
|
+
return { estimates, pulls };
|
|
381
390
|
}
|
|
382
391
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
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();
|