Files
Garry Tanandcapy-ai[bot] 67783c1885 v1.91.138.0 feat(evals): red-head and READY-arrival replays for the gate-cost model (GSTA-49 T0) (#3274)
* feat(evals): red-head and READY-arrival replays for the gate-cost model (GSTA-49 T0)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(egress): reasoned scanner exemption for gate-cost-replay.ts read-only git fetch

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: v1.91.138.0 (GSTA-49 T0b)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-18 19:26:19 +00:00

631 lines
27 KiB
TypeScript

/**
* gate-cost — measurement-as-code for the paid PR gate's bill (GSTA-49 plan
* lane T0; numbers in its MEASUREMENT.md). Pure logic: callers pass artifact
* contents, changed-file lists and selector functions in; nothing here reads
* files, runs git or calls GitHub. scripts/gate-cost-replay.ts is the CLI.
*
* Four parts:
* 1. Paid report artifacts -> per-case records, a gate's recorded cost by
* model and by token class (priced through lib/pricing.ts).
* 2. A per-case mean cost table across several gates; a case with no
* recorded cost is estimated from its duration and marked estimated.
* 3. Selection cost of one merged PR under today's selector, R4's shadow
* selection and the screening rule. The screening rule is labelled
* `screen-provisional` wherever it is printed: its preamble detector is
* a line-commonality heuristic, not T2's production hunk classifier.
* 4. A seeded Monte Carlo of merge trains (every merged PR lands in a train;
* defects, outside flakes, bisects, requeues, one rebuild per
* unattributed red). It ports the measurement's sim.py and uses CPython's
* Mersenne Twister, so the same seed gives the same figures.
*/
import { pricingRow } from './pricing';
export const SCREEN_LABEL = 'screen-provisional';
/** Anthropic bills 5-minute prompt-cache writes at 1.25x the model's input price (lib/pricing.ts excludes cache writes). */
export const CACHE_WRITE_INPUT_MULTIPLIER = 1.25;
export const TODAY_WEEKLY_USD = 9_950;
export const AFTER_GSTA48_WEEKLY_USD = 8_400;
/** Files that add the screening canary set (T2's full-gate list); package.json only when more than its version changed. */
export const SCREEN_FULL_GATE_FILES = [
'setup', 'bun.lock', '.github/docker/Dockerfile.ci', '.github/workflows/evals.yml',
'scripts/build.sh', 'scripts/gen-skill-docs.ts', 'package.json',
] as const;
/** The heuristic preamble detector runs only on diffs that change at least this many SKILL.md files. */
export const PREAMBLE_MIN_SKILL_FILES = 10;
/** A SKILL.md change counts as preamble-only when every changed line also changed in this share of the changed SKILL.md files. */
export const PREAMBLE_LINE_SHARE = 0.6;
// --- 1. Artifacts ---
export interface UsageTotals {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
thinkingTokens: number;
costUSD: number;
}
export interface EvalTestRecord {
shard: string;
file: string | null;
name: string;
tier: string | null;
model: string | null;
costUsd: number;
durationMs: number;
/** Summed over every `result` event in the test's transcript. */
modelUsage: Record<string, UsageTotals>;
}
export interface TrialRecord {
caseId: string;
file: string;
kind: string;
durationMs: number;
runner: string | null;
}
export interface GateRecord {
runId: string;
tests: EvalTestRecord[];
trials: TrialRecord[];
}
const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadInputTokens', 'cacheCreationInputTokens', 'thinkingTokens', 'costUSD'] as const;
const emptyUsage = (): UsageTotals => ({ inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, thinkingTokens: 0, costUSD: 0 });
const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
/** `skill-e2e-deploy--canary-workflow.t2` -> `test/skill-e2e-deploy.test.ts` (scripts/lib/paid-select.ts shardSlug, reversed). */
export function shardTestFile(shard: string): string | null {
const base = shard.replace(/\.t\d+$/, '').split('--')[0];
return base ? `test/${base}.test.ts` : null;
}
/** One eval JSON from a paid-slice shard directory; anything without a `tests` array yields no records. */
export function parseEvalReport(shard: string, report: unknown): EvalTestRecord[] {
const tests = (report as { tests?: unknown } | null)?.tests;
if (!Array.isArray(tests)) return [];
return tests.filter((test): test is Record<string, unknown> => !!test && typeof test === 'object').map(test => {
const modelUsage: Record<string, UsageTotals> = {};
for (const event of Array.isArray(test.transcript) ? test.transcript : []) {
if (event?.type !== 'result' || !event.modelUsage || typeof event.modelUsage !== 'object') continue;
for (const [model, usage] of Object.entries(event.modelUsage as Record<string, Record<string, unknown>>)) {
const totals = modelUsage[model] ??= emptyUsage();
for (const key of USAGE_KEYS) totals[key] += num(usage?.[key]);
}
}
return {
shard, file: shardTestFile(shard), name: String(test.name ?? ''),
tier: typeof test.tier === 'string' ? test.tier : null, model: typeof test.model === 'string' ? test.model : null,
costUsd: num(test.cost_usd), durationMs: num(test.duration_ms), modelUsage,
};
});
}
/** trial-outcomes.jsonl; a torn or foreign line is skipped. */
export function parseTrialOutcomes(text: string): TrialRecord[] {
const trials: TrialRecord[] = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
let row: Record<string, unknown>;
try { row = JSON.parse(line); } catch { continue; }
if (typeof row?.case !== 'string' || typeof row.file !== 'string') continue;
const sessions = Array.isArray(row.sessions) ? row.sessions as Array<{ runner?: unknown }> : [];
trials.push({
caseId: row.case, file: row.file, kind: typeof row.kind === 'string' ? row.kind : 'rule',
durationMs: num(row.duration_ms), runner: typeof sessions[0]?.runner === 'string' ? sessions[0].runner : null,
});
}
return trials;
}
export interface TokenClass { tokens: number; usd: number }
export interface ModelTokenClasses {
cacheWrite: TokenClass;
output: TokenClass & { thinkingTokens: number };
cacheRead: TokenClass;
uncachedInput: TokenClass;
pricedUsd: number;
recordedUsd: number;
priced: boolean;
}
export interface GateCostBreakdown {
recordedUsd: number;
/** modelUsage costUSD per model; a test's cost that modelUsage does not cover goes to its row model (judges: `llm-judge`). */
byModel: Record<string, number>;
tokenClasses: Record<string, ModelTokenClasses>;
}
/** Price one model's tokens with lib/pricing.ts; output tokens include thinking tokens. */
export function priceTokenClasses(model: string, usage: UsageTotals): ModelTokenClasses {
const row = pricingRow(model);
const input = row?.input_per_mtok ?? 0;
const cls = (tokens: number, perMtok: number): TokenClass => ({ tokens, usd: tokens * perMtok / 1_000_000 });
const cacheWrite = cls(usage.cacheCreationInputTokens, input * CACHE_WRITE_INPUT_MULTIPLIER);
const output = { ...cls(usage.outputTokens, row?.output_per_mtok ?? 0), thinkingTokens: usage.thinkingTokens };
const cacheRead = cls(usage.cacheReadInputTokens, row?.cached_input_per_mtok ?? input * 0.1);
const uncachedInput = cls(usage.inputTokens, input);
return { cacheWrite, output, cacheRead, uncachedInput, pricedUsd: cacheWrite.usd + output.usd + cacheRead.usd + uncachedInput.usd,
recordedUsd: usage.costUSD, priced: row !== undefined };
}
export function gateCostBreakdown(gate: Pick<GateRecord, 'tests'>): GateCostBreakdown {
const byModel: Record<string, number> = {};
const usageByModel: Record<string, UsageTotals> = {};
let recordedUsd = 0;
for (const test of gate.tests) {
recordedUsd += test.costUsd;
let covered = 0;
for (const [model, usage] of Object.entries(test.modelUsage)) {
byModel[model] = (byModel[model] ?? 0) + usage.costUSD;
covered += usage.costUSD;
const totals = usageByModel[model] ??= emptyUsage();
for (const key of USAGE_KEYS) totals[key] += usage[key];
}
const rest = test.costUsd - covered;
if (Math.abs(rest) < 0.005) continue;
const label = test.tier === 'llm-judge' ? 'llm-judge' : `${test.model ?? 'model not recorded'} (no modelUsage)`;
byModel[label] = (byModel[label] ?? 0) + rest;
}
const tokenClasses = Object.fromEntries(Object.entries(usageByModel).map(([model, usage]) => [model, priceTokenClasses(model, usage)]));
return { recordedUsd, byModel, tokenClasses };
}
export interface GateSummary {
gates: number;
recordedUsd: { mean: number; min: number; max: number };
byModel: Record<string, number>;
tokenClasses: Record<string, ModelTokenClasses>;
}
/** Mean over gates of each breakdown figure. */
export function summarizeGates(gates: Array<Pick<GateRecord, 'tests'>>): GateSummary {
const breakdowns = gates.map(gateCostBreakdown);
const n = Math.max(1, breakdowns.length);
const totals = breakdowns.map(b => b.recordedUsd);
const byModel: Record<string, number> = {};
const tokenClasses: Record<string, ModelTokenClasses> = {};
for (const b of breakdowns) {
for (const [model, usd] of Object.entries(b.byModel)) byModel[model] = (byModel[model] ?? 0) + usd / n;
for (const [model, c] of Object.entries(b.tokenClasses)) {
const t = tokenClasses[model] ??= {
cacheWrite: { tokens: 0, usd: 0 }, output: { tokens: 0, usd: 0, thinkingTokens: 0 }, cacheRead: { tokens: 0, usd: 0 },
uncachedInput: { tokens: 0, usd: 0 }, pricedUsd: 0, recordedUsd: 0, priced: c.priced,
};
for (const key of ['cacheWrite', 'output', 'cacheRead', 'uncachedInput'] as const) {
t[key].tokens += c[key].tokens / n;
t[key].usd += c[key].usd / n;
}
t.output.thinkingTokens += c.output.thinkingTokens / n;
t.pricedUsd += c.pricedUsd / n;
t.recordedUsd += c.recordedUsd / n;
}
}
return {
gates: breakdowns.length,
recordedUsd: totals.length ? { mean: totals.reduce((a, b) => a + b, 0) / n, min: Math.min(...totals), max: Math.max(...totals) } : { mean: 0, min: 0, max: 0 },
byModel, tokenClasses,
};
}
// --- 2. Per-case cost table ---
/**
* Bind each trial outcome to the eval test entry that recorded its cost: an
* unused entry named like the case first, else the unused entry of the same
* test file nearest in duration (within 5 s or 10%). `null` = no recorded
* cost (PTY sessions and the few runners that do not report one).
*/
export function recordedCaseCosts(gate: GateRecord): Array<{ trial: TrialRecord; costUsd: number | null }> {
const used = new Set<number>();
return gate.trials.map(trial => {
const unused = gate.tests.map((test, index) => ({ test, index })).filter(({ index }) => !used.has(index));
const named = unused.filter(({ test }) => test.name === trial.caseId);
const pool = named.length ? named : unused.filter(({ test }) => test.file === trial.file);
const best = pool.sort((a, b) => Math.abs(a.test.durationMs - trial.durationMs) - Math.abs(b.test.durationMs - trial.durationMs))[0];
if (!best || (!named.length && Math.abs(best.test.durationMs - trial.durationMs) >= Math.max(5_000, 0.1 * trial.durationMs))) {
return { trial, costUsd: null };
}
used.add(best.index);
return { trial, costUsd: best.test.costUsd };
});
}
export interface CaseCostEntry {
caseId: string;
file: string;
kind: string;
runner: string | null;
costUsd: number;
estimated: boolean;
/** Gates that recorded a cost for the case (0 when estimated). */
recordedGates: number;
meanDurationMs: number;
}
export interface CaseCostTable {
cases: Record<string, CaseCostEntry>;
/** $ per session-second used for estimates: recorded cost over the duration of the trials that recorded a nonzero cost. */
usdPerSecond: number;
}
export function caseCostTable(gates: GateRecord[], options: { usdPerSecond?: number } = {}): CaseCostTable {
const seen = new Map<string, { trial: TrialRecord; costs: number[]; durations: number[] }>();
let recordedUsd = 0, recordedMs = 0;
for (const gate of gates) {
for (const { trial, costUsd } of recordedCaseCosts(gate)) {
const entry = seen.get(trial.caseId) ?? { trial, costs: [], durations: [] };
seen.set(trial.caseId, entry);
entry.durations.push(trial.durationMs);
if (costUsd === null) continue;
entry.costs.push(costUsd);
if (costUsd > 0) { recordedUsd += costUsd; recordedMs += trial.durationMs; }
}
}
const usdPerSecond = options.usdPerSecond ?? (recordedMs ? recordedUsd / (recordedMs / 1000) : 0);
const mean = (values: number[]) => values.reduce((a, b) => a + b, 0) / Math.max(1, values.length);
const cases: Record<string, CaseCostEntry> = {};
for (const [caseId, { trial, costs, durations }] of seen) {
const meanDurationMs = mean(durations);
cases[caseId] = {
caseId, file: trial.file, kind: trial.kind, runner: trial.runner, meanDurationMs, recordedGates: costs.length,
estimated: costs.length === 0, costUsd: costs.length ? mean(costs) : meanDurationMs / 1000 * usdPerSecond,
};
}
return { cases, usdPerSecond };
}
// --- 3. Selection cost of a merged PR ---
export interface PrDiff {
pr: number;
sha: string;
files: string[];
/** package.json differs from the parent only in `version` (scripts/test-pr-profile.ts packageChangeOnlyVersion). */
packageVersionOnly: boolean;
/** Distinct trimmed changed lines per SKILL.md file; read only when the diff changes PREAMBLE_MIN_SKILL_FILES or more. */
skillMdChangedLines: Record<string, string[]>;
}
export interface TodaySelection {
e2e: string[];
judges: string[];
/** R4's exercised-map shadow (PrProfileSelection.shadow.e2e); null when none was computed. */
shadowE2e: string[] | null;
mode: string;
}
export interface SelectionDeps {
gateCases: readonly string[];
judgeCases: readonly string[];
/** Today's PR-profile selector (computePaidCaseSelection, profile pr). */
today(files: string[], packageVersionOnly: boolean): TodaySelection;
/** Declared touchfiles plus harness globals only (profile full); `global` = a harness global file changed. */
touchfiles(files: string[]): { e2e: string[]; global: boolean };
}
export interface ScreenSelection {
/** Gate cases the declared touchfiles and harness globals select (all of them when a global changed). */
e2e: string[];
global: boolean;
canary: boolean;
canaryReasons: string[];
preambleOnly: string[];
}
export interface SelectorCost { cases: number; judges: number; usd: number }
export interface PrSelectionCost {
pr: number;
sha: string;
files: number;
mode: string;
today: SelectorCost;
shadow: SelectorCost;
screenProvisional: SelectorCost & { canary: boolean; canaryReasons: string[]; preambleOnly: string[] };
}
/**
* `git diff -U0` text -> distinct trimmed changed lines per file. Lines that
* start with `++`, `+-`, `-+` or `--` (the file headers among them) are skipped,
* as in the measurement's heuristic.
*/
export function changedLinesByFile(diff: string): Record<string, string[]> {
const out: Record<string, Set<string>> = {};
let current: string | null = null;
for (const line of diff.split('\n')) {
const header = line.match(/^diff --git a\/.* b\/(.*)$/);
if (header) { current = header[1]!; out[current] ??= new Set(); continue; }
if (current && /^[+-][^+-]/.test(line)) out[current]!.add(line.trim());
}
return Object.fromEntries(Object.entries(out).map(([file, lines]) => [file, [...lines]]));
}
/** The line-commonality heuristic standing in for T2's hunk classifier (provisional). */
export function heuristicPreambleOnly(changed: Record<string, string[]>): string[] {
const files = Object.keys(changed);
if (files.length < PREAMBLE_MIN_SKILL_FILES) return [];
const count = new Map<string, number>();
for (const lines of Object.values(changed)) for (const line of new Set(lines)) count.set(line, (count.get(line) ?? 0) + 1);
return files.filter(file => changed[file]!.every(line => (count.get(line) ?? 0) >= PREAMBLE_LINE_SHARE * files.length));
}
export const isSkillMd = (file: string) => /(^|\/)SKILL\.md$/.test(file);
function costOf(ids: Iterable<string>, table: CaseCostTable): number {
let usd = 0;
for (const id of ids) usd += table.cases[id]?.costUsd ?? 0;
return usd;
}
export function fullGateUsd(deps: Pick<SelectionDeps, 'gateCases' | 'judgeCases'>, table: CaseCostTable): number {
return costOf(deps.gateCases, table) + costOf(deps.judgeCases, table);
}
export const todaySelection = (diff: PrDiff, deps: SelectionDeps): TodaySelection =>
(diff.files.length ? deps.today(diff.files, diff.packageVersionOnly) : { e2e: [], judges: [], shadowE2e: null, mode: 'none' });
/**
* The screen-provisional rule: declared touchfiles and harness globals of the
* files that are not heuristically preamble-only, every judge, and a canary
* set when the diff has preamble-only SKILL.md files, a full-gate file, a
* resolver, or files that reach cases only transitively. A package.json whose
* only change is the version stamp is neither selected nor a canary trigger.
*/
export function screenSelection(diff: PrDiff, deps: SelectionDeps, today: TodaySelection): ScreenSelection {
const gate = new Set(deps.gateCases);
const stampOnly = (file: string) => file === 'package.json' && diff.packageVersionOnly;
const preambleOnly = heuristicPreambleOnly(Object.fromEntries(Object.entries(diff.skillMdChangedLines).filter(([file]) => diff.files.includes(file))));
const screenFiles = diff.files.filter(file => !preambleOnly.includes(file) && !stampOnly(file));
const touch = screenFiles.length ? deps.touchfiles(screenFiles) : { e2e: [], global: false };
const screenE2e = touch.e2e.filter(id => gate.has(id));
const canaryReasons = [
...(preambleOnly.length ? [`${preambleOnly.length} preamble-only SKILL.md file(s) (heuristic)`] : []),
...diff.files.filter(file => (SCREEN_FULL_GATE_FILES as readonly string[]).includes(file) && !stampOnly(file)).map(file => `full-gate file ${file}`),
...diff.files.filter(file => file.startsWith('scripts/resolvers/')).map(file => `resolver ${file}`),
...(today.e2e.length > screenE2e.length ? ['transitive-only files (today selects more cases than declared touchfiles)'] : []),
];
return { e2e: screenE2e, global: touch.global, canary: canaryReasons.length > 0, canaryReasons, preambleOnly };
}
export function prSelectionCost(diff: PrDiff, deps: SelectionDeps, table: CaseCostTable, options: { canaryUsd?: number } = {}): PrSelectionCost {
const today = todaySelection(diff, deps);
const shadowE2e = today.shadowE2e ?? today.e2e;
const { e2e, global, canary, canaryReasons, preambleOnly } = screenSelection(diff, deps, today);
const screenUsd = costOf(e2e, table) + (canary && !global ? options.canaryUsd ?? 12 : 0) + costOf(deps.judgeCases, table);
return {
pr: diff.pr, sha: diff.sha, files: diff.files.length, mode: today.mode,
today: { cases: today.e2e.length, judges: today.judges.length, usd: costOf(today.e2e, table) + costOf(today.judges, table) },
shadow: { cases: shadowE2e.length, judges: today.judges.length, usd: costOf(shadowE2e, table) + costOf(today.judges, table) },
screenProvisional: { cases: e2e.length, judges: deps.judgeCases.length, usd: Math.min(screenUsd, fullGateUsd(deps, table)), canary, canaryReasons, preambleOnly },
};
}
export interface SelectorSummary { meanUsd: number; medianUsd: number; meanCases: number; nearFull: number }
/** One cohort: every merged PR counts, zero-cost PRs included. The median is the upper middle value. */
export function summarizeSelector(rows: SelectorCost[], fullUsd: number): SelectorSummary {
const usd = rows.map(row => row.usd).sort((a, b) => a - b);
const n = Math.max(1, rows.length);
return {
meanUsd: usd.reduce((a, b) => a + b, 0) / n,
medianUsd: usd[usd.length >> 1] ?? 0,
meanCases: rows.reduce((sum, row) => sum + row.cases, 0) / n,
nearFull: usd.filter(value => value >= 0.9 * fullUsd).length,
};
}
// --- 4. Merge-train model ---
/** CPython's random.Random (MT19937 with init_by_array seeding, random(), shuffle(), sample()), so a seed reproduces sim.py. */
export class PyRandom {
private readonly mt = new Uint32Array(624);
private index = 624;
constructor(seed: number) {
if (!Number.isSafeInteger(seed) || seed < 0) throw new Error('seed must be a non-negative safe integer');
const key: number[] = [];
for (let rest = seed; key.length === 0 || rest > 0; rest = Math.floor(rest / 2 ** 32)) key.push(rest % 2 ** 32);
const mt = this.mt;
mt[0] = 19650218;
for (let i = 1; i < 624; i += 1) mt[i] = Math.imul(0x6c078965, mt[i - 1]! ^ (mt[i - 1]! >>> 30)) + i;
let i = 1, j = 0;
for (let k = Math.max(624, key.length); k > 0; k -= 1) {
mt[i] = (mt[i]! ^ Math.imul(mt[i - 1]! ^ (mt[i - 1]! >>> 30), 0x0019660d)) + key[j]! + j;
i += 1; j += 1;
if (i >= 624) { mt[0] = mt[623]!; i = 1; }
if (j >= key.length) j = 0;
}
for (let k = 623; k > 0; k -= 1) {
mt[i] = (mt[i]! ^ Math.imul(mt[i - 1]! ^ (mt[i - 1]! >>> 30), 0x5d588b65)) - i;
i += 1;
if (i >= 624) { mt[0] = mt[623]!; i = 1; }
}
mt[0] = 0x80000000;
}
private next32(): number {
const mt = this.mt;
if (this.index >= 624) {
for (let k = 0; k < 624; k += 1) {
const y = (mt[k]! & 0x80000000) | (mt[(k + 1) % 624]! & 0x7fffffff);
mt[k] = mt[(k + 397) % 624]! ^ (y >>> 1) ^ (y & 1 ? 0x9908b0df : 0);
}
this.index = 0;
}
let y = mt[this.index++]!;
y ^= y >>> 11;
y ^= (y << 7) & 0x9d2c5680;
y ^= (y << 15) & 0xefc60000;
y ^= y >>> 18;
return y >>> 0;
}
random(): number {
const a = this.next32() >>> 5, b = this.next32() >>> 6;
return (a * 67108864 + b) / 9007199254740992;
}
/** CPython's _randbelow: rejection sampling on n.bit_length() random bits. */
private below(n: number): number {
const bits = n.toString(2).length;
let r = this.next32() >>> (32 - bits);
while (r >= n) r = this.next32() >>> (32 - bits);
return r;
}
shuffle<T>(items: T[]): void {
for (let i = items.length - 1; i > 0; i -= 1) {
const j = this.below(i + 1);
[items[i], items[j]] = [items[j]!, items[i]!];
}
}
sample<T>(population: readonly T[], k: number): T[] {
const n = population.length;
if (!Number.isInteger(k) || k < 0 || k > n) throw new Error('sample larger than population or negative');
const setsize = 21 + (k > 5 ? 4 ** Math.ceil(Math.log(k * 3) / Math.log(4)) : 0);
const result: T[] = [];
if (n <= setsize) {
const pool = [...population];
for (let i = 0; i < k; i += 1) {
const j = this.below(n - i);
result.push(pool[j]!);
pool[j] = pool[n - i - 1]!;
}
return result;
}
const picked = new Set<number>();
for (let i = 0; i < k; i += 1) {
let j = this.below(n);
while (picked.has(j)) j = this.below(n);
picked.add(j);
result.push(population[j]!);
}
return result;
}
}
export type ScreenPolicy =
| { kind: 'all' } | { kind: 'none' } | { kind: 'random'; fraction: number }
| { kind: 'cheapest-half' } | { kind: 'expensive-half' };
export function policyLabel(policy: ScreenPolicy): string {
return policy.kind === 'random' ? `random ${Math.round(policy.fraction * 100)}%` : policy.kind;
}
export interface TrainModelInput {
/** Every merged PR of the window; `screenUsd` is its replayed screen cost on the recorded full-gate scale. */
prs: Array<{ paidRelevant: boolean; screenUsd: number }>;
policy: ScreenPolicy;
windowDays: number;
trainSize?: number;
outsideRed?: number;
redScreened?: number;
redUnscreened?: number;
fullGateUsd?: number;
bisectUsd?: number;
rescreenFactor?: number;
reps?: number;
seed?: number;
todayWeeklyUsd?: number;
afterGsta48WeeklyUsd?: number;
}
export interface TrainModelResult {
policy: string;
trainSize: number;
outsideRed: number;
weeklyUsd: number;
perMergedPrUsd: number;
multipleVsToday: number;
multipleVsAfterGsta48: number;
screenWeeklyUsd: number;
initialTrainsWeekly: number;
gateAttemptsWeekly: number;
bisectsWeekly: number;
rebuildOnceWeekly: number;
stallsWeekly: number;
firstCandidateRedShare: number;
attemptRedShare: number;
}
/** Indexes of the PRs a policy screens: only paid-relevant PRs can screen; `random` draws from rnd. */
export function screenedIndexes(prs: ReadonlyArray<{ paidRelevant: boolean; screenUsd: number }>, policy: ScreenPolicy, rnd: PyRandom): Set<number> {
const relevant = prs.map((pr, index) => ({ ...pr, index })).filter(pr => pr.paidRelevant);
const byCost = [...relevant].sort((a, b) => a.screenUsd - b.screenUsd);
const half = relevant.length >> 1;
const chosen = policy.kind === 'all' ? relevant : policy.kind === 'none' ? []
: policy.kind === 'random' ? rnd.sample(relevant, Math.floor(relevant.length * policy.fraction))
: policy.kind === 'cheapest-half' ? byCost.slice(0, half) : byCost.slice(half);
return new Set(chosen.map(pr => pr.index));
}
/**
* Every merged PR lands in a train. Each paid-relevant PR carries a defect at
* the screened or unscreened rate, and each gate attempt can also flake
* (outside red). A red attempt pays a bisect. A defect is attributed to the
* train's first defective PR, which is dropped, repaired and requeued at the
* back; a red with no defect gets one unchanged rebuild, and a second such
* red counts as a stall (and is rebuilt again).
*/
export function simulateTrains(input: TrainModelInput): TrainModelResult {
const { prs, policy, windowDays } = input;
const k = input.trainSize ?? 5, o = input.outsideRed ?? 0.15, rs = input.redScreened ?? 0.06, ru = input.redUnscreened ?? 0.25;
const full = input.fullGateUsd ?? 93.14, bisect = input.bisectUsd ?? 6, rescreen = input.rescreenFactor ?? 1.3;
const reps = input.reps ?? 400;
const rnd = new PyRandom(input.seed ?? 1);
const t = { screen: 0, gates: 0, init: 0, initRed: 0, attRed: 0, bisects: 0, rebuildOnce: 0, stalls: 0 };
for (let rep = 0; rep < reps; rep += 1) {
const screened = screenedIndexes(prs, policy, rnd);
const queue = prs.map((pr, index) => {
const s = screened.has(index);
if (s) t.screen += pr.screenUsd * rescreen;
return { defect: pr.paidRelevant && rnd.random() < (s ? rs : ru) };
});
rnd.shuffle(queue);
while (queue.length) {
let train = queue.splice(0, k);
t.init += 1;
let first = true, rebuilt = false;
while (true) {
t.gates += 1;
const flake = rnd.random() < o;
const bad = train.filter(item => item.defect);
const red = flake || bad.length > 0;
if (first && red) t.initRed += 1;
first = false;
if (!red) break;
t.attRed += 1;
t.bisects += 1;
if (bad.length) {
const culprit = bad[0]!;
train = train.filter(item => item !== culprit);
culprit.defect = false;
queue.push(culprit);
rebuilt = false;
if (!train.length) break;
} else if (!rebuilt) {
rebuilt = true;
t.rebuildOnce += 1;
} else {
t.stalls += 1;
rebuilt = false;
}
}
}
}
const perWeek = 7 / windowDays / reps;
const weeklyUsd = (t.screen + t.gates * full + t.bisects * bisect) * perWeek;
return {
policy: policyLabel(policy), trainSize: k, outsideRed: o, weeklyUsd,
perMergedPrUsd: weeklyUsd / (prs.length * 7 / windowDays),
multipleVsToday: (input.todayWeeklyUsd ?? TODAY_WEEKLY_USD) / weeklyUsd,
multipleVsAfterGsta48: (input.afterGsta48WeeklyUsd ?? AFTER_GSTA48_WEEKLY_USD) / weeklyUsd,
screenWeeklyUsd: t.screen * perWeek, initialTrainsWeekly: t.init * perWeek, gateAttemptsWeekly: t.gates * perWeek,
bisectsWeekly: t.bisects * perWeek, rebuildOnceWeekly: t.rebuildOnce * perWeek, stallsWeekly: t.stalls * perWeek,
firstCandidateRedShare: t.init ? t.initRed / t.init : 0, attemptRedShare: t.gates ? t.attRed / t.gates : 0,
};
}