{"version":3,"file":"hybrid-search.d.ts","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAS1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAuCxH,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC9B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACxD,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAYD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CACnC,UAAU,EAAE,SAAS,cAAc,EAAE,EACrC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,GAC7B,cAAc,EAAE,CAQlB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,SAAS,cAAc,EAAE,GAAG,cAAc,EAAE,CA+B7F;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAyK1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CA+BnF","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { crossEncoderRerank } from \"./cross-rerank.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n *  uncapped lexical tail diluting hybrid below plain semantic: lexical\n *  precision is front-loaded by the adapter's term-evidence ranking, while\n *  RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/**\n * Embedding hits fetched per query when fusing with the grep leg.\n *\n * Deliberately shallow. The grep list is capped at {@link LEXICAL_FUSION_CAP}\n * because its tail is unranked noise, so a deep dense pool here simply\n * outnumbers it: raising this to 200 alongside a 20-candidate grep list cost\n * `auto +rr` 0.472 -> 0.450 MRR.\n */\nconst EMBED_TOP_K = 50;\n/**\n * Per-leg depth when both retrievers are ranked ones (dense + BM25).\n *\n * Four times {@link FUSED_WINDOW}, matching what the daemon's `query_hybrid`\n * does internally (`pool = 4·k`). Fusing at the same depth as the window loses\n * any candidate ranked well by one retriever but just outside the other's\n * top-50 — worth 12pp R@10 and 8pp R@50 for `bm25+dense`, which is exactly\n * what closed the gap to daemon-side fusion.\n */\nconst FUSION_POOL_TOP_K = 200;\n/** BM25 depth, matching {@link FUSION_POOL_TOP_K} so neither ranked leg is\n *  structurally advantaged by pool size. The daemon returns only documents\n *  sharing a query term, so this is an upper bound, not a fill. */\nconst BM25_TOP_K = FUSION_POOL_TOP_K;\n/** Candidates from unindexed/stale files moved ahead of the fused window.\n *  Small on purpose: this answers \"what did I just write\", not \"search the\n *  working tree\". */\nconst STALE_HOIST_CAP = 5;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Optional glob filter applied to file paths. */\n\tglob?: string;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\t/**\n\t * Ask the daemon to fuse its own BM25 index with the vectors and return one\n\t * already-fused ranking, instead of taking a dense-only list. Needs a store\n\t * built with `--hybrid`. The fused list arrives as a single \"embed\" leg,\n\t * because a pre-fused ranking has no per-retriever structure left to record.\n\t *\n\t * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace\n\t * and lets the grep leg participate.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as its own ranked list and fuse it here,\n\t * alongside dense and grep.\n\t *\n\t * Defaults to on wherever the daemon can serve it, because BM25 is the\n\t * better lexical leg on the indexed corpus: Recall@50 0.790 -> 0.879, 6 of\n\t * 62 queries better and 0 worse (p <= 0.05). When it is on, the grep leg\n\t * narrows to files the index has not read — see `staleFiles`.\n\t *\n\t * Set `false` to force ripgrep as the only lexical leg; the eval harness\n\t * does this to keep measuring what the old rows measured.\n\t */\n\tbm25Leg?: boolean;\n\t/**\n\t * Reorder the fused shortlist with the daemon's cross-encoder instead of\n\t * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model\n\t * pass per scored candidate.\n\t */\n\tcrossEncoder?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nfunction normalizeSearchGlob(glob: string | undefined): string | undefined {\n\tif (!glob) return undefined;\n\t// Match fd/rg semantics: a slash-containing glob is anchored anywhere in\n\t// the tree, so prepend \"**/\" unless it already starts with a slash or \"**/\".\n\tif (glob.includes(\"/\") && !glob.startsWith(\"/\") && !glob.startsWith(\"**/\")) {\n\t\treturn `**/${glob}`;\n\t}\n\treturn glob;\n}\n\n/**\n * Move candidates from files the index has not read to the front.\n *\n * They are there because grep found them and nothing else could: the index is\n * ranking a stale copy of the file, or has never seen it. Left to fuse, they\n * lose — RRF rewards agreement, and one leg reporting a single document is\n * outvoted by two legs agreeing on hundreds. Measured: scoping grep to stale\n * files without this hoist scored 25% on the live-edit set where unscoped grep\n * scored 100%, because the fused window filled with consensus hits about the\n * indexed copy.\n *\n * Capped, because \"stale\" scales with how far behind the index is. A few\n * edited files is the case this exists for; a fresh checkout makes everything\n * stale, and hoisting all of it would quietly turn hybrid search back into\n * grep. Past the cap the remainder keeps its fused position.\n */\nexport function hoistStaleCandidates(\n\tcandidates: readonly FusedCandidate[],\n\tstaleFiles: ReadonlySet<string>,\n): FusedCandidate[] {\n\tif (staleFiles.size === 0) return [...candidates];\n\tconst hoisted: FusedCandidate[] = [];\n\tconst rest: FusedCandidate[] = [];\n\tfor (const candidate of candidates) {\n\t\t(staleFiles.has(candidate.path) && hoisted.length < STALE_HOIST_CAP ? hoisted : rest).push(candidate);\n\t}\n\treturn [...hoisted, ...rest];\n}\n\n/**\n * Fold overlapping or adjacent spans of the same file into their best-ranked\n * occurrence.\n *\n * Chunks overlap by design (see the chunker's `CHUNK_OVERLAP_LINES`) and each\n * one is a separate id, so neighbouring chunks of one region survive fusion as\n * separate candidates and take separate result slots — showing the model code\n * it already has. Measured on the 62-query set at the tool's default\n * `limit=5`, 43 queries had such a pair in their top 5 and 58 of 310 slots\n * went to repeated code.\n *\n * Merging is free recall: the union of two overlapping spans matches exactly\n * what either matched, so nothing is gained by widening — the gain is entirely\n * the slot handed back to the ranked tail. Recall@5 0.597 -> 0.677, Recall@1\n * unchanged (merging cannot alter the top result).\n *\n * Adjacency counts as overlap (`endLine + 1`): two chunks that abut describe\n * one continuous region, and rendering them as separate results implies a gap\n * that is not there.\n */\nexport function mergeOverlappingSpans(candidates: readonly FusedCandidate[]): FusedCandidate[] {\n\tconst merged: FusedCandidate[] = [];\n\tfor (const candidate of candidates) {\n\t\tconst into = merged.find(\n\t\t\t(m) =>\n\t\t\t\tm.path === candidate.path && candidate.startLine <= m.endLine + 1 && m.startLine <= candidate.endLine + 1,\n\t\t);\n\t\tif (!into) {\n\t\t\tmerged.push({ ...candidate, ranks: { ...candidate.ranks }, rawScores: { ...candidate.rawScores } });\n\t\t\tcontinue;\n\t\t}\n\t\tinto.startLine = Math.min(into.startLine, candidate.startLine);\n\t\tinto.endLine = Math.max(into.endLine, candidate.endLine);\n\t\t// Keep the folded id: the trace is the only record that retrieval found\n\t\t// it, and dropping it would make a merged result look like a miss.\n\t\tif (!into.mergedFrom) into.mergedFrom = [];\n\t\tinto.mergedFrom.push(candidate.id);\n\t\t// The merged region really was reached by every retriever that found any\n\t\t// part of it, so the source label and the trace should say so. Best rank\n\t\t// per source wins; the fused score stays the survivor's, which is what\n\t\t// ordered it here.\n\t\tfor (const [source, rank] of Object.entries(candidate.ranks)) {\n\t\t\tconst held = into.ranks[source as keyof typeof into.ranks];\n\t\t\tif (held === undefined || rank < held) into.ranks[source as keyof typeof into.ranks] = rank;\n\t\t}\n\t\tfor (const [source, score] of Object.entries(candidate.rawScores)) {\n\t\t\tconst held = into.rawScores[source as keyof typeof into.rawScores];\n\t\t\tif (held === undefined || score > held) into.rawScores[source as keyof typeof into.rawScores] = score;\n\t\t}\n\t}\n\treturn merged;\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst glob = normalizeSearchGlob(options.glob);\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\t/** Files the index does not have current content for, when grep is scoped\n\t *  to them. Read again after fusion — see the hoist below. */\n\tconst staleSet = new Set<string>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (scopeToStale = false): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Scoped run: grep covers only what the index has not read yet, so it\n\t\t\t// adds the one thing BM25 cannot see without re-voting on documents\n\t\t\t// BM25 already ranked. Fusing two lexical views of the same corpus is\n\t\t\t// what made the three-leg configuration lose (18 of 62 queries worse).\n\t\t\tconst paths = scopeToStale ? service?.staleFiles(signal) : undefined;\n\t\t\tif (paths) for (const rel of paths) staleSet.add(rel);\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal, paths });\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// The flat index pads top-k with whatever exists; with the cosine\n\t\t\t// metric the store uses, score <= 0 means \"no relation at all\", so\n\t\t\t// those padding hits would cast RRF votes on pure noise.\n\t\t\t// A BM25 leg is itself ranked, so the pair can afford — and needs — the\n\t\t\t// deeper pool; the grep leg cannot (see EMBED_TOP_K).\n\t\t\tconst topK = options.bm25Leg ? FUSION_POOL_TOP_K : EMBED_TOP_K;\n\t\t\tconst chunkHits = (\n\t\t\t\tawait service!.searchChunks(query, topK, glob, options.daemonHybrid ? \"hybrid\" : \"dense\")\n\t\t\t).filter((hit) => hit.score > 0);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runBm25 = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Raw BM25 sums; only the ordering enters fusion, the score is a\n\t\t\t// diagnostic. Unlike the dense leg there is no zero-score padding to\n\t\t\t// filter — the daemon omits documents sharing no query term.\n\t\t\tconst chunkHits = await service!.searchChunks(query, BM25_TOP_K, glob, \"lexical\");\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"bm25\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tif (!spans.has(hit.id)) {\n\t\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t\t}\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.bm25 = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.bm25 = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\t// BM25 is the better lexical leg where the index is current: it beats grep\n\t// on Recall@50 (0.790 -> 0.879, 6 queries better and 0 worse, p <= 0.05)\n\t// because it ranks the whole corpus rather than truncating a match stream.\n\t// It is also blind to anything indexed later than it was written, which is\n\t// precisely where grep still wins — so grep runs scoped to that set instead\n\t// of being dropped or left to duplicate BM25 over the whole tree.\n\tconst bm25AsLexicalLeg =\n\t\t(options.bm25Leg ?? service?.supportsLexicalRetriever() ?? false) && embedAvailable && mode !== \"lexical\";\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical(bm25AsLexicalLeg));\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\t// `semantic` gets the BM25 leg too: the caller asked for the index, and\n\t// BM25 is part of it. Only an explicit `lexical` request excludes it.\n\tif (bm25AsLexicalLeg) runs.push(runBm25());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.crossEncoder && service?.supportsCrossEncoder()) {\n\t\tconst reranked = await crossEncoderRerank(query, candidates, cwd, service);\n\t\trerankInfo = { applied: true, candidateCount: reranked.scored, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t} else if (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\t// After reranking, not before: both rerankers weight the fused prior, and a\n\t// lone grep hit from an unindexed file has the lowest prior there is, so\n\t// hoisting first would simply be undone. The reranker still orders the\n\t// hoisted set against itself.\n\t// Merge before slicing, so a slot freed by a duplicate is refilled from the\n\t// ranked tail rather than left empty.\n\tcandidates = mergeOverlappingSpans(hoistStaleCandidates(candidates, staleSet)).slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores, mergedFrom }) => ({\n\t\t\tid,\n\t\t\trrfScore,\n\t\t\tranks,\n\t\t\trawScores,\n\t\t\t...(mergedFrom ? { mergedFrom } : {}),\n\t\t})),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}