From 276da8ddbe8e3976f6c992d5b13e1c391239b507 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Thu, 25 Jun 2026 01:39:43 -0400 Subject: [PATCH] fix(scoring): truncate per-repo grace_period_hours to int for upstream parity (#1320) Upstream resolve_time_decay coerces only grace_period_hours to an integer (grace_period_hours=int(pick(...))) while the curve params stay floats. A repo may legally configure a fractional grace (upstream validates 0..168), so the un-truncated TS value left a PR aged between trunc(grace) and grace looking fresh in the preview while it was already decaying upstream. Wrap the resolved grace in Math.trunc to match, and pin the truncation + boundary-decay behavior with a regression test. --- src/scoring/preview.ts | 8 +++++++- test/unit/scoring.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 7e495c32f2..d02d4bab02 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -1017,13 +1017,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 d25ecc7bf1..f4847c3fbf 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -1510,6 +1510,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.