Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/loopover-engine/src/advisory/gate-advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ function sanitizeForCheckRun(text: string): string {
}

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

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

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

app.post("/v1/internal/jobs/refresh-registry", async (c) => {
const message: JobMessage = { type: "refresh-registry", requestedBy: "api" };
Expand Down
4 changes: 3 additions & 1 deletion src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,9 @@ function buildQualityGateWarning(policy: GateCheckPolicy): AdvisoryFinding | nul
}

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

function buildSlopGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | null {
if (gateMode(policy.slopGateMode) !== "block") return null;
Expand Down
15 changes: 14 additions & 1 deletion src/services/knob-loosening-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,9 @@ export type KnobRepoOverride = { repoFullName: string; value: number };

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

return {
knobId: knob.knobId,
applyMode: knob.applyMode,
flagEnabled,
tightenFlagEnabled,
shippedValue: knob.shippedValue,
Expand All @@ -633,7 +637,7 @@ export async function loadKnobStatus(env: Env, knob: LoosenableKnob): Promise<Kn
}

/** Every live knob's status (satisfaction floor included — the generic projector reads its legacy
* proposal spelling), for GET /v1/internal/calibration/knobs. */
* proposal spelling). Consumed by the advisor's reliability recs, which stay live-only by design. */
export async function loadLiveKnobStatuses(env: Env, knobs: readonly LoosenableKnob[] = Object.values(LOOSENABLE_KNOBS)): Promise<KnobStatus[]> {
const statuses: KnobStatus[] = [];
for (const knob of knobs) {
Expand All @@ -642,3 +646,12 @@ export async function loadLiveKnobStatuses(env: Env, knobs: readonly LoosenableK
}
return statuses;
}

/** EVERY registry knob's status, report-only included (#8224), for GET /v1/internal/calibration/knobs —
* the operator must see a report-only knob's evidence (drift, reliability, proposals-to-be) with its
* applyMode label, not discover it only when someone files the flip-to-live issue. */
export async function loadAllKnobStatuses(env: Env, knobs: readonly LoosenableKnob[] = Object.values(LOOSENABLE_KNOBS)): Promise<KnobStatus[]> {
const statuses: KnobStatus[] = [];
for (const knob of knobs) statuses.push(await loadKnobStatus(env, knob));
return statuses;
}
70 changes: 64 additions & 6 deletions src/services/loosening-knobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,28 @@ import {
type BacktestComparison,
} from "@loopover/engine";
import { LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR } from "./linked-issue-satisfaction";
import { DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE } from "../rules/advisory";
import { DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE, DEFAULT_SLOP_BLOCK_THRESHOLD } from "../rules/advisory";

export type LoosenableKnob = {
/** Stable id — used in override flag keys, audit events, and advisor labels. Never rename. */
knobId: string;
ruleId: string;
shippedValue: number;
/** Candidate loosened values, nearest-to-shipped first — the smallest evidence-cleared step wins. */
/** Which way LOOSER points (#8224): a `floor` knob (a rule fires at/above the value; every pre-#8224
* entry) loosens DOWNWARD; a `ceiling` knob (a gate blocks at/above the value — slop) loosens UPWARD,
* raising the cap so fewer PRs block. The classifier math is identical either way (both knob families
* fire on value >= threshold, so buildConfidenceThresholdClassifier applies unchanged) — orientation
* only decides which side of shipped the candidates sit on and which hard bound applies. */
orientation: "floor" | "ceiling";
/** Candidate loosened values, nearest-to-shipped first — the smallest evidence-cleared step wins.
* Floor knobs: strictly below shipped, descending. Ceiling knobs: strictly above shipped, ascending. */
candidates: readonly number[];
/** No backtest result, however good, may loosen below this. */
/** Floor knobs: no backtest result, however good, may loosen below this. Ceiling knobs declare the
* mirror bound in {@link LoosenableKnob.hardMaximum} instead and set this to the shipped value (it
* still bounds the drift pool's tighter side). */
hardMinimum: number;
/** Ceiling knobs only (#8224): no backtest result may loosen (raise) the cap above this. */
hardMaximum?: number;
minVisibleCases: number;
minHeldOutCases: number;
heldOutFraction: number;
Expand Down Expand Up @@ -76,6 +87,7 @@ export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object
satisfaction_floor: {
knobId: "satisfaction_floor",
ruleId: "linked_issue_scope_mismatch",
orientation: "floor",
shippedValue: LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR,
candidates: [0.45, 0.4, 0.35, 0.3],
hardMinimum: 0.3,
Expand All @@ -99,6 +111,7 @@ export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object
ai_review_close_confidence: {
knobId: "ai_review_close_confidence",
ruleId: "ai_consensus_defect",
orientation: "floor",
shippedValue: DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE,
candidates: [0.9, 0.85],
hardMinimum: 0.85,
Expand All @@ -125,6 +138,36 @@ export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object
eventType: "calibration.ai_review_close_confidence_tightened",
},
},
// #8224: the slop gate's block threshold enters REPORT-ONLY — proposals surface with full evidence in
// the advisor and the knobs endpoint; the apply path refuses until a flip-to-live ships as its own
// reviewed change, and per #8224 that issue gets filed only AFTER proposals with real evidence exist.
// The registry's first CEILING knob: the gate blocks when slopRisk/100 >= this value (advisory.ts's
// recordRuleFired writes confidence = risk/100 with shipped DEFAULT_SLOP_BLOCK_THRESHOLD), so LOOSER
// means RAISING the cap. Bounds rationale (tighter than close-confidence's, per the issue): this value
// gates contributor-facing verdicts directly, so two small steps (+0.05, +0.10) and a hard ceiling of
// 0.70 — beyond that a "slop gate" that only blocks 70+/100 risk isn't gating. Sample floors match the
// close-confidence knob's strictest-in-registry 50/12 given score noisiness.
//
// quality_gate_score deliberately does NOT enter (#8224's recorded finding): qualityGateMinScore is
// per-repo nullable with NO global shipped default, and a registry entry anchors the whole discipline
// on the shipped constant. It stays out until a global default exists.
slop_gate_score: {
knobId: "slop_gate_score",
ruleId: "slop_gate_score",
orientation: "ceiling",
shippedValue: DEFAULT_SLOP_BLOCK_THRESHOLD / 100,
candidates: [0.65, 0.7],
hardMinimum: DEFAULT_SLOP_BLOCK_THRESHOLD / 100,
hardMaximum: 0.7,
minVisibleCases: 50,
minHeldOutCases: 12,
heldOutFraction: 0.25,
splitSeed: "slop-gate-loosening-v1",
applyMode: "report_only",
overrideFlagKey: "slop_gate_score_override",
looseningEventType: "calibration.slop_gate_score_loosened",
autotuneEnvVar: "SLOP_GATE_SCORE_AUTOTUNE_ENABLED",
},
});

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

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

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

for (const alternative of alternatives) {
Expand All @@ -282,7 +334,13 @@ export function evaluateKnobDrift(
ruleId: knob.ruleId,
liveValue,
dominatingValue: alternative,
direction: alternative === knob.shippedValue ? "shipped" : alternative < liveValue ? "looser" : "tighter",
// Orientation decides the label (#8224): for a ceiling knob a HIGHER alternative is the looser one.
direction:
alternative === knob.shippedValue
? "shipped"
: (knob.orientation === "ceiling" ? alternative > liveValue : alternative < liveValue)
? "looser"
: "tighter",
visibleCases: visible.length,
heldOutCases: heldOut.length,
visible: visibleComparison,
Expand Down
9 changes: 7 additions & 2 deletions test/unit/knob-loosening-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,14 @@ describe("processor + endpoint wiring (#8176)", () => {
expect((await app.request("/v1/internal/calibration/knobs", {}, env)).status).toBe(401);
const res = await app.request("/v1/internal/calibration/knobs", { headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` } }, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { knobs: Array<{ knobId: string; flagEnabled: boolean }> };
expect(body.knobs.map((knob) => knob.knobId).sort()).toEqual(["ai_review_close_confidence", "satisfaction_floor"]);
const body = (await res.json()) as { knobs: Array<{ knobId: string; flagEnabled: boolean; applyMode: string }> };
// #8224: report-only knobs list too, labeled by applyMode — the operator sees evidence surfaces
// before any flip-to-live exists.
expect(body.knobs.map((knob) => knob.knobId).sort()).toEqual(["ai_review_close_confidence", "satisfaction_floor", "slop_gate_score"]);
expect(body.knobs.every((knob) => knob.flagEnabled === false)).toBe(true);
const byId = Object.fromEntries(body.knobs.map((knob) => [knob.knobId, knob]));
expect(byId.slop_gate_score!.applyMode).toBe("report_only");
expect(byId.ai_review_close_confidence!.applyMode).toBe("live");
expect(JSON.stringify(body)).not.toMatch(/reward|payout|trust|wallet|hotkey|issueText|modelResponse/i);
});
});
Expand Down
Loading