Skip to content
Merged
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
26 changes: 26 additions & 0 deletions src/services/score-breakdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions test/unit/score-breakdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe("explainScoreBreakdown", () => {
"openPrMultiplier",
"openIssueMultiplier",
"mergedHistoryMultiplier",
"timeDecayMultiplier",
]),
);
for (const component of breakdown.components) {
Expand Down Expand Up @@ -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,
Expand Down
Loading