{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../../src/core/search/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC3B,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED;uDACuD;AACvD,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC,aAAa,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,SAAS,CAAC;AAatG,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,4DAA4D;IAC5D,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CAClC;AAYD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,EAAE,WAAW,CAAC,EAAE,WAAW,GAAG,eAAe,CAsF1G","sourcesContent":["/**\n * Grep→chunk adapter (docs/hybrid-retrieval-design.md, Decision 3).\n *\n * Fusion needs a shared identity, and the embedding side already has one:\n * per-build chunk ids with line ranges in the sidecar. This adapter turns raw\n * grep line-hits into a gap-free ranked list of those ids:\n *\n *   1. map each line-hit to its enclosing indexed chunk;\n *   2. coalesce unindexed hits into per-file clusters with synthetic\n *      `rel#L<line>` ids, so lexical-only files still enter fusion without\n *      one file's adjacent lines flooding the candidate list;\n *   3. collapse multiple hits in the same chunk/cluster to one candidate;\n *   4. rank candidates by evidence — distinct query terms matched, then hit\n *      count — and cap candidates per file, before assigning gap-free ranks.\n *      RRF's `1/(k + rank)` assumes gap-free ranks, and grep output order\n *      carries no relevance, so evidence-based ordering here is what makes\n *      the lexical list a real ranking rather than a directory walk.\n */\n\nimport type { CandidateSpan, RankedHit } from \"./types.js\";\n\nexport interface GrepLineHit {\n\t/** Repo-relative POSIX path. */\n\trel: string;\n\t/** 1-based line number of the match. */\n\tline: number;\n\t/** Lowercased query terms present on this line (retriever-computed). */\n\tterms?: readonly string[];\n}\n\n/** Resolves a line to its enclosing indexed chunk, or undefined when the file\n *  (or line) is not covered by the embedding index. */\nexport type ChunkLookup = (rel: string, line: number) => (CandidateSpan & { id: string }) | undefined;\n\n/** Context padding around a fallback (unindexed) cluster, mirroring chunk-ish\n *  size without pretending to know real chunk boundaries. */\nconst FALLBACK_PAD_LINES = 5;\n/** Unindexed hits within this many lines of each other merge into one cluster. */\nconst FALLBACK_MERGE_GAP = 10;\n/** Hard cap on a cluster's own line span, so a file with hits every few lines\n *  still splits into readable windows. */\nconst FALLBACK_MAX_CLUSTER_LINES = 40;\n/** Max candidates one file may contribute, to keep the fused list diverse. */\nconst PER_FILE_CANDIDATE_CAP = 8;\n\nexport interface AdaptedGrepHits {\n\thits: RankedHit[];\n\t/** Span for every emitted id, for post-fusion expansion. */\n\tspans: Map<string, CandidateSpan>;\n}\n\ninterface Candidate {\n\tid: string;\n\tspan: CandidateSpan;\n\tterms: Set<string>;\n\thitCount: number;\n\t/** Index of the candidate's earliest contributing hit — the deterministic\n\t *  last-resort tie-break. */\n\tfirstSeen: number;\n}\n\nexport function adaptGrepHits(lineHits: readonly GrepLineHit[], lookupChunk?: ChunkLookup): AdaptedGrepHits {\n\tconst candidates = new Map<string, Candidate>();\n\t// Unmapped hits, grouped per file for clustering.\n\tconst unmapped = new Map<string, Array<{ line: number; terms?: readonly string[]; index: number }>>();\n\n\tlineHits.forEach(({ rel, line, terms }, index) => {\n\t\tconst chunk = lookupChunk?.(rel, line);\n\t\tif (!chunk) {\n\t\t\tlet list = unmapped.get(rel);\n\t\t\tif (!list) {\n\t\t\t\tlist = [];\n\t\t\t\tunmapped.set(rel, list);\n\t\t\t}\n\t\t\tlist.push({ line, terms, index });\n\t\t\treturn;\n\t\t}\n\t\tconst existing = candidates.get(chunk.id);\n\t\tif (existing) {\n\t\t\texisting.hitCount++;\n\t\t\tfor (const t of terms ?? []) existing.terms.add(t);\n\t\t} else {\n\t\t\tcandidates.set(chunk.id, {\n\t\t\t\tid: chunk.id,\n\t\t\t\tspan: { path: chunk.path, startLine: chunk.startLine, endLine: chunk.endLine },\n\t\t\t\tterms: new Set(terms),\n\t\t\t\thitCount: 1,\n\t\t\t\tfirstSeen: index,\n\t\t\t});\n\t\t}\n\t});\n\n\t// Cluster unindexed hits: sort by line, merge while the gap stays small\n\t// and the cluster stays readable.\n\tfor (const [rel, hits] of unmapped) {\n\t\thits.sort((a, b) => a.line - b.line || a.index - b.index);\n\t\tlet cluster: typeof hits = [];\n\t\tconst flush = () => {\n\t\t\tif (cluster.length === 0) return;\n\t\t\tconst first = cluster[0].line;\n\t\t\tconst last = cluster[cluster.length - 1].line;\n\t\t\tconst candidate: Candidate = {\n\t\t\t\tid: `${rel}#L${first}`,\n\t\t\t\tspan: { path: rel, startLine: Math.max(1, first - FALLBACK_PAD_LINES), endLine: last + FALLBACK_PAD_LINES },\n\t\t\t\tterms: new Set(cluster.flatMap((h) => [...(h.terms ?? [])])),\n\t\t\t\thitCount: cluster.length,\n\t\t\t\tfirstSeen: Math.min(...cluster.map((h) => h.index)),\n\t\t\t};\n\t\t\tcandidates.set(candidate.id, candidate);\n\t\t\tcluster = [];\n\t\t};\n\t\tfor (const hit of hits) {\n\t\t\tconst clusterStart = cluster[0]?.line;\n\t\t\tconst prevLine = cluster[cluster.length - 1]?.line;\n\t\t\tif (\n\t\t\t\tcluster.length > 0 &&\n\t\t\t\t(hit.line - prevLine > FALLBACK_MERGE_GAP || hit.line - clusterStart > FALLBACK_MAX_CLUSTER_LINES)\n\t\t\t) {\n\t\t\t\tflush();\n\t\t\t}\n\t\t\tcluster.push(hit);\n\t\t}\n\t\tflush();\n\t}\n\n\t// Evidence-based ordering: distinct terms, hit count, earliest appearance,\n\t// then id — fully deterministic.\n\tconst ordered = [...candidates.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.terms.size - a.terms.size ||\n\t\t\tb.hitCount - a.hitCount ||\n\t\t\ta.firstSeen - b.firstSeen ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n\n\tconst hits: RankedHit[] = [];\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst perFile = new Map<string, number>();\n\tfor (const candidate of ordered) {\n\t\tconst count = perFile.get(candidate.span.path) ?? 0;\n\t\tif (count >= PER_FILE_CANDIDATE_CAP) continue;\n\t\tperFile.set(candidate.span.path, count + 1);\n\t\tspans.set(candidate.id, candidate.span);\n\t\thits.push({ id: candidate.id, rank: hits.length + 1, source: \"grep\" });\n\t}\n\n\treturn { hits, spans };\n}\n"]}