Skip to content

Commit d63c718

Browse files
fix(signals): floor unknown issue ages in reward-risk freshness scoring (#2875)
* fix(signals): floor unknown issue ages in reward-risk freshness scoring The HTTP/decision-pack reward-risk path treated missing or unparseable issue timestamps as age 0 (maximally fresh), while gittensory-engine computeOpportunityFreshness already floors unknown ages to the 0.05 minimum. That divergence inflated freshnessFactor for backfill gaps and malformed metadata in decision packs and scoring previews. Mirror the engine semantics in src/signals/reward-risk.ts: - pickIssueTimestamp prefers updatedAt, falls back to createdAt - unknown/unparseable timestamps use POSITIVE_INFINITY (floors to 0.05) - iterative min scan (no Math.min spread) for large issue lists Adds reward-risk-freshness parity tests against computeOpportunityFreshness. Co-authored-by: Cursor <cursoragent@cursor.com> * test(signals): cover reward-risk freshness timestamp branches for codecov Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 02af6c1 commit d63c718

4 files changed

Lines changed: 222 additions & 9 deletions

File tree

src/queue-intelligence.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ function computeDaysSince(isoDateString: string, now: Date): number {
7474
// A malformed/empty timestamp -> NaN, which flows into computePrivateBurdenReductionScore and then the
7575
// analyzePRQueue sort comparator (`b.score - a.score`). NaN makes Array.sort non-deterministic, and even
7676
// Infinity would reintroduce that (`Infinity - Infinity = NaN` when two PRs share a bad timestamp), so the
77-
// fallback must be finite. 0 degrades a bad timestamp to "just-created" (lowest burden priority); mirrors the
78-
// issueAgeDays guard in signals/reward-risk.ts.
77+
// fallback must be finite. 0 degrades a bad timestamp to "just-created" (lowest burden priority) for queue
78+
// sorting — independent of reward-risk freshness, which floors unknown issue ages to minimum freshness.
7979
const parsed = Date.parse(isoDateString);
8080
return Number.isFinite(parsed) ? (now.getTime() - parsed) / MILLISECONDS_PER_DAY : 0;
8181
}

src/signals/reward-risk.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -834,16 +834,34 @@ function opportunityCompetitionFactor(highRiskDuplicateClusters: number, openPul
834834
function opportunityFreshnessFactor(issues: IssueRecord[]): number {
835835
const openIssues = issues.filter((issue) => issue.state === "open");
836836
if (openIssues.length === 0) return 0;
837-
const mostRecentAgeDays = Math.min(...openIssues.map((issue) => issueAgeDays(issue.updatedAt ?? issue.createdAt)));
837+
let mostRecentAgeDays = Number.POSITIVE_INFINITY;
838+
for (const issue of openIssues) {
839+
const ageDays = issueAgeDays(pickIssueTimestamp(issue));
840+
if (ageDays < mostRecentAgeDays) mostRecentAgeDays = ageDays;
841+
}
838842
// Freshness decays exponentially: ~1.0 at 0 days, ~0.6 at 7 days, ~0.2 at 30 days, ~0.05 at 90 days.
839843
return round(clamp(Math.exp(-mostRecentAgeDays / 20), 0.05, 1));
840844
}
841845

842-
function issueAgeDays(value: string | null | undefined): number {
843-
if (!value) return 0;
846+
function isParseableIssueTimestamp(value: string): boolean {
847+
return Number.isFinite(Date.parse(value));
848+
}
849+
850+
function pickIssueTimestamp(issue: IssueRecord): string | null {
851+
const updated = typeof issue.updatedAt === "string" ? issue.updatedAt.trim() : "";
852+
if (updated && isParseableIssueTimestamp(updated)) return updated;
853+
854+
const created = typeof issue.createdAt === "string" ? issue.createdAt.trim() : "";
855+
if (created && isParseableIssueTimestamp(created)) return created;
856+
857+
return null;
858+
}
859+
860+
/** Unknown/unparseable timestamps floor freshness (parity with gittensory-engine opportunity-freshness.ts). */
861+
function issueAgeDays(value: string | null): number {
862+
if (!value) return Number.POSITIVE_INFINITY;
844863
const parsed = Date.parse(value);
845-
/* v8 ignore next -- Invalid provider timestamps normalize to fresh; stale timestamp handling is covered by signal tests. */
846-
if (!Number.isFinite(parsed)) return 0;
864+
if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY;
847865
return Math.floor((Date.now() - parsed) / 86_400_000);
848866
}
849867

@@ -892,3 +910,10 @@ function round(value: number): number {
892910
function clamp(value: number, min: number, max: number): number {
893911
return Math.max(min, Math.min(max, value));
894912
}
913+
914+
/* v8 ignore start -- Test-only export surface for branch coverage. */
915+
export const rewardRiskFreshnessInternals = {
916+
pickIssueTimestamp,
917+
issueAgeDays,
918+
};
919+
/* v8 ignore stop */
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
computeOpportunityFreshness,
4+
type FreshnessIssue,
5+
} from "../../packages/gittensory-engine/src/opportunity-freshness";
6+
import {
7+
buildContributorOutcomeHistory,
8+
buildContributorProfile,
9+
} from "../../src/signals/engine";
10+
import { buildRepoRewardRisk, rewardRiskFreshnessInternals } from "../../src/signals/reward-risk";
11+
import type { IssueRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types";
12+
13+
function scoringSnapshot(): ScoringModelSnapshotRecord {
14+
return {
15+
id: "freshness-scoring",
16+
sourceKind: "test",
17+
sourceUrl: "fixture://freshness",
18+
fetchedAt: "2026-05-25T00:00:00.000Z",
19+
activeModel: "current_density_model",
20+
constants: {},
21+
programmingLanguages: {},
22+
warnings: [],
23+
payload: {},
24+
};
25+
}
26+
27+
function repo(fullName: string): RepositoryRecord {
28+
const [owner, name] = fullName.split("/") as [string, string];
29+
return {
30+
fullName,
31+
owner,
32+
name,
33+
isInstalled: true,
34+
isRegistered: true,
35+
isPrivate: false,
36+
defaultBranch: "main",
37+
registryConfig: {
38+
repo: fullName,
39+
emissionShare: 0.02,
40+
issueDiscoveryShare: 0,
41+
labelMultipliers: {},
42+
trustedLabelPipeline: false,
43+
maintainerCut: 0,
44+
raw: {},
45+
},
46+
};
47+
}
48+
49+
function issue(fullName: string, number: number, title: string, overrides: Partial<IssueRecord> = {}): IssueRecord {
50+
return {
51+
repoFullName: fullName,
52+
number,
53+
title,
54+
state: "open",
55+
authorLogin: "dev",
56+
authorAssociation: "NONE",
57+
labels: [],
58+
linkedPrs: [],
59+
body: "Issue body",
60+
createdAt: new Date().toISOString(),
61+
updatedAt: new Date().toISOString(),
62+
...overrides,
63+
};
64+
}
65+
66+
function toFreshnessIssues(issues: IssueRecord[]): FreshnessIssue[] {
67+
return issues.map((item) => ({
68+
state: item.state,
69+
updatedAt: item.updatedAt ?? null,
70+
createdAt: item.createdAt ?? null,
71+
}));
72+
}
73+
74+
describe("reward-risk freshness parity with gittensory-engine", () => {
75+
const collab = repo("owner/collab-repo");
76+
const profile = buildContributorProfile("dev", { login: "dev", topLanguages: [], source: "github" }, [], []);
77+
const history = buildContributorOutcomeHistory({
78+
login: "dev",
79+
profile,
80+
repositories: [collab],
81+
pullRequests: [],
82+
issues: [],
83+
repoStats: [],
84+
});
85+
const base = {
86+
login: "dev" as const,
87+
repo: collab,
88+
repoFullName: collab.fullName,
89+
profile,
90+
outcomeHistory: history,
91+
scoringSnapshot: scoringSnapshot(),
92+
};
93+
94+
it("matches computeOpportunityFreshness for fresh, stale, and undated open issues", () => {
95+
const nowMs = Date.now();
96+
const freshIssues = [
97+
issue(collab.fullName, 1, "Fresh", { updatedAt: new Date(nowMs - 2 * 86_400_000).toISOString() }),
98+
];
99+
const staleIssues = [issue(collab.fullName, 2, "Stale", { updatedAt: "2020-01-01T00:00:00.000Z" })];
100+
const undatedIssues = [issue(collab.fullName, 3, "Undated", { updatedAt: null, createdAt: null })];
101+
102+
const fresh = buildRepoRewardRisk({ ...base, issues: freshIssues, pullRequests: [] });
103+
const stale = buildRepoRewardRisk({ ...base, issues: staleIssues, pullRequests: [] });
104+
const undated = buildRepoRewardRisk({ ...base, issues: undatedIssues, pullRequests: [] });
105+
106+
expect(fresh.rewardUpside.opportunityFactors.freshnessFactor).toBe(
107+
computeOpportunityFreshness(toFreshnessIssues(freshIssues), nowMs),
108+
);
109+
expect(stale.rewardUpside.opportunityFactors.freshnessFactor).toBe(
110+
computeOpportunityFreshness(toFreshnessIssues(staleIssues), nowMs),
111+
);
112+
expect(undated.rewardUpside.opportunityFactors.freshnessFactor).toBe(
113+
computeOpportunityFreshness(toFreshnessIssues(undatedIssues), nowMs),
114+
);
115+
expect(undated.rewardUpside.opportunityFactors.freshnessFactor).toBeLessThanOrEqual(0.05);
116+
});
117+
118+
it("uses createdAt when updatedAt is malformed", () => {
119+
const issuesForRisk = [
120+
issue(collab.fullName, 1, "Fallback", {
121+
updatedAt: "not-a-date",
122+
createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
123+
}),
124+
];
125+
const result = buildRepoRewardRisk({ ...base, issues: issuesForRisk, pullRequests: [] });
126+
expect(result.rewardUpside.opportunityFactors.freshnessFactor).toBeGreaterThan(0.7);
127+
});
128+
129+
it("scores from the freshest open issue when multiple are present", () => {
130+
const issuesForRisk = [
131+
issue(collab.fullName, 1, "Stale", { updatedAt: "2020-01-01T00:00:00.000Z" }),
132+
issue(collab.fullName, 2, "Fresh", { updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString() }),
133+
];
134+
const result = buildRepoRewardRisk({ ...base, issues: issuesForRisk, pullRequests: [] });
135+
expect(result.rewardUpside.opportunityFactors.freshnessFactor).toBeGreaterThan(0.7);
136+
});
137+
138+
it("pickIssueTimestamp and issueAgeDays cover defensive timestamp branches", () => {
139+
const { pickIssueTimestamp, issueAgeDays } = rewardRiskFreshnessInternals;
140+
expect(
141+
pickIssueTimestamp({
142+
repoFullName: collab.fullName,
143+
number: 1,
144+
title: "t",
145+
state: "open",
146+
labels: [],
147+
linkedPrs: [],
148+
updatedAt: "2026-07-03T00:00:00.000Z",
149+
createdAt: "2020-01-01T00:00:00.000Z",
150+
}),
151+
).toBe("2026-07-03T00:00:00.000Z");
152+
expect(
153+
pickIssueTimestamp({
154+
repoFullName: collab.fullName,
155+
number: 2,
156+
title: "t",
157+
state: "open",
158+
labels: [],
159+
linkedPrs: [],
160+
updatedAt: " ",
161+
createdAt: "2026-07-03T00:00:00.000Z",
162+
}),
163+
).toBe("2026-07-03T00:00:00.000Z");
164+
expect(
165+
pickIssueTimestamp({
166+
repoFullName: collab.fullName,
167+
number: 3,
168+
title: "t",
169+
state: "open",
170+
labels: [],
171+
linkedPrs: [],
172+
updatedAt: null,
173+
createdAt: null,
174+
}),
175+
).toBeNull();
176+
expect(issueAgeDays(null)).toBe(Number.POSITIVE_INFINITY);
177+
expect(issueAgeDays("not-a-date")).toBe(Number.POSITIVE_INFINITY);
178+
expect(issueAgeDays("2026-07-03T00:00:00.000Z")).toBeGreaterThanOrEqual(0);
179+
});
180+
});

test/unit/signals-coverage.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,10 +1981,18 @@ describe("signal coverage edge cases", () => {
19811981
const withClosed = buildRepoRewardRisk({ ...base, issues: [closedIssue], pullRequests: [] });
19821982
expect(withClosed.rewardUpside.opportunityFactors.freshnessFactor).toBe(0);
19831983

1984-
// Issue with null dates → treated as fresh (conservative fallback)
1984+
// Issue with null dates → unknown age floors to minimum freshness (parity with gittensory-engine)
19851985
const noDateIssue = issue(collab.fullName, 23, "Undated request", { updatedAt: null, createdAt: null });
19861986
const withNoDate = buildRepoRewardRisk({ ...base, issues: [noDateIssue], pullRequests: [] });
1987-
expect(withNoDate.rewardUpside.opportunityFactors.freshnessFactor).toBeGreaterThan(0);
1987+
expect(withNoDate.rewardUpside.opportunityFactors.freshnessFactor).toBeLessThanOrEqual(0.05);
1988+
1989+
// Malformed updatedAt falls back to createdAt before scoring age
1990+
const fallbackIssue = issue(collab.fullName, 24, "Fallback timestamp", {
1991+
updatedAt: "not-a-date",
1992+
createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
1993+
});
1994+
const withFallback = buildRepoRewardRisk({ ...base, issues: [fallbackIssue], pullRequests: [] });
1995+
expect(withFallback.rewardUpside.opportunityFactors.freshnessFactor).toBeGreaterThan(0.7);
19881996
});
19891997

19901998
it("eligibilityGap: surfaces repos within 1–5 PR cleanups of threshold, excludes zero-cleanup and out-of-range repos", () => {

0 commit comments

Comments
 (0)