|
1 | 1 | import { describe, expect, it } from "vitest"; |
2 | 2 | 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"; |
4 | 4 | import { |
5 | 5 | SATISFACTION_FLOOR_HARD_MINIMUM, |
6 | 6 | SATISFACTION_FLOOR_HELD_OUT_FRACTION, |
@@ -134,3 +134,111 @@ describe("buildReportOnlyKnobRecs (#8159)", () => { |
134 | 134 | expect(buildReportOnlyKnobRecs([])).toEqual([]); |
135 | 135 | }); |
136 | 136 | }); |
| 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