Skip to content
Merged
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
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/gittensory-engine/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
dist-test/
39 changes: 39 additions & 0 deletions packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion packages/gittensory-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 7 additions & 4 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
82 changes: 82 additions & 0 deletions packages/gittensory-engine/src/opportunity-ranker.ts
Original file line number Diff line number Diff line change
@@ -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<T, "rankScore">` in the result), so a caller carrying its own field can't collide with the annotation.
*/
export function rankOpportunities<T>(
candidates: Array<T & OpportunityRankInput>,
): Array<Omit<T, "rankScore"> & 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 }));
}
105 changes: 105 additions & 0 deletions packages/gittensory-engine/test/opportunity-ranker.test.ts
Original file line number Diff line number Diff line change
@@ -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([]), []);
});
11 changes: 11 additions & 0 deletions packages/gittensory-engine/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"],
"rootDir": "test",
"outDir": "dist-test",
"declaration": false,
"noEmit": false
},
"include": ["test"]
}
Loading