From 03022f17d628e79cc0c5ff7549ff976fa4f30fa0 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sat, 25 Jul 2026 17:49:47 -0700 Subject: [PATCH] fix(engine): restore default blend on NaN/negative calibration weights Closes #8643 --- .../src/finding-severity-calibration.ts | 19 +++++++++++++++-- .../src/gate-verdict-calibration.ts | 19 +++++++++++++++-- .../src/reviewer-consensus-calibration.ts | 20 ++++++++++++++++-- .../test/finding-severity-calibration.test.ts | 17 +++++++++++++++ .../test/gate-verdict-calibration.test.ts | 21 +++++++++++++++++++ .../reviewer-consensus-calibration.test.ts | 18 ++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/loopover-engine/src/finding-severity-calibration.ts b/packages/loopover-engine/src/finding-severity-calibration.ts index 5b292eb771..1f10111dd4 100644 --- a/packages/loopover-engine/src/finding-severity-calibration.ts +++ b/packages/loopover-engine/src/finding-severity-calibration.ts @@ -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); @@ -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, diff --git a/packages/loopover-engine/src/gate-verdict-calibration.ts b/packages/loopover-engine/src/gate-verdict-calibration.ts index c0569ebe68..653dbf104d 100644 --- a/packages/loopover-engine/src/gate-verdict-calibration.ts +++ b/packages/loopover-engine/src/gate-verdict-calibration.ts @@ -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; } @@ -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, diff --git a/packages/loopover-engine/src/reviewer-consensus-calibration.ts b/packages/loopover-engine/src/reviewer-consensus-calibration.ts index 105f58e9f3..327bd0e53e 100644 --- a/packages/loopover-engine/src/reviewer-consensus-calibration.ts +++ b/packages/loopover-engine/src/reviewer-consensus-calibration.ts @@ -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; } @@ -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, diff --git a/packages/loopover-engine/test/finding-severity-calibration.test.ts b/packages/loopover-engine/test/finding-severity-calibration.test.ts index ee5d1f07c5..1653e8db8e 100644 --- a/packages/loopover-engine/test/finding-severity-calibration.test.ts +++ b/packages/loopover-engine/test/finding-severity-calibration.test.ts @@ -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); +}); diff --git a/packages/loopover-engine/test/gate-verdict-calibration.test.ts b/packages/loopover-engine/test/gate-verdict-calibration.test.ts index 60c995e75b..1dbaa6774f 100644 --- a/packages/loopover-engine/test/gate-verdict-calibration.test.ts +++ b/packages/loopover-engine/test/gate-verdict-calibration.test.ts @@ -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, diff --git a/packages/loopover-engine/test/reviewer-consensus-calibration.test.ts b/packages/loopover-engine/test/reviewer-consensus-calibration.test.ts index 0e86892882..1cf3c9834b 100644 --- a/packages/loopover-engine/test/reviewer-consensus-calibration.test.ts +++ b/packages/loopover-engine/test/reviewer-consensus-calibration.test.ts @@ -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: [