Skip to content

Commit 45471e7

Browse files
authored
fix(review): gate the zero-error label floor at the Bonferroni split delta (#9922)
calibrateActThreshold compared the label count against a floor computed at the raw delta, but the scan itself tests each candidate at delta/K after #9066's Bonferroni split. A zero-error calibration set that clears the raw floor but falls short of the split-delta floor fell through the whole scan and was misreported as no_certifiable_threshold (an error-rate shortfall) instead of insufficient_labels (a label-count shortfall), losing #9048's status split. Also stop reporting a recalibration infra failure as insufficient_labels in runRiskControlRecalibration's catch. Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent c5a6cb4 commit 45471e7

4 files changed

Lines changed: 84 additions & 25 deletions

File tree

src/review/risk-control-wire.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ async function recalibrateArm(env: Env, arm: "close" | "merge", verdict: "close"
178178
* (#8835's "per-repo where sample size permits, global fallback otherwise"). A repo key certifies or is
179179
* retracted independently of the global one; the actuation read prefers the repo key. Returns per-arm
180180
* global statuses for the caller's log line. */
181-
export async function runRiskControlRecalibration(env: Env): Promise<Record<string, CalibrationResult["status"]>> {
182-
const summary: Record<string, CalibrationResult["status"]> = {};
181+
export async function runRiskControlRecalibration(env: Env): Promise<Record<string, CalibrationResult["status"] | "error">> {
182+
const summary: Record<string, CalibrationResult["status"] | "error"> = {};
183183
for (const { arm, verdict, alpha } of riskControlArms(env)) {
184184
try {
185185
summary[arm] = (await recalibrateArm(env, arm, verdict, alpha, null)).status;
@@ -195,7 +195,10 @@ export async function runRiskControlRecalibration(env: Env): Promise<Record<stri
195195
}
196196
} catch (error) {
197197
console.warn(JSON.stringify({ event: "risk_control_recalibrate_error", arm, message: errorMessage(error).slice(0, 160) }));
198-
summary[arm] = "insufficient_labels";
198+
// #9637: an infrastructure failure (DB read/write, JSON parse) is not a statistical shortfall — reporting
199+
// it as insufficient_labels told an operator to go collect labels for a problem collecting labels never
200+
// caused, in the same daily-tick log line #9048 split for the opposite reason.
201+
summary[arm] = "error";
199202
}
200203
}
201204
return summary;

src/review/risk-control.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
// HONESTY GUARDS, all load-bearing:
2323
// • INSUFFICIENT LABELS IS A REFUSAL, never a degraded guess. Even a zero-error calibration set cannot
2424
// certify α until n ≥ ln(δ)/ln(1−α) (the exact rule-of-three bound) — at α=0.005, δ=0.05 that is 598
25-
// clean labels. Below it this module refuses and the caller keeps the static floor.
25+
// clean labels. Below it this module refuses and the caller keeps the static floor. #9637: this floor is
26+
// evaluated at the δ the scan actually TESTS (δ/K, the Bonferroni split above), not the raw δ — a set that
27+
// clears the raw floor can still be short of what the split scan needs, and reporting that as
28+
// `no_certifiable_threshold` instead would misdirect an operator toward investigating precision when the
29+
// real gap is label count.
2630
// • NO CERTIFIABLE THRESHOLD IS ALSO A REFUSAL, distinct from insufficient labels (#9048): a repo can have
2731
// plenty of labels and still refuse when no λ's bound clears α — a fundamentally different shortfall
2832
// ("the error rate is too high", not "there aren't enough labels"). `no_certifiable_threshold` carries the
@@ -146,19 +150,29 @@ export function calibrateActThreshold(pairs: CalibrationPair[], alpha: number, d
146150
// #9066: Bonferroni-split δ across the K candidates this scan actually tests — see the module header for
147151
// why this, not literal fixed-sequence stopping, is the chosen fix.
148152
const testDelta = delta / candidates.length;
153+
// #9637: the label floor must be re-checked at the δ the scan actually tests (testDelta), not the raw δ
154+
// above — a zero-error set can clear the raw-δ floor yet still be short of what the split-δ scan needs,
155+
// and without this re-check every candidate below fails its n >= effectiveNeeded gate, falling through to
156+
// `no_certifiable_threshold` (an error-rate shortfall) when the true shortfall is labels. candidates.length
157+
// is always ≥ 1 here (the raw-δ floor check above already returned for an empty `pairs`), so this divides
158+
// safely and, at K=1, effectiveNeeded === needed — identical to pre-#9637 behavior.
159+
const effectiveNeeded = minimumCalibrationLabels(alpha, testDelta);
160+
if (sorted.length < effectiveNeeded) {
161+
return { status: "insufficient_labels", needed: effectiveNeeded, have: sorted.length, alpha, delta };
162+
}
149163
let dropped = 0;
150164
let droppedErrors = 0;
151165
let index = 0;
152166
// Tracks the closest-to-certifying candidate for `no_certifiable_threshold`'s message. The very first
153-
// candidate always computes a bound (n = sorted.length ≥ needed, guaranteed by the floor check above), so
154-
// these are always overwritten before any caller can observe the initial values.
167+
// candidate always computes a bound (n = sorted.length ≥ effectiveNeeded, guaranteed by the floor check
168+
// above), so these are always overwritten before any caller can observe the initial values.
155169
let bestLambda = candidates[0]!;
156170
let bestN = sorted.length;
157171
let bestUpperBound = Infinity;
158172
for (const lambda of candidates) {
159173
const n = sorted.length - dropped;
160174
const errors = totalErrors - droppedErrors;
161-
if (n >= needed) {
175+
if (n >= effectiveNeeded) {
162176
const bound = clopperPearsonUpperBound(errors, n, testDelta);
163177
if (bound < bestUpperBound) {
164178
bestUpperBound = bound;
@@ -206,6 +220,11 @@ export function validateCalibrationPayload(value: unknown): { alpha: number; lam
206220
if (typeof v.coverageAtLambda !== "number" || !(v.coverageAtLambda >= 0 && v.coverageAtLambda <= 1)) return null;
207221
if (typeof v.delta !== "number" || !(v.delta > 0 && v.delta <= 1)) return null;
208222
if (typeof v.nAtLambda !== "number" || !Number.isFinite(v.nAtLambda)) return null;
223+
// #9637: deliberately the UNSPLIT delta, not delta/K — the stored payload only ever records the originally-
224+
// requested delta (candidates.length is not persisted), and nAtLambda is the coverage at the ALREADY-
225+
// CERTIFIED lambda, not a candidate count mid-scan. A payload legitimately certified by the split-delta scan
226+
// always clears this looser, unsplit-delta floor too (effectiveNeeded ≥ needed), so this stays the coarser,
227+
// always-safe check calibrateActThreshold itself no longer uses for its own scan.
209228
if (v.nAtLambda < minimumCalibrationLabels(v.alpha, v.delta)) return null;
210229
return { alpha: v.alpha, lambda: v.lambda, coverageAtLambda: v.coverageAtLambda, nAtLambda: v.nAtLambda, delta: v.delta };
211230
}

test/unit/risk-control-wire.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,18 +123,19 @@ describe("runRiskControlRecalibration", () => {
123123

124124
it("REGRESSION (#9048): NO CERTIFIABLE THRESHOLD — ample labels but no λ clears α reports the true total, not a residual stratum, under a distinct event_type", async () => {
125125
const env = createTestEnv({ LOOPOVER_RISK_CONTROL_CLOSE_ALPHA: "0.005" }); // fixed alpha; floor = 598
126-
// 600 dirty pairs (all wrong) at 0.5, then 50 clean pairs at 0.99 — 650 total, well over the 598 floor,
127-
// but no candidate certifies: 0.5 has ruinous error, 0.99 falls below the floor once 0.5 is dropped.
128-
for (let i = 1; i <= 600; i += 1) await seedLabeledDecision(env, i, "close", "incorrect", 0.5);
129-
for (let i = 601; i <= 650; i += 1) await seedLabeledDecision(env, i, "close", "correct", 0.99);
126+
// 700 dirty pairs (all wrong) at 0.5, then 100 clean pairs at 0.99 — 800 total, over the split-delta floor
127+
// for these 2 candidates (#9637: 736, not the raw 598), but no candidate certifies: 0.5 has ruinous error,
128+
// 0.99 falls below the split-delta floor once 0.5 is dropped.
129+
for (let i = 1; i <= 700; i += 1) await seedLabeledDecision(env, i, "close", "incorrect", 0.5);
130+
for (let i = 701; i <= 800; i += 1) await seedLabeledDecision(env, i, "close", "correct", 0.99);
130131
const summary = await runRiskControlRecalibration(env);
131132
expect(summary.close).toBe("no_certifiable_threshold");
132133
const flag = await env.DB.prepare(`SELECT value FROM system_flags WHERE key = ?`).bind(riskControlFlagKey("close")).first();
133134
expect(flag).toBeFalsy(); // no certifiable guarantee — retracted, same as insufficient_labels
134135
const audit = await env.DB.prepare(
135136
`SELECT detail FROM audit_events WHERE event_type = 'risk_control_no_certifiable_threshold' AND target_key = 'riskcontrol:close'`,
136137
).first<{ detail: string }>();
137-
expect(audit!.detail).toContain("650 labels available but no threshold achieves α=0.005");
138+
expect(audit!.detail).toContain("800 labels available but no threshold achieves α=0.005");
138139
// The label-shortfall event must NOT also fire for this scope — it is a distinct outcome (#9048).
139140
const shortfall = await env.DB.prepare(
140141
`SELECT detail FROM audit_events WHERE event_type = 'risk_control_insufficient' AND target_key = 'riskcontrol:close'`,
@@ -157,7 +158,7 @@ describe("fail-safe arms", () => {
157158
vi.restoreAllMocks();
158159
});
159160

160-
it("a throwing arm recalibration is contained: the OTHER arm still runs and the summary reports insufficient", async () => {
161+
it("REGRESSION (#9637): a throwing arm recalibration reports 'error', never a statistical status — an infra failure is not a label shortfall", async () => {
161162
const env = createTestEnv();
162163
const { vi } = await import("vitest");
163164
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
@@ -169,7 +170,7 @@ describe("fail-safe arms", () => {
169170
return realPrepare(sql);
170171
});
171172
const summary = await runRiskControlRecalibration(env);
172-
expect(summary).toEqual({ close: "insufficient_labels", merge: "insufficient_labels" });
173+
expect(summary).toEqual({ close: "error", merge: "error" });
173174
expect(warn).toHaveBeenCalled();
174175
vi.restoreAllMocks();
175176
});

test/unit/risk-control.test.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,31 +71,67 @@ describe("calibrateActThreshold", () => {
7171
});
7272

7373
it("REGRESSION (#9048): a repo with AMPLE labels that cannot certify reports its true total, not a residual stratum size", () => {
74-
// 650 total labels (well over the 598-label floor for alpha=0.005) — but every candidate either has a
75-
// ruinous error rate (the 600 pairs at 0.5, all wrong) or falls below the sample-size floor once that
76-
// dirty stratum is dropped (only 50 remain at 0.99). Before #9048 this reported `have: 50` under
77-
// `insufficient_labels` — exactly the "N usable labels of 59/598 needed" bug: a residual stratum size
78-
// misreported as the repo's total label supply.
79-
const pairs = [...Array.from({ length: 600 }, () => pair(0.5, false)), ...Array.from({ length: 50 }, () => pair(0.99, true))];
74+
// 800 total labels (well over the 736-label split-delta floor for these 2 candidates at alpha=0.005,
75+
// #9637) — but every candidate either has a ruinous error rate (the 700 pairs at 0.5, all wrong) or falls
76+
// below the split-delta sample-size floor once that dirty stratum is dropped (only 100 remain at 0.99).
77+
// Before #9048 this reported `have: 100` under `insufficient_labels` — exactly the "N usable labels of
78+
// 59/598 needed" bug: a residual stratum size misreported as the repo's total label supply.
79+
const pairs = [...Array.from({ length: 700 }, () => pair(0.5, false)), ...Array.from({ length: 100 }, () => pair(0.99, true))];
8080
const result = calibrateActThreshold(pairs, 0.005, 0.05);
8181
expect(result.status).toBe("no_certifiable_threshold");
8282
if (result.status === "no_certifiable_threshold") {
83-
expect(result.totalPairs).toBe(650); // the TRUE total, never a residual stratum size
84-
expect(result.bestN).toBe(650); // the only candidate that cleared the sample-size floor
83+
expect(result.totalPairs).toBe(800); // the TRUE total, never a residual stratum size
84+
expect(result.bestN).toBe(800); // the only candidate that cleared the split-delta sample-size floor
8585
expect(result.bestLambda).toBe(0.5);
8686
expect(result.bestUpperBound).toBeGreaterThan(0.005); // could not certify alpha
8787
expect(result.bestUpperBound).toBeLessThanOrEqual(1);
8888
}
8989
});
9090

9191
it("REGRESSION (#9066): does not over-certify across many observed confidence candidates — Bonferroni-splits delta across the K distinct thresholds actually tested", () => {
92-
// Same shape the pre-fix code certified at lambda=0.9 (700 clean pairs, 10 distinct confidences, zero
92+
// Same shape the pre-#9066 code certified at lambda=0.9 (700 clean pairs, 10 distinct confidences, zero
9393
// errors, alpha=0.005): the RAW zero-error bound at n=700, delta=0.05 is ≈0.0043 (<=0.005, would certify),
94-
// but split across K=10 candidates (delta/10=0.005) the bound is ≈0.0075 (>0.005) — correctly refuses,
95-
// because reporting whichever of 10 tested candidates passes first is a selection, not a single test.
94+
// but split across K=10 candidates (delta/10=0.005) the bound is ≈0.0075 (>0.005) — over-certification is
95+
// refused. #9637: the label floor at that split delta (1058) is now also enforced up front, so 700 pairs —
96+
// genuinely short of what 10 candidates need — refuses as `insufficient_labels`, not the pre-#9637
97+
// `no_certifiable_threshold` (which would have wrongly implied the error rate, not the label count, was
98+
// the shortfall). Either way `calibrated` must never be reached.
9699
const pairs = Array.from({ length: 700 }, (_, i) => pair(0.9 + (i % 10) / 100, true));
97100
const result = calibrateActThreshold(pairs, 0.005, 0.05);
101+
expect(result.status).toBe("insufficient_labels");
102+
expect(result.status).not.toBe("calibrated");
103+
if (result.status === "insufficient_labels") {
104+
expect(result.needed).toBe(1058);
105+
expect(result.have).toBe(700);
106+
}
107+
});
108+
109+
it("REGRESSION (#9637): the split-delta floor, not the raw floor, gates a zero-error set — 59 clean labels across 59 distinct confidences is insufficient, not uncertifiable", () => {
110+
// Exactly the raw floor (minimumCalibrationLabels(0.05, 0.05) = 59), so the pre-scan check alone would
111+
// let this through — but every pair is a distinct confidence (K=59 candidates), so the scan actually
112+
// tests each candidate at delta/59. The effective floor at that split delta is 138: below it, a
113+
// ZERO-ERROR set must still refuse as insufficient_labels, not fall through the whole scan into a
114+
// misleading no_certifiable_threshold (#9048's status split, reintroduced by #9066's Bonferroni
115+
// correction and the reason this issue exists).
116+
const pairs = Array.from({ length: 59 }, (_, i) => pair(0.4 + i / 1000, true));
117+
const result = calibrateActThreshold(pairs, 0.05, 0.05);
118+
expect(result).toMatchObject({ status: "insufficient_labels", needed: 138, have: 59 });
119+
});
120+
121+
it("REGRESSION (#9637): no_certifiable_threshold remains reachable once labels clear the split-delta floor", () => {
122+
// A single confidence bucket (K=1 candidate, so the split delta equals the raw delta — this is also the
123+
// `candidates.length === 1` edge the fix must leave behaviorally identical to before #9637) isolates a
124+
// genuine error-rate shortfall from the label-count shortfall the tests above exercise: 1000 pairs, 12%
125+
// wrong — comfortably over alpha=0.05's own floor (59) but nowhere near a certifiable error rate.
126+
const pairs = [...Array.from({ length: 880 }, () => pair(0.9, true)), ...Array.from({ length: 120 }, () => pair(0.9, false))];
127+
const result = calibrateActThreshold(pairs, 0.05, 0.05);
98128
expect(result.status).toBe("no_certifiable_threshold");
129+
if (result.status === "no_certifiable_threshold") {
130+
expect(result.totalPairs).toBe(1000);
131+
expect(result.bestN).toBe(1000);
132+
expect(result.bestLambda).toBe(0.9);
133+
expect(result.bestUpperBound).toBeGreaterThan(0.05);
134+
}
99135
});
100136

101137
it("is deterministic and input-order independent", () => {

0 commit comments

Comments
 (0)