diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index fad0709b8..998e6dd20 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -131,6 +131,31 @@ function mergedHistoryBreakdown(preview: ScorePreviewResult): ScoreMultiplierBre }; } +// Upstream time-decay (#703), env-gated by SCORING_TIME_DECAY_ENABLED (default OFF) and opted into per-preview +// via input.applyTimeDecay. When the flag is off (the common case) or the PR is fresh, the multiplier is 1 and +// the breakdown reads as "not enabled" / "fresh" — surfacing the value is a no-op for those previews but +// surfaces the aged-PR decay lever cleanly when time-decay IS applied. +function timeDecayBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { timeDecayMultiplier } = preview.scoreEstimate; + if (timeDecayMultiplier >= 0.99) { + return { + component: "timeDecayMultiplier", + band: "neutral", + summary: "Score is not time-decayed for this preview (the PR is within the fresh-PR grace period, or upstream time-decay is disabled — env SCORING_TIME_DECAY_ENABLED).", + lever: "No action needed; aged-PR projections automatically apply the upstream sigmoid decay when time-decay is enabled.", + leverageScore: 0, + }; + } + const band = bandForMultiplier(timeDecayMultiplier, false); + return { + component: "timeDecayMultiplier", + band, + summary: `Score is time-decayed for this aged PR preview (multiplier ${timeDecayMultiplier.toFixed(2)} — upstream sigmoid curve; grace 12h, 50% loss at 10 days, 5% floor through the lookback window).`, + lever: "Land the work while it is fresh, or extend the upstream time-decay curve in the repo's master_repositories.json override for this repo to slow the decay.", + leverageScore: 20, + }; +} + function credibilityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { const { credibilityMultiplier } = preview.scoreEstimate; const { credibilityObserved, credibilityFloor } = preview.gates; @@ -272,6 +297,7 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo openPrBreakdown(preview), openIssueBreakdown(preview), mergedHistoryBreakdown(preview), + timeDecayBreakdown(preview), ].map((entry) => ({ ...entry, summary: sanitizePublicComment(entry.summary), diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index 73b07c3cb..abaa55e2f 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -84,6 +84,7 @@ describe("explainScoreBreakdown", () => { "openPrMultiplier", "openIssueMultiplier", "mergedHistoryMultiplier", + "timeDecayMultiplier", ]), ); for (const component of breakdown.components) { @@ -164,6 +165,29 @@ describe("explainScoreBreakdown", () => { expect(breakdown.highestLeverageLever.lever).toMatch(/Land, merge, or close/i); }); + it("explains the time-decay multiplier as neutral (fresh / disabled) and reduced (aged with decay on)", () => { + // Default preview: applyTimeDecay is off / PR is fresh => multiplier is 1 => breakdown neutral, surface + // the decay-disable context so a contributor understands why there is no age penalty here. + const fresh = explainScoreBreakdown( + buildScorePreview({ repo, snapshot, input: { repoFullName: repo.fullName, contributorLogin: "miner", sourceTokenScore: 40, totalTokenScore: 60, sourceLines: 80, openPrCount: 1, credibility: 0.9, linkedIssueMode: "none" } }), + ); + expect(fresh.components.find((entry) => entry.component === "timeDecayMultiplier")).toMatchObject({ band: "neutral" }); + + // Opt-in + aged PR => upstream sigmoid reduces the multiplier => breakdown reduced with the aged-PR lever. + const aged = buildScorePreview({ + repo, + snapshot, + input: { repoFullName: repo.fullName, contributorLogin: "miner", sourceTokenScore: 40, totalTokenScore: 60, sourceLines: 80, openPrCount: 1, credibility: 0.9, linkedIssueMode: "none", applyTimeDecay: true, prAgeHours: 480 }, + }); + const agedBreakdown = explainScoreBreakdown(aged); + const decayed = agedBreakdown.components.find((entry) => entry.component === "timeDecayMultiplier")!; + expect(decayed.band).not.toBe("neutral"); + expect(decayed.band).not.toBe("full"); + expect(decayed.summary).toMatch(/time-decayed|decay/i); + expect(decayed.lever).toMatch(/fresh|time-decay curve|sigmoid/i); + expect(JSON.stringify(agedBreakdown)).not.toMatch(FORBIDDEN); + }); + it("includes gate highlights without leaking forbidden language", () => { const preview = buildScorePreview({ repo,