diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index f6d8ca6281..3ba286e04f 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -1199,13 +1199,19 @@ function constant(constants: Record, key: string): number { * Resolve a repo's time-decay curve: each parameter is the repo's per-repo override (from the registry's * `scoring.time_decay`) when present, else the global default constant from the live scoring snapshot. * Mirrors upstream's `resolve_time_decay` (RepoTimeDecayConfig overlaid on the module constants). + * + * Parity note (#1320): upstream coerces ONLY `grace_period_hours` to an integer + * (`grace_period_hours=int(pick(...))`) while the three curve params stay floats. A maintainer may legally + * configure a fractional grace (upstream validates `0 <= grace_period_hours <= 168`), so the resolved grace + * must be truncated toward zero to match the validator — otherwise a PR aged between `trunc(grace)` and + * `grace` is treated as fresh in the preview but already decaying upstream. */ export function resolveTimeDecay( constants: Record, overrides?: RepoTimeDecayOverrides | null, ): { gracePeriodHours: number; sigmoidMidpointDays: number; sigmoidSteepness: number; minMultiplier: number } { return { - gracePeriodHours: pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS")), + gracePeriodHours: Math.trunc(pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS"))), sigmoidMidpointDays: pickOverride(overrides?.sigmoidMidpointDays, constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT")), sigmoidSteepness: pickOverride(overrides?.sigmoidSteepness, constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR")), minMultiplier: pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER")), diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 7166626a11..1ee4a5b24c 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -1747,6 +1747,23 @@ NOVELTY_BONUS_SCALAR = 3 expect(calculateTimeDecay(120, c, { sigmoidMidpointDays: 5 })).toBeCloseTo(0.5, 5); }); + it("truncates a fractional grace_period_hours override toward zero, mirroring upstream int() (#1320)", () => { + const c = DEFAULT_SCORING_CONSTANTS; + // Upstream resolve_time_decay does `grace_period_hours=int(pick(...))` — and only that field. A + // fractional override (legal under upstream's 0..168 range check) resolves to its truncated integer, + // while the float curve params are untouched. + expect(resolveTimeDecay(c, { gracePeriodHours: 13.9 }).gracePeriodHours).toBe(13); + expect(resolveTimeDecay(c, { gracePeriodHours: 13.9, sigmoidSteepness: 0.4 })).toEqual({ + gracePeriodHours: 13, + sigmoidMidpointDays: 10, + sigmoidSteepness: 0.4, + minMultiplier: 0.05, + }); + // The boundary case the bug hid: a PR aged between trunc(grace) and grace is already decaying + // upstream (13.5 >= 13), so the preview must decay it too rather than reporting it as fresh. + expect(calculateTimeDecay(13.5, c, { gracePeriodHours: 13.9 })).toBeLessThan(1); + }); + it("applies each live repo's resolved curve in the preview (per-repo, not global)", () => { const input: ScorePreviewInput = { repoFullName: repo.fullName, sourceTokenScore: 58, totalTokenScore: 600, sourceLines: 60, openPrCount: 0, credibility: 1, applyTimeDecay: true, prAgeHours: 18 }; // Repo with a 24h grace override (like JSONbored/gittensory) → an 18h-old PR is still fresh.