diff --git a/package-lock.json b/package-lock.json index 93c5de32d7..a78f51c8e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15482,10 +15482,31 @@ "name": "@jsonbored/gittensory-engine", "version": "0.1.0", "license": "AGPL-3.0-only", + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.6.3" + }, "engines": { "node": ">=22.0.0" } }, + "packages/gittensory-engine/node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/gittensory-engine/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", "version": "0.6.0", diff --git a/packages/gittensory-engine/.gitignore b/packages/gittensory-engine/.gitignore new file mode 100644 index 0000000000..9e63e4e942 --- /dev/null +++ b/packages/gittensory-engine/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +dist-test/ diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index f55b10994c..3232c38b6c 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -18,3 +18,42 @@ npm run build --workspace @jsonbored/gittensory-engine ``` This runs `tsc -p tsconfig.json`, emitting `dist/` (the only published output alongside `CHANGELOG.md`). + +## Test + +``` +npm test --workspace @jsonbored/gittensory-engine +``` + +Compiles the package and the `test/` suite (`node:test`) to plain JS and runs it — no experimental runtime +flags, so it works on the whole declared `engines` range. + +## `opportunity-ranker` + +The Phase-1 miner-discovery ranker. It composes five already-normalized `[0, 1]` signals into one ordinal score: + +``` +score = potential * feasibility * laneFit * freshness * (1 - dupRisk) +``` + +Every field is normalized before use, so a malformed upstream signal always degrades the score toward `0` rather +than inverting or overflowing it — but the two directions are handled asymmetrically: + +- The four **positive** factors (`potential`, `feasibility`, `laneFit`, `freshness`) clamp into `[0, 1]`; a + non-finite value (`NaN`/`±Infinity`) maps to `0`. +- **`dupRisk`** is clamped into `[0, 1]` like the others (below-range → `0`, above-range → `1`), so `-0.1` reads as + no contention. The one exception: a **non-finite** `dupRisk` (`NaN`/`±Infinity`) can't be clamped, so it **fails + closed** to `1` (maximum risk) rather than `0` — a broken contention signal must never masquerade as safe. + +Any single factor at `0` (or a `dupRisk` of `1`) collapses the whole score to `0`. + +```ts +import { rankOpportunities, rankOpportunityScore } from "@jsonbored/gittensory-engine"; + +rankOpportunityScore({ potential: 0.9, feasibility: 0.8, laneFit: 1, freshness: 0.7, dupRisk: 0.1 }); // → 0.4536 + +rankOpportunities(candidates); // sorted by descending score, each annotated with `rankScore` +``` + +`rankOpportunities` is a stable sort with an explicit index tie-break: candidates with an equal score keep their +input order. diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json index 946cb1a0f6..ea44365380 100644 --- a/packages/gittensory-engine/package.json +++ b/packages/gittensory-engine/package.json @@ -37,7 +37,12 @@ "CHANGELOG.md" ], "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "test": "npm run build && tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\"" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.6.3" }, "engines": { "node": ">=22.0.0" diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index f6d53a9a27..c6458fbc46 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -1,8 +1,11 @@ // Barrel export for @jsonbored/gittensory-engine. // -// This package will house the deterministic, side-effect-free logic shared by the Gittensory review-stack +// This package houses the deterministic, side-effect-free logic shared by the Gittensory review-stack // backend and the gittensory-miner (scoring preview/model, predicted-gate types, reward-risk, slop signals, // focus-manifest parse/compile core, duplicate-winner adjudication, and their engine-parity fixtures). -// Extraction lands in follow-up issues; until the first module moves here, this barrel is intentionally empty -// so the package builds cleanly on its own. -export {}; +// More modules land in follow-up issues. +export { + rankOpportunityScore, + rankOpportunities, + type OpportunityRankInput, +} from "./opportunity-ranker.js"; diff --git a/packages/gittensory-engine/src/opportunity-ranker.ts b/packages/gittensory-engine/src/opportunity-ranker.ts new file mode 100644 index 0000000000..69ad70c01d --- /dev/null +++ b/packages/gittensory-engine/src/opportunity-ranker.ts @@ -0,0 +1,82 @@ +// Opportunity ranker (#2302). The core Phase-1 miner-discovery ranker: it composes five already-normalized, +// deterministic signals into a single ordinal score used to sort a cross-repo candidate-issue list, so a later +// `gittensory_find_opportunities` tool has something deterministic to sort by. +// +// This module is PURE — no IO, no Date, no random — so identical inputs always produce identical order, matching +// the house convention in src/signals/duplicate-winner.ts. Every input is clamped to [0, 1] before use; the sole +// exception is a NON-finite `dupRisk` (NaN/±Infinity), which can't be clamped and fails closed to max risk so a +// broken contention signal never looks safe. Either way a malformed signal degrades the score toward 0 rather than +// inverting or blowing up the product. + +/** The five 0-1 normalized signals for one candidate opportunity. */ +export type OpportunityRankInput = { + /** Expected reward if the work is won (score / label-multiplier potential). */ + potential: number; + /** How achievable the issue is for the miner. */ + feasibility: number; + /** Fit with the miner's preferred lanes. */ + laneFit: number; + /** How recently actionable the opportunity is (decays as it ages). */ + freshness: number; + /** Risk the work is already claimed / contested; higher means more likely a wasted attempt. */ + dupRisk: number; +}; + +/** Clamp a positive factor to [0, 1]; a non-finite value (NaN/±Infinity from a broken upstream) degrades to 0. */ +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** + * Normalize the contention/risk signal to [0, 1]. A FINITE value is clamped like every other field — below-range + * → 0, above-range → 1 — so `dupRisk = -0.1` reads as no contention and `dupRisk = 1.4` as full contention. A + * NON-finite value (`NaN`/`±Infinity`) cannot be clamped and signals a broken upstream, so it FAILS CLOSED to + * maximum risk (1), never 0: a broken contention signal must not masquerade as a safe, uncontested opportunity + * (mirroring the fail-closed convention in `src/signals/duplicate-winner.ts`, where sparse rows fail closed). + */ +function clampRisk(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.min(1, Math.max(0, value)); +} + +/** + * The ordinal opportunity score: `potential * feasibility * laneFit * freshness * (1 - dupRisk)`, with every field + * clamped to [0, 1] first. Because it is a product, ANY single factor at 0 — or a `dupRisk` of exactly 1 — collapses + * the whole score to 0: a candidate that fails any one dimension is not an opportunity. Malformed input never passes + * through raw and always degrades the score toward 0: the four positive factors clamp a non-finite value to 0, and a + * non-finite `dupRisk` fails closed to 1 (max risk). So a bad signal can neither invert the sign nor overflow the + * product. Pure. + * + * Signal-source map for the composing caller (a later issue): `feasibility` ← the per-repo report in + * `src/services/issue-quality.ts`; `laneFit` ← `MinerGoalSpec.preferredLanes` (the goal-model issue); `freshness` + * ← `src/signals/reward-risk.ts`'s `freshnessFactor`; `dupRisk` ← `src/signals/reward-risk.ts`'s + * `competitionFactor` combined with `src/signals/duplicate-winner.ts`'s claim adjudication. + */ +export function rankOpportunityScore(input: OpportunityRankInput): number { + return ( + clamp01(input.potential) * + clamp01(input.feasibility) * + clamp01(input.laneFit) * + clamp01(input.freshness) * + (1 - clampRisk(input.dupRisk)) + ); +} + +/** + * Rank a candidate list by descending {@link rankOpportunityScore}, annotating each candidate with its `rankScore`. + * Equal scores keep their input order: the tie-break is made EXPLICIT via a carried index (`rankScore` desc, then + * `index` asc) rather than relying on `Array.prototype.sort` stability, so the contract holds on any engine and is + * enforced by this function. Mirrors the tie-break intent of `isDuplicateClusterWinnerByClaim` in + * src/signals/duplicate-winner.ts, where an earlier entry wins a tie. Pure — returns a new array; the input array + * and its elements are not mutated. The computed `rankScore` REPLACES any `rankScore` already on an input element + * (`Omit` in the result), so a caller carrying its own field can't collide with the annotation. + */ +export function rankOpportunities( + candidates: Array, +): Array & OpportunityRankInput & { rankScore: number }> { + return candidates + .map((candidate, index) => ({ candidate, rankScore: rankOpportunityScore(candidate), index })) + .sort((a, b) => b.rankScore - a.rankScore || a.index - b.index) + .map(({ candidate, rankScore }) => ({ ...candidate, rankScore })); +} diff --git a/packages/gittensory-engine/test/opportunity-ranker.test.ts b/packages/gittensory-engine/test/opportunity-ranker.test.ts new file mode 100644 index 0000000000..17ec0e0545 --- /dev/null +++ b/packages/gittensory-engine/test/opportunity-ranker.test.ts @@ -0,0 +1,105 @@ +// Units for the opportunity ranker (#2302). Runs against the compiled dist/ (built by the `test` script first), +// mirroring the review-enrichment package's node:test convention. Imports through the package's public barrel +// (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"; + +test("barrel: the public entrypoint re-exports the ranker API", () => { + assert.equal(typeof rankOpportunityScore, "function"); + assert.equal(typeof rankOpportunities, "function"); +}); + +const full = { potential: 1, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0 }; + +/** Product of floats isn't bit-exact (0.5*0.8*0.5*0.8 = 0.16000000000000003), so compare within a tolerance. */ +const closeTo = (actual: number, expected: number): void => + assert.ok(Math.abs(actual - expected) < 1e-9, `expected ~${expected}, got ${actual}`); + +test("rankOpportunityScore: all factors at max, no dup risk → 1", () => { + assert.equal(rankOpportunityScore(full), 1); +}); + +test("rankOpportunityScore: composes the five signals as a product", () => { + // 0.5 * 0.8 * 0.5 * 1 * (1 - 0.2) = 0.16 + closeTo( + rankOpportunityScore({ potential: 0.5, feasibility: 0.8, laneFit: 0.5, freshness: 1, dupRisk: 0.2 }), + 0.16, + ); +}); + +test("rankOpportunityScore: any single factor at 0 collapses the score to 0", () => { + for (const field of ["potential", "feasibility", "laneFit", "freshness"] as const) { + assert.equal(rankOpportunityScore({ ...full, [field]: 0 }), 0, `${field}=0 must zero the score`); + } +}); + +test("rankOpportunityScore: a dupRisk of exactly 1 zeroes the score", () => { + assert.equal(rankOpportunityScore({ ...full, dupRisk: 1 }), 0); +}); + +const POSITIVE_FACTORS = ["potential", "feasibility", "laneFit", "freshness"] as const; +const NON_FINITE = [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]; + +test("rankOpportunityScore: every positive factor clamps out-of-range/non-finite input, never passing it raw", () => { + for (const field of POSITIVE_FACTORS) { + // Over-max clamps to 1 → unchanged from the all-max baseline of 1. + assert.equal(rankOpportunityScore({ ...full, [field]: 5 }), 1, `${field}>1 must clamp to 1`); + // Negative clamps to 0 → collapses the product (never a negative / sign-inverted score). + assert.equal(rankOpportunityScore({ ...full, [field]: -3 }), 0, `${field}<0 must clamp to 0`); + // Non-finite degrades to 0 → collapses the product (never NaN). + for (const bad of NON_FINITE) { + assert.equal(rankOpportunityScore({ ...full, [field]: bad }), 0, `${field}=${bad} must degrade to 0`); + } + } +}); + +test("rankOpportunityScore: dupRisk clamps finite values; only a non-finite value fails closed", () => { + closeTo(rankOpportunityScore({ ...full, dupRisk: 0.25 }), 0.75); // in-range penalty applies: 1 - 0.25 + // A FINITE out-of-range dupRisk is clamped like every field: above-range → 1 (full penalty → score 0), + // below-range → 0 (no penalty → score 1). So -0.1 reads as no contention, matching the documented formula. + assert.equal(rankOpportunityScore({ ...full, dupRisk: 1.4 }), 0); + assert.equal(rankOpportunityScore({ ...full, dupRisk: -0.1 }), 1); + assert.equal(rankOpportunityScore({ ...full, dupRisk: -2 }), 1); + // A NON-finite dupRisk can't be clamped, so it fails closed to MAX risk (1) → (1 - 1) = 0: a broken contention + // signal must not look safe. This is the one asymmetry vs the positive factors, which degrade to 0. + for (const bad of NON_FINITE) { + assert.equal(rankOpportunityScore({ ...full, dupRisk: bad }), 0, `dupRisk=${bad} must fail closed to 0`); + } +}); + +test("rankOpportunities: sorts descending by score and annotates rankScore", () => { + const ranked = rankOpportunities([ + { 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 }, + ]); + assert.deepEqual(ranked.map((c) => c.id), ["high", "mid", "low"]); + assert.equal(ranked[0]!.rankScore, 0.9); +}); + +test("rankOpportunities: equal scores keep input order (stable tie-break)", () => { + const tie = { potential: 0.5, feasibility: 1, laneFit: 1, freshness: 1, dupRisk: 0 }; + const ranked = rankOpportunities([ + { id: "a", ...tie }, + { id: "b", ...tie }, + { id: "c", ...tie }, + ]); + assert.deepEqual(ranked.map((c) => c.id), ["a", "b", "c"]); +}); + +test("rankOpportunities: does not mutate the input array or its elements", () => { + const input = [{ id: "x", ...full }]; + const snapshot = JSON.parse(JSON.stringify(input)); + rankOpportunities(input); + assert.deepEqual(input, snapshot); // no rankScore leaked back onto the source +}); + +test("rankOpportunities: a stale rankScore on the input is overwritten with the computed score", () => { + const ranked = rankOpportunities([{ id: "x", rankScore: 999, ...full }]); + assert.equal(ranked[0]!.rankScore, 1); // the freshly computed score wins; the caller's stale value is discarded +}); + +test("rankOpportunities: an empty list ranks to an empty list", () => { + assert.deepEqual(rankOpportunities([]), []); +}); diff --git a/packages/gittensory-engine/tsconfig.test.json b/packages/gittensory-engine/tsconfig.test.json new file mode 100644 index 0000000000..45f2856087 --- /dev/null +++ b/packages/gittensory-engine/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "rootDir": "test", + "outDir": "dist-test", + "declaration": false, + "noEmit": false + }, + "include": ["test"] +}