diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 628780968b..e2493007d1 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -11,6 +11,7 @@ export { type OpportunityRankInput, } from "./opportunity-ranker.js"; export { rankOpportunitiesAtOrAboveScore } from "./ranked-opportunity-min-score.js"; +export { pickTopRankedOpportunitiesAtOrAboveScore } from "./ranked-opportunity-top-min-score.js"; export { extractObjectiveAnchorHistory, extractObjectiveAnchorFeatures, diff --git a/packages/gittensory-engine/src/ranked-opportunity-top-min-score.ts b/packages/gittensory-engine/src/ranked-opportunity-top-min-score.ts new file mode 100644 index 0000000000..63f4432f08 --- /dev/null +++ b/packages/gittensory-engine/src/ranked-opportunity-top-min-score.ts @@ -0,0 +1,17 @@ +import { rankOpportunitiesAtOrAboveScore } from "./ranked-opportunity-min-score.js"; +import type { OpportunityRankInput } from "./opportunity-ranker.js"; + +/** + * Rank candidates, drop entries below `minScore`, and return the top `limit` survivors. + * Non-finite limits return an empty list. Pure — delegates to {@link rankOpportunitiesAtOrAboveScore}. + */ +export function pickTopRankedOpportunitiesAtOrAboveScore( + candidates: Array, + minScore: number, + limit: number, +): Array & OpportunityRankInput & { rankScore: number }> { + if (!Number.isFinite(limit)) return []; + const safeLimit = Math.max(0, Math.trunc(limit)); + if (safeLimit === 0) return []; + return rankOpportunitiesAtOrAboveScore(candidates, minScore).slice(0, safeLimit); +} diff --git a/test/unit/ranked-opportunity-top-min-score.test.ts b/test/unit/ranked-opportunity-top-min-score.test.ts new file mode 100644 index 0000000000..a80f2b9cd9 --- /dev/null +++ b/test/unit/ranked-opportunity-top-min-score.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { pickTopRankedOpportunitiesAtOrAboveScore } from "../../packages/gittensory-engine/src/ranked-opportunity-top-min-score"; +import type { OpportunityRankInput } from "../../packages/gittensory-engine/src/opportunity-ranker"; + +function input(over: Partial = {}): OpportunityRankInput { + return { potential: 1, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0, ...over }; +} + +describe("pickTopRankedOpportunitiesAtOrAboveScore", () => { + const candidates = [ + { id: "low", ...input({ potential: 0.2 }) }, + { id: "mid", ...input({ potential: 0.5 }) }, + { id: "top", ...input() }, + { id: "weak", ...input({ potential: 0.5, feasibility: 0.5, laneFit: 0.5 }) }, + ]; + + it("returns the top survivors after applying the score threshold", () => { + const topTwo = pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.5, 2); + expect(topTwo.map((entry) => entry.id)).toEqual(["top", "mid"]); + expect(topTwo.every((entry) => entry.rankScore >= 0.5)).toBe(true); + }); + + it("returns every qualifying candidate when the limit exceeds the filtered list", () => { + expect(pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.125, 10).map((entry) => entry.id)).toEqual([ + "top", + "mid", + "low", + "weak", + ]); + }); + + it("returns an empty array when the score threshold excludes every candidate", () => { + expect( + pickTopRankedOpportunitiesAtOrAboveScore( + [ + { id: "a", ...input({ potential: 0.2 }) }, + { id: "b", ...input({ potential: 0.3 }) }, + ], + 0.5, + 2, + ), + ).toEqual([]); + }); + + it("returns an empty array for a non-finite or zero limit", () => { + expect(pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.5, 0)).toEqual([]); + expect(pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.5, -1)).toEqual([]); + expect(pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.5, Number.NaN)).toEqual([]); + expect(pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.5, Number.POSITIVE_INFINITY)).toEqual([]); + }); + + it("returns an empty array when minScore is non-finite", () => { + expect(pickTopRankedOpportunitiesAtOrAboveScore(candidates, Number.NaN, 2)).toEqual([]); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.pickTopRankedOpportunitiesAtOrAboveScore).toBe("function"); + expect( + barrel.pickTopRankedOpportunitiesAtOrAboveScore(candidates, 0.5, 1).map((entry: { id: string }) => entry.id), + ).toEqual(["top"]); + }); +});