Implement the core Phase 1 ranker: a pure function that composes five existing/adjacent deterministic signals into a single ordinal opportunity score for a cross-repo candidate-issue list, so gittensory_find_opportunities (a later issue) has something deterministic to sort by. No IO, no network, no randomness — the same inputs always produce the same ranked order, matching the house pattern already used in src/signals/duplicate-winner.ts ("This module is PURE — no IO, no Date, no random").
Deliverables
References
src/signals/duplicate-winner.ts (60 lines) — the PURE-module convention ("no IO, no Date, no random") and the tie-break-by-claim-time pattern (isDuplicateClusterWinnerByClaim, line 43)
src/signals/reward-risk.ts lines 88-93 — existing competitionFactor / freshnessFactor opportunity factors this ranker composes
src/services/issue-quality.ts (52 lines) — existing per-repo feasibility/quality source
src/signals/local-scorer.ts (34 lines) — sibling deterministic-scorer style to match (input clamping, Math.max(0, ...) guards)
- new path:
packages/gittensory-engine/lib/opportunity-ranker.ts (this issue creates it)
Implement the core Phase 1 ranker: a pure function that composes five existing/adjacent deterministic signals into a single ordinal opportunity score for a cross-repo candidate-issue list, so
gittensory_find_opportunities(a later issue) has something deterministic to sort by. No IO, no network, no randomness — the same inputs always produce the same ranked order, matching the house pattern already used insrc/signals/duplicate-winner.ts("This module is PURE — no IO, no Date, no random").Deliverables
packages/gittensory-engine/lib/opportunity-ranker.ts—export type OpportunityRankInput = { potential: number; feasibility: number; laneFit: number; freshness: number; dupRisk: number }(each a 0-1 normalized score) andexport function rankOpportunityScore(input: OpportunityRankInput): numbercomputingpotential * feasibility * laneFit * freshness * (1 - dupRisk), clamping every input field to[0, 1]before multiplying so a malformed upstream signal (e.g.dupRisk = 1.4) can't invert or blow up the product.export function rankOpportunities<T>(candidates: Array<T & OpportunityRankInput>): Array<T & OpportunityRankInput & { rankScore: number }>— sorts descending byrankOpportunityScore, stable-sorted on tie (preserve input order for equal scores, mirroring the tie-break convention inisDuplicateClusterWinnerByClaim,src/signals/duplicate-winner.ts:43).dupRiskof exactly 1 zeroes the score; out-of-range inputs (negative, >1) are clamped, not passed through raw; the multi-candidate sort is stable on exact ties and correctly orders a 3+ candidate list.rankOpportunityScoreexplaining howfeasibilityshould be sourced fromsrc/services/issue-quality.ts's existing per-repo report,laneFitfromMinerGoalSpec.preferredLanes(from the goal-model issue), andfreshness/dupRiskfromsrc/signals/reward-risk.ts'sfreshnessFactor/competitionFactor(~lines 88-93) andsrc/signals/duplicate-winner.ts's claim-adjudication respectively — so the composing caller (a later issue) has an explicit signal-source map.References
src/signals/duplicate-winner.ts(60 lines) — the PURE-module convention ("no IO, no Date, no random") and the tie-break-by-claim-time pattern (isDuplicateClusterWinnerByClaim, line 43)src/signals/reward-risk.tslines 88-93 — existingcompetitionFactor/freshnessFactoropportunity factors this ranker composessrc/services/issue-quality.ts(52 lines) — existing per-repo feasibility/quality sourcesrc/signals/local-scorer.ts(34 lines) — sibling deterministic-scorer style to match (input clamping,Math.max(0, ...)guards)packages/gittensory-engine/lib/opportunity-ranker.ts(this issue creates it)