Skip to content

Commit 5bf6efe

Browse files
authored
feat(review): pure config-drift evaluator over the loosenable-knob registry (#8232)
* feat(review): pure config-drift evaluator over the loosenable-knob registry (#8212) The loosening evaluator answers "can we safely loosen?"; nothing answered the inverse operator question from the #8170 retro's largest wrongness source -- stale configuration: "is what is CURRENTLY live still the best-supported setting, in either direction?". Add evaluateKnobDrift(knob, cases, liveValue) beside evaluateKnobLoosening, mirroring its discipline verbatim: the knob's own split seed/fraction via splitBacktestCorpus, the same Pareto floor via compareBacktestScores (strictly improved on visible AND non-regressed on held-out), the same never-on-noise sample minimums, and the hard minimum no evidence may cross. The candidate pool is every registry candidate PLUS the shipped value -- a TIGHTER alternative dominating live is exactly the stale-config signal -- minus the live value itself, tried nearest-to-live first (minimal config change wins, mirroring smallest-step-first; equidistant ties deterministically prefer the tighter value). The report distinguishes direction ("shipped" checked first: a drifted override should revert; else looser = informational duplicate of the loosening loop, tighter = actionable staleness warning) and carries corpus sizes plus both split comparisons per the #8121 evidence-trail convention. Null -- never a guess -- on sample-floor misses or when nothing strictly dominates. Pure evaluation only: no cron, no alert, no writes. Tests mirror the suite's membership-probe seeding: dominance in both directions, the shipped-value revert signal, no-dominance null, sample-floor null, held-out regression rejection, and byte-identical determinism. * test(review): cover the drift evaluator's equidistant tie-break and sub-hard-minimum filter (#8212)
1 parent 0c8d3d6 commit 5bf6efe

2 files changed

Lines changed: 172 additions & 1 deletion

File tree

src/services/loosening-knobs.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,69 @@ export function evaluateKnobLoosening(
138138
return null;
139139
}
140140

141+
export type KnobDriftDirection = "looser" | "tighter" | "shipped";
142+
143+
export type KnobDriftReport = {
144+
knobId: string;
145+
ruleId: string;
146+
liveValue: number;
147+
dominatingValue: number;
148+
/** `"shipped"` when the dominating alternative IS the registry's shipped value (a drifted override should
149+
* revert -- checked FIRST, before the looser/tighter reading); otherwise `"looser"` (below live) or
150+
* `"tighter"` (above live). The consumer's messaging differs: a looser winner duplicates the loosening
151+
* loop's own proposal (informational), a tighter winner means live config is likely stale (actionable). */
152+
direction: KnobDriftDirection;
153+
visibleCases: number;
154+
heldOutCases: number;
155+
visible: BacktestComparison;
156+
heldOut: BacktestComparison;
157+
};
158+
159+
/**
160+
* Evaluate whether ANY alternative setting Pareto-dominates the live value on the trailing corpus (#8212,
161+
* epic #8211 track A) -- the inverse operator question to {@link evaluateKnobLoosening}: not "can we safely
162+
* loosen?" but "is what is CURRENTLY live still the best-supported setting, in either direction?". Same
163+
* discipline verbatim: the knob's own split seed/fraction, the same Pareto floor (strictly `"improved"` on
164+
* the visible split AND non-`"regressed"` on the deterministic held-out split), the same never-on-noise
165+
* sample minimums, and the hard minimum no evidence may cross. The candidate pool is every registry
166+
* candidate PLUS the shipped value (a TIGHTER alternative dominating live is exactly the stale-config
167+
* signal), minus the live value itself; alternatives are tried nearest-to-live first (the minimal config
168+
* change wins, mirroring smallest-step-first; equidistant ties prefer the higher/tighter value,
169+
* deterministically). Null -- never a guess -- when the corpus misses the sample floors or nothing strictly
170+
* dominates. Pure and deterministic: same knob + corpus + value ⇒ same report.
171+
*/
172+
export function evaluateKnobDrift(
173+
knob: LoosenableKnob,
174+
cases: readonly BacktestCase[],
175+
liveValue: number = knob.shippedValue,
176+
): KnobDriftReport | null {
177+
const { visible, heldOut } = splitBacktestCorpus(cases, knob.heldOutFraction, knob.splitSeed);
178+
if (visible.length < knob.minVisibleCases || heldOut.length < knob.minHeldOutCases) return null;
179+
180+
const alternatives = [...new Set([knob.shippedValue, ...knob.candidates])]
181+
.filter((value) => value !== liveValue && value >= knob.hardMinimum)
182+
.sort((left, right) => Math.abs(left - liveValue) - Math.abs(right - liveValue) || right - left);
183+
184+
for (const alternative of alternatives) {
185+
const visibleComparison = compareOnSlice(knob.ruleId, visible, liveValue, alternative);
186+
if (visibleComparison.verdict !== "improved") continue;
187+
const heldOutComparison = compareOnSlice(knob.ruleId, heldOut, liveValue, alternative);
188+
if (heldOutComparison.verdict === "regressed") continue;
189+
return {
190+
knobId: knob.knobId,
191+
ruleId: knob.ruleId,
192+
liveValue,
193+
dominatingValue: alternative,
194+
direction: alternative === knob.shippedValue ? "shipped" : alternative < liveValue ? "looser" : "tighter",
195+
visibleCases: visible.length,
196+
heldOutCases: heldOut.length,
197+
visible: visibleComparison,
198+
heldOut: heldOutComparison,
199+
};
200+
}
201+
return null;
202+
}
203+
141204
function compareOnSlice(ruleId: string, slice: readonly BacktestCase[], currentValue: number, candidate: number): BacktestComparison {
142205
const baseline = scoreBacktest(ruleId, slice, buildConfidenceThresholdClassifier(currentValue));
143206
const proposed = scoreBacktest(ruleId, slice, buildConfidenceThresholdClassifier(candidate));

test/unit/loosening-knobs.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import { splitBacktestCorpus, type BacktestCase } from "@loopover/engine";
3-
import { evaluateKnobLoosening, LOOSENABLE_KNOBS, type LoosenableKnob } from "../../src/services/loosening-knobs";
3+
import { evaluateKnobDrift, evaluateKnobLoosening, LOOSENABLE_KNOBS, type LoosenableKnob } from "../../src/services/loosening-knobs";
44
import {
55
SATISFACTION_FLOOR_HARD_MINIMUM,
66
SATISFACTION_FLOOR_HELD_OUT_FRACTION,
@@ -134,3 +134,111 @@ describe("buildReportOnlyKnobRecs (#8159)", () => {
134134
expect(buildReportOnlyKnobRecs([])).toEqual([]);
135135
});
136136
});
137+
138+
// ── #8212: config-drift evaluation — does ANY alternative Pareto-dominate the live value? ───────────────────
139+
140+
describe("evaluateKnobDrift on the close-confidence knob (#8212)", () => {
141+
function tighterFriendlyCorpus(): BacktestCase[] {
142+
// Mid-band firings (0.87) a human REVERSED: live 0.85 classifies them confirmed (missed reversals --
143+
// false negatives), the tighter 0.9 catches them (true positives) -- recall improves, precision holds.
144+
const cases: BacktestCase[] = [];
145+
for (const key of visibleKeys.slice(0, AI_KNOB.minVisibleCases + 6)) cases.push(aiCase(key, 0.87, "reversed"));
146+
for (const key of heldOutKeys.slice(0, AI_KNOB.minHeldOutCases + 3)) cases.push(aiCase(key, 0.87, "reversed"));
147+
// Deep-low reversed anchors keep a true positive on both sides of every comparison.
148+
cases.push(aiCase(visibleKeys[AI_KNOB.minVisibleCases + 10]!, 0.5, "reversed"));
149+
cases.push(aiCase(heldOutKeys[AI_KNOB.minHeldOutCases + 6]!, 0.5, "reversed"));
150+
return cases;
151+
}
152+
153+
it("reports a LOOSER dominating alternative from the shipped live value (duplicates the loosening signal)", () => {
154+
const report = evaluateKnobDrift(AI_KNOB, aiLooseningFriendlyCorpus());
155+
expect(report).not.toBeNull();
156+
expect(report!.knobId).toBe("ai_review_close_confidence");
157+
expect(report!.liveValue).toBe(DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE);
158+
expect(report!.dominatingValue).toBe(0.9);
159+
expect(report!.direction).toBe("looser");
160+
expect(report!.visible.verdict).toBe("improved");
161+
expect(report!.heldOut.verdict).not.toBe("regressed");
162+
expect(report!.visibleCases).toBeGreaterThanOrEqual(AI_KNOB.minVisibleCases);
163+
expect(report!.heldOutCases).toBeGreaterThanOrEqual(AI_KNOB.minHeldOutCases);
164+
});
165+
166+
it("reports a TIGHTER (non-shipped) dominating alternative from a loosened live value — the stale-config signal", () => {
167+
const report = evaluateKnobDrift(AI_KNOB, tighterFriendlyCorpus(), 0.85);
168+
expect(report).not.toBeNull();
169+
expect(report!.liveValue).toBe(0.85);
170+
expect(report!.dominatingValue).toBe(0.9); // nearest-to-live dominating alternative, not the farthest
171+
expect(report!.direction).toBe("tighter");
172+
expect(report!.visible.verdict).toBe("improved");
173+
});
174+
175+
it("labels a dominating alternative that IS the shipped value as direction 'shipped' (revert-the-override signal)", () => {
176+
// Same mid-band-reversed shape, but at 0.91 so ONLY the shipped 0.93 catches them: live 0.9 and the
177+
// other candidate 0.85 both miss (0.91 >= both), so the nearest dominating alternative is shipped.
178+
const cases: BacktestCase[] = [];
179+
for (const key of visibleKeys.slice(0, AI_KNOB.minVisibleCases + 6)) cases.push(aiCase(key, 0.91, "reversed"));
180+
for (const key of heldOutKeys.slice(0, AI_KNOB.minHeldOutCases + 3)) cases.push(aiCase(key, 0.91, "reversed"));
181+
cases.push(aiCase(visibleKeys[AI_KNOB.minVisibleCases + 10]!, 0.5, "reversed"));
182+
cases.push(aiCase(heldOutKeys[AI_KNOB.minHeldOutCases + 6]!, 0.5, "reversed"));
183+
const report = evaluateKnobDrift(AI_KNOB, cases, 0.9);
184+
expect(report).not.toBeNull();
185+
expect(report!.dominatingValue).toBe(AI_KNOB.shippedValue);
186+
expect(report!.direction).toBe("shipped");
187+
});
188+
189+
it("returns null when nothing strictly dominates the live value (uniform corpus, all comparisons unchanged)", () => {
190+
// Every case sits far below every threshold with a reversed label: all values classify identically.
191+
const cases: BacktestCase[] = [];
192+
for (const key of visibleKeys.slice(0, AI_KNOB.minVisibleCases + 6)) cases.push(aiCase(key, 0.5, "reversed"));
193+
for (const key of heldOutKeys.slice(0, AI_KNOB.minHeldOutCases + 3)) cases.push(aiCase(key, 0.5, "reversed"));
194+
expect(evaluateKnobDrift(AI_KNOB, cases)).toBeNull();
195+
});
196+
197+
it("returns null on a sample below the knob's floors — never a drift call on noise", () => {
198+
const thin = [
199+
...visibleKeys.slice(0, AI_KNOB.minVisibleCases - 1).map((key) => aiCase(key, 0.91, "confirmed")),
200+
...heldOutKeys.slice(0, AI_KNOB.minHeldOutCases + 3).map((key) => aiCase(key, 0.91, "confirmed")),
201+
];
202+
expect(evaluateKnobDrift(AI_KNOB, thin)).toBeNull();
203+
});
204+
205+
it("returns null when the visible split improves but the held-out split regresses (Pareto floor holds)", () => {
206+
// Visible: 0.91-confirmed mass (0.9 improves precision over live 0.93). Held-out: 0.91-REVERSED mass
207+
// (0.9 stops catching them -- recall regresses), so every looser alternative fails the held-out floor
208+
// and no tighter alternative exists above shipped.
209+
const cases: BacktestCase[] = [];
210+
for (const key of visibleKeys.slice(0, AI_KNOB.minVisibleCases + 6)) cases.push(aiCase(key, 0.91, "confirmed"));
211+
for (const key of heldOutKeys.slice(0, AI_KNOB.minHeldOutCases + 3)) cases.push(aiCase(key, 0.91, "reversed"));
212+
cases.push(aiCase(visibleKeys[AI_KNOB.minVisibleCases + 10]!, 0.5, "reversed"));
213+
cases.push(aiCase(heldOutKeys[AI_KNOB.minHeldOutCases + 6]!, 0.5, "reversed"));
214+
expect(evaluateKnobDrift(AI_KNOB, cases)).toBeNull();
215+
});
216+
217+
it("breaks an equidistant tie toward the tighter value and never considers a sub-hard-minimum candidate", () => {
218+
// Custom knob: live 0.875 sits exactly between candidates 0.9 and 0.85 (tie -> tighter 0.9 tried first),
219+
// and the 0.2 candidate below the 0.3 hard minimum must be filtered before evaluation entirely.
220+
const tieKnob: LoosenableKnob = {
221+
...AI_KNOB,
222+
knobId: "tie_probe",
223+
candidates: [0.9, 0.85, 0.2],
224+
hardMinimum: 0.3,
225+
};
226+
const cases: BacktestCase[] = [];
227+
// 0.88-confidence REVERSED mass: live 0.875 misses them (false negatives), tighter 0.9 catches them.
228+
for (const key of visibleKeys.slice(0, tieKnob.minVisibleCases + 6)) cases.push(aiCase(key, 0.88, "reversed"));
229+
for (const key of heldOutKeys.slice(0, tieKnob.minHeldOutCases + 3)) cases.push(aiCase(key, 0.88, "reversed"));
230+
cases.push(aiCase(visibleKeys[tieKnob.minVisibleCases + 10]!, 0.5, "reversed"));
231+
cases.push(aiCase(heldOutKeys[tieKnob.minHeldOutCases + 6]!, 0.5, "reversed"));
232+
233+
const report = evaluateKnobDrift(tieKnob, cases, 0.875);
234+
expect(report).not.toBeNull();
235+
expect(report!.dominatingValue).toBe(0.9); // the equidistant tie prefers the tighter alternative
236+
expect(report!.direction).toBe("tighter");
237+
});
238+
239+
it("is deterministic: the same corpus and live value always produce the same report", () => {
240+
expect(JSON.stringify(evaluateKnobDrift(AI_KNOB, aiLooseningFriendlyCorpus()))).toBe(
241+
JSON.stringify(evaluateKnobDrift(AI_KNOB, aiLooseningFriendlyCorpus())),
242+
);
243+
});
244+
});

0 commit comments

Comments
 (0)