Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ export {
computeMinerGoalLaneFit,
isMinerRepoTargetable,
} from "./miner-goal-lane-fit.js";
export {
computeOpportunityFreshness,
type FreshnessIssue,
} from "./opportunity-freshness.js";
38 changes: 38 additions & 0 deletions packages/gittensory-engine/src/opportunity-freshness.ts
Original file line number Diff line number Diff line change
@@ -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));
}
45 changes: 45 additions & 0 deletions packages/gittensory-engine/test/opportunity-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});