diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index eb72564dbc..0bf70e93ea 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -5,6 +5,7 @@ // focus-manifest parse/compile core, duplicate-winner adjudication, and their engine-parity fixtures). // More modules land in follow-up issues. export { + pickTopRankedOpportunities, rankOpportunityScore, rankOpportunities, type OpportunityRankInput, diff --git a/packages/gittensory-engine/src/opportunity-ranker.ts b/packages/gittensory-engine/src/opportunity-ranker.ts index 69ad70c01d..4e0f566484 100644 --- a/packages/gittensory-engine/src/opportunity-ranker.ts +++ b/packages/gittensory-engine/src/opportunity-ranker.ts @@ -80,3 +80,17 @@ export function rankOpportunities( .sort((a, b) => b.rankScore - a.rankScore || a.index - b.index) .map(({ candidate, rankScore }) => ({ ...candidate, rankScore })); } + +/** + * Rank candidates and return the top `limit` entries. Non-finite or negative limits return an empty list. + * Pure — delegates to {@link rankOpportunities} for ordering and tie-breaking. + */ +export function pickTopRankedOpportunities( + candidates: Array, + limit: number, +): Array & OpportunityRankInput & { rankScore: number }> { + if (!Number.isFinite(limit)) return []; + const safeLimit = Math.max(0, Math.trunc(limit)); + if (safeLimit === 0 || candidates.length === 0) return []; + return rankOpportunities(candidates).slice(0, safeLimit); +} diff --git a/packages/gittensory-engine/test/opportunity-ranker.test.ts b/packages/gittensory-engine/test/opportunity-ranker.test.ts index 17ec0e0545..27ff3f3c2a 100644 --- a/packages/gittensory-engine/test/opportunity-ranker.test.ts +++ b/packages/gittensory-engine/test/opportunity-ranker.test.ts @@ -3,11 +3,12 @@ // (dist/index.js) so the export contract itself is exercised. Pure module — no network, never flakes. import { test } from "node:test"; import assert from "node:assert/strict"; -import { rankOpportunityScore, rankOpportunities } from "../dist/index.js"; +import { rankOpportunityScore, rankOpportunities, pickTopRankedOpportunities } from "../dist/index.js"; test("barrel: the public entrypoint re-exports the ranker API", () => { assert.equal(typeof rankOpportunityScore, "function"); assert.equal(typeof rankOpportunities, "function"); + assert.equal(typeof pickTopRankedOpportunities, "function"); }); const full = { potential: 1, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0 }; @@ -103,3 +104,19 @@ test("rankOpportunities: a stale rankScore on the input is overwritten with the test("rankOpportunities: an empty list ranks to an empty list", () => { assert.deepEqual(rankOpportunities([]), []); }); + +test("pickTopRankedOpportunities: returns the top N ranked candidates", () => { + const candidates = [ + { id: "low", potential: 0.2, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0 }, + { id: "high", potential: 0.9, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0 }, + { id: "mid", potential: 0.5, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0 }, + ]; + const topTwo = pickTopRankedOpportunities(candidates, 2); + assert.deepEqual(topTwo.map((entry) => entry.id), ["high", "mid"]); +}); + +test("pickTopRankedOpportunities: rejects non-finite limits", () => { + const candidates = [{ id: "only", ...full }]; + assert.deepEqual(pickTopRankedOpportunities(candidates, Number.NaN), []); + assert.deepEqual(pickTopRankedOpportunities(candidates, Number.POSITIVE_INFINITY), []); +}); diff --git a/test/unit/opportunity-ranker.test.ts b/test/unit/opportunity-ranker.test.ts index 1cb0df8ec7..d79285f2b8 100644 --- a/test/unit/opportunity-ranker.test.ts +++ b/test/unit/opportunity-ranker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { rankOpportunities, rankOpportunityScore, type OpportunityRankInput } from "../../packages/gittensory-engine/src/opportunity-ranker"; +import { pickTopRankedOpportunities, rankOpportunities, rankOpportunityScore, type OpportunityRankInput } from "../../packages/gittensory-engine/src/opportunity-ranker"; // A neutral, all-passing candidate (every factor 1, no contention → score 1); tests override one field at a time. function input(over: Partial = {}): OpportunityRankInput { @@ -137,3 +137,52 @@ describe("rankOpportunities", () => { expect(rankOpportunities([])).toEqual([]); }); }); + +describe("pickTopRankedOpportunities", () => { + const candidates = [ + { id: "mid", ...input({ potential: 0.5 }) }, + { id: "top", ...input() }, + { id: "low", ...input({ freshness: 0.25 }) }, + ]; + + it("returns the highest-scoring candidates up to the limit", () => { + const topTwo = pickTopRankedOpportunities(candidates, 2); + expect(topTwo.map((entry) => entry.id)).toEqual(["top", "mid"]); + expect(topTwo.map((entry) => entry.rankScore)).toEqual([1, 0.5]); + }); + + it("returns every candidate when the limit exceeds the list size", () => { + expect(pickTopRankedOpportunities(candidates, 10).map((entry) => entry.id)).toEqual([ + "top", + "mid", + "low", + ]); + }); + + it("returns an empty array for a zero, negative, or non-finite limit", () => { + expect(pickTopRankedOpportunities(candidates, 0)).toEqual([]); + expect(pickTopRankedOpportunities(candidates, -1)).toEqual([]); + expect(pickTopRankedOpportunities(candidates, Number.NaN)).toEqual([]); + expect(pickTopRankedOpportunities(candidates, Number.POSITIVE_INFINITY)).toEqual([]); + }); + + it("returns an empty array for no candidates", () => { + expect(pickTopRankedOpportunities([], 3)).toEqual([]); + }); + + it("preserves rankOpportunities tie-breaking within the slice", () => { + const tie = input({ potential: 0.5 }); + const tied = [ + { id: "first", ...tie }, + { id: "second", ...tie }, + { id: "winner", ...input() }, + ]; + expect(pickTopRankedOpportunities(tied, 2).map((entry) => entry.id)).toEqual(["winner", "first"]); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.pickTopRankedOpportunities).toBe("function"); + expect(barrel.pickTopRankedOpportunities(candidates, 1).map((entry) => entry.id)).toEqual(["top"]); + }); +});