Skip to content

Commit 112ead6

Browse files
authored
feat(calibration): register the slop gate score as the registry's first ceiling knob, report-only (#8224) (#8275)
The registry gains the orientation axis #8224's design finding called for: a floor knob (every prior entry) loosens DOWNWARD toward hardMinimum; a ceiling knob — the slop gate blocks when risk/100 >= the value — loosens UPWARD toward a declared hardMaximum, with the classifier math unchanged (both knob families fire on value >= threshold). evaluateKnobLoosening and evaluateKnobDrift branch on orientation (including the drift direction label: above-live is LOOSER for a ceiling), and the structural invariants pin ceiling entries to report_only with no tightening ladder until the live storage generalizes. slop_gate_score enters report-only: shipped 0.60 (the gate constant /100), two raises [0.65, 0.70], hard ceiling 0.70, the registry's strictest 50/12 sample floors — bounds rationale in the entry comment. The generic report-only proposals path picks it up with zero new plumbing, and the knobs endpoint now lists EVERY registry knob labeled by applyMode. quality_gate_score stays out (no global shipped default to anchor on), recorded in the registry comment; the flip-to-live issue gets filed only after proposals with real evidence exist.
1 parent 516fb44 commit 112ead6

7 files changed

Lines changed: 235 additions & 18 deletions

File tree

packages/loopover-engine/src/advisory/gate-advisory.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ function sanitizeForCheckRun(text: string): string {
3838
}
3939

4040
const DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE = 0.93;
41-
const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;
41+
/** Exported to mirror the src twin (#8224): loopover's LOOSENABLE_KNOBS registry anchors the slop knob's
42+
* shipped value on this constant (divided by 100 onto the corpus's confidence scale). Value unchanged. */
43+
export const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;
4244

4345
export type GateCheckConclusion = "success" | "failure" | "action_required" | "neutral" | "skipped";
4446

src/api/routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
322322
import { loadPublicRulePrecision } from "../review/public-rule-precision";
323323
import { loadCalibrationTrend } from "../services/rule-calibration-trend";
324324
import { isSatisfactionFloorAutotuneEnabled, loadSatisfactionFloorStatus, runSatisfactionFloorLoosening } from "../services/satisfaction-floor-loosening-run";
325-
import { loadLiveKnobStatuses } from "../services/knob-loosening-run";
325+
import { loadAllKnobStatuses } from "../services/knob-loosening-run";
326326
import { loadPublicReuseRateTrend } from "../services/public-reuse-rate-trend";
327327
import { loadPublicReviewVolumeTrend } from "../services/public-review-volume-trend";
328328
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
@@ -4840,7 +4840,7 @@ export function createApp() {
48404840
// The #8161 surface generalized across EVERY live registry knob (#8176): one endpoint, one projector,
48414841
// per-knob flag state + shipped/live/override values + applied history (both split verdicts). Same
48424842
// deliberate non-flag-gating and INTERNAL_JOB_TOKEN posture as the satisfaction-floor read above.
4843-
app.get("/v1/internal/calibration/knobs", async (c) => c.json({ knobs: await loadLiveKnobStatuses(c.env) }));
4843+
app.get("/v1/internal/calibration/knobs", async (c) => c.json({ knobs: await loadAllKnobStatuses(c.env) }));
48444844

48454845
app.post("/v1/internal/jobs/refresh-registry", async (c) => {
48464846
const message: JobMessage = { type: "refresh-registry", requestedBy: "api" };

src/rules/advisory.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,9 @@ function buildQualityGateWarning(policy: GateCheckPolicy): AdvisoryFinding | nul
12271227
}
12281228

12291229
// Default block threshold = the `high` band (60), used when a maintainer sets slop: block without a minScore.
1230-
const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;
1230+
/** Exported for the LOOSENABLE_KNOBS registry (#8224): the slop knob's shipped value anchors on this
1231+
* constant (divided by 100 onto the corpus's confidence scale). */
1232+
export const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;
12311233

12321234
function buildSlopGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | null {
12331235
if (gateMode(policy.slopGateMode) !== "block") return null;

src/services/knob-loosening-run.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,9 @@ export type KnobRepoOverride = { repoFullName: string; value: number };
476476

477477
export type KnobStatus = {
478478
knobId: string;
479+
/** The registry's apply contract for this knob (#8224): report_only knobs surface evidence here and in
480+
* the advisor but their apply path refuses — the operator sees WHAT would move before anything can. */
481+
applyMode: "live" | "report_only";
479482
flagEnabled: boolean;
480483
/** The tighten direction's own flag (#8225) — null for a knob that declares no tightening ladder. */
481484
tightenFlagEnabled: boolean | null;
@@ -620,6 +623,7 @@ export async function loadKnobStatus(env: Env, knob: LoosenableKnob): Promise<Kn
620623

621624
return {
622625
knobId: knob.knobId,
626+
applyMode: knob.applyMode,
623627
flagEnabled,
624628
tightenFlagEnabled,
625629
shippedValue: knob.shippedValue,
@@ -633,7 +637,7 @@ export async function loadKnobStatus(env: Env, knob: LoosenableKnob): Promise<Kn
633637
}
634638

635639
/** Every live knob's status (satisfaction floor included — the generic projector reads its legacy
636-
* proposal spelling), for GET /v1/internal/calibration/knobs. */
640+
* proposal spelling). Consumed by the advisor's reliability recs, which stay live-only by design. */
637641
export async function loadLiveKnobStatuses(env: Env, knobs: readonly LoosenableKnob[] = Object.values(LOOSENABLE_KNOBS)): Promise<KnobStatus[]> {
638642
const statuses: KnobStatus[] = [];
639643
for (const knob of knobs) {
@@ -642,3 +646,12 @@ export async function loadLiveKnobStatuses(env: Env, knobs: readonly LoosenableK
642646
}
643647
return statuses;
644648
}
649+
650+
/** EVERY registry knob's status, report-only included (#8224), for GET /v1/internal/calibration/knobs —
651+
* the operator must see a report-only knob's evidence (drift, reliability, proposals-to-be) with its
652+
* applyMode label, not discover it only when someone files the flip-to-live issue. */
653+
export async function loadAllKnobStatuses(env: Env, knobs: readonly LoosenableKnob[] = Object.values(LOOSENABLE_KNOBS)): Promise<KnobStatus[]> {
654+
const statuses: KnobStatus[] = [];
655+
for (const knob of knobs) statuses.push(await loadKnobStatus(env, knob));
656+
return statuses;
657+
}

src/services/loosening-knobs.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,28 @@ import {
2020
type BacktestComparison,
2121
} from "@loopover/engine";
2222
import { LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR } from "./linked-issue-satisfaction";
23-
import { DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE } from "../rules/advisory";
23+
import { DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE, DEFAULT_SLOP_BLOCK_THRESHOLD } from "../rules/advisory";
2424

2525
export type LoosenableKnob = {
2626
/** Stable id — used in override flag keys, audit events, and advisor labels. Never rename. */
2727
knobId: string;
2828
ruleId: string;
2929
shippedValue: number;
30-
/** Candidate loosened values, nearest-to-shipped first — the smallest evidence-cleared step wins. */
30+
/** Which way LOOSER points (#8224): a `floor` knob (a rule fires at/above the value; every pre-#8224
31+
* entry) loosens DOWNWARD; a `ceiling` knob (a gate blocks at/above the value — slop) loosens UPWARD,
32+
* raising the cap so fewer PRs block. The classifier math is identical either way (both knob families
33+
* fire on value >= threshold, so buildConfidenceThresholdClassifier applies unchanged) — orientation
34+
* only decides which side of shipped the candidates sit on and which hard bound applies. */
35+
orientation: "floor" | "ceiling";
36+
/** Candidate loosened values, nearest-to-shipped first — the smallest evidence-cleared step wins.
37+
* Floor knobs: strictly below shipped, descending. Ceiling knobs: strictly above shipped, ascending. */
3138
candidates: readonly number[];
32-
/** No backtest result, however good, may loosen below this. */
39+
/** Floor knobs: no backtest result, however good, may loosen below this. Ceiling knobs declare the
40+
* mirror bound in {@link LoosenableKnob.hardMaximum} instead and set this to the shipped value (it
41+
* still bounds the drift pool's tighter side). */
3342
hardMinimum: number;
43+
/** Ceiling knobs only (#8224): no backtest result may loosen (raise) the cap above this. */
44+
hardMaximum?: number;
3445
minVisibleCases: number;
3546
minHeldOutCases: number;
3647
heldOutFraction: number;
@@ -76,6 +87,7 @@ export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object
7687
satisfaction_floor: {
7788
knobId: "satisfaction_floor",
7889
ruleId: "linked_issue_scope_mismatch",
90+
orientation: "floor",
7991
shippedValue: LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR,
8092
candidates: [0.45, 0.4, 0.35, 0.3],
8193
hardMinimum: 0.3,
@@ -99,6 +111,7 @@ export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object
99111
ai_review_close_confidence: {
100112
knobId: "ai_review_close_confidence",
101113
ruleId: "ai_consensus_defect",
114+
orientation: "floor",
102115
shippedValue: DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE,
103116
candidates: [0.9, 0.85],
104117
hardMinimum: 0.85,
@@ -125,6 +138,36 @@ export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object
125138
eventType: "calibration.ai_review_close_confidence_tightened",
126139
},
127140
},
141+
// #8224: the slop gate's block threshold enters REPORT-ONLY — proposals surface with full evidence in
142+
// the advisor and the knobs endpoint; the apply path refuses until a flip-to-live ships as its own
143+
// reviewed change, and per #8224 that issue gets filed only AFTER proposals with real evidence exist.
144+
// The registry's first CEILING knob: the gate blocks when slopRisk/100 >= this value (advisory.ts's
145+
// recordRuleFired writes confidence = risk/100 with shipped DEFAULT_SLOP_BLOCK_THRESHOLD), so LOOSER
146+
// means RAISING the cap. Bounds rationale (tighter than close-confidence's, per the issue): this value
147+
// gates contributor-facing verdicts directly, so two small steps (+0.05, +0.10) and a hard ceiling of
148+
// 0.70 — beyond that a "slop gate" that only blocks 70+/100 risk isn't gating. Sample floors match the
149+
// close-confidence knob's strictest-in-registry 50/12 given score noisiness.
150+
//
151+
// quality_gate_score deliberately does NOT enter (#8224's recorded finding): qualityGateMinScore is
152+
// per-repo nullable with NO global shipped default, and a registry entry anchors the whole discipline
153+
// on the shipped constant. It stays out until a global default exists.
154+
slop_gate_score: {
155+
knobId: "slop_gate_score",
156+
ruleId: "slop_gate_score",
157+
orientation: "ceiling",
158+
shippedValue: DEFAULT_SLOP_BLOCK_THRESHOLD / 100,
159+
candidates: [0.65, 0.7],
160+
hardMinimum: DEFAULT_SLOP_BLOCK_THRESHOLD / 100,
161+
hardMaximum: 0.7,
162+
minVisibleCases: 50,
163+
minHeldOutCases: 12,
164+
heldOutFraction: 0.25,
165+
splitSeed: "slop-gate-loosening-v1",
166+
applyMode: "report_only",
167+
overrideFlagKey: "slop_gate_score_override",
168+
looseningEventType: "calibration.slop_gate_score_loosened",
169+
autotuneEnvVar: "SLOP_GATE_SCORE_AUTOTUNE_ENABLED",
170+
},
128171
});
129172

130173
export type KnobLooseningProposal = {
@@ -155,7 +198,13 @@ export function evaluateKnobLoosening(
155198
if (visible.length < knob.minVisibleCases || heldOut.length < knob.minHeldOutCases) return null;
156199

157200
for (const candidate of knob.candidates) {
158-
if (candidate >= currentValue || candidate < knob.hardMinimum) continue;
201+
// Orientation decides which way "looser" points (#8224): floor knobs step DOWN toward hardMinimum,
202+
// ceiling knobs step UP toward hardMaximum. Same evidence discipline either way.
203+
const loosens =
204+
knob.orientation === "ceiling"
205+
? candidate > currentValue && candidate <= (knob.hardMaximum ?? currentValue)
206+
: candidate < currentValue && candidate >= knob.hardMinimum;
207+
if (!loosens) continue;
159208
const visibleComparison = compareOnSlice(knob.ruleId, visible, currentValue, candidate);
160209
if (visibleComparison.verdict !== "improved") continue;
161210
const heldOutComparison = compareOnSlice(knob.ruleId, heldOut, currentValue, candidate);
@@ -268,8 +317,11 @@ export function evaluateKnobDrift(
268317

269318
// #8225: a declared tightening ladder joins the pool, so the sentinel's tighter findings and the tighten
270319
// apply path judge the SAME candidate values (bounded by the ladder's own hard maximum via declaration).
320+
// #8224: ceiling knobs bound the pool from above (hardMaximum) instead of below.
271321
const alternatives = [...new Set([knob.shippedValue, ...knob.candidates, ...(knob.tightening?.candidates ?? [])])]
272-
.filter((value) => value !== liveValue && value >= knob.hardMinimum)
322+
.filter((value) =>
323+
value !== liveValue && (knob.orientation === "ceiling" ? value <= (knob.hardMaximum ?? knob.shippedValue) : value >= knob.hardMinimum),
324+
)
273325
.sort((left, right) => Math.abs(left - liveValue) - Math.abs(right - liveValue) || right - left);
274326

275327
for (const alternative of alternatives) {
@@ -282,7 +334,13 @@ export function evaluateKnobDrift(
282334
ruleId: knob.ruleId,
283335
liveValue,
284336
dominatingValue: alternative,
285-
direction: alternative === knob.shippedValue ? "shipped" : alternative < liveValue ? "looser" : "tighter",
337+
// Orientation decides the label (#8224): for a ceiling knob a HIGHER alternative is the looser one.
338+
direction:
339+
alternative === knob.shippedValue
340+
? "shipped"
341+
: (knob.orientation === "ceiling" ? alternative > liveValue : alternative < liveValue)
342+
? "looser"
343+
: "tighter",
286344
visibleCases: visible.length,
287345
heldOutCases: heldOut.length,
288346
visible: visibleComparison,

test/unit/knob-loosening-run.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,14 @@ describe("processor + endpoint wiring (#8176)", () => {
278278
expect((await app.request("/v1/internal/calibration/knobs", {}, env)).status).toBe(401);
279279
const res = await app.request("/v1/internal/calibration/knobs", { headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` } }, env);
280280
expect(res.status).toBe(200);
281-
const body = (await res.json()) as { knobs: Array<{ knobId: string; flagEnabled: boolean }> };
282-
expect(body.knobs.map((knob) => knob.knobId).sort()).toEqual(["ai_review_close_confidence", "satisfaction_floor"]);
281+
const body = (await res.json()) as { knobs: Array<{ knobId: string; flagEnabled: boolean; applyMode: string }> };
282+
// #8224: report-only knobs list too, labeled by applyMode — the operator sees evidence surfaces
283+
// before any flip-to-live exists.
284+
expect(body.knobs.map((knob) => knob.knobId).sort()).toEqual(["ai_review_close_confidence", "satisfaction_floor", "slop_gate_score"]);
283285
expect(body.knobs.every((knob) => knob.flagEnabled === false)).toBe(true);
286+
const byId = Object.fromEntries(body.knobs.map((knob) => [knob.knobId, knob]));
287+
expect(byId.slop_gate_score!.applyMode).toBe("report_only");
288+
expect(byId.ai_review_close_confidence!.applyMode).toBe("live");
284289
expect(JSON.stringify(body)).not.toMatch(/reward|payout|trust|wallet|hotkey|issueText|modelResponse/i);
285290
});
286291
});

0 commit comments

Comments
 (0)