@kolisachint/hoocode-agent 0.4.164 → 0.4.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/embsearch/client.d.ts +39 -2
  3. package/dist/core/embsearch/client.d.ts.map +1 -1
  4. package/dist/core/embsearch/client.js +33 -3
  5. package/dist/core/embsearch/client.js.map +1 -1
  6. package/dist/core/embsearch/embsearch-service.d.ts +30 -1
  7. package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
  8. package/dist/core/embsearch/embsearch-service.js +78 -5
  9. package/dist/core/embsearch/embsearch-service.js.map +1 -1
  10. package/dist/core/search/cross-rerank.d.ts +44 -0
  11. package/dist/core/search/cross-rerank.d.ts.map +1 -0
  12. package/dist/core/search/cross-rerank.js +77 -0
  13. package/dist/core/search/cross-rerank.js.map +1 -0
  14. package/dist/core/search/eval-compare.d.ts +57 -0
  15. package/dist/core/search/eval-compare.d.ts.map +1 -0
  16. package/dist/core/search/eval-compare.js +114 -0
  17. package/dist/core/search/eval-compare.js.map +1 -0
  18. package/dist/core/search/eval-gold.d.ts +47 -0
  19. package/dist/core/search/eval-gold.d.ts.map +1 -0
  20. package/dist/core/search/eval-gold.js +172 -0
  21. package/dist/core/search/eval-gold.js.map +1 -0
  22. package/dist/core/search/eval-harness.d.ts +140 -0
  23. package/dist/core/search/eval-harness.d.ts.map +1 -0
  24. package/dist/core/search/eval-harness.js +225 -0
  25. package/dist/core/search/eval-harness.js.map +1 -0
  26. package/dist/core/search/eval-live.d.ts +50 -0
  27. package/dist/core/search/eval-live.d.ts.map +1 -0
  28. package/dist/core/search/eval-live.js +48 -0
  29. package/dist/core/search/eval-live.js.map +1 -0
  30. package/dist/core/search/eval.d.ts +69 -8
  31. package/dist/core/search/eval.d.ts.map +1 -1
  32. package/dist/core/search/eval.js +77 -12
  33. package/dist/core/search/eval.js.map +1 -1
  34. package/dist/core/search/hybrid-search.d.ts +22 -0
  35. package/dist/core/search/hybrid-search.d.ts.map +1 -1
  36. package/dist/core/search/hybrid-search.js +61 -3
  37. package/dist/core/search/hybrid-search.js.map +1 -1
  38. package/dist/core/search/mode.d.ts +21 -5
  39. package/dist/core/search/mode.d.ts.map +1 -1
  40. package/dist/core/search/mode.js +23 -10
  41. package/dist/core/search/mode.js.map +1 -1
  42. package/dist/core/search/rerank.d.ts +21 -0
  43. package/dist/core/search/rerank.d.ts.map +1 -1
  44. package/dist/core/search/rerank.js +208 -11
  45. package/dist/core/search/rerank.js.map +1 -1
  46. package/dist/core/search/rrf.d.ts +19 -6
  47. package/dist/core/search/rrf.d.ts.map +1 -1
  48. package/dist/core/search/rrf.js +19 -6
  49. package/dist/core/search/rrf.js.map +1 -1
  50. package/dist/core/search/types.d.ts +11 -1
  51. package/dist/core/search/types.d.ts.map +1 -1
  52. package/dist/core/search/types.js.map +1 -1
  53. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  54. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  55. package/examples/extensions/sandbox/package.json +1 -1
  56. package/examples/extensions/with-deps/package.json +1 -1
  57. package/package.json +7 -4
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Live-edit evaluation: the one thing the main gold set structurally cannot
3
+ * measure.
4
+ *
5
+ * The eval corpus is a pinned checkout that exactly matches the embedding
6
+ * index, so every retriever sees the same content and the grep leg looks
7
+ * redundant — measurably so, since it is a significant regression once BM25 is
8
+ * present. But an agent's corpus is *not* a clean checkout: it edits files as
9
+ * it works, and the daemon has no file watcher, so anything written during a
10
+ * session is invisible to both dense and BM25 until the next index build.
11
+ * Grep is the only leg that sees it.
12
+ *
13
+ * This applies edits to the corpus **after** indexing and then asks queries
14
+ * that only those edits can answer. A retriever backed by the index scores 0
15
+ * by construction; grep scores what it can actually find. That is the number
16
+ * the decision to keep or drop the grep leg rests on, and nothing in the main
17
+ * sweep produces it.
18
+ *
19
+ * Gold line ranges are resolved from anchors after the edits land, so the
20
+ * fixture never hardcodes line numbers for content it also defines.
21
+ */
22
+ import type { EvalQuery } from "./eval.js";
23
+ /** A file written into the corpus after indexing. */
24
+ export interface LiveEdit {
25
+ /** Repo-relative POSIX path. Created if absent, overwritten if present. */
26
+ path: string;
27
+ /** Full file contents. Held inline so the edit is reproducible from the
28
+ * fixture alone, with no dependency on what the corpus already held. */
29
+ content: string;
30
+ /** Why this edit exists — reporting only. */
31
+ note?: string;
32
+ }
33
+ export interface LiveEditFixture {
34
+ edits: LiveEdit[];
35
+ /** Queries answerable *only* from the edited content. */
36
+ queries: EvalQuery[];
37
+ }
38
+ export declare function loadLiveEditFixture(fixturePath: string): LiveEditFixture;
39
+ /**
40
+ * Write the edits into `corpusRoot`, then resolve the queries' gold ranges
41
+ * against the resulting tree.
42
+ *
43
+ * Order matters: resolving before the write would look for anchors in content
44
+ * that does not exist yet.
45
+ */
46
+ export declare function applyLiveEdits(corpusRoot: string, fixture: LiveEditFixture): {
47
+ queries: EvalQuery[];
48
+ issues: string[];
49
+ };
50
+ //# sourceMappingURL=eval-live.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eval-live.d.ts","sourceRoot":"","sources":["../../../src/core/search/eval-live.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAKH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG3C,qDAAqD;AACrD,MAAM,WAAW,QAAQ;IACxB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb;6EACyE;IACzE,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA6C;IAC7C,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,yDAAyD;IACzD,OAAO,EAAE,SAAS,EAAE,CAAC;CACrB;AAED,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,eAAe,CAExE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC7B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,eAAe,GACtB;IAAE,OAAO,EAAE,SAAS,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAY5C","sourcesContent":["/**\n * Live-edit evaluation: the one thing the main gold set structurally cannot\n * measure.\n *\n * The eval corpus is a pinned checkout that exactly matches the embedding\n * index, so every retriever sees the same content and the grep leg looks\n * redundant — measurably so, since it is a significant regression once BM25 is\n * present. But an agent's corpus is *not* a clean checkout: it edits files as\n * it works, and the daemon has no file watcher, so anything written during a\n * session is invisible to both dense and BM25 until the next index build.\n * Grep is the only leg that sees it.\n *\n * This applies edits to the corpus **after** indexing and then asks queries\n * that only those edits can answer. A retriever backed by the index scores 0\n * by construction; grep scores what it can actually find. That is the number\n * the decision to keep or drop the grep leg rests on, and nothing in the main\n * sweep produces it.\n *\n * Gold line ranges are resolved from anchors after the edits land, so the\n * fixture never hardcodes line numbers for content it also defines.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { readFileSync } from \"fs\";\nimport type { EvalQuery } from \"./eval.js\";\nimport { resolveGoldSet } from \"./eval-gold.js\";\n\n/** A file written into the corpus after indexing. */\nexport interface LiveEdit {\n\t/** Repo-relative POSIX path. Created if absent, overwritten if present. */\n\tpath: string;\n\t/** Full file contents. Held inline so the edit is reproducible from the\n\t * fixture alone, with no dependency on what the corpus already held. */\n\tcontent: string;\n\t/** Why this edit exists — reporting only. */\n\tnote?: string;\n}\n\nexport interface LiveEditFixture {\n\tedits: LiveEdit[];\n\t/** Queries answerable *only* from the edited content. */\n\tqueries: EvalQuery[];\n}\n\nexport function loadLiveEditFixture(fixturePath: string): LiveEditFixture {\n\treturn JSON.parse(readFileSync(fixturePath, \"utf-8\")) as LiveEditFixture;\n}\n\n/**\n * Write the edits into `corpusRoot`, then resolve the queries' gold ranges\n * against the resulting tree.\n *\n * Order matters: resolving before the write would look for anchors in content\n * that does not exist yet.\n */\nexport function applyLiveEdits(\n\tcorpusRoot: string,\n\tfixture: LiveEditFixture,\n): { queries: EvalQuery[]; issues: string[] } {\n\tfor (const edit of fixture.edits) {\n\t\tconst target = path.resolve(corpusRoot, edit.path);\n\t\tmkdirSync(path.dirname(target), { recursive: true });\n\t\twriteFileSync(target, edit.content);\n\t}\n\n\tconst { dataset, issues } = resolveGoldSet(corpusRoot, fixture.queries);\n\treturn {\n\t\tqueries: dataset,\n\t\tissues: issues.map((i) => `${i.queryId} [${i.path}]: ${i.problem}`),\n\t};\n}\n"]}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Live-edit evaluation: the one thing the main gold set structurally cannot
3
+ * measure.
4
+ *
5
+ * The eval corpus is a pinned checkout that exactly matches the embedding
6
+ * index, so every retriever sees the same content and the grep leg looks
7
+ * redundant — measurably so, since it is a significant regression once BM25 is
8
+ * present. But an agent's corpus is *not* a clean checkout: it edits files as
9
+ * it works, and the daemon has no file watcher, so anything written during a
10
+ * session is invisible to both dense and BM25 until the next index build.
11
+ * Grep is the only leg that sees it.
12
+ *
13
+ * This applies edits to the corpus **after** indexing and then asks queries
14
+ * that only those edits can answer. A retriever backed by the index scores 0
15
+ * by construction; grep scores what it can actually find. That is the number
16
+ * the decision to keep or drop the grep leg rests on, and nothing in the main
17
+ * sweep produces it.
18
+ *
19
+ * Gold line ranges are resolved from anchors after the edits land, so the
20
+ * fixture never hardcodes line numbers for content it also defines.
21
+ */
22
+ import { mkdirSync, writeFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { readFileSync } from "fs";
25
+ import { resolveGoldSet } from "./eval-gold.js";
26
+ export function loadLiveEditFixture(fixturePath) {
27
+ return JSON.parse(readFileSync(fixturePath, "utf-8"));
28
+ }
29
+ /**
30
+ * Write the edits into `corpusRoot`, then resolve the queries' gold ranges
31
+ * against the resulting tree.
32
+ *
33
+ * Order matters: resolving before the write would look for anchors in content
34
+ * that does not exist yet.
35
+ */
36
+ export function applyLiveEdits(corpusRoot, fixture) {
37
+ for (const edit of fixture.edits) {
38
+ const target = path.resolve(corpusRoot, edit.path);
39
+ mkdirSync(path.dirname(target), { recursive: true });
40
+ writeFileSync(target, edit.content);
41
+ }
42
+ const { dataset, issues } = resolveGoldSet(corpusRoot, fixture.queries);
43
+ return {
44
+ queries: dataset,
45
+ issues: issues.map((i) => `${i.queryId} [${i.path}]: ${i.problem}`),
46
+ };
47
+ }
48
+ //# sourceMappingURL=eval-live.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eval-live.js","sourceRoot":"","sources":["../../../src/core/search/eval-live.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAElC,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAmBhD,MAAM,UAAU,mBAAmB,CAAC,WAAmB,EAAmB;IACzE,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAoB,CAAC;AAAA,CACzE;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC7B,UAAkB,EAClB,OAAwB,EACqB;IAC7C,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QAClC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACnD,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACrD,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,UAAU,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACxE,OAAO;QACN,OAAO,EAAE,OAAO;QAChB,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC;KACnE,CAAC;AAAA,CACF","sourcesContent":["/**\n * Live-edit evaluation: the one thing the main gold set structurally cannot\n * measure.\n *\n * The eval corpus is a pinned checkout that exactly matches the embedding\n * index, so every retriever sees the same content and the grep leg looks\n * redundant — measurably so, since it is a significant regression once BM25 is\n * present. But an agent's corpus is *not* a clean checkout: it edits files as\n * it works, and the daemon has no file watcher, so anything written during a\n * session is invisible to both dense and BM25 until the next index build.\n * Grep is the only leg that sees it.\n *\n * This applies edits to the corpus **after** indexing and then asks queries\n * that only those edits can answer. A retriever backed by the index scores 0\n * by construction; grep scores what it can actually find. That is the number\n * the decision to keep or drop the grep leg rests on, and nothing in the main\n * sweep produces it.\n *\n * Gold line ranges are resolved from anchors after the edits land, so the\n * fixture never hardcodes line numbers for content it also defines.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { readFileSync } from \"fs\";\nimport type { EvalQuery } from \"./eval.js\";\nimport { resolveGoldSet } from \"./eval-gold.js\";\n\n/** A file written into the corpus after indexing. */\nexport interface LiveEdit {\n\t/** Repo-relative POSIX path. Created if absent, overwritten if present. */\n\tpath: string;\n\t/** Full file contents. Held inline so the edit is reproducible from the\n\t * fixture alone, with no dependency on what the corpus already held. */\n\tcontent: string;\n\t/** Why this edit exists — reporting only. */\n\tnote?: string;\n}\n\nexport interface LiveEditFixture {\n\tedits: LiveEdit[];\n\t/** Queries answerable *only* from the edited content. */\n\tqueries: EvalQuery[];\n}\n\nexport function loadLiveEditFixture(fixturePath: string): LiveEditFixture {\n\treturn JSON.parse(readFileSync(fixturePath, \"utf-8\")) as LiveEditFixture;\n}\n\n/**\n * Write the edits into `corpusRoot`, then resolve the queries' gold ranges\n * against the resulting tree.\n *\n * Order matters: resolving before the write would look for anchors in content\n * that does not exist yet.\n */\nexport function applyLiveEdits(\n\tcorpusRoot: string,\n\tfixture: LiveEditFixture,\n): { queries: EvalQuery[]; issues: string[] } {\n\tfor (const edit of fixture.edits) {\n\t\tconst target = path.resolve(corpusRoot, edit.path);\n\t\tmkdirSync(path.dirname(target), { recursive: true });\n\t\twriteFileSync(target, edit.content);\n\t}\n\n\tconst { dataset, issues } = resolveGoldSet(corpusRoot, fixture.queries);\n\treturn {\n\t\tqueries: dataset,\n\t\tissues: issues.map((i) => `${i.queryId} [${i.path}]: ${i.problem}`),\n\t};\n}\n"]}
@@ -2,14 +2,19 @@
2
2
  * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the
3
3
  * shipping order).
4
4
  *
5
- * Measures Recall@K for lexical, semantic, and hybrid retrieval across an
6
- * RRF `k` sweep, against a gold set keyed by **path + optional line range
7
- * matched by span overlap** — never by chunkId, which is only stable per
8
- * index build. Recall@50 doubles as the reranker gate: a gold span that
9
- * never reaches the fused top-50 cannot be rescued by any reranker.
5
+ * Measures Recall@K and MRR for lexical, semantic, and hybrid retrieval across
6
+ * an RRF `k` sweep, against a gold set keyed by **path + line range matched by
7
+ * span overlap** — never by chunkId, which is only stable per index build.
8
+ * Recall@50 doubles as the reranker gate: a gold span that never reaches the
9
+ * fused top-50 cannot be rescued by any reranker.
10
10
  *
11
- * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is
12
- * unit-testable without an embedding index.
11
+ * Rank-sensitive metrics are the point of Recall@1 and MRR: an agent reads the
12
+ * top result or two, so "the gold span is somewhere in the top 5" understates
13
+ * how much ordering matters. Recall@5/10/50 are kept for continuity with the
14
+ * numbers already published in the design note.
15
+ *
16
+ * Driven by `scripts/search-eval.ts` via `eval-harness.ts`; the scoring logic
17
+ * lives here so it is unit-testable without an embedding index.
13
18
  */
14
19
  import type { EmbsearchService } from "../embsearch/embsearch-service.js";
15
20
  import type { CandidateSpan, ResolvedSearchMode, SearchMode } from "./types.js";
@@ -19,6 +24,22 @@ export interface EvalGoldSpan {
19
24
  /** 1-based inclusive; omit both to accept any span in the file. */
20
25
  startLine?: number;
21
26
  endLine?: number;
27
+ /**
28
+ * Literal source text that must occur inside `[startLine, endLine]`. Not
29
+ * used for scoring — it is how the gold set survives the corpus moving
30
+ * underneath it: `scripts/search-eval-gold.ts` re-resolves the line range
31
+ * from this anchor, and the fixture test fails when an anchor no longer
32
+ * sits inside its recorded range. Omitted for `scope: "file"` entries.
33
+ */
34
+ anchor?: string;
35
+ /**
36
+ * `"span"` (default) scores by overlap with the recorded line range;
37
+ * `"file"` accepts any span in the file. File scope is deliberate for
38
+ * path-class queries, where the whole file *is* the answer — it is
39
+ * recorded explicitly so a file-level score is never mistaken for a
40
+ * span-level one.
41
+ */
42
+ scope?: "span" | "file";
22
43
  }
23
44
  export interface EvalQuery {
24
45
  id: string;
@@ -33,17 +54,57 @@ export interface EvalConfig {
33
54
  mode: SearchMode;
34
55
  rrfK?: number;
35
56
  rerank?: boolean;
57
+ /**
58
+ * Score this config against a daemon-side hybrid store (BM25 fused with
59
+ * vectors inside the Rust daemon) instead of the dense-only index. Skipped
60
+ * when the harness has no hybrid service, so records without one simply
61
+ * omit the row rather than silently scoring it as plain semantic.
62
+ */
63
+ daemonHybrid?: boolean;
64
+ /**
65
+ * Fetch the daemon's BM25 index as a separate leg and fuse it here with
66
+ * dense and grep. Like `daemonHybrid` it needs a hybrid store, so it is
67
+ * skipped when the harness has no hybrid service.
68
+ */
69
+ bm25Leg?: boolean;
70
+ /** Rerank with the daemon's cross-encoder rather than the deterministic
71
+ * scorer. Needs a service, so it is skipped without one. */
72
+ crossEncoder?: boolean;
36
73
  }
74
+ /**
75
+ * The sweep from the design doc — single retrievers, hybrid across k, the
76
+ * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate.
77
+ *
78
+ * Exported because `scripts/search-eval.ts` and `test/search-eval.test.ts`
79
+ * both consume it. It previously lost its `export` to a knip-driven
80
+ * dead-export sweep (02efaab): the only importer was a `.mjs` script reading
81
+ * from `dist/`, which static analysis cannot see, and the eval gate silently
82
+ * stopped running. The TypeScript importers are the fix — do not "clean up"
83
+ * this export without checking them.
84
+ */
85
+ export declare const EVAL_CONFIGS: readonly EvalConfig[];
37
86
  export declare function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean;
38
87
  /** Fraction of gold spans matched by at least one of the top-`k` candidates. */
39
88
  export declare function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number;
89
+ /**
90
+ * Mean reciprocal rank, averaged over gold spans: for each gold span, `1/rank`
91
+ * of the first candidate matching it (0 when it never appears).
92
+ *
93
+ * Averaging per gold span rather than taking the single first hit keeps
94
+ * multi-span queries (the cross-file class) honest — finding one of two
95
+ * required sites should not score like finding both. For single-span queries
96
+ * this reduces to textbook MRR.
97
+ */
98
+ export declare function mrr(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[]): number;
40
99
  export interface EvalQueryResult {
41
100
  label: string;
42
101
  resolvedMode: ResolvedSearchMode;
43
102
  degraded: boolean;
103
+ recallAt1: number;
44
104
  recallAt5: number;
45
105
  recallAt10: number;
46
106
  recallAt50: number;
107
+ mrr: number;
47
108
  }
48
- export declare function evaluateQuery(cwd: string, evalQuery: EvalQuery, configs?: readonly EvalConfig[], service?: EmbsearchService): Promise<EvalQueryResult[]>;
109
+ export declare function evaluateQuery(cwd: string, evalQuery: EvalQuery, configs?: readonly EvalConfig[], service?: EmbsearchService, hybridService?: EmbsearchService): Promise<EvalQueryResult[]>;
49
110
  //# sourceMappingURL=eval.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"eval.d.ts","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAE1E,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEhF,MAAM,WAAW,YAAY;IAC5B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX;6DACyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;AAsBD,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAIhF;AAED,gFAAgF;AAChF,wBAAgB,SAAS,CAAC,UAAU,EAAE,SAAS,aAAa,EAAE,EAAE,IAAI,EAAE,SAAS,YAAY,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAQhH;AAED,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,kBAAkB,CAAC;IACjC,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,aAAa,CAClC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,SAAS,EACpB,OAAO,GAAE,SAAS,UAAU,EAAiB,EAC7C,OAAO,CAAC,EAAE,gBAAgB,GACxB,OAAO,CAAC,eAAe,EAAE,CAAC,CAsB5B","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K for lexical, semantic, and hybrid retrieval across an\n * RRF `k` sweep, against a gold set keyed by **path + optional line range\n * matched by span overlap** — never by chunkId, which is only stable per\n * index build. Recall@50 doubles as the reranker gate: a gold span that\n * never reaches the fused top-50 cannot be rescued by any reranker.\n *\n * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is\n * unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n}\n\n/** The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate. */\nconst EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
1
+ {"version":3,"file":"eval.d.ts","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAE1E,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEhF,MAAM,WAAW,YAAY;IAC5B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX;6DACyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;iEAC6D;IAC7D,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EA6B7C,CAAC;AAKF,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAKhF;AAED,gFAAgF;AAChF,wBAAgB,SAAS,CAAC,UAAU,EAAE,SAAS,aAAa,EAAE,EAAE,IAAI,EAAE,SAAS,YAAY,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAQhH;AAED;;;;;;;;GAQG;AACH,wBAAgB,GAAG,CAAC,UAAU,EAAE,SAAS,aAAa,EAAE,EAAE,IAAI,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAQ/F;AAED,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,kBAAkB,CAAC;IACjC,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,wBAAsB,aAAa,CAClC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,SAAS,EACpB,OAAO,GAAE,SAAS,UAAU,EAAiB,EAC7C,OAAO,CAAC,EAAE,gBAAgB,EAC1B,aAAa,CAAC,EAAE,gBAAgB,GAC9B,OAAO,CAAC,eAAe,EAAE,CAAC,CAiC5B","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K and MRR for lexical, semantic, and hybrid retrieval across\n * an RRF `k` sweep, against a gold set keyed by **path + line range matched by\n * span overlap** — never by chunkId, which is only stable per index build.\n * Recall@50 doubles as the reranker gate: a gold span that never reaches the\n * fused top-50 cannot be rescued by any reranker.\n *\n * Rank-sensitive metrics are the point of Recall@1 and MRR: an agent reads the\n * top result or two, so \"the gold span is somewhere in the top 5\" understates\n * how much ordering matters. Recall@5/10/50 are kept for continuity with the\n * numbers already published in the design note.\n *\n * Driven by `scripts/search-eval.ts` via `eval-harness.ts`; the scoring logic\n * lives here so it is unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n\t/**\n\t * Literal source text that must occur inside `[startLine, endLine]`. Not\n\t * used for scoring — it is how the gold set survives the corpus moving\n\t * underneath it: `scripts/search-eval-gold.ts` re-resolves the line range\n\t * from this anchor, and the fixture test fails when an anchor no longer\n\t * sits inside its recorded range. Omitted for `scope: \"file\"` entries.\n\t */\n\tanchor?: string;\n\t/**\n\t * `\"span\"` (default) scores by overlap with the recorded line range;\n\t * `\"file\"` accepts any span in the file. File scope is deliberate for\n\t * path-class queries, where the whole file *is* the answer — it is\n\t * recorded explicitly so a file-level score is never mistaken for a\n\t * span-level one.\n\t */\n\tscope?: \"span\" | \"file\";\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n\t/**\n\t * Score this config against a daemon-side hybrid store (BM25 fused with\n\t * vectors inside the Rust daemon) instead of the dense-only index. Skipped\n\t * when the harness has no hybrid service, so records without one simply\n\t * omit the row rather than silently scoring it as plain semantic.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as a separate leg and fuse it here with\n\t * dense and grep. Like `daemonHybrid` it needs a hybrid store, so it is\n\t * skipped when the harness has no hybrid service.\n\t */\n\tbm25Leg?: boolean;\n\t/** Rerank with the daemon's cross-encoder rather than the deterministic\n\t * scorer. Needs a service, so it is skipped without one. */\n\tcrossEncoder?: boolean;\n}\n\n/**\n * The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate.\n *\n * Exported because `scripts/search-eval.ts` and `test/search-eval.test.ts`\n * both consume it. It previously lost its `export` to a knip-driven\n * dead-export sweep (02efaab): the only importer was a `.mjs` script reading\n * from `dist/`, which static analysis cannot see, and the eval gate silently\n * stopped running. The TypeScript importers are the fix — do not \"clean up\"\n * this export without checking them.\n */\nexport const EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n\t// Is BM25 a better lexical leg than ripgrep? These two run the daemon's own\n\t// vector+BM25 fusion and no ripgrep at all, so comparing them against\n\t// \"semantic\" isolates what BM25 adds, and against \"hybrid k=2\" compares the\n\t// two lexical legs at the system level.\n\t{ label: \"daemon-hybrid\", mode: \"semantic\", daemonHybrid: true },\n\t{ label: \"daemon-hybrid +rr\", mode: \"semantic\", rerank: true, daemonHybrid: true },\n\t// Three-way fusion: dense + BM25 + grep, all fused here so `k` is ours and\n\t// the per-leg ranks survive into the trace. `bm25+dense` isolates what the\n\t// grep leg still contributes once BM25 is present.\n\t{ label: \"bm25+dense +rr\", mode: \"semantic\", rerank: true, bm25Leg: true },\n\t{ label: \"3-way +rr\", mode: \"hybrid\", rerank: true, bm25Leg: true },\n\t// Cross-encoder reranking, against the same retrieval each deterministic\n\t// `+rr` row uses — so the delta is the reranker and nothing else.\n\t{ label: \"semantic +ce\", mode: \"semantic\", crossEncoder: true },\n\t{ label: \"auto +ce\", mode: \"auto\", crossEncoder: true },\n\t{ label: \"bm25+dense +ce\", mode: \"semantic\", bm25Leg: true, crossEncoder: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.scope === \"file\") return true;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\n/**\n * Mean reciprocal rank, averaged over gold spans: for each gold span, `1/rank`\n * of the first candidate matching it (0 when it never appears).\n *\n * Averaging per gold span rather than taking the single first hit keeps\n * multi-span queries (the cross-file class) honest — finding one of two\n * required sites should not score like finding both. For single-span queries\n * this reduces to textbook MRR.\n */\nexport function mrr(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[]): number {\n\tif (gold.length === 0) return 0;\n\tlet total = 0;\n\tfor (const g of gold) {\n\t\tconst index = candidates.findIndex((c) => spanMatchesGold(c, g));\n\t\tif (index >= 0) total += 1 / (index + 1);\n\t}\n\treturn total / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt1: number;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n\tmrr: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n\thybridService?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\t// A daemon-hybrid config against the plain store would error in the\n\t\t// daemon (`query_hybrid requires a hybrid store`); omit the row instead.\n\t\tif ((config.daemonHybrid || config.bm25Leg) && !hybridService) continue;\n\t\t// Cross-encoder rows need a live daemon; omit rather than silently\n\t\t// falling back to the deterministic reranker under a \"+ce\" label.\n\t\tif (config.crossEncoder && !(config.bm25Leg ? hybridService : service)?.supportsCrossEncoder()) continue;\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice: config.daemonHybrid || config.bm25Leg ? hybridService : service,\n\t\t\tdaemonHybrid: config.daemonHybrid,\n\t\t\tbm25Leg: config.bm25Leg,\n\t\t\tcrossEncoder: config.crossEncoder,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt1: recallAtK(retrieved.candidates, evalQuery.gold, 1),\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t\tmrr: mrr(retrieved.candidates, evalQuery.gold),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
@@ -2,19 +2,33 @@
2
2
  * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the
3
3
  * shipping order).
4
4
  *
5
- * Measures Recall@K for lexical, semantic, and hybrid retrieval across an
6
- * RRF `k` sweep, against a gold set keyed by **path + optional line range
7
- * matched by span overlap** — never by chunkId, which is only stable per
8
- * index build. Recall@50 doubles as the reranker gate: a gold span that
9
- * never reaches the fused top-50 cannot be rescued by any reranker.
5
+ * Measures Recall@K and MRR for lexical, semantic, and hybrid retrieval across
6
+ * an RRF `k` sweep, against a gold set keyed by **path + line range matched by
7
+ * span overlap** — never by chunkId, which is only stable per index build.
8
+ * Recall@50 doubles as the reranker gate: a gold span that never reaches the
9
+ * fused top-50 cannot be rescued by any reranker.
10
10
  *
11
- * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is
12
- * unit-testable without an embedding index.
11
+ * Rank-sensitive metrics are the point of Recall@1 and MRR: an agent reads the
12
+ * top result or two, so "the gold span is somewhere in the top 5" understates
13
+ * how much ordering matters. Recall@5/10/50 are kept for continuity with the
14
+ * numbers already published in the design note.
15
+ *
16
+ * Driven by `scripts/search-eval.ts` via `eval-harness.ts`; the scoring logic
17
+ * lives here so it is unit-testable without an embedding index.
13
18
  */
14
19
  import { retrieveCandidates } from "./hybrid-search.js";
15
- /** The sweep from the design doc — single retrievers, hybrid across k, the
16
- * routed auto mode plus reranked (`+rr`) variants for the step 7 gate. */
17
- const EVAL_CONFIGS = [
20
+ /**
21
+ * The sweep from the design doc single retrievers, hybrid across k, the
22
+ * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate.
23
+ *
24
+ * Exported because `scripts/search-eval.ts` and `test/search-eval.test.ts`
25
+ * both consume it. It previously lost its `export` to a knip-driven
26
+ * dead-export sweep (02efaab): the only importer was a `.mjs` script reading
27
+ * from `dist/`, which static analysis cannot see, and the eval gate silently
28
+ * stopped running. The TypeScript importers are the fix — do not "clean up"
29
+ * this export without checking them.
30
+ */
31
+ export const EVAL_CONFIGS = [
18
32
  { label: "lexical", mode: "lexical" },
19
33
  { label: "semantic", mode: "semantic" },
20
34
  { label: "hybrid k=0", mode: "hybrid", rrfK: 0 },
@@ -27,12 +41,30 @@ const EVAL_CONFIGS = [
27
41
  { label: "hybrid k=2 +rr", mode: "hybrid", rrfK: 2, rerank: true },
28
42
  { label: "hybrid k=60 +rr", mode: "hybrid", rrfK: 60, rerank: true },
29
43
  { label: "auto +rr", mode: "auto", rerank: true },
44
+ // Is BM25 a better lexical leg than ripgrep? These two run the daemon's own
45
+ // vector+BM25 fusion and no ripgrep at all, so comparing them against
46
+ // "semantic" isolates what BM25 adds, and against "hybrid k=2" compares the
47
+ // two lexical legs at the system level.
48
+ { label: "daemon-hybrid", mode: "semantic", daemonHybrid: true },
49
+ { label: "daemon-hybrid +rr", mode: "semantic", rerank: true, daemonHybrid: true },
50
+ // Three-way fusion: dense + BM25 + grep, all fused here so `k` is ours and
51
+ // the per-leg ranks survive into the trace. `bm25+dense` isolates what the
52
+ // grep leg still contributes once BM25 is present.
53
+ { label: "bm25+dense +rr", mode: "semantic", rerank: true, bm25Leg: true },
54
+ { label: "3-way +rr", mode: "hybrid", rerank: true, bm25Leg: true },
55
+ // Cross-encoder reranking, against the same retrieval each deterministic
56
+ // `+rr` row uses — so the delta is the reranker and nothing else.
57
+ { label: "semantic +ce", mode: "semantic", crossEncoder: true },
58
+ { label: "auto +ce", mode: "auto", crossEncoder: true },
59
+ { label: "bm25+dense +ce", mode: "semantic", bm25Leg: true, crossEncoder: true },
30
60
  ];
31
61
  /** Candidates fetched per eval query — deep enough for the reranker gate. */
32
62
  const EVAL_FETCH_LIMIT = 50;
33
63
  export function spanMatchesGold(span, gold) {
34
64
  if (span.path !== gold.path)
35
65
  return false;
66
+ if (gold.scope === "file")
67
+ return true;
36
68
  if (gold.startLine === undefined || gold.endLine === undefined)
37
69
  return true;
38
70
  return span.startLine <= gold.endLine && span.endLine >= gold.startLine;
@@ -49,9 +81,37 @@ export function recallAtK(candidates, gold, k) {
49
81
  }
50
82
  return matched / gold.length;
51
83
  }
52
- export async function evaluateQuery(cwd, evalQuery, configs = EVAL_CONFIGS, service) {
84
+ /**
85
+ * Mean reciprocal rank, averaged over gold spans: for each gold span, `1/rank`
86
+ * of the first candidate matching it (0 when it never appears).
87
+ *
88
+ * Averaging per gold span rather than taking the single first hit keeps
89
+ * multi-span queries (the cross-file class) honest — finding one of two
90
+ * required sites should not score like finding both. For single-span queries
91
+ * this reduces to textbook MRR.
92
+ */
93
+ export function mrr(candidates, gold) {
94
+ if (gold.length === 0)
95
+ return 0;
96
+ let total = 0;
97
+ for (const g of gold) {
98
+ const index = candidates.findIndex((c) => spanMatchesGold(c, g));
99
+ if (index >= 0)
100
+ total += 1 / (index + 1);
101
+ }
102
+ return total / gold.length;
103
+ }
104
+ export async function evaluateQuery(cwd, evalQuery, configs = EVAL_CONFIGS, service, hybridService) {
53
105
  const results = [];
54
106
  for (const config of configs) {
107
+ // A daemon-hybrid config against the plain store would error in the
108
+ // daemon (`query_hybrid requires a hybrid store`); omit the row instead.
109
+ if ((config.daemonHybrid || config.bm25Leg) && !hybridService)
110
+ continue;
111
+ // Cross-encoder rows need a live daemon; omit rather than silently
112
+ // falling back to the deterministic reranker under a "+ce" label.
113
+ if (config.crossEncoder && !(config.bm25Leg ? hybridService : service)?.supportsCrossEncoder())
114
+ continue;
55
115
  const retrieved = await retrieveCandidates({
56
116
  cwd,
57
117
  query: evalQuery.query,
@@ -59,15 +119,20 @@ export async function evaluateQuery(cwd, evalQuery, configs = EVAL_CONFIGS, serv
59
119
  rrfK: config.rrfK,
60
120
  rerank: config.rerank ?? false,
61
121
  limit: EVAL_FETCH_LIMIT,
62
- service,
122
+ service: config.daemonHybrid || config.bm25Leg ? hybridService : service,
123
+ daemonHybrid: config.daemonHybrid,
124
+ bm25Leg: config.bm25Leg,
125
+ crossEncoder: config.crossEncoder,
63
126
  });
64
127
  results.push({
65
128
  label: config.label,
66
129
  resolvedMode: retrieved.resolvedMode,
67
130
  degraded: retrieved.degradedReason !== undefined,
131
+ recallAt1: recallAtK(retrieved.candidates, evalQuery.gold, 1),
68
132
  recallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),
69
133
  recallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),
70
134
  recallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),
135
+ mrr: mrr(retrieved.candidates, evalQuery.gold),
71
136
  });
72
137
  }
73
138
  return results;
@@ -1 +1 @@
1
- {"version":3,"file":"eval.js","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K for lexical, semantic, and hybrid retrieval across an\n * RRF `k` sweep, against a gold set keyed by **path + optional line range\n * matched by span overlap** — never by chunkId, which is only stable per\n * index build. Recall@50 doubles as the reranker gate: a gold span that\n * never reaches the fused top-50 cannot be rescued by any reranker.\n *\n * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is\n * unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n}\n\n/** The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate. */\nconst EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
1
+ {"version":3,"file":"eval.js","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K and MRR for lexical, semantic, and hybrid retrieval across\n * an RRF `k` sweep, against a gold set keyed by **path + line range matched by\n * span overlap** — never by chunkId, which is only stable per index build.\n * Recall@50 doubles as the reranker gate: a gold span that never reaches the\n * fused top-50 cannot be rescued by any reranker.\n *\n * Rank-sensitive metrics are the point of Recall@1 and MRR: an agent reads the\n * top result or two, so \"the gold span is somewhere in the top 5\" understates\n * how much ordering matters. Recall@5/10/50 are kept for continuity with the\n * numbers already published in the design note.\n *\n * Driven by `scripts/search-eval.ts` via `eval-harness.ts`; the scoring logic\n * lives here so it is unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n\t/**\n\t * Literal source text that must occur inside `[startLine, endLine]`. Not\n\t * used for scoring — it is how the gold set survives the corpus moving\n\t * underneath it: `scripts/search-eval-gold.ts` re-resolves the line range\n\t * from this anchor, and the fixture test fails when an anchor no longer\n\t * sits inside its recorded range. Omitted for `scope: \"file\"` entries.\n\t */\n\tanchor?: string;\n\t/**\n\t * `\"span\"` (default) scores by overlap with the recorded line range;\n\t * `\"file\"` accepts any span in the file. File scope is deliberate for\n\t * path-class queries, where the whole file *is* the answer — it is\n\t * recorded explicitly so a file-level score is never mistaken for a\n\t * span-level one.\n\t */\n\tscope?: \"span\" | \"file\";\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n\t/**\n\t * Score this config against a daemon-side hybrid store (BM25 fused with\n\t * vectors inside the Rust daemon) instead of the dense-only index. Skipped\n\t * when the harness has no hybrid service, so records without one simply\n\t * omit the row rather than silently scoring it as plain semantic.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as a separate leg and fuse it here with\n\t * dense and grep. Like `daemonHybrid` it needs a hybrid store, so it is\n\t * skipped when the harness has no hybrid service.\n\t */\n\tbm25Leg?: boolean;\n\t/** Rerank with the daemon's cross-encoder rather than the deterministic\n\t * scorer. Needs a service, so it is skipped without one. */\n\tcrossEncoder?: boolean;\n}\n\n/**\n * The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate.\n *\n * Exported because `scripts/search-eval.ts` and `test/search-eval.test.ts`\n * both consume it. It previously lost its `export` to a knip-driven\n * dead-export sweep (02efaab): the only importer was a `.mjs` script reading\n * from `dist/`, which static analysis cannot see, and the eval gate silently\n * stopped running. The TypeScript importers are the fix — do not \"clean up\"\n * this export without checking them.\n */\nexport const EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n\t// Is BM25 a better lexical leg than ripgrep? These two run the daemon's own\n\t// vector+BM25 fusion and no ripgrep at all, so comparing them against\n\t// \"semantic\" isolates what BM25 adds, and against \"hybrid k=2\" compares the\n\t// two lexical legs at the system level.\n\t{ label: \"daemon-hybrid\", mode: \"semantic\", daemonHybrid: true },\n\t{ label: \"daemon-hybrid +rr\", mode: \"semantic\", rerank: true, daemonHybrid: true },\n\t// Three-way fusion: dense + BM25 + grep, all fused here so `k` is ours and\n\t// the per-leg ranks survive into the trace. `bm25+dense` isolates what the\n\t// grep leg still contributes once BM25 is present.\n\t{ label: \"bm25+dense +rr\", mode: \"semantic\", rerank: true, bm25Leg: true },\n\t{ label: \"3-way +rr\", mode: \"hybrid\", rerank: true, bm25Leg: true },\n\t// Cross-encoder reranking, against the same retrieval each deterministic\n\t// `+rr` row uses — so the delta is the reranker and nothing else.\n\t{ label: \"semantic +ce\", mode: \"semantic\", crossEncoder: true },\n\t{ label: \"auto +ce\", mode: \"auto\", crossEncoder: true },\n\t{ label: \"bm25+dense +ce\", mode: \"semantic\", bm25Leg: true, crossEncoder: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.scope === \"file\") return true;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\n/**\n * Mean reciprocal rank, averaged over gold spans: for each gold span, `1/rank`\n * of the first candidate matching it (0 when it never appears).\n *\n * Averaging per gold span rather than taking the single first hit keeps\n * multi-span queries (the cross-file class) honest — finding one of two\n * required sites should not score like finding both. For single-span queries\n * this reduces to textbook MRR.\n */\nexport function mrr(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[]): number {\n\tif (gold.length === 0) return 0;\n\tlet total = 0;\n\tfor (const g of gold) {\n\t\tconst index = candidates.findIndex((c) => spanMatchesGold(c, g));\n\t\tif (index >= 0) total += 1 / (index + 1);\n\t}\n\treturn total / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt1: number;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n\tmrr: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n\thybridService?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\t// A daemon-hybrid config against the plain store would error in the\n\t\t// daemon (`query_hybrid requires a hybrid store`); omit the row instead.\n\t\tif ((config.daemonHybrid || config.bm25Leg) && !hybridService) continue;\n\t\t// Cross-encoder rows need a live daemon; omit rather than silently\n\t\t// falling back to the deterministic reranker under a \"+ce\" label.\n\t\tif (config.crossEncoder && !(config.bm25Leg ? hybridService : service)?.supportsCrossEncoder()) continue;\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice: config.daemonHybrid || config.bm25Leg ? hybridService : service,\n\t\t\tdaemonHybrid: config.daemonHybrid,\n\t\t\tbm25Leg: config.bm25Leg,\n\t\t\tcrossEncoder: config.crossEncoder,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt1: recallAtK(retrieved.candidates, evalQuery.gold, 1),\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t\tmrr: mrr(retrieved.candidates, evalQuery.gold),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
@@ -26,6 +26,28 @@ export interface RetrieveOptions {
26
26
  rrfK?: number;
27
27
  /** Rerank the fused top-50 before slicing to `limit`. Default: true. */
28
28
  rerank?: boolean;
29
+ /**
30
+ * Ask the daemon to fuse its own BM25 index with the vectors and return one
31
+ * already-fused ranking, instead of taking a dense-only list. Needs a store
32
+ * built with `--hybrid`. The fused list arrives as a single "embed" leg,
33
+ * because a pre-fused ranking has no per-retriever structure left to record.
34
+ *
35
+ * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace
36
+ * and lets the grep leg participate.
37
+ */
38
+ daemonHybrid?: boolean;
39
+ /**
40
+ * Fetch the daemon's BM25 index as its own ranked list and fuse it here,
41
+ * alongside dense and grep. Needs a store built with `--hybrid` and an
42
+ * embsearch new enough to serve `retriever: "lexical"`.
43
+ */
44
+ bm25Leg?: boolean;
45
+ /**
46
+ * Reorder the fused shortlist with the daemon's cross-encoder instead of
47
+ * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model
48
+ * pass per scored candidate.
49
+ */
50
+ crossEncoder?: boolean;
29
51
  service?: EmbsearchService;
30
52
  signal?: AbortSignal;
31
53
  }
@@ -1 +1 @@
1
- {"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;AAQ1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAcxH,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,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,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CA2G1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyBnF","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 { 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/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\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\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\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\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });\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\tconst chunkHits = (await service!.searchChunks(query, EMBED_TOP_K, glob)).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 runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\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.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\tcandidates = candidates.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 }) => ({ id, rrfScore, ranks, rawScores })),\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"]}
1
+ {"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;AAmCxH,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;;;;OAIG;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,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CA+I1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyBnF","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/** 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. Needs a store built with `--hybrid` and an\n\t * embsearch new enough to serve `retriever: \"lexical\"`.\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\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\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });\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\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tif (options.bm25Leg && embedAvailable && mode !== \"lexical\") 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\tcandidates = candidates.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 }) => ({ id, rrfScore, ranks, rawScores })),\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"]}