/** * Reciprocal Rank Fusion (Task 308). * * Replaces the weighted-sum fusion (`0.7 * vector + 0.3 * bm25_norm`) when * the `MAXY_GS_RRF` flag is on. RRF is robust to score-distribution drift * between the vector and BM25 indexes — it sums `1 / (k + rank_i)` for each * node across the ranked lists it appears in. Higher rank (lower index) → * larger contribution; the constant `k` (default 60, per the gbrain * default) smooths the long tail. * * The weighted-sum baseline stays as the fallback so flag flips are * reversible without code edits. */ export interface FusionCandidate { nodeId: string; } /** * Fuse multiple ranked lists by Reciprocal Rank Fusion. * * Each input list is treated as already sorted (best first). For each * candidate, score = sum over lists of `1 / (k + rank)`. Returns the union * sorted by descending RRF score. * * Ties on RRF score are broken by the candidate's earliest appearance * across the input lists (first-seen wins), which is deterministic given * a deterministic input order. */ export function fuseRrf( rankedLists: T[][], k = 60, ): Array { const scores = new Map(); const representatives = new Map(); const firstSeen = new Map(); let order = 0; for (const list of rankedLists) { for (let rank = 0; rank < list.length; rank++) { const candidate = list[rank]; const contribution = 1 / (k + rank); const prior = scores.get(candidate.nodeId) ?? 0; scores.set(candidate.nodeId, prior + contribution); if (!representatives.has(candidate.nodeId)) { representatives.set(candidate.nodeId, candidate); firstSeen.set(candidate.nodeId, order++); } } } const out: Array = []; for (const [nodeId, rrfScore] of scores) { const rep = representatives.get(nodeId)!; out.push({ ...rep, rrfScore }); } out.sort((a, b) => { if (b.rrfScore !== a.rrfScore) return b.rrfScore - a.rrfScore; return (firstSeen.get(a.nodeId)! - firstSeen.get(b.nodeId)!); }); return out; }