@@ -20,17 +20,28 @@ import {
2020 type BacktestComparison ,
2121} from "@loopover/engine" ;
2222import { 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
2525export 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
130173export 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 ,
0 commit comments