Skip to content

Commit 5ac47e1

Browse files
authored
feat(calibration): unify tightening under the backtest evidence standard (#8225) (#8267)
The registry gains direction-aware tightening: an optional per-knob ladder declares its candidates, hard maximum, and the EXPLICIT axes orientation of the trade (for the confidence-threshold corpus polarity, raising a bar helps recall and risks precision — never the symmetric Pareto floor reused blind). compareDirectionalBacktestScores enforces win-axis-strictly-up within a declared sacrifice budget; evaluateKnobTightening/runKnobTightening transpose the loosening discipline verbatim (same splits, same floors, smallest step first); override storage validates per direction under per-direction flags (tighten autonomy is its own default-off var); the drift sentinel's pool gains declared ladders so tighter findings and the apply path judge the same values. First ladder: ai_review_close_confidence 0.93 -> [0.95, 0.97]. Bespoke triggers (precision circuit-breakers) stay, with T3's constants pinned byte-stable per the migration map on the issue.
1 parent d9cf5e1 commit 5ac47e1

13 files changed

Lines changed: 796 additions & 26 deletions

File tree

apps/loopover-ui/content/docs/backtest-calibration.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,31 @@ loosening loop owns that direction). One alert per drift episode: a standing unc
108108
re-alerts, a changed one does, and recovery clears the episode. The sentinel is **alert-only** — it never
109109
writes a knob value — and the knobs status endpoint shows each knob's current drift report regardless of
110110
the flag, so a standing drift is always visible.
111+
## Tightening under the same evidence standard
112+
113+
Historically the two directions ran on different evidence regimes: loosening moved only on Pareto-floored
114+
backtests, while tightening lived in a legacy loop (precision circuit-breakers plus a shadow-soaked
115+
advisor). The tightening unification brings threshold *raises* under the same backtest standard — with the
116+
axes orientation made **explicit** instead of reused blind. A deliberate tightening exists to move one axis
117+
at a bounded cost to the other; which axis is which depends on corpus polarity (for the confidence-threshold
118+
classifier, the positive class is "predicted reversed", so raising a bar helps *recall* and risks
119+
*precision* — the inverse of the rule-firing frame). Each knob's optional tightening ladder therefore
120+
declares its own orientation: the axis a raise must strictly improve, and the maximum sacrifice the other
121+
axis may suffer per comparison slice before the trade is a regression.
122+
123+
Everything else is the loosening discipline transposed verbatim: the same split seed and fraction (held-out
124+
membership is identical in both directions), the same never-on-noise sample floors, smallest step first, a
125+
hard maximum no evidence may cross, and per-direction double gating — the tighten loop and the above-shipped
126+
override read are both gated by the ladder's **own** default-off flag, separate from the loosening flag, so
127+
each direction's autonomy is opted into independently and flipping either flag off instantly restores the
128+
shipped default for that direction. A declared ladder also joins the drift sentinel's candidate pool, so
129+
tighter-alternative findings and the tighten apply path judge the same values under the same floors.
130+
131+
The legacy tightening triggers that are *not* threshold moves stay bespoke on purpose: the merge/close
132+
precision circuit-breakers are boolean capability breakers judged on realized outcomes (the strongest
133+
evidence regime there is), and re-basing them on fired-signal backtests would weaken, not strengthen, their
134+
grounds. Their behavior is pinned byte-stable by their own suites.
135+
111136
## Reliability curves
112137

113138
Beside the candidate-ladder machinery, each live knob's status now carries its rule's

packages/loopover-engine/src/calibration/backtest-compare.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import type { BacktestScoreReport } from "./backtest-score.js";
99

1010
/** The two comparable axes of a {@link BacktestScoreReport}. */
11-
type ComparisonAxis = "precision" | "recall";
11+
export type ComparisonAxis = "precision" | "recall";
1212

1313
export type BacktestComparison = {
1414
ruleId: string;
@@ -50,3 +50,65 @@ export function compareBacktestScores(baseline: BacktestScoreReport, candidate:
5050
verdict: regressedAxes.length > 0 ? "regressed" : improvedAxes.length > 0 ? "improved" : "unchanged",
5151
};
5252
}
53+
54+
/** The explicit axes orientation of a directional comparison (#8225): which axis the change exists to move
55+
* (and must move strictly up to earn "improved"), and how much the OTHER axis may be sacrificed for it. */
56+
export type DirectionalOrientation = {
57+
mustImprove: ComparisonAxis;
58+
/** Absolute drop the non-`mustImprove` axis may suffer before the trade is a regression. */
59+
maxSacrifice: number;
60+
};
61+
62+
/**
63+
* Direction-aware comparator for a deliberate axis trade (#8225) -- a TIGHTENING exists to move one axis at
64+
* a bounded cost to the other, so reusing the symmetric {@link compareBacktestScores} blind would brand
65+
* every honest trade "regressed" the moment the sacrificed axis dips. Which axis is which depends on the
66+
* corpus polarity (for the confidence-threshold classifier the positive class is "predicted reversed", so
67+
* RAISING a threshold helps recall and risks precision -- the inverse of the rule-firing frame), hence the
68+
* orientation is the CALLER's explicit declaration, never an assumption baked in here. The re-oriented
69+
* floor:
70+
* • the `mustImprove` axis must move STRICTLY up for an "improved" verdict, and any drop on it is
71+
* "regressed" -- a trade that loses the axis it exists to win is simply wrong;
72+
* • the other axis may drop by at most `maxSacrifice`; a within-bound drop is the accepted trade and
73+
* appears in NEITHER axis list, an over-bound drop is "regressed", and a gain still counts;
74+
* • a null on either side of an axis excludes that axis entirely -- unknown stays unknown, exactly as in
75+
* the symmetric comparator (so a corpus with no judgeable win-axis can never yield "improved").
76+
* Throws on a rule mismatch or a non-finite/negative bound: both are caller bugs, not valid comparisons.
77+
*/
78+
export function compareDirectionalBacktestScores(
79+
baseline: BacktestScoreReport,
80+
candidate: BacktestScoreReport,
81+
orientation: DirectionalOrientation,
82+
): BacktestComparison {
83+
if (baseline.ruleId !== candidate.ruleId) {
84+
throw new Error(`cannot compare backtest scores for different rules: ${baseline.ruleId} vs ${candidate.ruleId}`);
85+
}
86+
if (!Number.isFinite(orientation.maxSacrifice) || orientation.maxSacrifice < 0) {
87+
throw new Error(`maxSacrifice must be a non-negative finite number, got ${orientation.maxSacrifice}`);
88+
}
89+
const sacrificeAxis: ComparisonAxis = orientation.mustImprove === "precision" ? "recall" : "precision";
90+
const regressedAxes: ComparisonAxis[] = [];
91+
const improvedAxes: ComparisonAxis[] = [];
92+
const winBaseline = baseline[orientation.mustImprove];
93+
const winCandidate = candidate[orientation.mustImprove];
94+
if (winBaseline !== null && winCandidate !== null) {
95+
if (winCandidate < winBaseline) regressedAxes.push(orientation.mustImprove);
96+
else if (winCandidate > winBaseline) improvedAxes.push(orientation.mustImprove);
97+
}
98+
const sacBaseline = baseline[sacrificeAxis];
99+
const sacCandidate = candidate[sacrificeAxis];
100+
if (sacBaseline !== null && sacCandidate !== null) {
101+
if (sacBaseline - sacCandidate > orientation.maxSacrifice) regressedAxes.push(sacrificeAxis);
102+
else if (sacCandidate > sacBaseline) improvedAxes.push(sacrificeAxis);
103+
}
104+
return {
105+
ruleId: baseline.ruleId,
106+
baseline,
107+
candidate,
108+
regressedAxes,
109+
improvedAxes,
110+
// "improved" requires the WIN axis specifically -- a lone gain on the sacrifice axis is not what the
111+
// trade is for, so it stays "unchanged" (harmless, but no evidence the step earned its keep).
112+
verdict: regressedAxes.length > 0 ? "regressed" : improvedAxes.includes(orientation.mustImprove) ? "improved" : "unchanged",
113+
};
114+
}

packages/loopover-engine/test/backtest-compare.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert/strict";
22
import { test } from "node:test";
33

4-
import { compareBacktestScores, type BacktestScoreReport } from "../dist/index.js";
4+
import { compareBacktestScores, compareDirectionalBacktestScores, type BacktestScoreReport } from "../dist/index.js";
55

66
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
77
return {
@@ -61,3 +61,15 @@ test("compareBacktestScores: mismatched ruleIds throw, naming both rules", () =>
6161
/cannot compare backtest scores for different rules: missing_linked_issue vs other_rule/,
6262
);
6363
});
64+
65+
test("barrel: the public entrypoint re-exports the direction-aware comparator (#8225)", () => {
66+
assert.equal(typeof compareDirectionalBacktestScores, "function");
67+
});
68+
69+
test("compareDirectionalBacktestScores: win axis up + within-budget sacrifice is improved; over-budget regresses", () => {
70+
const orientation = { mustImprove: "recall" as const, maxSacrifice: 0.1 };
71+
const ok = compareDirectionalBacktestScores(report(), report({ recall: 0.7, precision: 0.45 }), orientation);
72+
assert.equal(ok.verdict, "improved");
73+
const over = compareDirectionalBacktestScores(report(), report({ recall: 0.9, precision: 0.3 }), orientation);
74+
assert.equal(over.verdict, "regressed");
75+
});

src/queue/job-dispatch.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride, run
3131
import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride, runActiveReviewReconciliation } from "../review/active-review-reconciliation";
3232
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
3333
import { isSatisfactionFloorAutotuneEnabled, runScheduledSatisfactionFloorLoosening } from "../services/satisfaction-floor-loosening-run";
34-
import { GENERIC_LIVE_KNOBS, isConfigDriftSentinelEnabled, isKnobAutotuneEnabled, runConfigDriftSentinel, runPerRepoKnobLoosening, runScheduledKnobLoosening } from "../services/knob-loosening-run";
34+
import { GENERIC_LIVE_KNOBS, isConfigDriftSentinelEnabled, isKnobAutotuneEnabled, isKnobTightenEnabled, runConfigDriftSentinel, runPerRepoKnobLoosening, runScheduledKnobLoosening, runScheduledKnobTightening } from "../services/knob-loosening-run";
3535
import { runSelfTuneBreaker } from "../review/outcomes-wire";
3636
import { isRagEnabled } from "../review/rag-wire";
3737
import { processSubmitDraft } from "../services/draft";
@@ -358,6 +358,9 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
358358
// inherit global. Bounded per tick with a rotating cursor; fail-safe internally.
359359
await runPerRepoKnobLoosening(env, knob);
360360
}
361+
// #8225: the tighten direction rides the same tick under its OWN per-knob default-off var —
362+
// direction autonomy is opted into separately, never inherited from the loosening flag.
363+
if (isKnobTightenEnabled(env, knob)) await runScheduledKnobTightening(env, knob);
361364
}
362365
// #8213: the drift sentinel rides the same calibration tick, behind its own default-off flag.
363366
// Alert-only — it never writes a knob value — and internally fail-safe per knob.

0 commit comments

Comments
 (0)