{"version":3,"file":"fusion.d.ts","sourceRoot":"","sources":["../../../src/core/workspace/fusion.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,WAAW,eAAe;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,oBAAoB,CAAC;IAC5B,IAAI,EAAE,MAAM,CAAC;CACb;AAID,oEAAoE;AACpE,wBAAgB,cAAc,CAC7B,cAAc,EAAE,eAAe,EAAE,EAAE,EACnC,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GACzD,WAAW,EAAE,CA2Ff;AAaD,+EAA+E;AAC/E,wBAAgB,eAAe,CAC9B,OAAO,EAAE,WAAW,EAAE,EACtB,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAO,GAC1D,WAAW,EAAE,CA4Bf","sourcesContent":["/**\n * Candidate fusion (reciprocal rank fusion) and deterministic heuristic\n * reranking. Duplicate chunks are merged, per-file diversity is enforced, exact\n * matches stay strong, and every result exposes its contributing signals.\n */\n\nimport type { HybridCandidateScore } from \"./types.js\";\n\nexport interface FusionCandidate {\n\tchunkId: string;\n\tfile: string;\n\tranking: number;\n\tgenerator: string;\n\treasonCode: string;\n\tbonus?: number;\n}\n\nexport interface FusedResult {\n\tchunkId: string;\n\tfile: string;\n\tscore: HybridCandidateScore;\n\trank: number;\n}\n\nconst RRF_K = 60;\n\n/** Fuse ranked candidate lists from multiple generators via RRF. */\nexport function fuseCandidates(\n\tgeneratorLists: FusionCandidate[][],\n\topts: { maxPerFile?: number; maximumResults?: number } = {},\n): FusedResult[] {\n\tconst maxPerFile = opts.maxPerFile ?? 4;\n\tconst maximumResults = opts.maximumResults ?? 50;\n\n\tconst perChunk = new Map<string, FusionCandidate[]>();\n\tfor (const list of generatorLists) {\n\t\tfor (const c of list) {\n\t\t\tconst prev = perChunk.get(c.chunkId) ?? [];\n\t\t\tprev.push(c);\n\t\t\tperChunk.set(c.chunkId, prev);\n\t\t}\n\t}\n\n\tconst fused: Array<{ chunkId: string; file: string; fused: number; gens: string[]; score: HybridCandidateScore }> =\n\t\t[];\n\n\tfor (const [chunkId, candidates] of perChunk) {\n\t\tlet fusedScore = 0;\n\t\tconst gens = new Set<string>();\n\t\tlet lexical: number | undefined;\n\t\tlet semantic: number | undefined;\n\t\tlet symbol: number | undefined;\n\t\tlet lsp: number | undefined;\n\t\tlet path: number | undefined;\n\t\tlet git: number | undefined;\n\t\tlet bonus = 0;\n\n\t\tfor (const c of candidates) {\n\t\t\tfusedScore += 1 / (RRF_K + c.ranking);\n\t\t\tgens.add(c.generator);\n\t\t\tbonus += c.bonus ?? 0;\n\t\t\tswitch (c.generator) {\n\t\t\t\tcase \"lexical\":\n\t\t\t\t\tlexical = (lexical ?? 0) + 1 / (RRF_K + c.ranking);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"semantic\":\n\t\t\t\t\tsemantic = (semantic ?? 0) + 1 / (RRF_K + c.ranking);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"symbol\":\n\t\t\t\t\tsymbol = (symbol ?? 0) + 1 / (RRF_K + c.ranking);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"lsp\":\n\t\t\t\t\tlsp = (lsp ?? 0) + 1 / (RRF_K + c.ranking);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"path\":\n\t\t\t\t\tpath = (path ?? 0) + 1 / (RRF_K + c.ranking);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"git\":\n\t\t\t\t\tgit = (git ?? 0) + 1 / (RRF_K + c.ranking);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tconst order = significantOrder(candidates);\n\t\tfused.push({\n\t\t\tchunkId,\n\t\t\tfile: candidates[0].file,\n\t\t\tfused: fusedScore + bonus,\n\t\t\tgens: order,\n\t\t\tscore: {\n\t\t\t\tlexical: round(lexical),\n\t\t\t\tsemantic: round(semantic),\n\t\t\t\tsymbol: round(symbol),\n\t\t\t\tlsp: round(lsp),\n\t\t\t\tpath: round(path),\n\t\t\t\tgit: round(git),\n\t\t\t\tfused: round(fusedScore + bonus) ?? 0,\n\t\t\t\treasonCodes: order,\n\t\t\t},\n\t\t});\n\t}\n\n\tfused.sort((a, b) => b.fused - a.fused || a.chunkId.localeCompare(b.chunkId));\n\n\t// Per-file diversity + bounded results.\n\tconst perFileSeen = new Map<string, number>();\n\tconst output: FusedResult[] = [];\n\tlet rank = 1;\n\tfor (const f of fused) {\n\t\tconst count = perFileSeen.get(f.file) ?? 0;\n\t\tif (count >= maxPerFile) continue;\n\t\tperFileSeen.set(f.file, count + 1);\n\t\toutput.push({\n\t\t\tchunkId: f.chunkId,\n\t\t\tfile: f.file,\n\t\t\trank: rank++,\n\t\t\tscore: f.score,\n\t\t});\n\t\tif (output.length >= maximumResults) break;\n\t}\n\treturn output;\n}\n\n/** Order of generators by their contribution strength (for reason codes). */\nfunction significantOrder(candidates: FusionCandidate[]): string[] {\n\tconst byGen = new Map<string, number>();\n\tfor (const c of candidates) byGen.set(c.generator, (byGen.get(c.generator) ?? 0) + 1 / (RRF_K + c.ranking));\n\treturn [...byGen.entries()].sort((a, b) => b[1] - a[1]).map(([g]) => g);\n}\n\nfunction round(n: number | undefined): number | undefined {\n\treturn n === undefined ? undefined : Number(n.toFixed(4));\n}\n\n/** Deterministic heuristic reranker over fused results (no separate model). */\nexport function rerankHeuristic(\n\tresults: FusedResult[],\n\topts: { exactTerm?: string; symbolMatch?: Set<string> } = {},\n): FusedResult[] {\n\tconst exactTerm = opts.exactTerm?.toLowerCase();\n\treturn [...results]\n\t\t.map((r, _i) => {\n\t\t\tlet delta = 0;\n\t\t\tlet reason = \"\";\n\t\t\t// Exact identifier match on file basename.\n\t\t\tif (exactTerm && r.file.toLowerCase().includes(exactTerm)) {\n\t\t\t\tdelta += 2;\n\t\t\t\treason = \"exact_file_match\";\n\t\t\t}\n\t\t\t// Symbol match bonus.\n\t\t\tif (opts.symbolMatch?.has(r.chunkId)) {\n\t\t\t\tdelta += 1.5;\n\t\t\t\treason = reason ? `${reason},symbol_match` : \"symbol_match\";\n\t\t\t}\n\t\t\treturn { ...r, score: { ...r.score, fused: round((r.score.fused ?? 0) + delta) ?? 0 }, _deltaReason: reason };\n\t\t})\n\t\t.sort((a, b) => (b.score.fused ?? 0) - (a.score.fused ?? 0) || a.chunkId.localeCompare(b.chunkId))\n\t\t.map((r, i) => {\n\t\t\t// rebuild final\n\t\t\treturn {\n\t\t\t\tchunkId: r.chunkId,\n\t\t\t\tfile: r.file,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: r.score,\n\t\t\t};\n\t\t});\n}\n"]}