graph-indexer 1.0.3 → 1.2.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.
@@ -0,0 +1,416 @@
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
+ export const EMBEDDING_CONTEXT_LIMIT = 8000;
17
+
18
+ /**
19
+ * Cache key for a chunk's embedding vector.
20
+ *
21
+ * The embedding payload includes the LLM enrichment summary when present, so a
22
+ * vector computed for an enriched chunk is NOT interchangeable with one computed
23
+ * for the same code without enrichment. Keying by content_hash alone caused two
24
+ * defects: enriched chunks were re-embedded on every index run (cache never hit),
25
+ * and a stale un-enriched vector could be silently reused for an enriched chunk.
26
+ *
27
+ * The key is content_hash for plain chunks, and content_hash + a digest of the
28
+ * enrichment text for enriched ones — deterministic as long as the enrichment
29
+ * cache returns the same summary for the same code.
30
+ *
31
+ * @param {object} chunk
32
+ * @returns {string}
33
+ */
34
+ export function embeddingKeyFor(chunk) {
35
+ const hasEnrichment = Boolean(chunk.summary) || (chunk.concepts?.length > 0);
36
+ if (!hasEnrichment) return chunk.content_hash;
37
+ const enrichText = `${chunk.summary || ''}|${(chunk.concepts || []).join(',')}`;
38
+ const suffix = createHash('sha256').update(enrichText).digest('hex').slice(0, 12);
39
+ return `${chunk.content_hash}|e:${suffix}`;
40
+ }
41
+
42
+ export function truncateForEmbedding(text) {
43
+ return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
44
+ }
45
+
46
+ export function cosineSimilarity(vecA, vecB) {
47
+ let dot = 0, nA = 0, nB = 0;
48
+ for (let i = 0; i < vecA.length; i++) {
49
+ dot += vecA[i] * vecB[i];
50
+ nA += vecA[i] * vecA[i];
51
+ nB += vecB[i] * vecB[i];
52
+ }
53
+ return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
54
+ }
55
+
56
+ /**
57
+ * Tokenise code/identifiers: lowercase words plus camelCase sub-parts, so
58
+ * `dispatchRequest` indexes as `dispatchrequest`, `dispatch`, `request`.
59
+ */
60
+ export function tokenize(text) {
61
+ if (!text) return [];
62
+ const rawTokens = text.split(/[\s\W_]+/);
63
+ const tokens = [];
64
+ for (const word of rawTokens) {
65
+ if (word.length < 2) continue;
66
+ tokens.push(word.toLowerCase());
67
+ const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
68
+ if (camelParts.length > 1) {
69
+ for (const part of camelParts) {
70
+ if (part.length >= 2) tokens.push(part.toLowerCase());
71
+ }
72
+ }
73
+ }
74
+ return tokens;
75
+ }
76
+
77
+ /**
78
+ * Suffix appended to an embedding key for a chunk's summary-only vector.
79
+ * Enriched chunks carry TWO vectors in the bin: the full code payload (base key)
80
+ * and this compact summary+concepts text. A one-line natural-language query is
81
+ * far closer in embedding space to a one-line summary than to 700 chars of code,
82
+ * so the summary vector is what lets behavioural queries hit code that shares
83
+ * none of their words. Scan hits on `<key>|s` map back to the same chunk.
84
+ */
85
+ export const SUMMARY_VEC_SUFFIX = '|s';
86
+
87
+ /** The text embedded as the summary-only vector, or null when not enriched. */
88
+ export function summaryEmbeddingText(chunk) {
89
+ if (!chunk.summary && !(chunk.concepts?.length > 0)) return null;
90
+ return [chunk.summary, (chunk.concepts || []).join(', '), chunk.name]
91
+ .filter(Boolean).join('. ');
92
+ }
93
+
94
+ /**
95
+ * Build the lexical document indexed for BM25. Concatenates the discriminative
96
+ * fields of a chunk (name, docstring, neighbour basenames, calls, params, return
97
+ * type, qualified name, type refs, LLM concept tags, body).
98
+ *
99
+ * Single source of truth shared by the in-memory engine, the watch daemon and the
100
+ * SQLite writer so every backend indexes identical text. Decorators are
101
+ * deliberately excluded — measured to regress framework repos (see core-engine).
102
+ * When enrichment ran, chunk.concepts contains domain keyword strings that bridge
103
+ * lexical gaps (e.g. "authentication JWT middleware"); chunk.hyde is concepts.join(' ')
104
+ * and serves as the backward-compatible field for existing serialized indexes.
105
+ *
106
+ * @param {object} chunk
107
+ * @param {string[]} depRelPaths Resolved local imports of the chunk's file.
108
+ * @returns {string}
109
+ */
110
+ export function buildLexicalDocument(chunk, depRelPaths = []) {
111
+ const cleanDeps = depRelPaths.map(d => d.split('/').pop().split('.')[0]);
112
+ // Prefer the structured concepts array (new); fall back to the joined hyde string
113
+ // (old serialized indexes) so both formats index identical terms.
114
+ const conceptTokens = chunk.concepts?.length
115
+ ? chunk.concepts.join(' ')
116
+ : (chunk.hyde || '');
117
+ return [
118
+ chunk.name,
119
+ chunk.docstring || '',
120
+ cleanDeps.join(' '),
121
+ (chunk.calls || []).join(' '),
122
+ (chunk.params || []).join(' '),
123
+ chunk.return_type || '',
124
+ chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
125
+ (chunk.type_refs || []).join(' '),
126
+ conceptTokens, // domain concept keywords from LLM enrichment (opt-in)
127
+ chunk.code_snippet,
128
+ ].join(' ');
129
+ }
130
+
131
+ // ─── BM25 (tuned for code) ─────────────────────────────────────────────────────
132
+ // b lowered from 0.75 → 0.3: code chunks have purposeful length variation unlike
133
+ // prose, so heavy length normalisation wrongly penalises long implementations vs
134
+ // short export stubs.
135
+ export const BM25_K1 = 1.5;
136
+ export const BM25_B = 0.3;
137
+
138
+ /** Okapi IDF — always positive, avoids negative IDF for very common terms. */
139
+ export function okapiIdf(docCount, docFreq) {
140
+ return Math.log((docCount - docFreq + 0.5) / (docFreq + 0.5) + 1);
141
+ }
142
+
143
+ /** BM25 term contribution: diminishing TF returns + document-length normalisation. */
144
+ export function bm25Score(idf, tf, docLen, avgdl, k1 = BM25_K1, b = BM25_B) {
145
+ return idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgdl));
146
+ }
147
+
148
+ // ─── Hybrid fusion (RRF + boost ladder) ────────────────────────────────────────
149
+
150
+ // Lexical candidates handed to fusion — shared by BOTH backends. An asymmetric
151
+ // cap would let deep-ranked chunks earn RRF contributions on one backend only,
152
+ // silently breaking rank parity.
153
+ export const LEXICAL_FUSION_CAP = 2000;
154
+
155
+ // Unique vector candidates handed to fusion (after summary-vector dedupe).
156
+ export const VECTOR_FUSION_CAP = 200;
157
+ // Raw bin entries to collect before dedupe: each chunk owns at most TWO entries
158
+ // (code vector + summary vector), so 2× the cap guarantees the full unique set.
159
+ export const VECTOR_SCAN_RAW_N = VECTOR_FUSION_CAP * 2;
160
+
161
+ /**
162
+ * Normalize a store's raw vector hits into the fusion-ready candidate list:
163
+ * best score per chunk id, deterministic (score desc, id asc) order, capped.
164
+ * EVERY backend path (eager matrix, streaming scan, binary sketch) must funnel
165
+ * through this so the vector channel is rank-identical across stores — dedupe
166
+ * scope and tie order were measured to silently diverge otherwise.
167
+ *
168
+ * @param {Array<{id:string, score:number}>} entries May contain duplicate ids.
169
+ * @returns {Array<{id:string, score:number, rank:number}>}
170
+ */
171
+ export function finalizeVectorCandidates(entries, cap = VECTOR_FUSION_CAP) {
172
+ const best = new Map();
173
+ for (const e of entries) {
174
+ const prev = best.get(e.id);
175
+ if (prev === undefined || e.score > prev) best.set(e.id, e.score);
176
+ }
177
+ return Array.from(best.entries())
178
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
179
+ .slice(0, cap)
180
+ .map(([id, score], i) => ({ id, score, rank: i + 1 }));
181
+ }
182
+
183
+ 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;
191
+
192
+ const QUERY_STOPWORDS = new Set([
193
+ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
194
+ 'by', 'from', 'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what',
195
+ 'when', 'where', 'which', 'who', 'why', 'does', 'do', 'it', 'its', 'into',
196
+ 'back', 'if', 'then', 'their', 'there', 'all', 'any',
197
+ ]);
198
+
199
+ /**
200
+ * Heuristic: is this an agent-style natural-language query (a behavioural
201
+ * description / question) rather than a keyword or symbol lookup? NL queries are
202
+ * long and contain English function words; symbol queries ("ShouldBindJSON bind
203
+ * request body") contain none.
204
+ */
205
+ export function isNaturalLanguageQuery(queryText) {
206
+ const words = String(queryText).toLowerCase().split(/[\s\W_]+/).filter(Boolean);
207
+ if (words.length < 5) return false;
208
+ const stops = words.filter(w => QUERY_STOPWORDS.has(w)).length;
209
+ return stops >= 2 && stops / words.length >= 0.2;
210
+ }
211
+
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?[/\\]/;
216
+
217
+ /**
218
+ * Reciprocal-Rank-Fusion of lexical + vector candidate lists, with the measured
219
+ * boost ladder (test/example demotion, expression demotion, TS barrel demotion,
220
+ * file-path boost, exact/snake name boost, optional exact-symbol boost).
221
+ *
222
+ * All backend-specific data is reached through accessors so the identical math
223
+ * serves both the in-memory Maps and SQLite row lookups.
224
+ *
225
+ * @param {object} p
226
+ * @param {Array<{id:string,rank:number}>} p.lexicalResults
227
+ * @param {Array<{id:string,rank:number}>} p.vectorResults
228
+ * @param {(id:string)=>object|undefined} p.getChunk
229
+ * @param {(id:string)=>Set<string>|undefined} p.getPathTokens
230
+ * @param {(token:string)=>number} p.getDf Document frequency of a token.
231
+ * @param {number} p.docCount
232
+ * @param {number} p.rrfK
233
+ * @param {number} p.topK
234
+ * @param {string} p.queryText
235
+ * @param {string|null} [p.exactBoostName]
236
+ * @param {(termLower:string)=>Iterable<string>} [p.resolveExact] Ids whose name === term.
237
+ * @returns {Array<{score:number, chunk:object}>}
238
+ */
239
+ export function fuseAndRank({
240
+ lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
241
+ docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
242
+ }) {
243
+ const rrfScores = new Map();
244
+ const K = rrfK;
245
+ const queryLower = queryText.toLowerCase();
246
+
247
+ const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
248
+
249
+ // Name-boost eligibility: long tokens, or short-but-discriminative ones whose
250
+ // document frequency is ≤15% of the corpus (self-tunes per repo/language,
251
+ // surfacing exact matches on short API names without re-introducing stopwords).
252
+ const _docN = docCount || 1;
253
+ const _queryNameTokens = queryLower.split(/[\s\W_]+/).filter(t =>
254
+ t.length >= 5 || (t.length >= 3 && (getDf(t) || 0) <= 0.15 * _docN)
255
+ );
256
+
257
+ // Query-adaptive channel weights: lexical-led for keyword/symbol lookups,
258
+ // vector-led for natural-language behavioural queries (only when a vector
259
+ // channel actually produced candidates — lexical-only mode is unaffected).
260
+ const nlQuery = vectorResults.length > 0 && isNaturalLanguageQuery(queryText);
261
+ const wLex = nlQuery ? NL_LEXICAL_WEIGHT : LEXICAL_WEIGHT;
262
+ const wVec = nlQuery ? NL_VECTOR_WEIGHT : VECTOR_WEIGHT;
263
+
264
+ // Re-rank the vector channel with test/example demotion applied to the cosine
265
+ // scores BEFORE ranks are assigned: a test helper that out-scores the real
266
+ // implementation in raw cosine otherwise occupies the top RRF positions and
267
+ // pushes the right answer's reciprocal rank down, even though the boost
268
+ // ladder demotes the helper later.
269
+ if (vectorResults.length > 0) {
270
+ const adjusted = vectorResults.map(r => {
271
+ const c = getChunk(r.id);
272
+ let f = 1;
273
+ if (c) {
274
+ if (TEST_FILE_RE.test(c.file_path) && !queryLower.includes('test') && !queryLower.includes('spec')) f = 0.25;
275
+ else if (EXAMPLE_DIR_RE.test(c.file_path)) f = 0.5;
276
+ }
277
+ return { id: r.id, score: (r.score ?? 0) * f };
278
+ });
279
+ adjusted.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : 1));
280
+ vectorResults = adjusted.map((r, i) => ({ ...r, rank: i + 1 }));
281
+ }
282
+
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
+
289
+ const allResults = [
290
+ ...vectorResults.map(r => ({ ...r, _w: wVec })),
291
+ ...lexicalResults.map(r => ({ ...r, _w: wLex })),
292
+ ];
293
+
294
+ for (const { id, rank, _w } of allResults) {
295
+ let baseScore = (_w ?? 1.0) / (K + rank);
296
+ const chunk = getChunk(id);
297
+ if (!chunk) continue;
298
+
299
+ // Demotion: test / spec files (unless the query is itself about tests).
300
+ if (TEST_FILE_RE.test(chunk.file_path)) {
301
+ if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
302
+ }
303
+ // Demotion: example / docs dirs (tutorial snippets over-rank on short length
304
+ // + high keyword density vs the real implementation).
305
+ if (EXAMPLE_DIR_RE.test(chunk.file_path)) baseScore *= 0.5;
306
+ // Demotion: pure expression sites.
307
+ if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
308
+ baseScore *= 0.8;
309
+ }
310
+ // Demotion: TypeScript barrel re-exports (`export { X } from 'y'`) — no
311
+ // implementation. JS exports are excluded since those often ARE the module.
312
+ if (chunk.name && chunk.name.endsWith('_export_statement') && chunk.file_path?.endsWith('.ts')) {
313
+ baseScore *= 0.7;
314
+ }
315
+ // Demotion: Python public re-exports (`from x import Y as Y`) — but only
316
+ // when a REAL definition of the same name exists in the index. When the
317
+ // re-export is the only in-repo occurrence (the implementation lives in a
318
+ // dependency, e.g. fastapi re-exporting starlette's BackgroundTasks), the
319
+ // alias IS the best available answer and must not be demoted.
320
+ if (chunk.node_type === 're_export' && resolveExact && chunk.name) {
321
+ for (const rid of resolveExact(String(chunk.name).toLowerCase())) {
322
+ const rc = getChunk(rid);
323
+ if (rc && rc.node_type !== 're_export') { baseScore *= 0.7; break; }
324
+ }
325
+ }
326
+
327
+ // File-path boost via the separate path-token set (not the BM25 index), so
328
+ // length normalisation never penalises long implementations sharing a path.
329
+ const canBoost = !boostEligible || boostEligible.has(id);
330
+ if (canBoost && _queryPathTokens.length > 0) {
331
+ const pathToks = getPathTokens(id);
332
+ if (pathToks) {
333
+ const hasExact = _queryPathTokens.some(t => pathToks.has(t));
334
+ const hasPrefix = !hasExact && _queryPathTokens.some(t =>
335
+ t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
336
+ );
337
+ if (hasExact) baseScore *= 1.4;
338
+ else if (hasPrefix) baseScore *= 1.2;
339
+ }
340
+ }
341
+
342
+ // Name boost: 2.0× exact (token IS the name), 1.4× snake_case suffix match.
343
+ // Only [._] splitting keeps camelCase names atomic (no generic-suffix bleed).
344
+ // Plural equivalence (`BackgroundTask` ↔ `BackgroundTasks`) is included —
345
+ // API names pluralize and a strict equality check missed them.
346
+ if (canBoost && chunk.name && chunk.name !== 'anonymous') {
347
+ const nameLower = chunk.name.toLowerCase();
348
+ const lastDotted = nameLower.split('.').pop() ?? nameLower;
349
+ const queryTokensAll = _queryNameTokens;
350
+ const eq = (a, b) => a === b || a === b + 's' || b === a + 's';
351
+ if (queryTokensAll.some(t => eq(nameLower, t) || eq(lastDotted, t))) {
352
+ // NOTE: a PageRank multiplier was trialled here (tie-break duplicate
353
+ // names toward central files) and measured NEGATIVE — hub files win
354
+ // exact-name matches on common words and the semantic channel drops.
355
+ baseScore *= 2.0;
356
+ } else {
357
+ const snakeParts = nameLower.split(/[._]+/);
358
+ const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
359
+ if (snakeParts.length >= 2 && lastSnake.length >= 3 && queryTokensAll.includes(lastSnake)) {
360
+ baseScore *= 1.4;
361
+ }
362
+ }
363
+ }
364
+
365
+ rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
366
+ }
367
+
368
+ // Optional guaranteed boost for an exactly-named symbol (search_code exact_tokens).
369
+ if (exactBoostName && resolveExact) {
370
+ const boostTerm = String(exactBoostName).toLowerCase().trim();
371
+ for (const id of resolveExact(boostTerm)) {
372
+ rrfScores.set(id, (rrfScores.get(id) || 0) + 1 / (K + 1));
373
+ }
374
+ }
375
+
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
+ return Array.from(rrfScores.entries())
379
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
380
+ .slice(0, topK)
381
+ .map(([id, rrfScore]) => ({ score: rrfScore, chunk: getChunk(id) }))
382
+ .filter(r => r.chunk !== undefined);
383
+ }
384
+
385
+ // ─── Graph centrality ──────────────────────────────────────────────────────────
386
+
387
+ /**
388
+ * Simplified PageRank over the file dependency graph. Files imported by many
389
+ * others receive higher rank (= more important). Used by get_repo_map ordering
390
+ * and by LLM enrichment to pick the "core" files worth summarising.
391
+ *
392
+ * @param {{dependencies:Object<string,string[]>}} graph
393
+ * @returns {Map<string, number>} file → rank
394
+ */
395
+ export function computePageRank(graph, iters = 30, damping = 0.85) {
396
+ const files = Object.keys(graph?.dependencies || {});
397
+ const N = files.length;
398
+ if (N === 0) return new Map();
399
+
400
+ const idx = new Map(files.map((f, i) => [f, i]));
401
+ const ranks = new Float64Array(N).fill(1.0 / N);
402
+ const outDeg = files.map(f => Math.max((graph.dependencies[f] || []).length, 1));
403
+
404
+ for (let iter = 0; iter < iters; iter++) {
405
+ const next = new Float64Array(N).fill((1 - damping) / N);
406
+ for (let i = 0; i < N; i++) {
407
+ const contrib = damping * ranks[i] / outDeg[i];
408
+ for (const dep of (graph.dependencies[files[i]] || [])) {
409
+ const j = idx.get(dep);
410
+ if (j !== undefined) next[j] += contrib;
411
+ }
412
+ }
413
+ ranks.set(next);
414
+ }
415
+ return new Map(files.map((f, i) => [f, ranks[i]]));
416
+ }