|
| 1 | +// Anti-overfit controls for the public benchmark (#9263, harness #9216, epic #8534). |
| 2 | +// |
| 3 | +// A public leaderboard creates a direct incentive to overfit, and an overfit agent is worse than useless: it |
| 4 | +// looks best exactly when it generalizes worst. The internal backtest gate already enforces this discipline; |
| 5 | +// this module applies the same controls to EXTERNAL submissions, plus the ones only an adversarial public |
| 6 | +// benchmark needs. Every knob below is one an adversary optimizes against, so each carries its reasoning |
| 7 | +// rather than a bare value. |
| 8 | +// |
| 9 | +// ── DECISION 1: SPLIT GRANULARITY IS THE REPO, NOT THE WORK UNIT ────────────────────────────────────── |
| 10 | +// The split reuses `splitBacktestCorpus` verbatim (requirement 1 — no second split mechanism), but the key |
| 11 | +// it splits on is the REPO, so every work unit belonging to a repo lands in the same slice. Work-unit-level |
| 12 | +// splitting leaks badly: two PRs in the same repo share a maintainer, a review culture, a CI setup and often |
| 13 | +// the same files, so an agent that saw repo A's visible PRs has effectively seen the answer pattern for repo |
| 14 | +// A's held-out PRs. Splitting per repo is what makes "held out" mean "generalizes to a repo it has never |
| 15 | +// seen" — which is the property actually worth measuring, and the one #9216 requirement 3 names. |
| 16 | +// |
| 17 | +// ── DECISION 2: HELD-OUT SCORES ARE NOT PUBLISHED PER SUBMISSION ───────────────────────────────────── |
| 18 | +// A leaderboard that reports a held-out score on every attempt converts the held-out set into a visible set |
| 19 | +// within a few dozen submissions: each score is an oracle query, and differencing successive scores recovers |
| 20 | +// per-unit membership. Held-out results publish on a fixed cadence or at evaluation close, never on demand; |
| 21 | +// `heldOutPublicationDecision` is the single place that rule is decided, and #9265's emitter is where it is |
| 22 | +// enforced, so a leaderboard cannot leak membership by accident. |
| 23 | +// |
| 24 | +// ── DECISION 3: SUBMISSION CAP PER (AGENT, WINDOW) ─────────────────────────────────────────────────── |
| 25 | +// Unbounded resubmission against a fixed corpus is gradient descent on the test set by brute force — with |
| 26 | +// enough attempts a random-search agent tops the board having learned nothing. The cap is per (agent, |
| 27 | +// benchmark window) rather than global or per-day: a per-day cap just spreads the same brute force over more |
| 28 | +// days, and a global cap would punish an agent that keeps competing across rotations. |
| 29 | +// |
| 30 | +// ── DECISION 4: ROTATION RETIRES A WINDOW, IT DOES NOT EXTEND IT ───────────────────────────────────── |
| 31 | +// A benchmark public for a year is not a test set any more — it is training data with extra steps. A window |
| 32 | +// has a fixed evaluation close; after it, the snapshot is RETIRED and a fresh one takes over. Retired |
| 33 | +// windows stay published for historical comparison but stop being the scoring basis, which is exactly the |
| 34 | +// distinction that keeps an old leaderboard honest instead of quietly authoritative. |
| 35 | +// |
| 36 | +// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads — the |
| 37 | +// caller supplies `now` wherever a time comparison is needed, so every decision here is reproducible. |
| 38 | + |
| 39 | +import type { BacktestCase } from "./backtest-corpus.js"; |
| 40 | +import { splitBacktestCorpus } from "./backtest-split.js"; |
| 41 | + |
| 42 | +/** Default share of REPOS (not work units — see decision 1) withheld from the visible slice. */ |
| 43 | +export const DEFAULT_HELD_OUT_FRACTION = 0.25; |
| 44 | + |
| 45 | +/** Default submissions permitted per (agent, benchmark window). See decision 3 for why the cap is scoped |
| 46 | + * this way; the value itself is deliberately generous enough for honest iteration and far too small for |
| 47 | + * brute-force search over a fixed corpus. */ |
| 48 | +export const DEFAULT_SUBMISSION_CAP = 20; |
| 49 | + |
| 50 | +export type BenchmarkSplitPolicy = { |
| 51 | + /** Unguessable without knowing it; identical across runs. Never published while a window is open. */ |
| 52 | + splitSeed: string; |
| 53 | + heldOutFraction: number; |
| 54 | +}; |
| 55 | + |
| 56 | +export type BenchmarkWorkUnitRef = { |
| 57 | + workUnitId: string; |
| 58 | + /** `owner/repo` — the split key (decision 1). */ |
| 59 | + repoFullName: string; |
| 60 | +}; |
| 61 | + |
| 62 | +/** |
| 63 | + * Partition work units into visible and held-out slices at REPO granularity, through the shared |
| 64 | + * `splitBacktestCorpus` primitive. |
| 65 | + * |
| 66 | + * The reuse is literal: one synthetic case per distinct repo, with the repo in the `targetKey` slot, so the |
| 67 | + * assignment digest is byte-identically the one the internal backtest split already computes. A repo's |
| 68 | + * assignment therefore depends only on (seed, benchmarkId, repo) — never on corpus size or ordering — so a |
| 69 | + * benchmark that grows over time never reshuffles which repos were already held out. |
| 70 | + */ |
| 71 | +export function splitBenchmarkWorkUnits( |
| 72 | + benchmarkId: string, |
| 73 | + workUnits: readonly BenchmarkWorkUnitRef[], |
| 74 | + policy: BenchmarkSplitPolicy, |
| 75 | +): { visible: BenchmarkWorkUnitRef[]; heldOut: BenchmarkWorkUnitRef[]; heldOutRepoCount: number; visibleRepoCount: number } { |
| 76 | + const repos = [...new Set(workUnits.map((unit) => unit.repoFullName))]; |
| 77 | + const repoCases: BacktestCase[] = repos.map((repoFullName) => ({ |
| 78 | + ruleId: benchmarkId, |
| 79 | + targetKey: repoFullName, |
| 80 | + outcome: "repo", |
| 81 | + label: "confirmed", |
| 82 | + firedAt: "", |
| 83 | + decidedAt: "", |
| 84 | + })); |
| 85 | + const split = splitBacktestCorpus(repoCases, policy.heldOutFraction, policy.splitSeed); |
| 86 | + const heldOutRepos = new Set(split.heldOut.map((repoCase) => repoCase.targetKey)); |
| 87 | + const visible: BenchmarkWorkUnitRef[] = []; |
| 88 | + const heldOut: BenchmarkWorkUnitRef[] = []; |
| 89 | + for (const unit of workUnits) { |
| 90 | + if (heldOutRepos.has(unit.repoFullName)) heldOut.push(unit); |
| 91 | + else visible.push(unit); |
| 92 | + } |
| 93 | + return { visible, heldOut, heldOutRepoCount: heldOutRepos.size, visibleRepoCount: repos.length - heldOutRepos.size }; |
| 94 | +} |
| 95 | + |
| 96 | +export type BenchmarkWindow = { |
| 97 | + benchmarkId: string; |
| 98 | + snapshotRef: string; |
| 99 | + opensAt: string; |
| 100 | + /** Inclusive evaluation close. After this the window is RETIRED (decision 4), not extended. */ |
| 101 | + closesAt: string; |
| 102 | + /** Held-out scores may also publish periodically before close; 0/absent means "at close only". */ |
| 103 | + heldOutPublishEveryDays?: number | undefined; |
| 104 | +}; |
| 105 | + |
| 106 | +export type BenchmarkWindowState = "pending" | "open" | "retired"; |
| 107 | + |
| 108 | +/** Where `now` sits relative to a window. A retired window stays readable for historical comparison but is |
| 109 | + * no longer a scoring basis — the caller enforces that by refusing submissions (below). */ |
| 110 | +export function benchmarkWindowState(window: BenchmarkWindow, now: string): BenchmarkWindowState { |
| 111 | + const at = Date.parse(now); |
| 112 | + if (at < Date.parse(window.opensAt)) return "pending"; |
| 113 | + return at > Date.parse(window.closesAt) ? "retired" : "open"; |
| 114 | +} |
| 115 | + |
| 116 | +export type SubmissionDecision = |
| 117 | + | { accepted: true; submissionIndex: number; remaining: number } |
| 118 | + | { accepted: false; reason: "window_not_open" | "window_retired" | "cap_reached"; remaining: number }; |
| 119 | + |
| 120 | +/** |
| 121 | + * Decide whether one more submission from this agent is admissible. |
| 122 | + * |
| 123 | + * PURE and total: it never throws for ordinarily-invalid input, and the refusal reason is NAMED so a |
| 124 | + * submitter learns which control stopped them (a cap they can wait out, versus a window that will never |
| 125 | + * reopen) rather than seeing an opaque rejection. |
| 126 | + */ |
| 127 | +export function decideSubmission(input: { |
| 128 | + window: BenchmarkWindow; |
| 129 | + now: string; |
| 130 | + /** Submissions this agent has already made against THIS window. */ |
| 131 | + priorSubmissions: number; |
| 132 | + cap?: number | undefined; |
| 133 | +}): SubmissionDecision { |
| 134 | + const cap = input.cap ?? DEFAULT_SUBMISSION_CAP; |
| 135 | + const remaining = Math.max(0, cap - input.priorSubmissions); |
| 136 | + const state = benchmarkWindowState(input.window, input.now); |
| 137 | + if (state === "pending") return { accepted: false, reason: "window_not_open", remaining }; |
| 138 | + if (state === "retired") return { accepted: false, reason: "window_retired", remaining }; |
| 139 | + if (remaining <= 0) return { accepted: false, reason: "cap_reached", remaining: 0 }; |
| 140 | + return { accepted: true, submissionIndex: input.priorSubmissions + 1, remaining: remaining - 1 }; |
| 141 | +} |
| 142 | + |
| 143 | +export type HeldOutPublicationDecision = |
| 144 | + | { publish: true; reason: "evaluation_closed" | "scheduled_cadence" } |
| 145 | + | { publish: false; reason: "window_open_between_cadences" | "window_not_open" }; |
| 146 | + |
| 147 | +/** |
| 148 | + * Decide whether held-out scores may be published right now (decision 2). |
| 149 | + * |
| 150 | + * At or after close, always — the window is over and there is nothing left to leak into. Before close, only |
| 151 | + * on the configured cadence boundary, counted in whole periods since `opensAt`, so the publication instants |
| 152 | + * are a fixed public schedule rather than something a submitter can trigger by submitting. |
| 153 | + */ |
| 154 | +export function heldOutPublicationDecision(window: BenchmarkWindow, now: string): HeldOutPublicationDecision { |
| 155 | + const state = benchmarkWindowState(window, now); |
| 156 | + if (state === "pending") return { publish: false, reason: "window_not_open" }; |
| 157 | + if (state === "retired") return { publish: true, reason: "evaluation_closed" }; |
| 158 | + const everyDays = window.heldOutPublishEveryDays ?? 0; |
| 159 | + if (everyDays <= 0) return { publish: false, reason: "window_open_between_cadences" }; |
| 160 | + const elapsedMs = Date.parse(now) - Date.parse(window.opensAt); |
| 161 | + const periodMs = everyDays * 24 * 60 * 60 * 1000; |
| 162 | + // A boundary instant publishes; anything strictly between boundaries does not. |
| 163 | + return elapsedMs > 0 && elapsedMs % periodMs === 0 |
| 164 | + ? { publish: true, reason: "scheduled_cadence" } |
| 165 | + : { publish: false, reason: "window_open_between_cadences" }; |
| 166 | +} |
| 167 | + |
| 168 | +/** What a leaderboard is allowed to show for one submission while its window is open. */ |
| 169 | +export type PublishableScores<T> = { |
| 170 | + visible: T; |
| 171 | + /** Present ONLY when {@link heldOutPublicationDecision} allowed it. `null` is the honest "not published |
| 172 | + * yet", and carries no count, no fraction and no per-unit membership — anything derived from the |
| 173 | + * held-out slice is an oracle query, so the shape itself refuses to answer one. */ |
| 174 | + heldOut: T | null; |
| 175 | + heldOutPublication: HeldOutPublicationDecision; |
| 176 | +}; |
| 177 | + |
| 178 | +/** |
| 179 | + * Gate a scored pair through the publication policy. |
| 180 | + * |
| 181 | + * The held-out score is DROPPED, not merely hidden behind a flag: a caller cannot forget to check the flag |
| 182 | + * and serialize a field that is present in memory. That is the difference between a policy and a habit. |
| 183 | + */ |
| 184 | +export function gateHeldOutPublication<T>( |
| 185 | + window: BenchmarkWindow, |
| 186 | + now: string, |
| 187 | + scores: { visible: T; heldOut: T }, |
| 188 | +): PublishableScores<T> { |
| 189 | + const decision = heldOutPublicationDecision(window, now); |
| 190 | + return { |
| 191 | + visible: scores.visible, |
| 192 | + heldOut: decision.publish ? scores.heldOut : null, |
| 193 | + heldOutPublication: decision, |
| 194 | + }; |
| 195 | +} |
0 commit comments