graph-indexer 1.2.0 → 2.1.0

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 (70) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +258 -595
  3. package/SECURITY.md +85 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/colbert.mjs +168 -0
  7. package/config.mjs +408 -32
  8. package/daemon-ctl.mjs +175 -0
  9. package/daemon-lock.mjs +69 -0
  10. package/embedders/python/mlx_embed_server.py +61 -0
  11. package/embedders/python/requirements-mlx.txt +5 -0
  12. package/embedders/setup-mlx.mjs +110 -0
  13. package/embeddings.mjs +571 -0
  14. package/engine/binary.mjs +437 -0
  15. package/{core-engine.mjs → engine/memory.mjs} +321 -441
  16. package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
  17. package/enrichment.mjs +206 -32
  18. package/git-signals.mjs +248 -0
  19. package/indexer.mjs +399 -45
  20. package/init.mjs +1626 -171
  21. package/layout.mjs +123 -0
  22. package/mcp/centrality.mjs +117 -0
  23. package/mcp/format.mjs +61 -0
  24. package/mcp/resolver.mjs +116 -0
  25. package/mcp/symbolgraph.mjs +137 -0
  26. package/mcp/taint.mjs +225 -0
  27. package/mcp/tools.mjs +1582 -0
  28. package/mcp/topology.mjs +676 -0
  29. package/mcp-server.mjs +80 -25
  30. package/package.json +66 -27
  31. package/parse/extractor.mjs +628 -0
  32. package/parse/imports.mjs +365 -0
  33. package/parse/interprocedural.mjs +134 -0
  34. package/parse/languages.mjs +253 -0
  35. package/parse/metadata.mjs +701 -0
  36. package/parse/routes.mjs +327 -0
  37. package/parse/scip.mjs +391 -0
  38. package/parse/taint-patterns.mjs +119 -0
  39. package/prompts/CORE.md +91 -0
  40. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  41. package/prompts/INTEGRATION.md +123 -0
  42. package/prompts/frameworks/ANDROID.md +17 -0
  43. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  44. package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
  45. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  46. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
  47. package/prompts/frameworks/RAILS.md +20 -0
  48. package/prompts/frameworks/REACT.md +20 -0
  49. package/prompts/frameworks/SPRING_BOOT.md +20 -0
  50. package/prompts/languages/BASH.md +22 -0
  51. package/prompts/languages/C.md +21 -0
  52. package/prompts/languages/CSHARP.md +24 -0
  53. package/prompts/languages/CSS_SCSS.md +22 -0
  54. package/prompts/languages/GO.md +24 -0
  55. package/prompts/languages/JAVA.md +23 -0
  56. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  57. package/prompts/languages/KOTLIN.md +24 -0
  58. package/prompts/languages/PHP.md +23 -0
  59. package/prompts/languages/PYTHON.md +23 -0
  60. package/prompts/languages/RUBY.md +23 -0
  61. package/prompts/languages/RUST.md +21 -0
  62. package/prompts/languages/SWIFT.md +21 -0
  63. package/seal.mjs +400 -0
  64. package/search-core.mjs +405 -44
  65. package/search-sparse.mjs +183 -0
  66. package/storage.mjs +33 -3
  67. package/watch-daemon.mjs +75 -24
  68. package/PROMPT.md +0 -182
  69. package/mcp-tools.mjs +0 -545
  70. package/parser-utils.mjs +0 -1170
package/search-core.mjs CHANGED
@@ -13,6 +13,10 @@
13
13
 
14
14
  import { createHash } from 'crypto';
15
15
 
16
+ // Max embedding-input length in chars. ~8000 chars ≈ the usable context of the
17
+ // default embedders (nomic-embed-text / MiniLM) once a query/document prefix is
18
+ // added; longer inputs are silently truncated by the backend, so this is also the
19
+ // per-window size for oversized definitions (see embeddingWindows).
16
20
  export const EMBEDDING_CONTEXT_LIMIT = 8000;
17
21
 
18
22
  /**
@@ -43,31 +47,173 @@ export function truncateForEmbedding(text) {
43
47
  return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
44
48
  }
45
49
 
50
+ // Window-and-pool sub-chunking for the dense channel. A large definition (e.g. a
51
+ // ~600-line free function) used to be embedded as a single vector over only its
52
+ // first EMBEDDING_CONTEXT_LIMIT chars — its tail was invisible to semantic search
53
+ // (BM25 still indexed the whole text, so this gap is dense-only). Instead we embed
54
+ // a few overlapping windows and retrieve the chunk by the MAX cosine over its
55
+ // windows (max-sim), which preserves tail recall where mean-pooling would dilute it.
56
+ //
57
+ // 12% overlap: a definition that straddles a boundary still lands wholly inside an
58
+ // adjacent window (EMBEDDING_CONTEXT_LIMIT*0.12 ≈ 960 chars of shared context).
59
+ export const EMBEDDING_WINDOW_OVERLAP = 0.12;
60
+ // Hard cap on windows per chunk. Bounds embed cost and .bin growth at ~MAX× a
61
+ // single vector. 4 windows at the 7040-char stride cover ~29k chars (~700 lines);
62
+ // beyond that the god-class split already shards classes into method chunks, and a
63
+ // non-class definition that large is rare enough to accept head-only coverage.
64
+ export const EMBEDDING_MAX_WINDOWS = 4;
65
+ // Suffix for a chunk's window vectors past the first: <key>|w1, <key>|w2, … Window 0
66
+ // is the base key itself (identical to the single-vector path), so small chunks and
67
+ // the head of large chunks are byte-identical to before windowing existed.
68
+ export const WINDOW_VEC_SUFFIX = '|w';
69
+
70
+ const _VEC_SUFFIX_RE = /\|(?:s|w\d+)$/;
71
+ /** Strip a vector-key suffix (summary `|s` or window `|wN`) back to the base key, so
72
+ * every per-chunk vector folds onto one fusion candidate. Both backends use this. */
73
+ export function baseEmbeddingKey(key) {
74
+ return key.replace(_VEC_SUFFIX_RE, '');
75
+ }
76
+
77
+ /**
78
+ * Split an oversized embedding payload into overlapping windows of
79
+ * ≤EMBEDDING_CONTEXT_LIMIT chars, capped at EMBEDDING_MAX_WINDOWS. Returns [] when
80
+ * the payload fits in a single window (the default path — the caller embeds it under
81
+ * the base key unchanged). Window 0 is the same first-EMBEDDING_CONTEXT_LIMIT slice
82
+ * the single-vector path produced, so the base vector is unchanged; windows 1..N
83
+ * cover the tail and are stored under WINDOW_VEC_SUFFIX keys.
84
+ *
85
+ * @param {string} payload
86
+ * @returns {string[]} [] or [window0, window1, …] (length ≥ 2 when it splits).
87
+ */
88
+ export function embeddingWindows(payload) {
89
+ if (!payload || payload.length <= EMBEDDING_CONTEXT_LIMIT) return [];
90
+ const stride = Math.max(1, Math.round(EMBEDDING_CONTEXT_LIMIT * (1 - EMBEDDING_WINDOW_OVERLAP)));
91
+ const windows = [];
92
+ for (let i = 0; i < EMBEDDING_MAX_WINDOWS; i++) {
93
+ const start = i * stride;
94
+ if (start >= payload.length) break;
95
+ windows.push(payload.slice(start, start + EMBEDDING_CONTEXT_LIMIT));
96
+ if (start + EMBEDDING_CONTEXT_LIMIT >= payload.length) break; // tail covered
97
+ }
98
+ return windows;
99
+ }
100
+
46
101
  export function cosineSimilarity(vecA, vecB) {
47
102
  let dot = 0, nA = 0, nB = 0;
48
103
  for (let i = 0; i < vecA.length; i++) {
49
104
  dot += vecA[i] * vecB[i];
50
- nA += vecA[i] * vecA[i];
51
- nB += vecB[i] * vecB[i];
105
+ nA += vecA[i] * vecA[i];
106
+ nB += vecB[i] * vecB[i];
52
107
  }
53
108
  return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
54
109
  }
55
110
 
111
+ // ─── Light Porter stemmer (additive recall bridge) ─────────────────────────────
112
+ // Collapses English morphological variants to a shared root so behavioural queries
113
+ // reach code identifiers across the inflection gap that lexical match otherwise
114
+ // misses: "intercepting"↔"Interceptor", "injection"↔"Injectable",
115
+ // "bootstrapping"↔"bootstrap", "managing"↔"Manager". Classic Porter (1980) steps
116
+ // 1–5 plus an agent-noun "-or" rule for code (Interceptor→intercept,
117
+ // Constructor→construct). Used ADDITIVELY in tokenize(): the raw token is always
118
+ // emitted and the stem only when it differs — so exact matches, df statistics and
119
+ // the name/path boosts are byte-for-byte unchanged; only recall is added.
120
+ // Deterministic and pure → both backends produce identical postings (parity-safe).
121
+ const _isVowel = (s, i) => {
122
+ const c = s.charCodeAt(i);
123
+ // a e i o u
124
+ if (c === 97 || c === 101 || c === 105 || c === 111 || c === 117) return true;
125
+ if (s[i] === 'y') return i === 0 ? true : !_isVowel(s, i - 1);
126
+ return false;
127
+ };
128
+ const _measure = (s) => {
129
+ let n = 0, prevV = false;
130
+ for (let i = 0; i < s.length; i++) { const v = _isVowel(s, i); if (prevV && !v) n++; prevV = v; }
131
+ return n;
132
+ };
133
+ const _hasVowel = (s) => { for (let i = 0; i < s.length; i++) if (_isVowel(s, i)) return true; return false; };
134
+ const _endsDoubleCons = (s) => s.length >= 2 && s[s.length - 1] === s[s.length - 2] && !_isVowel(s, s.length - 1);
135
+ const _cvc = (s) => {
136
+ const n = s.length; if (n < 3) return false;
137
+ if (_isVowel(s, n - 1) || !_isVowel(s, n - 2) || _isVowel(s, n - 3)) return false;
138
+ const c = s[n - 1]; return c !== 'w' && c !== 'x' && c !== 'y';
139
+ };
140
+ const _STEP2 = [['ational', 'ate'], ['tional', 'tion'], ['enci', 'ence'], ['anci', 'ance'], ['izer', 'ize'], ['abli', 'able'], ['alli', 'al'], ['entli', 'ent'], ['eli', 'e'], ['ousli', 'ous'], ['ization', 'ize'], ['ation', 'ate'], ['ator', 'ate'], ['alism', 'al'], ['iveness', 'ive'], ['fulness', 'ful'], ['ousness', 'ous'], ['aliti', 'al'], ['iviti', 'ive'], ['biliti', 'ble'], ['logi', 'log']];
141
+ const _STEP3 = [['icate', 'ic'], ['ative', ''], ['alize', 'al'], ['iciti', 'ic'], ['ical', 'ic'], ['ful', ''], ['ness', '']];
142
+ // Longest-first; 'or' added to Porter's set so agent nouns reduce like '-er'.
143
+ const _STEP4 = ['ement', 'ance', 'ence', 'able', 'ible', 'ment', 'ant', 'ent', 'ion', 'ism', 'ate', 'iti', 'ous', 'ive', 'ize', 'al', 'er', 'or', 'ic', 'ou'];
144
+
145
+ /** Porter stem of a single lowercase word. Returns the input unchanged for short
146
+ * tokens (≤3 chars: acronyms, short API names) so precise symbols never blur. */
147
+ export function stemToken(word) {
148
+ let w = word;
149
+ if (w.length <= 3) return w;
150
+
151
+ if (w.endsWith('sses')) w = w.slice(0, -2);
152
+ else if (w.endsWith('ies')) w = w.slice(0, -2);
153
+ else if (!w.endsWith('ss') && w.endsWith('s')) w = w.slice(0, -1);
154
+
155
+ let fix = false;
156
+ if (w.endsWith('eed')) { if (_measure(w.slice(0, -3)) > 0) w = w.slice(0, -1); }
157
+ else if (w.endsWith('ed') && _hasVowel(w.slice(0, -2))) { w = w.slice(0, -2); fix = true; }
158
+ else if (w.endsWith('ing') && _hasVowel(w.slice(0, -3))) { w = w.slice(0, -3); fix = true; }
159
+ if (fix) {
160
+ if (w.endsWith('at') || w.endsWith('bl') || w.endsWith('iz')) w += 'e';
161
+ else if (_endsDoubleCons(w) && !/[lsz]$/.test(w)) w = w.slice(0, -1);
162
+ else if (_measure(w) === 1 && _cvc(w)) w += 'e';
163
+ }
164
+
165
+ if (w.length > 2 && w.endsWith('y') && _hasVowel(w.slice(0, -1))) w = w.slice(0, -1) + 'i';
166
+
167
+ for (const [suf, rep] of _STEP2) { if (w.endsWith(suf)) { const st = w.slice(0, -suf.length); if (_measure(st) > 0) w = st + rep; break; } }
168
+ for (const [suf, rep] of _STEP3) { if (w.endsWith(suf)) { const st = w.slice(0, -suf.length); if (_measure(st) > 0) w = st + rep; break; } }
169
+
170
+ for (const suf of _STEP4) {
171
+ if (w.endsWith(suf)) {
172
+ const st = w.slice(0, -suf.length);
173
+ if (suf === 'ion') { if (_measure(st) > 1 && /[st]$/.test(st)) w = st; }
174
+ else if (_measure(st) > 1) w = st;
175
+ break;
176
+ }
177
+ }
178
+
179
+ if (w.endsWith('e')) { const st = w.slice(0, -1); const m = _measure(st); if (m > 1 || (m === 1 && !_cvc(st))) w = st; }
180
+ if (_measure(w) > 1 && w.endsWith('l') && _endsDoubleCons(w)) w = w.slice(0, -1);
181
+
182
+ return w;
183
+ }
184
+
56
185
  /**
57
186
  * Tokenise code/identifiers: lowercase words plus camelCase sub-parts, so
58
- * `dispatchRequest` indexes as `dispatchrequest`, `dispatch`, `request`.
187
+ * `dispatchRequest` indexes as `dispatchrequest`, `dispatch`, `request`. Each
188
+ * emitted token also contributes its Porter stem (when different) so behavioural
189
+ * queries bridge the inflection gap to code names — see stemToken. Additive: the
190
+ * raw token is never dropped, so exact match and the boost ladder are unaffected.
59
191
  */
60
- export function tokenize(text) {
192
+ // Stem postings live in a separate term namespace (sentinel-prefixed) so a raw
193
+ // query token can NEVER match a stem posting: symbolic/exact lookups stay precisely
194
+ // what they were before stemming existed, and only a query that opts in (emits the
195
+ // prefixed stem) reaches the morphological bridges. Index always emits both; the
196
+ // query emits the prefixed stem only for natural-language queries.
197
+ export const STEM_PREFIX = '~stem~'; // sentinel namespace: raw tokens are [A-Za-z0-9]+, so '~' can never collide
198
+
199
+ export function tokenize(text, stem = true) {
61
200
  if (!text) return [];
62
201
  const rawTokens = text.split(/[\s\W_]+/);
63
202
  const tokens = [];
203
+ const emit = (t) => {
204
+ const lo = t.toLowerCase();
205
+ tokens.push(lo);
206
+ if (!stem) return;
207
+ const s = stemToken(lo);
208
+ if (s !== lo && s.length >= 3) tokens.push(STEM_PREFIX + s);
209
+ };
64
210
  for (const word of rawTokens) {
65
211
  if (word.length < 2) continue;
66
- tokens.push(word.toLowerCase());
212
+ emit(word);
67
213
  const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
68
214
  if (camelParts.length > 1) {
69
215
  for (const part of camelParts) {
70
- if (part.length >= 2) tokens.push(part.toLowerCase());
216
+ if (part.length >= 2) emit(part);
71
217
  }
72
218
  }
73
219
  }
@@ -84,10 +230,14 @@ export function tokenize(text) {
84
230
  */
85
231
  export const SUMMARY_VEC_SUFFIX = '|s';
86
232
 
87
- /** The text embedded as the summary-only vector, or null when not enriched. */
233
+ /** The text embedded as the summary-only vector, or null when not enriched.
234
+ * The leading LLM summary carries the behavioural signal; the concept tags add
235
+ * domain vocabulary; the qualified name (class_context.name when present) anchors
236
+ * the vector to the symbol so a method is disambiguated from same-named peers. */
88
237
  export function summaryEmbeddingText(chunk) {
89
238
  if (!chunk.summary && !(chunk.concepts?.length > 0)) return null;
90
- return [chunk.summary, (chunk.concepts || []).join(', '), chunk.name]
239
+ const qualifiedName = chunk.class_context ? `${chunk.class_context}.${chunk.name}` : chunk.name;
240
+ return [chunk.summary, (chunk.concepts || []).join(', '), qualifiedName]
91
241
  .filter(Boolean).join('. ');
92
242
  }
93
243
 
@@ -98,7 +248,7 @@ export function summaryEmbeddingText(chunk) {
98
248
  *
99
249
  * Single source of truth shared by the in-memory engine, the watch daemon and the
100
250
  * SQLite writer so every backend indexes identical text. Decorators are
101
- * deliberately excluded — measured to regress framework repos (see core-engine).
251
+ * deliberately excluded — measured to regress framework repos (see engine/memory.mjs).
102
252
  * When enrichment ran, chunk.concepts contains domain keyword strings that bridge
103
253
  * lexical gaps (e.g. "authentication JWT middleware"); chunk.hyde is concepts.join(' ')
104
254
  * and serves as the backward-compatible field for existing serialized indexes.
@@ -133,7 +283,7 @@ export function buildLexicalDocument(chunk, depRelPaths = []) {
133
283
  // prose, so heavy length normalisation wrongly penalises long implementations vs
134
284
  // short export stubs.
135
285
  export const BM25_K1 = 1.5;
136
- export const BM25_B = 0.3;
286
+ export const BM25_B = 0.3;
137
287
 
138
288
  /** Okapi IDF — always positive, avoids negative IDF for very common terms. */
139
289
  export function okapiIdf(docCount, docFreq) {
@@ -145,6 +295,43 @@ export function bm25Score(idf, tf, docLen, avgdl, k1 = BM25_K1, b = BM25_B) {
145
295
  return idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgdl));
146
296
  }
147
297
 
298
+ /**
299
+ * B3 — score the learned-sparse expansion channel: a weighted-term retrieval over the EXISTING BM25
300
+ * postings, used identically by both backends so the channel is parity-by-construction.
301
+ *
302
+ * PARITY INVARIANT (do not break): each chunk receives EXACTLY ONE addend per associate term, so a
303
+ * chunk's final float sum is the cross-term accumulation in `termWeights`' iteration order — which is
304
+ * deterministic and identical across backends (expandSparseQuery rebuilds the Map sorted: weight desc,
305
+ * term asc). The INNER getPostings(term) order legitimately differs between backends (memory Map vs
306
+ * SQL rows) and is parity-IRRELEVANT precisely because the keys within one term are distinct (one
307
+ * scores.set per chunk per term). Never accumulate within a term, or emit a chunk twice for one term.
308
+ *
309
+ * The backends differ only in HOW postings are reached (in-memory Map vs SQL), injected as accessors:
310
+ * getDf(term) → document frequency (0 ⇒ skip the term)
311
+ * getPostings(term) → iterable of [chunkId, tf]
312
+ * getDocLen(chunkId) → that chunk's BM25 doc length (falls back to avgdl)
313
+ *
314
+ * @param {Iterable<[string, number]>} termWeights associate term → fusion weight (ordered)
315
+ * @param {{docCount:number, avgdl:number, getDf:Function, getPostings:Function, getDocLen:Function}} acc
316
+ * @returns {Array<{id:string, score:number, rank:number}>} ranked, capped, id-tie-broken
317
+ */
318
+ export function scoreSparseExpanded(termWeights, { docCount, avgdl, getDf, getPostings, getDocLen }) {
319
+ const scores = new Map();
320
+ for (const [term, weight] of termWeights) {
321
+ const dfreq = getDf(term);
322
+ if (!dfreq) continue;
323
+ const idf = okapiIdf(docCount, dfreq);
324
+ for (const [chunkId, tf] of getPostings(term)) {
325
+ const dl = getDocLen(chunkId) ?? avgdl;
326
+ scores.set(chunkId, (scores.get(chunkId) || 0) + weight * bm25Score(idf, tf, dl, avgdl));
327
+ }
328
+ }
329
+ return Array.from(scores.entries())
330
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
331
+ .slice(0, LEXICAL_FUSION_CAP)
332
+ .map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
333
+ }
334
+
148
335
  // ─── Hybrid fusion (RRF + boost ladder) ────────────────────────────────────────
149
336
 
150
337
  // Lexical candidates handed to fusion — shared by BOTH backends. An asymmetric
@@ -180,14 +367,57 @@ export function finalizeVectorCandidates(entries, cap = VECTOR_FUSION_CAP) {
180
367
  .map(([id, score], i) => ({ id, score, rank: i + 1 }));
181
368
  }
182
369
 
370
+ // Non-natural-language channel weights (keyword / symbol-lookup queries). The
371
+ // vector weight was lowered 1.0 → 0.7 after the 15-fixture search-eval was fixed
372
+ // to actually exercise embeddings (its "semantic" pass had been silently lexical,
373
+ // because the test bridge forced embeddings off — so the prior 1.0 was tuned
374
+ // against a vector-less harness). With honest measurement, a full-strength vector
375
+ // channel slightly OVER-contributes on keyword queries (BM25's home turf): it
376
+ // displaces a confident exact-name lexical rank-1 with a same-prefixed sibling
377
+ // (`Map`→`key`, `GardenPlantingRepository`→`…Dao`). 0.7 is the top of the winning
378
+ // plateau — it restores those rank-1s (broad kw MRR 0.569 → 0.588, +1 hit@1) while
379
+ // keeping the vector channel's recall lift, and leaves the 5-suite repo-eval
380
+ // byte-identical (symbolic 0.78/0.83). Below 0.7 the repo-eval symbolic starts to
381
+ // erode; the NL path keeps its own (higher) vector weight below.
183
382
  const LEXICAL_WEIGHT = 1.5;
184
- const VECTOR_WEIGHT = 1.0;
185
-
186
- // Natural-language queries flip the channel weights: behavioural descriptions
187
- // ("how does the app parse incoming JSON payloads?") carry their signal in the
188
- // embedding, while their common English words only add BM25 noise.
189
- const NL_LEXICAL_WEIGHT = 1.0;
190
- const NL_VECTOR_WEIGHT = 1.6;
383
+ const VECTOR_WEIGHT = 0.7;
384
+
385
+ // Natural-language queries keep the lexical channel at full strength (exact-name and
386
+ // keyword matches are the most reliable signal); the vector channel's weight depends on
387
+ // whether the corpus carries LLM enrichment:
388
+ //
389
+ // • PLAIN corpus (no enrichment) — the only vectors are raw-code embeddings, a weak
390
+ // conceptual signal that DISPLACES correct lexical hits at any real weight. It acts
391
+ // as a low-weight rescue (0.4). This was the historical "joint optimum" finding.
392
+ // • ENRICHED corpus — every central chunk also owns a summary vector that speaks the
393
+ // vocabulary of behavioural queries, so the vector channel becomes a strong semantic
394
+ // signal and earns more weight. The value is the JOINT optimum of two independent
395
+ // suites that DISAGREE: the repo-eval behavioural channel keeps climbing to ~1.0,
396
+ // while the broader 15-fixture search-eval (mixed nl/kw/xc) peaks at 0.4 and decays
397
+ // above it (a high weight lets a confident-but-wrong summary vector displace a good
398
+ // lexical hit on cross-cutting queries). 0.6 improves BOTH over baseline and avoids
399
+ // overfitting either; the opt-in LLM reranker recovers the top-rank precision that a
400
+ // lower weight gives up. (Plain stays 0.4 — raw-code vectors are a weak rescue.)
401
+ //
402
+ // The engine selects the regime per index via `corpusEnriched` (see engine/memory.mjs /
403
+ // engine/sqlite.mjs), so a repo indexed without --enrich is never hurt by the strong weight.
404
+ const NL_LEXICAL_WEIGHT = 1.5;
405
+ const NL_VECTOR_WEIGHT_PLAIN = 0.4;
406
+ const NL_VECTOR_WEIGHT_ENRICHED = 0.6;
407
+
408
+ // B3 learned-sparse channel weight. The sparse channel is the corpus-learned vocabulary EXPANSION
409
+ // view (associate terms the literal query never mentions); it only fires for NL queries on a
410
+ // --learned-sparse index, so it never touches symbolic lookups or the default path.
411
+ //
412
+ // HONEST MEASUREMENT (strict test:eval, 5 core suites, in-process A/B — see the weight sweep in
413
+ // IMPROVEMENT_LEARNED_SPARSE.md): the channel does NOT beat the lexical baseline at any weight.
414
+ // Higher weights (0.5/0.3) REGRESS semantic s@5 (the co-occurrence associates add noise that buries
415
+ // correct hits); the best case is ≈neutral at 0.2 (semantic + symbolic rank-1 both unchanged,
416
+ // overall s@5 −0.5). Like BM25F / AST / the code-embedding NL weight before it, this measured
417
+ // net-neutral-to-negative, so the DEFAULT stays lexical (B3 ships opt-in via --learned-sparse only).
418
+ // 0.2 is the measured least-harmful active setting for users who opt in / whose corpus has the
419
+ // vocabulary mismatch the benchmark fixtures lack.
420
+ const NL_SPARSE_WEIGHT = 0.2;
191
421
 
192
422
  const QUERY_STOPWORDS = new Set([
193
423
  'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
@@ -209,10 +439,16 @@ export function isNaturalLanguageQuery(queryText) {
209
439
  return stops >= 2 && stops / words.length >= 0.2;
210
440
  }
211
441
 
212
- export const TEST_FILE_RE = /\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/;
213
- // `^integration/` is root-anchored ONLY: a repo-root integration/ tree is an e2e
214
- // test-app convention (NestJS), while nested src/integrations/ is real code.
215
- export const EXAMPLE_DIR_RE = /^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\]|[/\\]samples?[/\\]|^demos?[/\\]|[/\\]demos?[/\\]|[/\\]tutorials?[/\\]|^docs_src[/\\]|[/\\]docs_src[/\\]|^sandbox[/\\]|[/\\]sandbox[/\\]|^benchmarks?[/\\]|[/\\]benchmarks?[/\\]|^scripts?[/\\]|[/\\]scripts?[/\\]/;
442
+ export const TEST_FILE_RE = /\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/i;
443
+ // Example/sample/demo/sandbox/etc. trees are a top-level project convention
444
+ // (examples/, samples/, demo/, docs_src/, …). The keyword is matched ONLY as the
445
+ // FIRST or SECOND path segment. Matching it at ANY depth (the previous behaviour)
446
+ // false-positived on PACKAGE PATHS that merely contain the word — e.g. Spring's
447
+ // `src/main/java/org/springframework/samples/petclinic/…` and Google's
448
+ // `com/google/samples/apps/…` — silently excluding real application code from LLM
449
+ // enrichment and demoting it in search. The fixtures' legitimate example/sample
450
+ // dirs all sit at depth ≤1, so this only stops the deep package-path false hits.
451
+ export const EXAMPLE_DIR_RE = /^(?:[^/\\]+[/\\])?(?:examples?|samples?|demos?|tutorials?|docs_src|sandbox|benchmarks?|scripts?)[/\\]/;
216
452
 
217
453
  /**
218
454
  * Reciprocal-Rank-Fusion of lexical + vector candidate lists, with the measured
@@ -239,13 +475,31 @@ export const EXAMPLE_DIR_RE = /^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\
239
475
  export function fuseAndRank({
240
476
  lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
241
477
  docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
478
+ corpusEnriched = false, sparseResults = [],
242
479
  }) {
243
- const rrfScores = new Map();
244
- const K = rrfK;
480
+ const rrfScores = new Map();
481
+ const K = rrfK;
245
482
  const queryLower = queryText.toLowerCase();
246
483
 
247
484
  const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
248
485
 
486
+ // File-path boost discriminativeness gate (natural-language queries only).
487
+ // The file-path boost (further down) fires when a query token matches a
488
+ // filename segment — but a common low-IDF word ("path", "url", "config",
489
+ // "index") that merely appears in a filename then over-promotes EVERY chunk in
490
+ // that file (an NL query mentioning "path" boosts all of path.go, burying the
491
+ // real answer that lives elsewhere). Restrict the boost to query terms
492
+ // discriminative enough to be a genuine filename signal: IDF ≥ ln(docCount/2),
493
+ // a per-corpus structural threshold (not tuned to any query). Symbolic/keyword
494
+ // queries are excluded by the NL gate and keep the original token set
495
+ // byte-for-byte. Measured: lifts agent-style recall@5 (overall s@5 0.77→0.81;
496
+ // tuning-semantic s@5 0.52→0.61) with symbolic rankings unchanged, no regression.
497
+ const _pathBoostIsNL = isNaturalLanguageQuery(queryText);
498
+ const _pathBoostMinIdf = Math.log(Math.max(docCount, 2) / 2);
499
+ const _queryPathTokensBoost = _pathBoostIsNL
500
+ ? _queryPathTokens.filter(t => okapiIdf(docCount, getDf(t)) >= _pathBoostMinIdf)
501
+ : _queryPathTokens;
502
+
249
503
  // Name-boost eligibility: long tokens, or short-but-discriminative ones whose
250
504
  // document frequency is ≤15% of the corpus (self-tunes per repo/language,
251
505
  // surfacing exact matches on short API names without re-introducing stopwords).
@@ -258,8 +512,9 @@ export function fuseAndRank({
258
512
  // vector-led for natural-language behavioural queries (only when a vector
259
513
  // channel actually produced candidates — lexical-only mode is unaffected).
260
514
  const nlQuery = vectorResults.length > 0 && isNaturalLanguageQuery(queryText);
515
+ const nlVecWeight = corpusEnriched ? NL_VECTOR_WEIGHT_ENRICHED : NL_VECTOR_WEIGHT_PLAIN;
261
516
  const wLex = nlQuery ? NL_LEXICAL_WEIGHT : LEXICAL_WEIGHT;
262
- const wVec = nlQuery ? NL_VECTOR_WEIGHT : VECTOR_WEIGHT;
517
+ const wVec = nlQuery ? nlVecWeight : VECTOR_WEIGHT;
263
518
 
264
519
  // Re-rank the vector channel with test/example demotion applied to the cosine
265
520
  // scores BEFORE ranks are assigned: a test helper that out-scores the real
@@ -280,15 +535,29 @@ export function fuseAndRank({
280
535
  vectorResults = adjusted.map((r, i) => ({ ...r, rank: i + 1 }));
281
536
  }
282
537
 
283
- // On NL queries the name/path boosts misfire: identifiers that happen to share
284
- // a generic English word with the query ("_global" for "global configuration")
285
- // get 1.4–2.0× promotions over the semantically right answer. Gate the boosts
286
- // on semantic agreement — a chunk must also be a vector candidate to earn them.
287
- const boostEligible = nlQuery ? new Set(vectorResults.map(r => r.id)) : null;
288
-
538
+ // Boosts are NOT gated on vector-candidate membership. Gating was intended to
539
+ // stop generic-word name collisions on NL queries, but because the code-embedding
540
+ // channel is weak it mostly suppressed the EXACT-name boost on the correct hit
541
+ // (which then sank below a noisy vector candidate). Removing the gate measured
542
+ // strictly better on both retrieval suites; the exact-name boost is the single
543
+ // most reliable signal and must always apply.
544
+ const boostEligible = null;
545
+
546
+ // Style-intent detection: a stylesheet bundle vendored into a source tree
547
+ // (e.g. a bundled bootstrap.css) produces hundreds of synthetic-named
548
+ // `*_rule_set` chunks that pollute CODE searches in mixed repos. Demote those
549
+ // synthetic rule_sets UNLESS the query is actually about styling (a selector
550
+ // char, an exact_tokens pin, or a style keyword). Named SCSS @mixin/@function
551
+ // chunks are unaffected — only nameless rule_sets are noise for code queries.
552
+ const queryIsStyle = exactBoostName != null ||
553
+ /[.#]|css|scss|stylesheet|selector|\bstyle\b|\bclass\b|color|margin|padding|font|border|background|hover|keyframe|animation|mixin|breakpoint|responsive|\bwidth\b|\bheight\b/.test(queryLower);
554
+
555
+ // B3 learned-sparse channel (opt-in, presence + NL gated by the caller). When the caller passes
556
+ // no sparse candidates the spread contributes nothing → byte-identical to the default path.
289
557
  const allResults = [
290
558
  ...vectorResults.map(r => ({ ...r, _w: wVec })),
291
559
  ...lexicalResults.map(r => ({ ...r, _w: wLex })),
560
+ ...sparseResults.map(r => ({ ...r, _w: NL_SPARSE_WEIGHT })),
292
561
  ];
293
562
 
294
563
  for (const { id, rank, _w } of allResults) {
@@ -296,14 +565,13 @@ export function fuseAndRank({
296
565
  const chunk = getChunk(id);
297
566
  if (!chunk) continue;
298
567
 
299
- // Demotion: test / spec files (unless the query is itself about tests).
300
568
  if (TEST_FILE_RE.test(chunk.file_path)) {
301
569
  if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
302
570
  }
303
- // Demotion: example / docs dirs (tutorial snippets over-rank on short length
304
- // + high keyword density vs the real implementation).
571
+ // Demotion: synthetic-named stylesheet rule_sets on non-style queries
572
+ // (vendored CSS bundle noise in mixed repos). Named mixins/functions exempt.
573
+ if (!queryIsStyle && chunk.name && chunk.name.endsWith('_rule_set')) baseScore *= 0.2;
305
574
  if (EXAMPLE_DIR_RE.test(chunk.file_path)) baseScore *= 0.5;
306
- // Demotion: pure expression sites.
307
575
  if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
308
576
  baseScore *= 0.8;
309
577
  }
@@ -327,14 +595,14 @@ export function fuseAndRank({
327
595
  // File-path boost via the separate path-token set (not the BM25 index), so
328
596
  // length normalisation never penalises long implementations sharing a path.
329
597
  const canBoost = !boostEligible || boostEligible.has(id);
330
- if (canBoost && _queryPathTokens.length > 0) {
598
+ if (canBoost && _queryPathTokensBoost.length > 0) {
331
599
  const pathToks = getPathTokens(id);
332
600
  if (pathToks) {
333
- const hasExact = _queryPathTokens.some(t => pathToks.has(t));
334
- const hasPrefix = !hasExact && _queryPathTokens.some(t =>
601
+ const hasExact = _queryPathTokensBoost.some(t => pathToks.has(t));
602
+ const hasPrefix = !hasExact && _queryPathTokensBoost.some(t =>
335
603
  t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
336
604
  );
337
- if (hasExact) baseScore *= 1.4;
605
+ if (hasExact) baseScore *= 1.4;
338
606
  else if (hasPrefix) baseScore *= 1.2;
339
607
  }
340
608
  }
@@ -344,8 +612,8 @@ export function fuseAndRank({
344
612
  // Plural equivalence (`BackgroundTask` ↔ `BackgroundTasks`) is included —
345
613
  // API names pluralize and a strict equality check missed them.
346
614
  if (canBoost && chunk.name && chunk.name !== 'anonymous') {
347
- const nameLower = chunk.name.toLowerCase();
348
- const lastDotted = nameLower.split('.').pop() ?? nameLower;
615
+ const nameLower = chunk.name.toLowerCase();
616
+ const lastDotted = nameLower.split('.').pop() ?? nameLower;
349
617
  const queryTokensAll = _queryNameTokens;
350
618
  const eq = (a, b) => a === b || a === b + 's' || b === a + 's';
351
619
  if (queryTokensAll.some(t => eq(nameLower, t) || eq(lastDotted, t))) {
@@ -355,7 +623,7 @@ export function fuseAndRank({
355
623
  baseScore *= 2.0;
356
624
  } else {
357
625
  const snakeParts = nameLower.split(/[._]+/);
358
- const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
626
+ const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
359
627
  if (snakeParts.length >= 2 && lastSnake.length >= 3 && queryTokensAll.includes(lastSnake)) {
360
628
  baseScore *= 1.4;
361
629
  }
@@ -365,7 +633,6 @@ export function fuseAndRank({
365
633
  rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
366
634
  }
367
635
 
368
- // Optional guaranteed boost for an exactly-named symbol (search_code exact_tokens).
369
636
  if (exactBoostName && resolveExact) {
370
637
  const boostTerm = String(exactBoostName).toLowerCase().trim();
371
638
  for (const id of resolveExact(boostTerm)) {
@@ -373,8 +640,6 @@ export function fuseAndRank({
373
640
  }
374
641
  }
375
642
 
376
- // Deterministic tie-break on id: equal fused scores must order identically
377
- // on every backend (Map iteration vs SQL row order would otherwise differ).
378
643
  return Array.from(rrfScores.entries())
379
644
  .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
380
645
  .slice(0, topK)
@@ -382,6 +647,102 @@ export function fuseAndRank({
382
647
  .filter(r => r.chunk !== undefined);
383
648
  }
384
649
 
650
+ // ─── D3: learned re-ranker (opt-in, post-fusion) ─────────────────────────────────
651
+ //
652
+ // A learned linear model that RE-ORDERS the fused top-N using features the engine
653
+ // already computes — the RRF fused score plus PROGRAM-STRUCTURE signals A4/A5/A1 added
654
+ // (symbol centrality, resolved-edge in-degree, SCIP-resolved in-degree) and git recency.
655
+ // It is a *post-fusion* re-rank (like the LLM reranker / git boost — tool layer, never
656
+ // inside fuseAndRank), so the default RRF ordering is byte-identical: this runs ONLY when
657
+ // the caller opts in (config.ranker === 'learned'). Inference is a dot product — zero
658
+ // runtime dependency, no model server, deterministic (ties broken on id) → parity-safe.
659
+ //
660
+ // Honest scope: the discriminating features (centrality / in-degree / resolved-in) are
661
+ // ZERO without --symbol-graph, so on a default index the model degrades to the RRF score
662
+ // + git + node-type and is ≈neutral; its lift is on symbol-graph indexes. Trained offline
663
+ // (bench/train-ranker.mjs) on the benchmark's labelled query→chunk pairs; RRF stays the
664
+ // default. See docs/internals/IMPROVEMENT_LEARNED_RANKER.md for the measured deltas.
665
+
666
+ /** Feature order — the model's weight vector is keyed by these names. */
667
+ export const RANKER_FEATURES = ['rrf', 'rank', 'centrality', 'in_degree', 'resolved_in', 'git', 'is_def', 'is_test'];
668
+
669
+ // Node types that are NOT a definition (a re-export / bare expression / import is a weaker hit).
670
+ const NON_DEF_NODE_RE = /expression|call_expression|re_export|_export_statement|import|comment/;
671
+
672
+ /**
673
+ * Default shipped model — CONSERVATIVE and RRF-DOMINANT by design. The offline trainer
674
+ * (bench/train-ranker.mjs) measured that the program-structure features (centrality / resolved
675
+ * in-degree) carry ~no signal for symbol retrieval on the benchmark — the correct symbol is usually
676
+ * NOT the most central one (the fit assigns them near-zero weight) — and a fully data-fit model only
677
+ * TIES RRF on the held-out split (rank-1 0.579 vs 0.579). So the shipped default keeps RRF in charge
678
+ * (it already
679
+ * encodes lexical+vector+boosts), adds only a whisper of structure as a tie-breaker, and an is_test
680
+ * penalty that aligns with the existing demotion. RRF remains the engine default (config.ranker);
681
+ * this model only runs under the opt-in `--ranker learned`, where it is ≈neutral on the generic
682
+ * benchmark and exists for users to RETRAIN on their own symbol-graph-rich repo. See
683
+ * docs/internals/IMPROVEMENT_LEARNED_RANKER.md for the full measured story.
684
+ */
685
+ export const DEFAULT_RANKER_MODEL = Object.freeze({
686
+ bias: 0,
687
+ weights: Object.freeze({
688
+ rrf: 1.0, rank: 0.30, centrality: 0.04, in_degree: 0.04,
689
+ resolved_in: 0.04, git: 0.03, is_def: 0.0, is_test: -0.20,
690
+ }),
691
+ });
692
+
693
+ /**
694
+ * Extract the feature vector for one fused candidate. Pure; all backend data arrives through
695
+ * accessors so memory and SQLite produce identical features (parity).
696
+ *
697
+ * @param {{score:number, chunk:object}} result
698
+ * @param {number} idx 0-based position in the fused pool.
699
+ * @param {object} ctx { maxScore, getCentrality?, getInEdges?, gitScoreFor? }
700
+ */
701
+ export function extractRankerFeatures(result, idx, ctx) {
702
+ const c = result.chunk || {};
703
+ const cen = ctx.getCentrality ? ctx.getCentrality(c.id) : null;
704
+ const inEdges = ctx.getInEdges ? (ctx.getInEdges(c.id) || []) : [];
705
+ let resolvedIn = 0;
706
+ for (const e of inEdges) if (e.confidence === 'resolved') resolvedIn++;
707
+ const nodeType = String(c.node_type || '');
708
+ return {
709
+ rrf: ctx.maxScore > 0 ? (result.score || 0) / ctx.maxScore : 0,
710
+ rank: 1 / (1 + idx),
711
+ centrality: cen ? cen.score : 0,
712
+ in_degree: Math.min(inEdges.length / 10, 1), // squashed into [0,1]
713
+ resolved_in: Math.min(resolvedIn / 5, 1),
714
+ git: ctx.gitScoreFor ? (ctx.gitScoreFor(c.file_path) || 0) : 0,
715
+ is_def: nodeType && !NON_DEF_NODE_RE.test(nodeType) ? 1 : 0,
716
+ is_test: c.file_path && TEST_FILE_RE.test(c.file_path) ? 1 : 0,
717
+ };
718
+ }
719
+
720
+ /** Linear score of a feature vector under a model. */
721
+ export function scoreLearned(features, model = DEFAULT_RANKER_MODEL) {
722
+ let s = model.bias || 0;
723
+ const w = model.weights || {};
724
+ for (const f of RANKER_FEATURES) s += (w[f] || 0) * (features[f] || 0);
725
+ return s;
726
+ }
727
+
728
+ /**
729
+ * Re-rank fused results with the learned model. Returns a NEW array (score replaced by the learned
730
+ * score), ordered by learned score desc with id tie-break. ≤1 result is returned unchanged.
731
+ * Deterministic → both backends re-rank identically (parity-safe).
732
+ *
733
+ * @param {Array<{score:number, chunk:object}>} results The fused (RRF) pool, over-fetched.
734
+ * @param {object} ctx Accessors: { getCentrality?, getInEdges?, gitScoreFor? }.
735
+ * @param {object} [model]
736
+ */
737
+ export function learnedRerank(results, ctx = {}, model = DEFAULT_RANKER_MODEL) {
738
+ if (!Array.isArray(results) || results.length <= 1) return results;
739
+ const maxScore = results.reduce((m, r) => Math.max(m, r.score || 0), 0);
740
+ const fctx = { ...ctx, maxScore };
741
+ const scored = results.map((r, i) => ({ r, s: scoreLearned(extractRankerFeatures(r, i, fctx), model) }));
742
+ scored.sort((a, b) => (b.s - a.s) || ((a.r.chunk?.id ?? '') < (b.r.chunk?.id ?? '') ? -1 : 1));
743
+ return scored.map(({ r, s }) => ({ ...r, score: s }));
744
+ }
745
+
385
746
  // ─── Graph centrality ──────────────────────────────────────────────────────────
386
747
 
387
748
  /**