@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
@@ -14,6 +14,10 @@
14
14
  * file itself first, which content grep alone cannot do;
15
15
  * - fused prior: the RRF ordering, so retriever consensus still counts.
16
16
  *
17
+ * Not every signal suits every query. A query that names something and a query
18
+ * that describes behaviour want different evidence, so the name-matching
19
+ * signal is gated on {@link queryIsProse} — see its comment for the numbers.
20
+ *
17
21
  * Purely lexical-statistical and deterministic — no model, no I/O beyond
18
22
  * reading candidate windows. A cross-encoder can later replace the scoring
19
23
  * function behind the same signature; that model work belongs to
@@ -22,8 +26,8 @@
22
26
  import { readFileSync } from "fs";
23
27
  import path from "path";
24
28
  import { buildLexicalQueryPlan } from "./lexical-retriever.js";
25
- /** Weights of the scoring blend. The eval harness (scripts/search-eval.mjs)
26
- * is the instrument for changing them — don't tune blind. */
29
+ /** Weights of the scoring blend. The eval harness (`bun run search-eval`) is
30
+ * the instrument for changing them — don't tune blind. */
27
31
  const WEIGHT_FUSED_PRIOR = 0.4;
28
32
  const WEIGHT_TERM_COVERAGE = 0.35;
29
33
  const WEIGHT_PATH_AFFINITY = 0.25;
@@ -31,6 +35,174 @@ const WEIGHT_PATH_AFFINITY = 0.25;
31
35
  * the caller named the file, so no amount of content evidence elsewhere
32
36
  * should outrank it. */
33
37
  const EXACT_PATH_BONUS = 0.5;
38
+ /**
39
+ * Additive bonus when the window *declares* a query term rather than merely
40
+ * mentioning it.
41
+ *
42
+ * This targets the largest measured gap in the eval: on the 22 exact-symbol
43
+ * queries the definition is in the top 10 about 85% of the time but ranked
44
+ * first only about 20% of the time. Call sites outnumber definitions and
45
+ * contain the identical identifier, so term coverage — which saturates at 1.0
46
+ * for both — cannot separate them. Structure can.
47
+ */
48
+ const DECLARATION_BONUS = 0.3;
49
+ /**
50
+ * Sentence glue. Identifiers and paths never contain these words, so two or
51
+ * more of them means the query is a sentence rather than a name.
52
+ *
53
+ * This gates {@link DECLARATION_BONUS} because that bonus is a *name-matching*
54
+ * signal and a prose query has no name to match. Its plan terms are ordinary
55
+ * words — "results", "search", "index" — so it fires on whichever candidate
56
+ * happens to declare a variable by one of them, at a weight larger than the
57
+ * entire path-affinity term, and buries the fused ordering that did know the
58
+ * answer. On the conceptual class reranking was scoring *below not reranking
59
+ * at all* (MRR 0.246 un-reranked vs 0.124 reranked on `semantic`).
60
+ *
61
+ * Measured on the 62-query gold set, gating it moved conceptual MRR +0.046
62
+ * (`semantic +rr`), +0.040 (`auto +rr`), +0.056 (`bm25+dense +rr`) and left
63
+ * exact-symbol, error-fragment and path bit-identical — the gate never fires
64
+ * on those, by construction. Overall MRR +0.007 to +0.013, which the paired
65
+ * sign test does not call significant (p = 0.11 to 0.29); the classes it
66
+ * protects are what justify it, not the aggregate.
67
+ *
68
+ * Path affinity is deliberately *not* gated. It is a topic signal, not a name
69
+ * signal: "how does a grep line number become an embedding chunk id" wants
70
+ * files with `grep` and `chunk` in the path. Gating it too was measured and
71
+ * was strictly worse — same conceptual gain, roughly double the cross-file
72
+ * loss (−0.058 vs −0.032 on `semantic +rr`).
73
+ *
74
+ * Deliberately conservative: two hits, not one, so a terse query like
75
+ * `hybrid search fusion` keeps today's scoring untouched.
76
+ */
77
+ const PROSE_FUNCTION_WORDS = new Set([
78
+ "a",
79
+ "after",
80
+ "all",
81
+ "an",
82
+ "and",
83
+ "any",
84
+ "are",
85
+ "as",
86
+ "at",
87
+ "be",
88
+ "been",
89
+ "before",
90
+ "between",
91
+ "but",
92
+ "by",
93
+ "can",
94
+ "does",
95
+ "do",
96
+ "each",
97
+ "for",
98
+ "from",
99
+ "had",
100
+ "has",
101
+ "have",
102
+ "how",
103
+ "if",
104
+ "in",
105
+ "into",
106
+ "is",
107
+ "it",
108
+ "its",
109
+ "of",
110
+ "on",
111
+ "one",
112
+ "or",
113
+ "should",
114
+ "so",
115
+ "than",
116
+ "that",
117
+ "the",
118
+ "then",
119
+ "this",
120
+ "to",
121
+ "under",
122
+ "was",
123
+ "were",
124
+ "what",
125
+ "when",
126
+ "where",
127
+ "which",
128
+ "why",
129
+ "with",
130
+ "would",
131
+ ]);
132
+ /** Function words needed before a query counts as prose. */
133
+ const PROSE_WORD_THRESHOLD = 2;
134
+ /**
135
+ * Is `query` a sentence rather than a name?
136
+ *
137
+ * A quoted segment is never prose regardless of its words: the plan collapses
138
+ * it to one literal term, so the declaration bonus cannot fire on it anyway,
139
+ * and its path-token split is what lets `"Theme not initialized…"` find
140
+ * `core/theme.ts`.
141
+ */
142
+ export function queryIsProse(query) {
143
+ if (/["'`][^"'`]+["'`]/.test(query))
144
+ return false;
145
+ const words = query.toLowerCase().match(/[a-z]+/g) ?? [];
146
+ let hits = 0;
147
+ for (const word of words) {
148
+ if (PROSE_FUNCTION_WORDS.has(word) && ++hits >= PROSE_WORD_THRESHOLD)
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ /** Keywords that introduce a definition across the languages this indexes.
154
+ * Matched against lowercased text, so the term is lowercased too. */
155
+ const DECLARATION_KEYWORDS = "function|class|interface|type|enum|struct|impl|trait|fn|def|const|let|var|namespace|module";
156
+ /** Does `window` declare `term`, as opposed to referencing it? */
157
+ function declaresTerm(window, term) {
158
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ // `function foo(`, `class Foo {`, `const foo =` ...
160
+ if (new RegExp(`\\b(?:${DECLARATION_KEYWORDS})\\s+${escaped}\\b`).test(window))
161
+ return true;
162
+ // `foo(...) {` at the start of a line — methods, Go/Rust receivers, Python defs
163
+ // already covered above, but this catches object-literal and class members.
164
+ if (new RegExp(`^\\s*(?:(?:async|public|private|protected|static|export)\\s+)*${escaped}\\s*[(<]`, "m").test(window)) {
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+ /** Inverse document frequency over the candidate pool.
170
+ *
171
+ * True corpus IDF lives in the BM25 index and is not exposed over the daemon
172
+ * protocol, so this approximates it with the candidate set: a term present in
173
+ * every candidate discriminates nothing, one present in three carries the
174
+ * signal. That is the comparison the reranker actually needs to make, since it
175
+ * only ever orders candidates against each other. */
176
+ function inverseDocumentFrequency(documentFrequency, total) {
177
+ return Math.log(1 + total / Math.max(1, documentFrequency));
178
+ }
179
+ /**
180
+ * The exact source text each candidate stands for, as the model should see it.
181
+ *
182
+ * Shared with the cross-encoder path so both rerankers score identical text —
183
+ * otherwise a comparison between them would partly measure which one got a
184
+ * better view of the candidate.
185
+ */
186
+ export function readCandidateWindows(candidates, cwd) {
187
+ const fileCache = new Map();
188
+ const read = (rel) => {
189
+ if (!fileCache.has(rel)) {
190
+ try {
191
+ fileCache.set(rel, readFileSync(path.resolve(cwd, rel), "utf-8").split("\n"));
192
+ }
193
+ catch {
194
+ fileCache.set(rel, undefined);
195
+ }
196
+ }
197
+ return fileCache.get(rel);
198
+ };
199
+ return candidates.map((candidate) => {
200
+ const lines = read(candidate.path);
201
+ if (!lines)
202
+ return undefined;
203
+ return lines.slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine)).join("\n");
204
+ });
205
+ }
34
206
  export function rerankCandidates(query, candidates, cwd) {
35
207
  const startedMs = Date.now();
36
208
  const plan = buildLexicalQueryPlan(query);
@@ -39,6 +211,9 @@ export function rerankCandidates(query, candidates, cwd) {
39
211
  }
40
212
  const terms = plan.terms;
41
213
  const queryPath = query.trim().toLowerCase();
214
+ // Prose asks about behaviour, not about a name, so the one signal that reads
215
+ // candidates *as names* is switched off.
216
+ const prose = queryIsProse(query);
42
217
  const fileCache = new Map();
43
218
  const readLines = (rel) => {
44
219
  if (!fileCache.has(rel)) {
@@ -52,16 +227,37 @@ export function rerankCandidates(query, candidates, cwd) {
52
227
  }
53
228
  return fileCache.get(rel);
54
229
  };
55
- const scored = candidates.map((candidate, index) => {
56
- // Fused prior: normalized RRF ordering, 1 for the top candidate.
57
- const fusedPrior = 1 - index / candidates.length;
230
+ // Read every candidate window once: the term/declaration signals and the
231
+ // candidate-pool IDF all need them, and files repeat across candidates.
232
+ const windows = candidates.map((candidate) => {
58
233
  const lines = readLines(candidate.path);
234
+ if (!lines)
235
+ return undefined;
236
+ return lines.slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine)).join("\n");
237
+ });
238
+ // Candidate-pool document frequency per term, for the IDF weighting below.
239
+ const documentFrequency = new Map();
240
+ for (const term of terms) {
241
+ documentFrequency.set(term, windows.filter((w) => w?.includes(term)).length);
242
+ }
243
+ const termWeight = new Map(terms.map((t) => [t, inverseDocumentFrequency(documentFrequency.get(t) ?? 0, candidates.length)]));
244
+ const totalTermWeight = terms.reduce((sum, t) => sum + (termWeight.get(t) ?? 0), 0);
245
+ // Fused prior normalized by score, not by position: a candidate both
246
+ // retrievers agreed on should outrank one that squeaked in, and a uniform
247
+ // 1 - index/length ramp throws that magnitude away.
248
+ const maxRrfScore = Math.max(...candidates.map((c) => c.rrfScore), Number.MIN_VALUE);
249
+ const scored = candidates.map((candidate, index) => {
250
+ const fusedPrior = candidate.rrfScore / maxRrfScore;
251
+ const window = windows[index];
59
252
  let termCoverage = 0;
60
- if (lines && terms.length > 0) {
61
- const window = lines
62
- .slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine))
63
- .join("\n");
64
- termCoverage = terms.filter((t) => window.includes(t)).length / terms.length;
253
+ let declaresAnyTerm = false;
254
+ if (window && terms.length > 0) {
255
+ const present = terms.filter((t) => window.includes(t));
256
+ termCoverage =
257
+ totalTermWeight > 0
258
+ ? present.reduce((sum, t) => sum + (termWeight.get(t) ?? 0), 0) / totalTermWeight
259
+ : present.length / terms.length;
260
+ declaresAnyTerm = !prose && present.some((t) => declaresTerm(window, t));
65
261
  }
66
262
  const lowerPath = candidate.path.toLowerCase();
67
263
  // A quoted phrase rarely names a file; split it into path-ish tokens so
@@ -72,7 +268,8 @@ export function rerankCandidates(query, candidates, cwd) {
72
268
  const score = WEIGHT_FUSED_PRIOR * fusedPrior +
73
269
  WEIGHT_TERM_COVERAGE * termCoverage +
74
270
  WEIGHT_PATH_AFFINITY * pathAffinity +
75
- EXACT_PATH_BONUS * exactPath;
271
+ EXACT_PATH_BONUS * exactPath +
272
+ (declaresAnyTerm ? DECLARATION_BONUS : 0);
76
273
  return { candidate, index, score };
77
274
  });
78
275
  // Stable, deterministic: score desc, fused order as tie-break.
@@ -1 +1 @@
1
- {"version":3,"file":"rerank.js","sourceRoot":"","sources":["../../../src/core/search/rerank.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Deterministic reranker over the fused top-50\n * (docs/hybrid-retrieval-design.md, step 7 of the shipping order).\n *\n * The eval gate showed fused Recall@50 well above Recall@5/10 — the right\n * candidates survive fusion but sit too deep. This reranker re-orders them\n * using evidence that is only cheap to compute *after* fusion, when there\n * are ≤50 candidates instead of thousands of lines:\n *\n * - term coverage: how many distinct query terms appear in the candidate's\n * actual expanded window (read from disk);\n * - path affinity: query terms appearing in the candidate's file path —\n * this is what lets a query like `core/search/hybrid-search.ts` rank the\n * file itself first, which content grep alone cannot do;\n * - fused prior: the RRF ordering, so retriever consensus still counts.\n *\n * Purely lexical-statistical and deterministic — no model, no I/O beyond\n * reading candidate windows. A cross-encoder can later replace the scoring\n * function behind the same signature; that model work belongs to\n * `kolisachint/embeddingsearchtools`, not here.\n */\n\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { buildLexicalQueryPlan } from \"./lexical-retriever.js\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/** Weights of the scoring blend. The eval harness (scripts/search-eval.mjs)\n * is the instrument for changing them — don't tune blind. */\nconst WEIGHT_FUSED_PRIOR = 0.4;\nconst WEIGHT_TERM_COVERAGE = 0.35;\nconst WEIGHT_PATH_AFFINITY = 0.25;\n/** Additive bonus when the query *is* the candidate's path (or its suffix):\n * the caller named the file, so no amount of content evidence elsewhere\n * should outrank it. */\nconst EXACT_PATH_BONUS = 0.5;\n\nexport interface RerankResult {\n\tcandidates: FusedCandidate[];\n\tlatencyMs: number;\n}\n\nexport function rerankCandidates(query: string, candidates: readonly FusedCandidate[], cwd: string): RerankResult {\n\tconst startedMs = Date.now();\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan || candidates.length < 2) {\n\t\treturn { candidates: [...candidates], latencyMs: Date.now() - startedMs };\n\t}\n\tconst terms = plan.terms;\n\tconst queryPath = query.trim().toLowerCase();\n\n\tconst fileCache = new Map<string, string[] | undefined>();\n\tconst readLines = (rel: string): string[] | undefined => {\n\t\tif (!fileCache.has(rel)) {\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(path.resolve(cwd, rel), \"utf-8\");\n\t\t\t\tfileCache.set(rel, content.toLowerCase().split(\"\\n\"));\n\t\t\t} catch {\n\t\t\t\tfileCache.set(rel, undefined);\n\t\t\t}\n\t\t}\n\t\treturn fileCache.get(rel);\n\t};\n\n\tconst scored = candidates.map((candidate, index) => {\n\t\t// Fused prior: normalized RRF ordering, 1 for the top candidate.\n\t\tconst fusedPrior = 1 - index / candidates.length;\n\n\t\tconst lines = readLines(candidate.path);\n\t\tlet termCoverage = 0;\n\t\tif (lines && terms.length > 0) {\n\t\t\tconst window = lines\n\t\t\t\t.slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine))\n\t\t\t\t.join(\"\\n\");\n\t\t\ttermCoverage = terms.filter((t) => window.includes(t)).length / terms.length;\n\t\t}\n\n\t\tconst lowerPath = candidate.path.toLowerCase();\n\t\t// A quoted phrase rarely names a file; split it into path-ish tokens so\n\t\t// `\"token budget exceeded\"` still gets partial path credit.\n\t\tconst pathTerms = terms.length === 1 ? terms[0].split(/[^a-z0-9_$]+/).filter((t) => t.length >= 3) : terms;\n\t\tconst pathAffinity =\n\t\t\tpathTerms.length > 0 ? pathTerms.filter((t) => lowerPath.includes(t)).length / pathTerms.length : 0;\n\n\t\tconst exactPath =\n\t\t\tqueryPath.length >= 3 && (lowerPath === queryPath || lowerPath.endsWith(`/${queryPath}`)) ? 1 : 0;\n\n\t\tconst score =\n\t\t\tWEIGHT_FUSED_PRIOR * fusedPrior +\n\t\t\tWEIGHT_TERM_COVERAGE * termCoverage +\n\t\t\tWEIGHT_PATH_AFFINITY * pathAffinity +\n\t\t\tEXACT_PATH_BONUS * exactPath;\n\t\treturn { candidate, index, score };\n\t});\n\n\t// Stable, deterministic: score desc, fused order as tie-break.\n\tscored.sort((a, b) => b.score - a.score || a.index - b.index);\n\treturn { candidates: scored.map((s) => s.candidate), latencyMs: Date.now() - startedMs };\n}\n"]}
1
+ {"version":3,"file":"rerank.js","sourceRoot":"","sources":["../../../src/core/search/rerank.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Deterministic reranker over the fused top-50\n * (docs/hybrid-retrieval-design.md, step 7 of the shipping order).\n *\n * The eval gate showed fused Recall@50 well above Recall@5/10 — the right\n * candidates survive fusion but sit too deep. This reranker re-orders them\n * using evidence that is only cheap to compute *after* fusion, when there\n * are ≤50 candidates instead of thousands of lines:\n *\n * - term coverage: how many distinct query terms appear in the candidate's\n * actual expanded window (read from disk);\n * - path affinity: query terms appearing in the candidate's file path —\n * this is what lets a query like `core/search/hybrid-search.ts` rank the\n * file itself first, which content grep alone cannot do;\n * - fused prior: the RRF ordering, so retriever consensus still counts.\n *\n * Not every signal suits every query. A query that names something and a query\n * that describes behaviour want different evidence, so the name-matching\n * signal is gated on {@link queryIsProse} — see its comment for the numbers.\n *\n * Purely lexical-statistical and deterministic — no model, no I/O beyond\n * reading candidate windows. A cross-encoder can later replace the scoring\n * function behind the same signature; that model work belongs to\n * `kolisachint/embeddingsearchtools`, not here.\n */\n\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { buildLexicalQueryPlan } from \"./lexical-retriever.js\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/** Weights of the scoring blend. The eval harness (`bun run search-eval`) is\n * the instrument for changing them — don't tune blind. */\nconst WEIGHT_FUSED_PRIOR = 0.4;\nconst WEIGHT_TERM_COVERAGE = 0.35;\nconst WEIGHT_PATH_AFFINITY = 0.25;\n/** Additive bonus when the query *is* the candidate's path (or its suffix):\n * the caller named the file, so no amount of content evidence elsewhere\n * should outrank it. */\nconst EXACT_PATH_BONUS = 0.5;\n/**\n * Additive bonus when the window *declares* a query term rather than merely\n * mentioning it.\n *\n * This targets the largest measured gap in the eval: on the 22 exact-symbol\n * queries the definition is in the top 10 about 85% of the time but ranked\n * first only about 20% of the time. Call sites outnumber definitions and\n * contain the identical identifier, so term coverage — which saturates at 1.0\n * for both — cannot separate them. Structure can.\n */\nconst DECLARATION_BONUS = 0.3;\n\n/**\n * Sentence glue. Identifiers and paths never contain these words, so two or\n * more of them means the query is a sentence rather than a name.\n *\n * This gates {@link DECLARATION_BONUS} because that bonus is a *name-matching*\n * signal and a prose query has no name to match. Its plan terms are ordinary\n * words — \"results\", \"search\", \"index\" — so it fires on whichever candidate\n * happens to declare a variable by one of them, at a weight larger than the\n * entire path-affinity term, and buries the fused ordering that did know the\n * answer. On the conceptual class reranking was scoring *below not reranking\n * at all* (MRR 0.246 un-reranked vs 0.124 reranked on `semantic`).\n *\n * Measured on the 62-query gold set, gating it moved conceptual MRR +0.046\n * (`semantic +rr`), +0.040 (`auto +rr`), +0.056 (`bm25+dense +rr`) and left\n * exact-symbol, error-fragment and path bit-identical — the gate never fires\n * on those, by construction. Overall MRR +0.007 to +0.013, which the paired\n * sign test does not call significant (p = 0.11 to 0.29); the classes it\n * protects are what justify it, not the aggregate.\n *\n * Path affinity is deliberately *not* gated. It is a topic signal, not a name\n * signal: \"how does a grep line number become an embedding chunk id\" wants\n * files with `grep` and `chunk` in the path. Gating it too was measured and\n * was strictly worse — same conceptual gain, roughly double the cross-file\n * loss (−0.058 vs −0.032 on `semantic +rr`).\n *\n * Deliberately conservative: two hits, not one, so a terse query like\n * `hybrid search fusion` keeps today's scoring untouched.\n */\nconst PROSE_FUNCTION_WORDS = new Set([\n\t\"a\",\n\t\"after\",\n\t\"all\",\n\t\"an\",\n\t\"and\",\n\t\"any\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"be\",\n\t\"been\",\n\t\"before\",\n\t\"between\",\n\t\"but\",\n\t\"by\",\n\t\"can\",\n\t\"does\",\n\t\"do\",\n\t\"each\",\n\t\"for\",\n\t\"from\",\n\t\"had\",\n\t\"has\",\n\t\"have\",\n\t\"how\",\n\t\"if\",\n\t\"in\",\n\t\"into\",\n\t\"is\",\n\t\"it\",\n\t\"its\",\n\t\"of\",\n\t\"on\",\n\t\"one\",\n\t\"or\",\n\t\"should\",\n\t\"so\",\n\t\"than\",\n\t\"that\",\n\t\"the\",\n\t\"then\",\n\t\"this\",\n\t\"to\",\n\t\"under\",\n\t\"was\",\n\t\"were\",\n\t\"what\",\n\t\"when\",\n\t\"where\",\n\t\"which\",\n\t\"why\",\n\t\"with\",\n\t\"would\",\n]);\n/** Function words needed before a query counts as prose. */\nconst PROSE_WORD_THRESHOLD = 2;\n\n/**\n * Is `query` a sentence rather than a name?\n *\n * A quoted segment is never prose regardless of its words: the plan collapses\n * it to one literal term, so the declaration bonus cannot fire on it anyway,\n * and its path-token split is what lets `\"Theme not initialized…\"` find\n * `core/theme.ts`.\n */\nexport function queryIsProse(query: string): boolean {\n\tif (/[\"'`][^\"'`]+[\"'`]/.test(query)) return false;\n\tconst words = query.toLowerCase().match(/[a-z]+/g) ?? [];\n\tlet hits = 0;\n\tfor (const word of words) {\n\t\tif (PROSE_FUNCTION_WORDS.has(word) && ++hits >= PROSE_WORD_THRESHOLD) return true;\n\t}\n\treturn false;\n}\n\n/** Keywords that introduce a definition across the languages this indexes.\n * Matched against lowercased text, so the term is lowercased too. */\nconst DECLARATION_KEYWORDS =\n\t\"function|class|interface|type|enum|struct|impl|trait|fn|def|const|let|var|namespace|module\";\n\n/** Does `window` declare `term`, as opposed to referencing it? */\nfunction declaresTerm(window: string, term: string): boolean {\n\tconst escaped = term.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t// `function foo(`, `class Foo {`, `const foo =` ...\n\tif (new RegExp(`\\\\b(?:${DECLARATION_KEYWORDS})\\\\s+${escaped}\\\\b`).test(window)) return true;\n\t// `foo(...) {` at the start of a line — methods, Go/Rust receivers, Python defs\n\t// already covered above, but this catches object-literal and class members.\n\tif (\n\t\tnew RegExp(`^\\\\s*(?:(?:async|public|private|protected|static|export)\\\\s+)*${escaped}\\\\s*[(<]`, \"m\").test(window)\n\t) {\n\t\treturn true;\n\t}\n\treturn false;\n}\n\n/** Inverse document frequency over the candidate pool.\n *\n * True corpus IDF lives in the BM25 index and is not exposed over the daemon\n * protocol, so this approximates it with the candidate set: a term present in\n * every candidate discriminates nothing, one present in three carries the\n * signal. That is the comparison the reranker actually needs to make, since it\n * only ever orders candidates against each other. */\nfunction inverseDocumentFrequency(documentFrequency: number, total: number): number {\n\treturn Math.log(1 + total / Math.max(1, documentFrequency));\n}\n\nexport interface RerankResult {\n\tcandidates: FusedCandidate[];\n\tlatencyMs: number;\n}\n\n/**\n * The exact source text each candidate stands for, as the model should see it.\n *\n * Shared with the cross-encoder path so both rerankers score identical text —\n * otherwise a comparison between them would partly measure which one got a\n * better view of the candidate.\n */\nexport function readCandidateWindows(candidates: readonly FusedCandidate[], cwd: string): Array<string | undefined> {\n\tconst fileCache = new Map<string, string[] | undefined>();\n\tconst read = (rel: string): string[] | undefined => {\n\t\tif (!fileCache.has(rel)) {\n\t\t\ttry {\n\t\t\t\tfileCache.set(rel, readFileSync(path.resolve(cwd, rel), \"utf-8\").split(\"\\n\"));\n\t\t\t} catch {\n\t\t\t\tfileCache.set(rel, undefined);\n\t\t\t}\n\t\t}\n\t\treturn fileCache.get(rel);\n\t};\n\treturn candidates.map((candidate) => {\n\t\tconst lines = read(candidate.path);\n\t\tif (!lines) return undefined;\n\t\treturn lines.slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine)).join(\"\\n\");\n\t});\n}\n\nexport function rerankCandidates(query: string, candidates: readonly FusedCandidate[], cwd: string): RerankResult {\n\tconst startedMs = Date.now();\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan || candidates.length < 2) {\n\t\treturn { candidates: [...candidates], latencyMs: Date.now() - startedMs };\n\t}\n\tconst terms = plan.terms;\n\tconst queryPath = query.trim().toLowerCase();\n\t// Prose asks about behaviour, not about a name, so the one signal that reads\n\t// candidates *as names* is switched off.\n\tconst prose = queryIsProse(query);\n\n\tconst fileCache = new Map<string, string[] | undefined>();\n\tconst readLines = (rel: string): string[] | undefined => {\n\t\tif (!fileCache.has(rel)) {\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(path.resolve(cwd, rel), \"utf-8\");\n\t\t\t\tfileCache.set(rel, content.toLowerCase().split(\"\\n\"));\n\t\t\t} catch {\n\t\t\t\tfileCache.set(rel, undefined);\n\t\t\t}\n\t\t}\n\t\treturn fileCache.get(rel);\n\t};\n\n\t// Read every candidate window once: the term/declaration signals and the\n\t// candidate-pool IDF all need them, and files repeat across candidates.\n\tconst windows = candidates.map((candidate) => {\n\t\tconst lines = readLines(candidate.path);\n\t\tif (!lines) return undefined;\n\t\treturn lines.slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine)).join(\"\\n\");\n\t});\n\n\t// Candidate-pool document frequency per term, for the IDF weighting below.\n\tconst documentFrequency = new Map<string, number>();\n\tfor (const term of terms) {\n\t\tdocumentFrequency.set(term, windows.filter((w) => w?.includes(term)).length);\n\t}\n\tconst termWeight = new Map(\n\t\tterms.map((t) => [t, inverseDocumentFrequency(documentFrequency.get(t) ?? 0, candidates.length)]),\n\t);\n\tconst totalTermWeight = terms.reduce((sum, t) => sum + (termWeight.get(t) ?? 0), 0);\n\n\t// Fused prior normalized by score, not by position: a candidate both\n\t// retrievers agreed on should outrank one that squeaked in, and a uniform\n\t// 1 - index/length ramp throws that magnitude away.\n\tconst maxRrfScore = Math.max(...candidates.map((c) => c.rrfScore), Number.MIN_VALUE);\n\n\tconst scored = candidates.map((candidate, index) => {\n\t\tconst fusedPrior = candidate.rrfScore / maxRrfScore;\n\n\t\tconst window = windows[index];\n\t\tlet termCoverage = 0;\n\t\tlet declaresAnyTerm = false;\n\t\tif (window && terms.length > 0) {\n\t\t\tconst present = terms.filter((t) => window.includes(t));\n\t\t\ttermCoverage =\n\t\t\t\ttotalTermWeight > 0\n\t\t\t\t\t? present.reduce((sum, t) => sum + (termWeight.get(t) ?? 0), 0) / totalTermWeight\n\t\t\t\t\t: present.length / terms.length;\n\t\t\tdeclaresAnyTerm = !prose && present.some((t) => declaresTerm(window, t));\n\t\t}\n\n\t\tconst lowerPath = candidate.path.toLowerCase();\n\t\t// A quoted phrase rarely names a file; split it into path-ish tokens so\n\t\t// `\"token budget exceeded\"` still gets partial path credit.\n\t\tconst pathTerms = terms.length === 1 ? terms[0].split(/[^a-z0-9_$]+/).filter((t) => t.length >= 3) : terms;\n\t\tconst pathAffinity =\n\t\t\tpathTerms.length > 0 ? pathTerms.filter((t) => lowerPath.includes(t)).length / pathTerms.length : 0;\n\n\t\tconst exactPath =\n\t\t\tqueryPath.length >= 3 && (lowerPath === queryPath || lowerPath.endsWith(`/${queryPath}`)) ? 1 : 0;\n\n\t\tconst score =\n\t\t\tWEIGHT_FUSED_PRIOR * fusedPrior +\n\t\t\tWEIGHT_TERM_COVERAGE * termCoverage +\n\t\t\tWEIGHT_PATH_AFFINITY * pathAffinity +\n\t\t\tEXACT_PATH_BONUS * exactPath +\n\t\t\t(declaresAnyTerm ? DECLARATION_BONUS : 0);\n\t\treturn { candidate, index, score };\n\t});\n\n\t// Stable, deterministic: score desc, fused order as tie-break.\n\tscored.sort((a, b) => b.score - a.score || a.index - b.index);\n\treturn { candidates: scored.map((s) => s.candidate), latencyMs: Date.now() - startedMs };\n}\n"]}
@@ -8,13 +8,26 @@
8
8
  */
9
9
  import type { FusedHit, RankedHit } from "./types.js";
10
10
  /**
11
- * Default RRF constant. The literature folklore default is 60; the eval gate
12
- * (scripts/search-eval.mjs, 12-query gold set) measured k ∈ {0, 2} beating
13
- * k = 60 on every differing query, twice, with reranking on top of either —
14
- * small k keeps fusion top-heavy toward each retriever's best hits, and the
15
- * reranker corrects the tail. Small sample: re-sweep when the gold set grows.
11
+ * Default RRF constant, re-swept on the 62-query harness.
12
+ *
13
+ * History matters here, because the answer changed twice. The original
14
+ * 12-query gold set picked k = 2 over k = 60; that measurement ran on an
15
+ * unpinned corpus with a file-level metric and was never reproducible. The
16
+ * 62-query re-sweep then found the two *indistinguishable* — a 3pp R@10 gap
17
+ * carried by two queries (p = 0.50), with MRR worse on more queries than it
18
+ * was better.
19
+ *
20
+ * What made k decidable was fixing the reranker. Once it could tell a
21
+ * declaration from a call site, the deeper, flatter candidate mix that k = 60
22
+ * produces became worth having: MRR 0.403 -> 0.464, 20 queries better against
23
+ * 7 worse (p <= 0.05), with R@10 unchanged. Small k keeps fusion top-heavy
24
+ * toward each retriever's best hits, which only pays when the reranker cannot
25
+ * exploit the tail — and it now can.
26
+ *
27
+ * Re-sweep again (`bun run search-eval:compare`) after any reranker change,
28
+ * since that is what this value trades against.
16
29
  */
17
- export declare const DEFAULT_RRF_K = 2;
30
+ export declare const DEFAULT_RRF_K = 60;
18
31
  /**
19
32
  * Fuse ranked lists into one deterministic ordering by summed `1/(k + rank)`.
20
33
  *
@@ -1 +1 @@
1
- {"version":3,"file":"rrf.d.ts","sourceRoot":"","sources":["../../../src/core/search/rrf.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEtD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,IAAI,CAAC;AAE/B;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,SAAS,EAAE,CAAC,EAAE,EAAE,CAAC,SAAgB,GAAG,QAAQ,EAAE,CA6C/F","sourcesContent":["/**\n * Reciprocal Rank Fusion — the transparent, rank-only recall layer of hybrid\n * search (docs/hybrid-retrieval-design.md, Decision 2).\n *\n * Rank-only on purpose: BM25 and cosine scores are not comparable across\n * retrievers, so raw scores are carried through as diagnostics but never\n * enter the fused score.\n */\n\nimport type { FusedHit, RankedHit } from \"./types.js\";\n\n/**\n * Default RRF constant. The literature folklore default is 60; the eval gate\n * (scripts/search-eval.mjs, 12-query gold set) measured k {0, 2} beating\n * k = 60 on every differing query, twice, with reranking on top of either —\n * small k keeps fusion top-heavy toward each retriever's best hits, and the\n * reranker corrects the tail. Small sample: re-sweep when the gold set grows.\n */\nexport const DEFAULT_RRF_K = 2;\n\n/**\n * Fuse ranked lists into one deterministic ordering by summed `1/(k + rank)`.\n *\n * Ties break by number of agreeing retrievers, then lexicographic id, so the\n * same inputs always produce the same context.\n *\n * Duplicate `source:id` pairs within one list are counted once (best rank\n * wins). The adapter dedupes upstream, so this guard should never fire in\n * practice — it exists so a misbehaving retriever cannot inflate its vote.\n */\nexport function rrfFuse(lists: readonly (readonly RankedHit[])[], k = DEFAULT_RRF_K): FusedHit[] {\n\tif (!Number.isFinite(k) || k < 0) {\n\t\tthrow new Error(`RRF k must be a finite non-negative number; got ${k}`);\n\t}\n\n\tconst acc = new Map<string, FusedHit>();\n\n\tfor (const list of lists) {\n\t\t// Collapse duplicates to their best rank first, so the single vote a\n\t\t// duplicated id gets is cast at the best rank regardless of emit order.\n\t\tconst collapsed = new Map<string, RankedHit>();\n\t\tfor (const hit of list) {\n\t\t\tif (!Number.isInteger(hit.rank) || hit.rank < 1) {\n\t\t\t\tthrow new Error(`RRF rank must be a positive integer; got ${hit.rank}`);\n\t\t\t}\n\t\t\tconst dedupeKey = `${hit.source}:${hit.id}`;\n\t\t\tconst existing = collapsed.get(dedupeKey);\n\t\t\tif (!existing || hit.rank < existing.rank) collapsed.set(dedupeKey, hit);\n\t\t}\n\n\t\tfor (const hit of collapsed.values()) {\n\t\t\tlet current = acc.get(hit.id);\n\t\t\tif (!current) {\n\t\t\t\tcurrent = { id: hit.id, rrfScore: 0, ranks: {}, rawScores: {} };\n\t\t\t\tacc.set(hit.id, current);\n\t\t\t}\n\n\t\t\tcurrent.rrfScore += 1 / (k + hit.rank);\n\n\t\t\tconst oldRank = current.ranks[hit.source];\n\t\t\tif (oldRank === undefined || hit.rank < oldRank) {\n\t\t\t\tcurrent.ranks[hit.source] = hit.rank;\n\t\t\t\t// rawScores follows the best rank; a best-ranked hit without a\n\t\t\t\t// score leaves any earlier score in place rather than erasing it.\n\t\t\t\tif (hit.score !== undefined) current.rawScores[hit.source] = hit.score;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.rrfScore - a.rrfScore ||\n\t\t\tObject.keys(b.ranks).length - Object.keys(a.ranks).length ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n}\n"]}
1
+ {"version":3,"file":"rrf.d.ts","sourceRoot":"","sources":["../../../src/core/search/rrf.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,KAAK,CAAC;AAEhC;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,SAAS,EAAE,CAAC,EAAE,EAAE,CAAC,SAAgB,GAAG,QAAQ,EAAE,CA6C/F","sourcesContent":["/**\n * Reciprocal Rank Fusion — the transparent, rank-only recall layer of hybrid\n * search (docs/hybrid-retrieval-design.md, Decision 2).\n *\n * Rank-only on purpose: BM25 and cosine scores are not comparable across\n * retrievers, so raw scores are carried through as diagnostics but never\n * enter the fused score.\n */\n\nimport type { FusedHit, RankedHit } from \"./types.js\";\n\n/**\n * Default RRF constant, re-swept on the 62-query harness.\n *\n * History matters here, because the answer changed twice. The original\n * 12-query gold set picked k = 2 over k = 60; that measurement ran on an\n * unpinned corpus with a file-level metric and was never reproducible. The\n * 62-query re-sweep then found the two *indistinguishable* — a 3pp R@10 gap\n * carried by two queries (p = 0.50), with MRR worse on more queries than it\n * was better.\n *\n * What made k decidable was fixing the reranker. Once it could tell a\n * declaration from a call site, the deeper, flatter candidate mix that k = 60\n * produces became worth having: MRR 0.403 -> 0.464, 20 queries better against\n * 7 worse (p <= 0.05), with R@10 unchanged. Small k keeps fusion top-heavy\n * toward each retriever's best hits, which only pays when the reranker cannot\n * exploit the tail and it now can.\n *\n * Re-sweep again (`bun run search-eval:compare`) after any reranker change,\n * since that is what this value trades against.\n */\nexport const DEFAULT_RRF_K = 60;\n\n/**\n * Fuse ranked lists into one deterministic ordering by summed `1/(k + rank)`.\n *\n * Ties break by number of agreeing retrievers, then lexicographic id, so the\n * same inputs always produce the same context.\n *\n * Duplicate `source:id` pairs within one list are counted once (best rank\n * wins). The adapter dedupes upstream, so this guard should never fire in\n * practice — it exists so a misbehaving retriever cannot inflate its vote.\n */\nexport function rrfFuse(lists: readonly (readonly RankedHit[])[], k = DEFAULT_RRF_K): FusedHit[] {\n\tif (!Number.isFinite(k) || k < 0) {\n\t\tthrow new Error(`RRF k must be a finite non-negative number; got ${k}`);\n\t}\n\n\tconst acc = new Map<string, FusedHit>();\n\n\tfor (const list of lists) {\n\t\t// Collapse duplicates to their best rank first, so the single vote a\n\t\t// duplicated id gets is cast at the best rank regardless of emit order.\n\t\tconst collapsed = new Map<string, RankedHit>();\n\t\tfor (const hit of list) {\n\t\t\tif (!Number.isInteger(hit.rank) || hit.rank < 1) {\n\t\t\t\tthrow new Error(`RRF rank must be a positive integer; got ${hit.rank}`);\n\t\t\t}\n\t\t\tconst dedupeKey = `${hit.source}:${hit.id}`;\n\t\t\tconst existing = collapsed.get(dedupeKey);\n\t\t\tif (!existing || hit.rank < existing.rank) collapsed.set(dedupeKey, hit);\n\t\t}\n\n\t\tfor (const hit of collapsed.values()) {\n\t\t\tlet current = acc.get(hit.id);\n\t\t\tif (!current) {\n\t\t\t\tcurrent = { id: hit.id, rrfScore: 0, ranks: {}, rawScores: {} };\n\t\t\t\tacc.set(hit.id, current);\n\t\t\t}\n\n\t\t\tcurrent.rrfScore += 1 / (k + hit.rank);\n\n\t\t\tconst oldRank = current.ranks[hit.source];\n\t\t\tif (oldRank === undefined || hit.rank < oldRank) {\n\t\t\t\tcurrent.ranks[hit.source] = hit.rank;\n\t\t\t\t// rawScores follows the best rank; a best-ranked hit without a\n\t\t\t\t// score leaves any earlier score in place rather than erasing it.\n\t\t\t\tif (hit.score !== undefined) current.rawScores[hit.source] = hit.score;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.rrfScore - a.rrfScore ||\n\t\t\tObject.keys(b.ranks).length - Object.keys(a.ranks).length ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n}\n"]}
@@ -7,13 +7,26 @@
7
7
  * enter the fused score.
8
8
  */
9
9
  /**
10
- * Default RRF constant. The literature folklore default is 60; the eval gate
11
- * (scripts/search-eval.mjs, 12-query gold set) measured k ∈ {0, 2} beating
12
- * k = 60 on every differing query, twice, with reranking on top of either —
13
- * small k keeps fusion top-heavy toward each retriever's best hits, and the
14
- * reranker corrects the tail. Small sample: re-sweep when the gold set grows.
10
+ * Default RRF constant, re-swept on the 62-query harness.
11
+ *
12
+ * History matters here, because the answer changed twice. The original
13
+ * 12-query gold set picked k = 2 over k = 60; that measurement ran on an
14
+ * unpinned corpus with a file-level metric and was never reproducible. The
15
+ * 62-query re-sweep then found the two *indistinguishable* — a 3pp R@10 gap
16
+ * carried by two queries (p = 0.50), with MRR worse on more queries than it
17
+ * was better.
18
+ *
19
+ * What made k decidable was fixing the reranker. Once it could tell a
20
+ * declaration from a call site, the deeper, flatter candidate mix that k = 60
21
+ * produces became worth having: MRR 0.403 -> 0.464, 20 queries better against
22
+ * 7 worse (p <= 0.05), with R@10 unchanged. Small k keeps fusion top-heavy
23
+ * toward each retriever's best hits, which only pays when the reranker cannot
24
+ * exploit the tail — and it now can.
25
+ *
26
+ * Re-sweep again (`bun run search-eval:compare`) after any reranker change,
27
+ * since that is what this value trades against.
15
28
  */
16
- export const DEFAULT_RRF_K = 2;
29
+ export const DEFAULT_RRF_K = 60;
17
30
  /**
18
31
  * Fuse ranked lists into one deterministic ordering by summed `1/(k + rank)`.
19
32
  *
@@ -1 +1 @@
1
- {"version":3,"file":"rrf.js","sourceRoot":"","sources":["../../../src/core/search/rrf.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Reciprocal Rank Fusion — the transparent, rank-only recall layer of hybrid\n * search (docs/hybrid-retrieval-design.md, Decision 2).\n *\n * Rank-only on purpose: BM25 and cosine scores are not comparable across\n * retrievers, so raw scores are carried through as diagnostics but never\n * enter the fused score.\n */\n\nimport type { FusedHit, RankedHit } from \"./types.js\";\n\n/**\n * Default RRF constant. The literature folklore default is 60; the eval gate\n * (scripts/search-eval.mjs, 12-query gold set) measured k {0, 2} beating\n * k = 60 on every differing query, twice, with reranking on top of either —\n * small k keeps fusion top-heavy toward each retriever's best hits, and the\n * reranker corrects the tail. Small sample: re-sweep when the gold set grows.\n */\nexport const DEFAULT_RRF_K = 2;\n\n/**\n * Fuse ranked lists into one deterministic ordering by summed `1/(k + rank)`.\n *\n * Ties break by number of agreeing retrievers, then lexicographic id, so the\n * same inputs always produce the same context.\n *\n * Duplicate `source:id` pairs within one list are counted once (best rank\n * wins). The adapter dedupes upstream, so this guard should never fire in\n * practice — it exists so a misbehaving retriever cannot inflate its vote.\n */\nexport function rrfFuse(lists: readonly (readonly RankedHit[])[], k = DEFAULT_RRF_K): FusedHit[] {\n\tif (!Number.isFinite(k) || k < 0) {\n\t\tthrow new Error(`RRF k must be a finite non-negative number; got ${k}`);\n\t}\n\n\tconst acc = new Map<string, FusedHit>();\n\n\tfor (const list of lists) {\n\t\t// Collapse duplicates to their best rank first, so the single vote a\n\t\t// duplicated id gets is cast at the best rank regardless of emit order.\n\t\tconst collapsed = new Map<string, RankedHit>();\n\t\tfor (const hit of list) {\n\t\t\tif (!Number.isInteger(hit.rank) || hit.rank < 1) {\n\t\t\t\tthrow new Error(`RRF rank must be a positive integer; got ${hit.rank}`);\n\t\t\t}\n\t\t\tconst dedupeKey = `${hit.source}:${hit.id}`;\n\t\t\tconst existing = collapsed.get(dedupeKey);\n\t\t\tif (!existing || hit.rank < existing.rank) collapsed.set(dedupeKey, hit);\n\t\t}\n\n\t\tfor (const hit of collapsed.values()) {\n\t\t\tlet current = acc.get(hit.id);\n\t\t\tif (!current) {\n\t\t\t\tcurrent = { id: hit.id, rrfScore: 0, ranks: {}, rawScores: {} };\n\t\t\t\tacc.set(hit.id, current);\n\t\t\t}\n\n\t\t\tcurrent.rrfScore += 1 / (k + hit.rank);\n\n\t\t\tconst oldRank = current.ranks[hit.source];\n\t\t\tif (oldRank === undefined || hit.rank < oldRank) {\n\t\t\t\tcurrent.ranks[hit.source] = hit.rank;\n\t\t\t\t// rawScores follows the best rank; a best-ranked hit without a\n\t\t\t\t// score leaves any earlier score in place rather than erasing it.\n\t\t\t\tif (hit.score !== undefined) current.rawScores[hit.source] = hit.score;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.rrfScore - a.rrfScore ||\n\t\t\tObject.keys(b.ranks).length - Object.keys(a.ranks).length ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n}\n"]}
1
+ {"version":3,"file":"rrf.js","sourceRoot":"","sources":["../../../src/core/search/rrf.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Reciprocal Rank Fusion — the transparent, rank-only recall layer of hybrid\n * search (docs/hybrid-retrieval-design.md, Decision 2).\n *\n * Rank-only on purpose: BM25 and cosine scores are not comparable across\n * retrievers, so raw scores are carried through as diagnostics but never\n * enter the fused score.\n */\n\nimport type { FusedHit, RankedHit } from \"./types.js\";\n\n/**\n * Default RRF constant, re-swept on the 62-query harness.\n *\n * History matters here, because the answer changed twice. The original\n * 12-query gold set picked k = 2 over k = 60; that measurement ran on an\n * unpinned corpus with a file-level metric and was never reproducible. The\n * 62-query re-sweep then found the two *indistinguishable* — a 3pp R@10 gap\n * carried by two queries (p = 0.50), with MRR worse on more queries than it\n * was better.\n *\n * What made k decidable was fixing the reranker. Once it could tell a\n * declaration from a call site, the deeper, flatter candidate mix that k = 60\n * produces became worth having: MRR 0.403 -> 0.464, 20 queries better against\n * 7 worse (p <= 0.05), with R@10 unchanged. Small k keeps fusion top-heavy\n * toward each retriever's best hits, which only pays when the reranker cannot\n * exploit the tail and it now can.\n *\n * Re-sweep again (`bun run search-eval:compare`) after any reranker change,\n * since that is what this value trades against.\n */\nexport const DEFAULT_RRF_K = 60;\n\n/**\n * Fuse ranked lists into one deterministic ordering by summed `1/(k + rank)`.\n *\n * Ties break by number of agreeing retrievers, then lexicographic id, so the\n * same inputs always produce the same context.\n *\n * Duplicate `source:id` pairs within one list are counted once (best rank\n * wins). The adapter dedupes upstream, so this guard should never fire in\n * practice — it exists so a misbehaving retriever cannot inflate its vote.\n */\nexport function rrfFuse(lists: readonly (readonly RankedHit[])[], k = DEFAULT_RRF_K): FusedHit[] {\n\tif (!Number.isFinite(k) || k < 0) {\n\t\tthrow new Error(`RRF k must be a finite non-negative number; got ${k}`);\n\t}\n\n\tconst acc = new Map<string, FusedHit>();\n\n\tfor (const list of lists) {\n\t\t// Collapse duplicates to their best rank first, so the single vote a\n\t\t// duplicated id gets is cast at the best rank regardless of emit order.\n\t\tconst collapsed = new Map<string, RankedHit>();\n\t\tfor (const hit of list) {\n\t\t\tif (!Number.isInteger(hit.rank) || hit.rank < 1) {\n\t\t\t\tthrow new Error(`RRF rank must be a positive integer; got ${hit.rank}`);\n\t\t\t}\n\t\t\tconst dedupeKey = `${hit.source}:${hit.id}`;\n\t\t\tconst existing = collapsed.get(dedupeKey);\n\t\t\tif (!existing || hit.rank < existing.rank) collapsed.set(dedupeKey, hit);\n\t\t}\n\n\t\tfor (const hit of collapsed.values()) {\n\t\t\tlet current = acc.get(hit.id);\n\t\t\tif (!current) {\n\t\t\t\tcurrent = { id: hit.id, rrfScore: 0, ranks: {}, rawScores: {} };\n\t\t\t\tacc.set(hit.id, current);\n\t\t\t}\n\n\t\t\tcurrent.rrfScore += 1 / (k + hit.rank);\n\n\t\t\tconst oldRank = current.ranks[hit.source];\n\t\t\tif (oldRank === undefined || hit.rank < oldRank) {\n\t\t\t\tcurrent.ranks[hit.source] = hit.rank;\n\t\t\t\t// rawScores follows the best rank; a best-ranked hit without a\n\t\t\t\t// score leaves any earlier score in place rather than erasing it.\n\t\t\t\tif (hit.score !== undefined) current.rawScores[hit.source] = hit.score;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.rrfScore - a.rrfScore ||\n\t\t\tObject.keys(b.ranks).length - Object.keys(a.ranks).length ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n}\n"]}
@@ -7,7 +7,17 @@
7
7
  * query (fusion and span expansion consult the same sidecar snapshot) but not
8
8
  * across edits or rebuilds — never persist them as durable references.
9
9
  */
10
- type RetrieverSource = "grep" | "embed";
10
+ /**
11
+ * A retriever whose ranked list can enter fusion.
12
+ *
13
+ * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the
14
+ * `retriever: "lexical"` op rather than pre-fused by `query_hybrid`. Keeping
15
+ * it separate from `grep` matters: they are both "lexical" but they fail
16
+ * differently — BM25 has IDF and misses identifiers written in another naming
17
+ * convention, ripgrep has neither IDF nor an index but sees the working tree,
18
+ * including edits made this session.
19
+ */
20
+ type RetrieverSource = "grep" | "embed" | "bm25";
11
21
  export type SearchMode = "auto" | "lexical" | "semantic" | "hybrid";
12
22
  export type ResolvedSearchMode = Exclude<SearchMode, "auto">;
13
23
  export interface RankedHit {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,KAAK,eAAe,GAAG,MAAM,GAAG,OAAO,CAAC;AAExC,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;AACpE,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;AAE7D,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,uFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;CACxB;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,kEAAkE;IAClE,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,EAAE,aAAa;CAAG;AAElE;0BAC0B;AAC1B,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,UAAU,CAAC;IAC1B,YAAY,EAAE,kBAAkB,CAAC;IACjC,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,OAAO,GAAG,UAAU,GAAG,aAAa,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IACtF,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\ntype RetrieverSource = \"grep\" | \"embed\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;GASG;AACH,KAAK,eAAe,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAEjD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;AACpE,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;AAE7D,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,uFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;CACxB;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,kEAAkE;IAClE,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,EAAE,aAAa;CAAG;AAElE;0BAC0B;AAC1B,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,UAAU,CAAC;IAC1B,YAAY,EAAE,kBAAkB,CAAC;IACjC,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,OAAO,GAAG,UAAU,GAAG,aAAa,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IACtF,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\n/**\n * A retriever whose ranked list can enter fusion.\n *\n * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the\n * `retriever: \"lexical\"` op rather than pre-fused by `query_hybrid`. Keeping\n * it separate from `grep` matters: they are both \"lexical\" but they fail\n * differently — BM25 has IDF and misses identifiers written in another naming\n * convention, ripgrep has neither IDF nor an index but sees the working tree,\n * including edits made this session.\n */\ntype RetrieverSource = \"grep\" | \"embed\" | \"bm25\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\ntype RetrieverSource = \"grep\" | \"embed\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\n/**\n * A retriever whose ranked list can enter fusion.\n *\n * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the\n * `retriever: \"lexical\"` op rather than pre-fused by `query_hybrid`. Keeping\n * it separate from `grep` matters: they are both \"lexical\" but they fail\n * differently — BM25 has IDF and misses identifiers written in another naming\n * convention, ripgrep has neither IDF nor an index but sees the working tree,\n * including edits made this session.\n */\ntype RetrieverSource = \"grep\" | \"embed\" | \"bm25\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-custom-provider-anthropic",
3
3
  "private": true,
4
- "version": "0.2.161",
4
+ "version": "0.2.163",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-custom-provider-gitlab-duo",
3
3
  "private": true,
4
- "version": "0.2.161",
4
+ "version": "0.2.163",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-sandbox",
3
3
  "private": true,
4
- "version": "0.2.161",
4
+ "version": "0.2.163",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-with-deps",
3
3
  "private": true,
4
- "version": "0.2.161",
4
+ "version": "0.2.163",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-agent",
3
- "version": "0.4.164",
3
+ "version": "0.4.166",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "hoocodeConfig": {
@@ -42,12 +42,15 @@
42
42
  "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/ && shx mkdir -p dist/core/extensions/plugins && shx cp -r src/core/extensions/plugins/default-marketplace dist/core/extensions/plugins/",
43
43
  "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
44
44
  "test": "vitest --run",
45
+ "search-eval": "bun scripts/search-eval.ts",
46
+ "search-eval:gold": "bun scripts/search-eval-gold.ts",
47
+ "search-eval:compare": "bun scripts/search-eval-compare.ts",
45
48
  "prepublishOnly": "npm run clean && npm run build"
46
49
  },
47
50
  "dependencies": {
48
- "@kolisachint/hoocode-agent-core": "^0.4.164",
49
- "@kolisachint/hoocode-ai": "^0.4.164",
50
- "@kolisachint/hoocode-tui": "^0.4.164",
51
+ "@kolisachint/hoocode-agent-core": "^0.4.166",
52
+ "@kolisachint/hoocode-ai": "^0.4.166",
53
+ "@kolisachint/hoocode-tui": "^0.4.166",
51
54
  "@silvia-odwyer/photon-node": "^0.3.4",
52
55
  "chalk": "^5.5.0",
53
56
  "cli-highlight": "^2.1.11",