Skip to content

Commit 253dfcb

Browse files
fix(engine): preserve explicit zero pairwise calibration weights (#7477)
Closes #7443 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dca5d66 commit 253dfcb

3 files changed

Lines changed: 109 additions & 6 deletions

File tree

packages/loopover-engine/src/pairwise-calibration.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ function finiteNonNegative(value: number | undefined, fallback: number): number
5555
return value;
5656
}
5757

58+
function isInvalidWeight(value: number | undefined): boolean {
59+
return value !== undefined && (!Number.isFinite(value) || value < 0);
60+
}
61+
5862
function normalizePairwiseWeights(weights: PairwiseCalibrationWeights | undefined): {
5963
objectiveAnchor: number;
6064
pairwiseJudge: number;
@@ -64,7 +68,17 @@ function normalizePairwiseWeights(weights: PairwiseCalibrationWeights | undefine
6468
pairwiseJudge: finiteNonNegative(weights?.pairwiseJudge, DEFAULT_PAIRWISE_WEIGHTS.pairwiseJudge),
6569
};
6670
const total = raw.objectiveAnchor + raw.pairwiseJudge;
67-
if (total <= 0) return DEFAULT_PAIRWISE_WEIGHTS;
71+
// Preserve explicitly-zeroed weights rather than substituting the defaults: a caller that zeroes every
72+
// component must reach the objective-only fallback in computePairwiseCalibrationScore, not silently get
73+
// the default 50/50 blend (converges with reviewer-consensus-calibration.ts / #6170; #7443).
74+
// NaN/negative inputs still recover to DEFAULT_PAIRWISE_WEIGHTS when the clamped total is empty — same as
75+
// pre-#7443 — so the invalid-weight suite keeps asserting the 50/50 default.
76+
if (total <= 0) {
77+
if (isInvalidWeight(weights?.objectiveAnchor) || isInvalidWeight(weights?.pairwiseJudge)) {
78+
return DEFAULT_PAIRWISE_WEIGHTS;
79+
}
80+
return { objectiveAnchor: 0, pairwiseJudge: 0 };
81+
}
6882
return {
6983
objectiveAnchor: raw.objectiveAnchor / total,
7084
pairwiseJudge: raw.pairwiseJudge / total,
@@ -135,11 +149,25 @@ export function computePairwiseCalibrationScore(input: {
135149
.filter((score): score is number => score !== null);
136150
const pairwiseJudgeScore =
137151
stableScores.length === 0 ? null : roundScore(stableScores.reduce((sum, score) => sum + score, 0) / stableScores.length);
138-
const weights = normalizePairwiseWeights(input.weights);
139-
const compositeScore =
140-
pairwiseJudgeScore === null
141-
? objectiveAnchorScore
142-
: roundScore(objectiveAnchorScore * weights.objectiveAnchor + pairwiseJudgeScore * weights.pairwiseJudge);
152+
const rawWeights = normalizePairwiseWeights(input.weights);
153+
// Second-stage usable-weights pass mirrors reviewer-consensus-calibration.ts (#6170 / #7443): zero out any
154+
// component whose own signal is unavailable, then fall back to objective-only when that usable total is empty
155+
// (covers explicit all-zero weights even when pairwiseJudgeScore is present).
156+
const usableWeights = {
157+
objectiveAnchor: rawWeights.objectiveAnchor,
158+
pairwiseJudge: pairwiseJudgeScore === null ? 0 : rawWeights.pairwiseJudge,
159+
};
160+
const usableTotal = usableWeights.objectiveAnchor + usableWeights.pairwiseJudge;
161+
const weights =
162+
usableTotal <= 0
163+
? { objectiveAnchor: 1, pairwiseJudge: 0 }
164+
: {
165+
objectiveAnchor: usableWeights.objectiveAnchor / usableTotal,
166+
pairwiseJudge: usableWeights.pairwiseJudge / usableTotal,
167+
};
168+
const compositeScore = roundScore(
169+
objectiveAnchorScore * weights.objectiveAnchor + (pairwiseJudgeScore ?? 0) * weights.pairwiseJudge,
170+
);
143171
const unstableSamples = samples.filter((sample) => !sample.stable).length;
144172
const exhaustedSamples = samples.filter((sample) => sample.exhausted).length;
145173
return {

packages/loopover-engine/test/pairwise-calibration.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,17 @@ test("computePairwiseCalibrationScore normalizes invalid weights without produci
153153
assert.deepEqual(result.weights, { objectiveAnchor: 0.5, pairwiseJudge: 0.5 });
154154
assert.equal(result.compositeScore, 0.5);
155155
});
156+
157+
test("computePairwiseCalibrationScore falls back to objective-only when all weights are explicitly zero (#7443)", () => {
158+
const result = computePairwiseCalibrationScore({
159+
objectiveAnchor: 0.42,
160+
samples: [{ attempts: [{ replayFirst: "replay_better", revealedFirst: "revealed_better" }] }],
161+
weights: { objectiveAnchor: 0, pairwiseJudge: 0 },
162+
});
163+
164+
// Explicit zeros must not silently restore the 50/50 default, and must not collapse compositeScore to 0
165+
// when a real pairwiseJudgeScore is present — fall back to objective-anchor only (#6170 sibling pattern).
166+
assert.equal(result.pairwiseJudgeScore, 1);
167+
assert.deepEqual(result.weights, { objectiveAnchor: 1, pairwiseJudge: 0 });
168+
assert.equal(result.compositeScore, 0.42);
169+
});

test/unit/engine-calibration-convergence.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
computeGateVerdictCompositeCalibrationScore,
44
computeFindingSeverityCompositeCalibrationScore,
5+
computePairwiseCalibrationScore,
56
} from "../../packages/loopover-engine/src/index";
67

78
// Converges gate-verdict + finding-severity calibration with reviewer-consensus-calibration.ts's already-correct
@@ -75,3 +76,63 @@ describe("gate-verdict/finding-severity calibration convergence (#6170)", () =>
7576
]);
7677
});
7778
});
79+
80+
// Extends the #6170 all-zero-weight pattern to pairwise-calibration.ts (#7443). Vitest coverage is what
81+
// Codecov grades; the engine package's node:test suite mirrors the same assertions.
82+
describe("pairwise calibration zero-weight convergence (#7443)", () => {
83+
it("explicit all-zero weights fall back to objective-only even when pairwiseJudgeScore is present", () => {
84+
const result = computePairwiseCalibrationScore({
85+
objectiveAnchor: 0.42,
86+
samples: [{ attempts: [{ replayFirst: "replay_better", revealedFirst: "revealed_better" }] }],
87+
weights: { objectiveAnchor: 0, pairwiseJudge: 0 },
88+
});
89+
expect(result.pairwiseJudgeScore).toBe(1);
90+
expect(result.weights).toEqual({ objectiveAnchor: 1, pairwiseJudge: 0 });
91+
expect(result.compositeScore).toBe(0.42);
92+
});
93+
94+
it("NaN/negative weights still recover to the 50/50 default (not the objective-only fallback)", () => {
95+
const result = computePairwiseCalibrationScore({
96+
objectiveAnchor: 1,
97+
samples: [{ attempts: [{ replayFirst: "revealed_better", revealedFirst: "replay_better" }] }],
98+
weights: { objectiveAnchor: Number.NaN, pairwiseJudge: -1 },
99+
});
100+
expect(result.weights).toEqual({ objectiveAnchor: 0.5, pairwiseJudge: 0.5 });
101+
expect(result.compositeScore).toBe(0.5);
102+
});
103+
104+
it("non-zero weights take the normalized usable path (covers usableTotal > 0)", () => {
105+
const result = computePairwiseCalibrationScore({
106+
objectiveAnchor: 0.55,
107+
samples: [
108+
{ attempts: [{ replayFirst: "replay_better", revealedFirst: "revealed_better" }] },
109+
{ attempts: [{ replayFirst: "tie", revealedFirst: "tie" }] },
110+
],
111+
weights: { objectiveAnchor: 1, pairwiseJudge: 3 },
112+
});
113+
expect(result.weights).toEqual({ objectiveAnchor: 0.25, pairwiseJudge: 0.75 });
114+
expect(result.compositeScore).toBe(0.7);
115+
});
116+
117+
it("missing pairwise signal zeros that component then falls back to objective-only when usable total is empty", () => {
118+
const result = computePairwiseCalibrationScore({
119+
objectiveAnchor: 0.42,
120+
samples: [{ attempts: [{ replayFirst: "incomparable", revealedFirst: "incomparable" }] }],
121+
weights: { objectiveAnchor: 0, pairwiseJudge: 0 },
122+
});
123+
expect(result.pairwiseJudgeScore).toBeNull();
124+
expect(result.weights).toEqual({ objectiveAnchor: 1, pairwiseJudge: 0 });
125+
expect(result.compositeScore).toBe(0.42);
126+
});
127+
128+
it("missing pairwise signal with non-zero weights renormalizes to objective-only (covers usableTotal > 0 + null pairwise)", () => {
129+
const result = computePairwiseCalibrationScore({
130+
objectiveAnchor: 0.42,
131+
samples: [{ attempts: [{ replayFirst: "incomparable", revealedFirst: "incomparable" }] }],
132+
weights: { objectiveAnchor: 1, pairwiseJudge: 1 },
133+
});
134+
expect(result.pairwiseJudgeScore).toBeNull();
135+
expect(result.weights).toEqual({ objectiveAnchor: 1, pairwiseJudge: 0 });
136+
expect(result.compositeScore).toBe(0.42);
137+
});
138+
});

0 commit comments

Comments
 (0)