Skip to content

Commit e8aea64

Browse files
committed
feat(benchmark): anti-overfit controls — repo-level seeded split, caps, rotation (#9263)
A public leaderboard creates a direct incentive to overfit, and an overfit agent is worse than useless: it looks best exactly when it generalizes worst. Four decisions, each recorded with its reasoning in the module header, because each is a knob an adversary optimizes against. 1. SPLIT GRANULARITY IS THE REPO, NOT THE WORK UNIT. The split reuses splitBacktestCorpus verbatim -- asserted, not assumed: a test recomputes the assignment through the primitive directly and requires the same held-out repos -- but keys on the repo, so a repo's units never straddle the boundary. Work-unit splitting leaks: two PRs in one repo share a maintainer, a review culture and often the same files, so an agent that saw the visible ones has effectively seen the answer pattern for the held-out ones. Per-repo is what makes "held out" mean "generalizes to a repo it has never seen". 2. HELD-OUT SCORES DO NOT PUBLISH PER SUBMISSION. Each published score is an oracle query, and differencing successive ones recovers membership. Publication happens at evaluation close, or on a fixed public cadence counted from opensAt -- never on demand. gateHeldOutPublication DROPS the held-out value rather than flagging it, so a caller cannot forget to check a flag and serialize a field that is present in memory. 3. SUBMISSION CAP PER (AGENT, WINDOW). Unbounded resubmission against a fixed corpus is gradient descent on the test set by brute force. Scoped per window rather than per day (which just spreads the same brute force over more days) or globally (which would punish an agent that keeps competing across rotations). Refusals are NAMED, so a submitter can tell a cap they can wait out from a window that will never reopen. 4. ROTATION RETIRES A WINDOW, IT DOES NOT EXTEND IT. A benchmark public for a year is training data with extra steps. A retired window stays published for historical comparison but stops being a scoring basis, and refuses submissions even with quota left. Closes #9263
1 parent f41fd97 commit e8aea64

3 files changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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+
}

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export * from "./calibration/attester.js";
186186
export * from "./calibration/benchmark-proposal.js";
187187
export * from "./calibration/benchmark-ground-truth.js";
188188
export * from "./calibration/benchmark-score.js";
189+
export * from "./calibration/benchmark-anti-overfit.js";
189190
export {
190191
GOVERNOR_LEDGER_EVENT_TYPES,
191192
normalizeGovernorLedgerEvent,

0 commit comments

Comments
 (0)