From 7339609c92e77631fd2daf59f43516dfa5c46cef Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:41:17 +0200 Subject: [PATCH] feat(engine): add opportunity freshness scorer Expose a pure computeOpportunityFreshness helper with an injected clock so miners can derive the ranker freshness signal without importing reward-risk IO. Co-authored-by: Cursor --- packages/gittensory-engine/src/index.ts | 4 ++ .../src/opportunity-freshness.ts | 38 ++++++++++++++++ .../test/opportunity-freshness.test.ts | 45 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 packages/gittensory-engine/src/opportunity-freshness.ts create mode 100644 packages/gittensory-engine/test/opportunity-freshness.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 31d98a1285..41fab9ce1a 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -32,3 +32,7 @@ export { computeMinerGoalLaneFit, isMinerRepoTargetable, } from "./miner-goal-lane-fit.js"; +export { + computeOpportunityFreshness, + type FreshnessIssue, +} from "./opportunity-freshness.js"; diff --git a/packages/gittensory-engine/src/opportunity-freshness.ts b/packages/gittensory-engine/src/opportunity-freshness.ts new file mode 100644 index 0000000000..2267b976ac --- /dev/null +++ b/packages/gittensory-engine/src/opportunity-freshness.ts @@ -0,0 +1,38 @@ +export type FreshnessIssue = { + state: string; + updatedAt?: string | null; + createdAt?: string | null; +}; + +function round4(value: number): number { + return Math.round(value * 10000) / 10000; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function issueAgeDays(value: string | null | undefined, nowMs: number): number { + if (!value) return 0; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return 0; + return Math.floor((nowMs - parsed) / 86_400_000); +} + +/** + * Compute a [0.05, 1] freshness factor from open issue timestamps, mirroring + * `opportunityFreshnessFactor` in `src/signals/reward-risk.ts` but with an injected clock so the miner engine + * stays pure and testable. + */ +export function computeOpportunityFreshness( + issues: readonly FreshnessIssue[], + nowMs: number, +): number { + if (!Number.isFinite(nowMs)) return 0; + const openIssues = issues.filter((issue) => issue?.state === "open"); + if (openIssues.length === 0) return 0; + const mostRecentAgeDays = Math.min( + ...openIssues.map((issue) => issueAgeDays(issue.updatedAt ?? issue.createdAt ?? null, nowMs)), + ); + return round4(clamp(Math.exp(-mostRecentAgeDays / 20), 0.05, 1)); +} diff --git a/packages/gittensory-engine/test/opportunity-freshness.test.ts b/packages/gittensory-engine/test/opportunity-freshness.test.ts new file mode 100644 index 0000000000..0c8a0deaad --- /dev/null +++ b/packages/gittensory-engine/test/opportunity-freshness.test.ts @@ -0,0 +1,45 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { computeOpportunityFreshness } from "../dist/opportunity-freshness.js"; + +const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + +test("computeOpportunityFreshness returns 0 when no open issues exist", () => { + assert.equal(computeOpportunityFreshness([], nowMs), 0); + assert.equal( + computeOpportunityFreshness([{ state: "closed", updatedAt: "2026-07-01T00:00:00.000Z" }], nowMs), + 0, + ); +}); + +test("computeOpportunityFreshness decays with issue age", () => { + const fresh = computeOpportunityFreshness( + [{ state: "open", updatedAt: "2026-07-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(fresh > 0.7); + + const stale = computeOpportunityFreshness( + [{ state: "open", createdAt: "2023-01-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(stale <= 0.05); +}); + +test("computeOpportunityFreshness uses the most recently updated open issue", () => { + const score = computeOpportunityFreshness( + [ + { state: "open", updatedAt: "2023-01-01T00:00:00.000Z" }, + { state: "open", updatedAt: "2026-07-01T00:00:00.000Z" }, + ], + nowMs, + ); + assert.ok(score > 0.7); +}); + +test("computeOpportunityFreshness treats malformed timestamps as fresh", () => { + assert.ok( + computeOpportunityFreshness([{ state: "open", updatedAt: "not-a-date" }], nowMs) > 0.9, + ); +});