graph-indexer 2.1.0 → 3.0.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.
- package/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +31 -105
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
package/search-core.mjs
DELETED
|
@@ -1,777 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file search-core.mjs
|
|
3
|
-
* @description Storage-agnostic retrieval primitives shared by every backend:
|
|
4
|
-
* tokenisation, cosine similarity, BM25 scoring, the RRF fusion +
|
|
5
|
-
* boost ladder, and PageRank centrality. Keeping this math in one
|
|
6
|
-
* place is what lets the in-memory engine and the SQLite store
|
|
7
|
-
* return identical rankings — the numbers are measured once and
|
|
8
|
-
* reused, never re-derived per backend.
|
|
9
|
-
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
10
|
-
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
11
|
-
* @license MIT
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { createHash } from 'crypto';
|
|
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).
|
|
20
|
-
export const EMBEDDING_CONTEXT_LIMIT = 8000;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Cache key for a chunk's embedding vector.
|
|
24
|
-
*
|
|
25
|
-
* The embedding payload includes the LLM enrichment summary when present, so a
|
|
26
|
-
* vector computed for an enriched chunk is NOT interchangeable with one computed
|
|
27
|
-
* for the same code without enrichment. Keying by content_hash alone caused two
|
|
28
|
-
* defects: enriched chunks were re-embedded on every index run (cache never hit),
|
|
29
|
-
* and a stale un-enriched vector could be silently reused for an enriched chunk.
|
|
30
|
-
*
|
|
31
|
-
* The key is content_hash for plain chunks, and content_hash + a digest of the
|
|
32
|
-
* enrichment text for enriched ones — deterministic as long as the enrichment
|
|
33
|
-
* cache returns the same summary for the same code.
|
|
34
|
-
*
|
|
35
|
-
* @param {object} chunk
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
export function embeddingKeyFor(chunk) {
|
|
39
|
-
const hasEnrichment = Boolean(chunk.summary) || (chunk.concepts?.length > 0);
|
|
40
|
-
if (!hasEnrichment) return chunk.content_hash;
|
|
41
|
-
const enrichText = `${chunk.summary || ''}|${(chunk.concepts || []).join(',')}`;
|
|
42
|
-
const suffix = createHash('sha256').update(enrichText).digest('hex').slice(0, 12);
|
|
43
|
-
return `${chunk.content_hash}|e:${suffix}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function truncateForEmbedding(text) {
|
|
47
|
-
return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
|
|
48
|
-
}
|
|
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
|
-
|
|
101
|
-
export function cosineSimilarity(vecA, vecB) {
|
|
102
|
-
let dot = 0, nA = 0, nB = 0;
|
|
103
|
-
for (let i = 0; i < vecA.length; i++) {
|
|
104
|
-
dot += vecA[i] * vecB[i];
|
|
105
|
-
nA += vecA[i] * vecA[i];
|
|
106
|
-
nB += vecB[i] * vecB[i];
|
|
107
|
-
}
|
|
108
|
-
return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
|
|
109
|
-
}
|
|
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
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Tokenise code/identifiers: lowercase words plus camelCase sub-parts, so
|
|
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.
|
|
191
|
-
*/
|
|
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) {
|
|
200
|
-
if (!text) return [];
|
|
201
|
-
const rawTokens = text.split(/[\s\W_]+/);
|
|
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
|
-
};
|
|
210
|
-
for (const word of rawTokens) {
|
|
211
|
-
if (word.length < 2) continue;
|
|
212
|
-
emit(word);
|
|
213
|
-
const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
|
|
214
|
-
if (camelParts.length > 1) {
|
|
215
|
-
for (const part of camelParts) {
|
|
216
|
-
if (part.length >= 2) emit(part);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return tokens;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Suffix appended to an embedding key for a chunk's summary-only vector.
|
|
225
|
-
* Enriched chunks carry TWO vectors in the bin: the full code payload (base key)
|
|
226
|
-
* and this compact summary+concepts text. A one-line natural-language query is
|
|
227
|
-
* far closer in embedding space to a one-line summary than to 700 chars of code,
|
|
228
|
-
* so the summary vector is what lets behavioural queries hit code that shares
|
|
229
|
-
* none of their words. Scan hits on `<key>|s` map back to the same chunk.
|
|
230
|
-
*/
|
|
231
|
-
export const SUMMARY_VEC_SUFFIX = '|s';
|
|
232
|
-
|
|
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. */
|
|
237
|
-
export function summaryEmbeddingText(chunk) {
|
|
238
|
-
if (!chunk.summary && !(chunk.concepts?.length > 0)) return null;
|
|
239
|
-
const qualifiedName = chunk.class_context ? `${chunk.class_context}.${chunk.name}` : chunk.name;
|
|
240
|
-
return [chunk.summary, (chunk.concepts || []).join(', '), qualifiedName]
|
|
241
|
-
.filter(Boolean).join('. ');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Build the lexical document indexed for BM25. Concatenates the discriminative
|
|
246
|
-
* fields of a chunk (name, docstring, neighbour basenames, calls, params, return
|
|
247
|
-
* type, qualified name, type refs, LLM concept tags, body).
|
|
248
|
-
*
|
|
249
|
-
* Single source of truth shared by the in-memory engine, the watch daemon and the
|
|
250
|
-
* SQLite writer so every backend indexes identical text. Decorators are
|
|
251
|
-
* deliberately excluded — measured to regress framework repos (see engine/memory.mjs).
|
|
252
|
-
* When enrichment ran, chunk.concepts contains domain keyword strings that bridge
|
|
253
|
-
* lexical gaps (e.g. "authentication JWT middleware"); chunk.hyde is concepts.join(' ')
|
|
254
|
-
* and serves as the backward-compatible field for existing serialized indexes.
|
|
255
|
-
*
|
|
256
|
-
* @param {object} chunk
|
|
257
|
-
* @param {string[]} depRelPaths Resolved local imports of the chunk's file.
|
|
258
|
-
* @returns {string}
|
|
259
|
-
*/
|
|
260
|
-
export function buildLexicalDocument(chunk, depRelPaths = []) {
|
|
261
|
-
const cleanDeps = depRelPaths.map(d => d.split('/').pop().split('.')[0]);
|
|
262
|
-
// Prefer the structured concepts array (new); fall back to the joined hyde string
|
|
263
|
-
// (old serialized indexes) so both formats index identical terms.
|
|
264
|
-
const conceptTokens = chunk.concepts?.length
|
|
265
|
-
? chunk.concepts.join(' ')
|
|
266
|
-
: (chunk.hyde || '');
|
|
267
|
-
return [
|
|
268
|
-
chunk.name,
|
|
269
|
-
chunk.docstring || '',
|
|
270
|
-
cleanDeps.join(' '),
|
|
271
|
-
(chunk.calls || []).join(' '),
|
|
272
|
-
(chunk.params || []).join(' '),
|
|
273
|
-
chunk.return_type || '',
|
|
274
|
-
chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
|
|
275
|
-
(chunk.type_refs || []).join(' '),
|
|
276
|
-
conceptTokens, // domain concept keywords from LLM enrichment (opt-in)
|
|
277
|
-
chunk.code_snippet,
|
|
278
|
-
].join(' ');
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ─── BM25 (tuned for code) ─────────────────────────────────────────────────────
|
|
282
|
-
// b lowered from 0.75 → 0.3: code chunks have purposeful length variation unlike
|
|
283
|
-
// prose, so heavy length normalisation wrongly penalises long implementations vs
|
|
284
|
-
// short export stubs.
|
|
285
|
-
export const BM25_K1 = 1.5;
|
|
286
|
-
export const BM25_B = 0.3;
|
|
287
|
-
|
|
288
|
-
/** Okapi IDF — always positive, avoids negative IDF for very common terms. */
|
|
289
|
-
export function okapiIdf(docCount, docFreq) {
|
|
290
|
-
return Math.log((docCount - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** BM25 term contribution: diminishing TF returns + document-length normalisation. */
|
|
294
|
-
export function bm25Score(idf, tf, docLen, avgdl, k1 = BM25_K1, b = BM25_B) {
|
|
295
|
-
return idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgdl));
|
|
296
|
-
}
|
|
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
|
-
|
|
335
|
-
// ─── Hybrid fusion (RRF + boost ladder) ────────────────────────────────────────
|
|
336
|
-
|
|
337
|
-
// Lexical candidates handed to fusion — shared by BOTH backends. An asymmetric
|
|
338
|
-
// cap would let deep-ranked chunks earn RRF contributions on one backend only,
|
|
339
|
-
// silently breaking rank parity.
|
|
340
|
-
export const LEXICAL_FUSION_CAP = 2000;
|
|
341
|
-
|
|
342
|
-
// Unique vector candidates handed to fusion (after summary-vector dedupe).
|
|
343
|
-
export const VECTOR_FUSION_CAP = 200;
|
|
344
|
-
// Raw bin entries to collect before dedupe: each chunk owns at most TWO entries
|
|
345
|
-
// (code vector + summary vector), so 2× the cap guarantees the full unique set.
|
|
346
|
-
export const VECTOR_SCAN_RAW_N = VECTOR_FUSION_CAP * 2;
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Normalize a store's raw vector hits into the fusion-ready candidate list:
|
|
350
|
-
* best score per chunk id, deterministic (score desc, id asc) order, capped.
|
|
351
|
-
* EVERY backend path (eager matrix, streaming scan, binary sketch) must funnel
|
|
352
|
-
* through this so the vector channel is rank-identical across stores — dedupe
|
|
353
|
-
* scope and tie order were measured to silently diverge otherwise.
|
|
354
|
-
*
|
|
355
|
-
* @param {Array<{id:string, score:number}>} entries May contain duplicate ids.
|
|
356
|
-
* @returns {Array<{id:string, score:number, rank:number}>}
|
|
357
|
-
*/
|
|
358
|
-
export function finalizeVectorCandidates(entries, cap = VECTOR_FUSION_CAP) {
|
|
359
|
-
const best = new Map();
|
|
360
|
-
for (const e of entries) {
|
|
361
|
-
const prev = best.get(e.id);
|
|
362
|
-
if (prev === undefined || e.score > prev) best.set(e.id, e.score);
|
|
363
|
-
}
|
|
364
|
-
return Array.from(best.entries())
|
|
365
|
-
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
|
|
366
|
-
.slice(0, cap)
|
|
367
|
-
.map(([id, score], i) => ({ id, score, rank: i + 1 }));
|
|
368
|
-
}
|
|
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.
|
|
382
|
-
const LEXICAL_WEIGHT = 1.5;
|
|
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;
|
|
421
|
-
|
|
422
|
-
const QUERY_STOPWORDS = new Set([
|
|
423
|
-
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
|
|
424
|
-
'by', 'from', 'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what',
|
|
425
|
-
'when', 'where', 'which', 'who', 'why', 'does', 'do', 'it', 'its', 'into',
|
|
426
|
-
'back', 'if', 'then', 'their', 'there', 'all', 'any',
|
|
427
|
-
]);
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Heuristic: is this an agent-style natural-language query (a behavioural
|
|
431
|
-
* description / question) rather than a keyword or symbol lookup? NL queries are
|
|
432
|
-
* long and contain English function words; symbol queries ("ShouldBindJSON bind
|
|
433
|
-
* request body") contain none.
|
|
434
|
-
*/
|
|
435
|
-
export function isNaturalLanguageQuery(queryText) {
|
|
436
|
-
const words = String(queryText).toLowerCase().split(/[\s\W_]+/).filter(Boolean);
|
|
437
|
-
if (words.length < 5) return false;
|
|
438
|
-
const stops = words.filter(w => QUERY_STOPWORDS.has(w)).length;
|
|
439
|
-
return stops >= 2 && stops / words.length >= 0.2;
|
|
440
|
-
}
|
|
441
|
-
|
|
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?)[/\\]/;
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Reciprocal-Rank-Fusion of lexical + vector candidate lists, with the measured
|
|
455
|
-
* boost ladder (test/example demotion, expression demotion, TS barrel demotion,
|
|
456
|
-
* file-path boost, exact/snake name boost, optional exact-symbol boost).
|
|
457
|
-
*
|
|
458
|
-
* All backend-specific data is reached through accessors so the identical math
|
|
459
|
-
* serves both the in-memory Maps and SQLite row lookups.
|
|
460
|
-
*
|
|
461
|
-
* @param {object} p
|
|
462
|
-
* @param {Array<{id:string,rank:number}>} p.lexicalResults
|
|
463
|
-
* @param {Array<{id:string,rank:number}>} p.vectorResults
|
|
464
|
-
* @param {(id:string)=>object|undefined} p.getChunk
|
|
465
|
-
* @param {(id:string)=>Set<string>|undefined} p.getPathTokens
|
|
466
|
-
* @param {(token:string)=>number} p.getDf Document frequency of a token.
|
|
467
|
-
* @param {number} p.docCount
|
|
468
|
-
* @param {number} p.rrfK
|
|
469
|
-
* @param {number} p.topK
|
|
470
|
-
* @param {string} p.queryText
|
|
471
|
-
* @param {string|null} [p.exactBoostName]
|
|
472
|
-
* @param {(termLower:string)=>Iterable<string>} [p.resolveExact] Ids whose name === term.
|
|
473
|
-
* @returns {Array<{score:number, chunk:object}>}
|
|
474
|
-
*/
|
|
475
|
-
export function fuseAndRank({
|
|
476
|
-
lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
|
|
477
|
-
docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
|
|
478
|
-
corpusEnriched = false, sparseResults = [],
|
|
479
|
-
}) {
|
|
480
|
-
const rrfScores = new Map();
|
|
481
|
-
const K = rrfK;
|
|
482
|
-
const queryLower = queryText.toLowerCase();
|
|
483
|
-
|
|
484
|
-
const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
|
|
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
|
-
|
|
503
|
-
// Name-boost eligibility: long tokens, or short-but-discriminative ones whose
|
|
504
|
-
// document frequency is ≤15% of the corpus (self-tunes per repo/language,
|
|
505
|
-
// surfacing exact matches on short API names without re-introducing stopwords).
|
|
506
|
-
const _docN = docCount || 1;
|
|
507
|
-
const _queryNameTokens = queryLower.split(/[\s\W_]+/).filter(t =>
|
|
508
|
-
t.length >= 5 || (t.length >= 3 && (getDf(t) || 0) <= 0.15 * _docN)
|
|
509
|
-
);
|
|
510
|
-
|
|
511
|
-
// Query-adaptive channel weights: lexical-led for keyword/symbol lookups,
|
|
512
|
-
// vector-led for natural-language behavioural queries (only when a vector
|
|
513
|
-
// channel actually produced candidates — lexical-only mode is unaffected).
|
|
514
|
-
const nlQuery = vectorResults.length > 0 && isNaturalLanguageQuery(queryText);
|
|
515
|
-
const nlVecWeight = corpusEnriched ? NL_VECTOR_WEIGHT_ENRICHED : NL_VECTOR_WEIGHT_PLAIN;
|
|
516
|
-
const wLex = nlQuery ? NL_LEXICAL_WEIGHT : LEXICAL_WEIGHT;
|
|
517
|
-
const wVec = nlQuery ? nlVecWeight : VECTOR_WEIGHT;
|
|
518
|
-
|
|
519
|
-
// Re-rank the vector channel with test/example demotion applied to the cosine
|
|
520
|
-
// scores BEFORE ranks are assigned: a test helper that out-scores the real
|
|
521
|
-
// implementation in raw cosine otherwise occupies the top RRF positions and
|
|
522
|
-
// pushes the right answer's reciprocal rank down, even though the boost
|
|
523
|
-
// ladder demotes the helper later.
|
|
524
|
-
if (vectorResults.length > 0) {
|
|
525
|
-
const adjusted = vectorResults.map(r => {
|
|
526
|
-
const c = getChunk(r.id);
|
|
527
|
-
let f = 1;
|
|
528
|
-
if (c) {
|
|
529
|
-
if (TEST_FILE_RE.test(c.file_path) && !queryLower.includes('test') && !queryLower.includes('spec')) f = 0.25;
|
|
530
|
-
else if (EXAMPLE_DIR_RE.test(c.file_path)) f = 0.5;
|
|
531
|
-
}
|
|
532
|
-
return { id: r.id, score: (r.score ?? 0) * f };
|
|
533
|
-
});
|
|
534
|
-
adjusted.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : 1));
|
|
535
|
-
vectorResults = adjusted.map((r, i) => ({ ...r, rank: i + 1 }));
|
|
536
|
-
}
|
|
537
|
-
|
|
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.
|
|
557
|
-
const allResults = [
|
|
558
|
-
...vectorResults.map(r => ({ ...r, _w: wVec })),
|
|
559
|
-
...lexicalResults.map(r => ({ ...r, _w: wLex })),
|
|
560
|
-
...sparseResults.map(r => ({ ...r, _w: NL_SPARSE_WEIGHT })),
|
|
561
|
-
];
|
|
562
|
-
|
|
563
|
-
for (const { id, rank, _w } of allResults) {
|
|
564
|
-
let baseScore = (_w ?? 1.0) / (K + rank);
|
|
565
|
-
const chunk = getChunk(id);
|
|
566
|
-
if (!chunk) continue;
|
|
567
|
-
|
|
568
|
-
if (TEST_FILE_RE.test(chunk.file_path)) {
|
|
569
|
-
if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
|
|
570
|
-
}
|
|
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;
|
|
574
|
-
if (EXAMPLE_DIR_RE.test(chunk.file_path)) baseScore *= 0.5;
|
|
575
|
-
if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
|
|
576
|
-
baseScore *= 0.8;
|
|
577
|
-
}
|
|
578
|
-
// Demotion: TypeScript barrel re-exports (`export { X } from 'y'`) — no
|
|
579
|
-
// implementation. JS exports are excluded since those often ARE the module.
|
|
580
|
-
if (chunk.name && chunk.name.endsWith('_export_statement') && chunk.file_path?.endsWith('.ts')) {
|
|
581
|
-
baseScore *= 0.7;
|
|
582
|
-
}
|
|
583
|
-
// Demotion: Python public re-exports (`from x import Y as Y`) — but only
|
|
584
|
-
// when a REAL definition of the same name exists in the index. When the
|
|
585
|
-
// re-export is the only in-repo occurrence (the implementation lives in a
|
|
586
|
-
// dependency, e.g. fastapi re-exporting starlette's BackgroundTasks), the
|
|
587
|
-
// alias IS the best available answer and must not be demoted.
|
|
588
|
-
if (chunk.node_type === 're_export' && resolveExact && chunk.name) {
|
|
589
|
-
for (const rid of resolveExact(String(chunk.name).toLowerCase())) {
|
|
590
|
-
const rc = getChunk(rid);
|
|
591
|
-
if (rc && rc.node_type !== 're_export') { baseScore *= 0.7; break; }
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// File-path boost via the separate path-token set (not the BM25 index), so
|
|
596
|
-
// length normalisation never penalises long implementations sharing a path.
|
|
597
|
-
const canBoost = !boostEligible || boostEligible.has(id);
|
|
598
|
-
if (canBoost && _queryPathTokensBoost.length > 0) {
|
|
599
|
-
const pathToks = getPathTokens(id);
|
|
600
|
-
if (pathToks) {
|
|
601
|
-
const hasExact = _queryPathTokensBoost.some(t => pathToks.has(t));
|
|
602
|
-
const hasPrefix = !hasExact && _queryPathTokensBoost.some(t =>
|
|
603
|
-
t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
|
|
604
|
-
);
|
|
605
|
-
if (hasExact) baseScore *= 1.4;
|
|
606
|
-
else if (hasPrefix) baseScore *= 1.2;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// Name boost: 2.0× exact (token IS the name), 1.4× snake_case suffix match.
|
|
611
|
-
// Only [._] splitting keeps camelCase names atomic (no generic-suffix bleed).
|
|
612
|
-
// Plural equivalence (`BackgroundTask` ↔ `BackgroundTasks`) is included —
|
|
613
|
-
// API names pluralize and a strict equality check missed them.
|
|
614
|
-
if (canBoost && chunk.name && chunk.name !== 'anonymous') {
|
|
615
|
-
const nameLower = chunk.name.toLowerCase();
|
|
616
|
-
const lastDotted = nameLower.split('.').pop() ?? nameLower;
|
|
617
|
-
const queryTokensAll = _queryNameTokens;
|
|
618
|
-
const eq = (a, b) => a === b || a === b + 's' || b === a + 's';
|
|
619
|
-
if (queryTokensAll.some(t => eq(nameLower, t) || eq(lastDotted, t))) {
|
|
620
|
-
// NOTE: a PageRank multiplier was trialled here (tie-break duplicate
|
|
621
|
-
// names toward central files) and measured NEGATIVE — hub files win
|
|
622
|
-
// exact-name matches on common words and the semantic channel drops.
|
|
623
|
-
baseScore *= 2.0;
|
|
624
|
-
} else {
|
|
625
|
-
const snakeParts = nameLower.split(/[._]+/);
|
|
626
|
-
const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
|
|
627
|
-
if (snakeParts.length >= 2 && lastSnake.length >= 3 && queryTokensAll.includes(lastSnake)) {
|
|
628
|
-
baseScore *= 1.4;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (exactBoostName && resolveExact) {
|
|
637
|
-
const boostTerm = String(exactBoostName).toLowerCase().trim();
|
|
638
|
-
for (const id of resolveExact(boostTerm)) {
|
|
639
|
-
rrfScores.set(id, (rrfScores.get(id) || 0) + 1 / (K + 1));
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
return Array.from(rrfScores.entries())
|
|
644
|
-
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
|
|
645
|
-
.slice(0, topK)
|
|
646
|
-
.map(([id, rrfScore]) => ({ score: rrfScore, chunk: getChunk(id) }))
|
|
647
|
-
.filter(r => r.chunk !== undefined);
|
|
648
|
-
}
|
|
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
|
-
|
|
746
|
-
// ─── Graph centrality ──────────────────────────────────────────────────────────
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* Simplified PageRank over the file dependency graph. Files imported by many
|
|
750
|
-
* others receive higher rank (= more important). Used by get_repo_map ordering
|
|
751
|
-
* and by LLM enrichment to pick the "core" files worth summarising.
|
|
752
|
-
*
|
|
753
|
-
* @param {{dependencies:Object<string,string[]>}} graph
|
|
754
|
-
* @returns {Map<string, number>} file → rank
|
|
755
|
-
*/
|
|
756
|
-
export function computePageRank(graph, iters = 30, damping = 0.85) {
|
|
757
|
-
const files = Object.keys(graph?.dependencies || {});
|
|
758
|
-
const N = files.length;
|
|
759
|
-
if (N === 0) return new Map();
|
|
760
|
-
|
|
761
|
-
const idx = new Map(files.map((f, i) => [f, i]));
|
|
762
|
-
const ranks = new Float64Array(N).fill(1.0 / N);
|
|
763
|
-
const outDeg = files.map(f => Math.max((graph.dependencies[f] || []).length, 1));
|
|
764
|
-
|
|
765
|
-
for (let iter = 0; iter < iters; iter++) {
|
|
766
|
-
const next = new Float64Array(N).fill((1 - damping) / N);
|
|
767
|
-
for (let i = 0; i < N; i++) {
|
|
768
|
-
const contrib = damping * ranks[i] / outDeg[i];
|
|
769
|
-
for (const dep of (graph.dependencies[files[i]] || [])) {
|
|
770
|
-
const j = idx.get(dep);
|
|
771
|
-
if (j !== undefined) next[j] += contrib;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
ranks.set(next);
|
|
775
|
-
}
|
|
776
|
-
return new Map(files.map((f, i) => [f, ranks[i]]));
|
|
777
|
-
}
|