Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/loopover-engine/src/finding-severity-calibration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ function finiteNonNegative(value: number | undefined, fallback: number): number
return value;
}

function isInvalidWeight(value: number | undefined): boolean {
return value !== undefined && (!Number.isFinite(value) || value < 0);
}

function finiteNonNegativeInt(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value) || value < 0) return 0;
return Math.floor(value);
Expand Down Expand Up @@ -365,8 +369,19 @@ function normalizeCompositeWeights(weights: FindingSeverityCalibrationWeights |
const total = raw.objectiveAnchor + raw.pairwiseJudge + raw.structuredFindingSeverity;
// Preserve explicitly-zeroed weights rather than substituting the defaults: a caller that zeroes every component
// must reach the objective-only fallback in the composite scorer, not silently get the default 45/35/20 blend
// (converges with reviewer-consensus-calibration.ts's already-correct behavior; #6170).
if (total <= 0) return { objectiveAnchor: 0, pairwiseJudge: 0, structuredFindingSeverity: 0 };
// (converges with reviewer-consensus-calibration.ts / #6170; #7443 / #8643).
// NaN/negative inputs still recover to DEFAULT_COMPOSITE_WEIGHTS when the clamped total is empty — same as
// pairwise-calibration.ts — so the invalid-weight suite keeps asserting the 45/35/20 default.
if (total <= 0) {
if (
isInvalidWeight(weights?.objectiveAnchor) ||
isInvalidWeight(weights?.pairwiseJudge) ||
isInvalidWeight(weights?.structuredFindingSeverity)
) {
return DEFAULT_COMPOSITE_WEIGHTS;
}
return { objectiveAnchor: 0, pairwiseJudge: 0, structuredFindingSeverity: 0 };
}
return {
objectiveAnchor: raw.objectiveAnchor / total,
pairwiseJudge: raw.pairwiseJudge / total,
Expand Down
19 changes: 17 additions & 2 deletions packages/loopover-engine/src/gate-verdict-calibration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ function finiteNonNegative(value: number | undefined, fallback: number): number
return value;
}

function isInvalidWeight(value: number | undefined): boolean {
return value !== undefined && (!Number.isFinite(value) || value < 0);
}

function roundScore(value: number): number {
return Math.round(Math.min(1, Math.max(0, value)) * 1_000_000) / 1_000_000;
}
Expand Down Expand Up @@ -318,8 +322,19 @@ function normalizeCompositeWeights(weights: GateVerdictCalibrationWeights | unde
const total = raw.objectiveAnchor + raw.pairwiseJudge + raw.structuredGateVerdict;
// Preserve explicitly-zeroed weights rather than substituting the defaults: a caller that zeroes every component
// must reach the objective-only fallback in the composite scorer, not silently get the default 45/35/20 blend
// (converges with reviewer-consensus-calibration.ts's already-correct behavior; #6170).
if (total <= 0) return { objectiveAnchor: 0, pairwiseJudge: 0, structuredGateVerdict: 0 };
// (converges with reviewer-consensus-calibration.ts / #6170; #7443 / #8643).
// NaN/negative inputs still recover to DEFAULT_COMPOSITE_WEIGHTS when the clamped total is empty — same as
// pairwise-calibration.ts — so the invalid-weight suite keeps asserting the 45/35/20 default.
if (total <= 0) {
if (
isInvalidWeight(weights?.objectiveAnchor) ||
isInvalidWeight(weights?.pairwiseJudge) ||
isInvalidWeight(weights?.structuredGateVerdict)
) {
return DEFAULT_COMPOSITE_WEIGHTS;
}
return { objectiveAnchor: 0, pairwiseJudge: 0, structuredGateVerdict: 0 };
}
return {
objectiveAnchor: raw.objectiveAnchor / total,
pairwiseJudge: raw.pairwiseJudge / total,
Expand Down
20 changes: 18 additions & 2 deletions packages/loopover-engine/src/reviewer-consensus-calibration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ function finiteNonNegative(value: number | undefined, fallback: number): number
return value;
}

function isInvalidWeight(value: number | undefined): boolean {
return value !== undefined && (!Number.isFinite(value) || value < 0);
}

function roundScore(value: number): number {
return Math.round(Math.min(1, Math.max(0, value)) * 1_000_000) / 1_000_000;
}
Expand Down Expand Up @@ -401,8 +405,20 @@ function normalizeCompositeWeights(weights: ReviewerConsensusCalibrationWeights
};
const total = raw.objectiveAnchor + raw.pairwiseJudge + raw.structuredReviewerConsensus;
// Preserve explicitly-zeroed weights rather than substituting the defaults: a caller that zeroes every component
// must reach the objective-only fallback in the composite scorer, not silently get the default 45/35/20 blend.
if (total <= 0) return { objectiveAnchor: 0, pairwiseJudge: 0, structuredReviewerConsensus: 0 };
// must reach the objective-only fallback in the composite scorer, not silently get the default 45/35/20 blend
// (converges with pairwise-calibration.ts / #6170; #7443 / #8643).
// NaN/negative inputs still recover to DEFAULT_COMPOSITE_WEIGHTS when the clamped total is empty — same as
// pairwise-calibration.ts — so the invalid-weight suite keeps asserting the 45/35/20 default.
if (total <= 0) {
if (
isInvalidWeight(weights?.objectiveAnchor) ||
isInvalidWeight(weights?.pairwiseJudge) ||
isInvalidWeight(weights?.structuredReviewerConsensus)
) {
return DEFAULT_COMPOSITE_WEIGHTS;
}
return { objectiveAnchor: 0, pairwiseJudge: 0, structuredReviewerConsensus: 0 };
}
return {
objectiveAnchor: raw.objectiveAnchor / total,
pairwiseJudge: raw.pairwiseJudge / total,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,20 @@ test("computeFindingSeverityCompositeCalibrationScore falls back to objective-on
assert.deepEqual(result.weights, { objectiveAnchor: 1, pairwiseJudge: 0, structuredFindingSeverity: 0 });
assert.equal(result.compositeScore, 0.4);
});

test("computeFindingSeverityCompositeCalibrationScore normalizes invalid weights without producing NaN (#8643)", () => {
const result = computeFindingSeverityCompositeCalibrationScore({
objectiveAnchor: 0.4,
pairwise: 0.4,
findingSeverity: [signal()],
weights: { objectiveAnchor: Number.NaN, pairwiseJudge: -1, structuredFindingSeverity: -1 },
});

// NaN/negative inputs must recover to the documented 45/35/20 default, not collapse to objective-only.
assert.deepEqual(result.weights, {
objectiveAnchor: 0.45,
pairwiseJudge: 0.35,
structuredFindingSeverity: 0.2,
});
assert.equal(result.compositeScore, 0.52);
});
21 changes: 21 additions & 0 deletions packages/loopover-engine/test/gate-verdict-calibration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,27 @@ test("computeGateVerdictCompositeCalibrationScore falls back to objective-only w
assert.equal(result.compositeScore, 0.4);
});

test("computeGateVerdictCompositeCalibrationScore normalizes invalid weights without producing NaN (#8643)", () => {
const result = computeGateVerdictCompositeCalibrationScore({
objectiveAnchor: 0.4,
pairwise: 0.4,
gateVerdicts: [
{
repoFullName: "acme/widgets",
replayRunId: "replay-1",
gateRunId: "gate-1",
optedIn: true,
dimensions: [{ dimension: "correctness", outcome: "pass" }],
},
],
weights: { objectiveAnchor: Number.NaN, pairwiseJudge: -1, structuredGateVerdict: -1 },
});

// NaN/negative inputs must recover to the documented 45/35/20 default, not collapse to objective-only.
assert.deepEqual(result.weights, { objectiveAnchor: 0.45, pairwiseJudge: 0.35, structuredGateVerdict: 0.2 });
assert.equal(result.compositeScore, 0.52);
});

test("computeGateVerdictCompositeCalibrationScore preserves a malformed-repo (invalid_repo) rejected row instead of dropping it (#6170)", () => {
const result = computeGateVerdictCompositeCalibrationScore({
objectiveAnchor: 0.5,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,24 @@ test("composite honors custom weights and falls back to objective-only when all
assert.equal(allZero.compositeScore, 0.4);
});

test("computeReviewerConsensusCompositeCalibrationScore normalizes invalid weights without producing NaN (#8643)", () => {
const ingestion = ingestReviewerConsensusCalibrationSignals([signal()]);
const result = computeReviewerConsensusCompositeCalibrationScore({
objectiveAnchor: 0.4,
pairwise: 0.4,
reviewerConsensus: ingestion,
weights: { objectiveAnchor: Number.NaN, pairwiseJudge: -1, structuredReviewerConsensus: -1 },
});

// NaN/negative inputs must recover to the documented 45/35/20 default, not collapse to objective-only.
assert.deepEqual(result.weights, {
objectiveAnchor: 0.45,
pairwiseJudge: 0.35,
structuredReviewerConsensus: 0.2,
});
assert.equal(result.compositeScore, 0.52);
});

test("composite sanitizes pre-ingested reviewer-consensus rows before auditing", () => {
const poisoned = {
accepted: [
Expand Down