From 6ebf31242aff65bc3a8205a8c9f89dc9d627cc0c Mon Sep 17 00:00:00 2001 From: bohdansolovie Date: Mon, 22 Jun 2026 15:34:23 +0200 Subject: [PATCH] fix(scoring): apply penalty label multipliers instead of flooring to 1 --- src/scoring/preview.ts | 6 +++--- src/services/score-breakdown.ts | 12 ++++++++---- test/unit/score-breakdown.test.ts | 27 ++++++++++++++++++++++++++ test/unit/scoring.test.ts | 32 +++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index a901ed53b0..e1630fc77e 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -735,10 +735,10 @@ function deltaExplanationFor(core: ScoreCore, blockedBy: ScoreGateBlocker[]): st function selectLabelMultiplier(labels: string[], multipliers: Record, fallback: number): number { const normalized = new Set(labels.map((label) => label.toLowerCase())); - return Math.max( - fallback || 1, - ...Object.entries(multipliers).flatMap(([label, multiplier]) => (normalized.has(label.toLowerCase()) ? [multiplier] : [])), + const matched = Object.entries(multipliers).flatMap(([label, multiplier]) => + normalized.has(label.toLowerCase()) ? [multiplier] : [], ); + return matched.length > 0 ? Math.max(...matched) : fallback || 1; } function decideLinkedIssueMultiplier( diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index 42df9c3123..493da228d5 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -138,19 +138,23 @@ function reviewPenaltyBreakdown(preview: ScorePreviewResult): ScoreMultiplierBre function labelMultiplierBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { const { labelMultiplier } = preview.scoreEstimate; - const band = labelMultiplier > 1 ? "full" : "neutral"; + const band: ScoreMultiplierBand = labelMultiplier > 1 ? "full" : labelMultiplier < 1 ? "reduced" : "neutral"; return { component: "labelMultiplier", band, summary: labelMultiplier > 1 ? "A configured trusted label multiplier is applied." - : "No trusted label multiplier is applied beyond the default.", + : labelMultiplier < 1 + ? "A configured penalty label multiplier is reducing the preview." + : "No trusted label multiplier is applied beyond the default.", lever: labelMultiplier > 1 ? "Ensure the label match is legitimate and documented for maintainers." - : "Check whether the change legitimately matches a configured trusted label before submission.", - leverageScore: labelMultiplier > 1 ? 12 : 25, + : labelMultiplier < 1 + ? "Confirm the penalty label is accurate; substantive changes may warrant a different label." + : "Check whether the change legitimately matches a configured trusted label before submission.", + leverageScore: labelMultiplier > 1 ? 12 : labelMultiplier < 1 ? 40 : 25, }; } diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index 56a19a353e..f3d99840b3 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -165,6 +165,33 @@ describe("explainScoreBreakdown", () => { expect(breakdown.components.find((entry) => entry.component === "reviewPenaltyMultiplier")).toMatchObject({ band: "full" }); }); + it("marks penalty label multipliers as reduced strength (#994)", () => { + const penaltyRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, labelMultipliers: { refactor: 0.5, bug: 1.2 } }, + }; + const preview = buildScorePreview({ + repo: penaltyRepo, + snapshot, + input: { + repoFullName: penaltyRepo.fullName, + sourceTokenScore: 80, + totalTokenScore: 120, + sourceLines: 60, + openPrCount: 0, + credibility: 1, + labels: ["refactor"], + linkedIssueMode: "none", + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.components.find((entry) => entry.component === "labelMultiplier")).toMatchObject({ + band: "reduced", + summary: expect.stringMatching(/penalty label multiplier/i), + }); + }); + it("explains failed base-token and invalid linked-issue branches", () => { const preview = buildScorePreview({ repo, diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 040e12b78e..8f08ac8b94 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -371,6 +371,38 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(preview.scoreEstimate.labelMultiplier).toBe(1); }); + it("applies penalty label multipliers instead of flooring them to 1 (#994)", () => { + const baseInput: ScorePreviewInput = { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "none", + }; + const penaltyOnly = buildScorePreview({ repo, snapshot, input: { ...baseInput, labels: ["refactor"] } }); + const unmatched = buildScorePreview({ repo, snapshot, input: { ...baseInput, labels: ["unmatched"] } }); + const bonusAndPenalty = buildScorePreview({ repo, snapshot, input: { ...baseInput, labels: ["bug", "refactor"] } }); + const bonusOnly = buildScorePreview({ repo, snapshot, input: { ...baseInput, labels: ["bug"] } }); + const customFallback = buildScorePreview({ + repo: { ...repo, registryConfig: { ...repo.registryConfig!, defaultLabelMultiplier: 1.05, labelMultipliers: { bug: 1.2 } } }, + snapshot, + input: { ...baseInput, labels: ["unmatched"] }, + }); + + expect(penaltyOnly.scoreEstimate.labelMultiplier).toBe(0.5); + expect(unmatched.scoreEstimate.labelMultiplier).toBe(1); + expect(bonusAndPenalty.scoreEstimate.labelMultiplier).toBe(1.2); + expect(bonusOnly.scoreEstimate.labelMultiplier).toBe(1.2); + expect(customFallback.scoreEstimate.labelMultiplier).toBe(1.05); + expect(penaltyOnly.scoreEstimate.estimatedMergedScore).toBeLessThan(bonusOnly.scoreEstimate.estimatedMergedScore); + expect(penaltyOnly.scoreEstimate.estimatedMergedScore).toBeCloseTo( + bonusOnly.scoreEstimate.estimatedMergedScore * (0.5 / 1.2), + 5, + ); + }); + it("gates linked-issue assumptions with branch eligibility evidence", () => { const baseInput = { repoFullName: repo.fullName,