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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file core-engine.mjs
2
+ * @file engine/memory.mjs
3
3
  * @description In-Memory Graph Indexer Core Engine.
4
4
  * @author MaquinaTech <https://github.com/MaquinaTech>
5
5
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
@@ -7,405 +7,16 @@
7
7
  */
8
8
  import fs from 'fs';
9
9
  import {
10
- EMBEDDING_CONTEXT_LIMIT, truncateForEmbedding, cosineSimilarity, tokenize,
11
- okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
10
+ tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
12
11
  SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
13
- } from './search-core.mjs';
14
-
15
- // Re-exported for backward compatibility — callers historically imported these
16
- // retrieval primitives from core-engine; they now live in search-core.mjs.
17
- export { EMBEDDING_CONTEXT_LIMIT, truncateForEmbedding, cosineSimilarity };
18
-
19
- // Thresholds for adaptive vector loading strategy
20
- const HNSW_THRESHOLD = 5000; // Build HNSW index above this (eager mode only)
21
- const LAZY_VEC_THRESHOLD = 10000; // Switch to lazy (disk-backed) vector access above this
22
-
23
- // Optional HNSW accelerator for medium corpora (eager mode only)
24
- let HierarchicalNSW = null;
25
- try {
26
- const mod = await import('hnswlib-node');
27
- HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
28
- } catch { /* not installed — flat scan used */ }
29
-
30
- /**
31
- * Serializes embeddingCache to compact binary.
32
- * Format per entry: [uint32 hashLen][utf8 hash][uint32 dim][float32 × dim]
33
- */
34
- export function writeEmbeddingBinary(embeddingCache) {
35
- const entries = embeddingCache instanceof Map
36
- ? Array.from(embeddingCache.entries())
37
- : Object.entries(embeddingCache);
38
- let size = 4;
39
- for (const [hash, vec] of entries) {
40
- size += 4 + Buffer.byteLength(hash, 'utf8') + 4 + vec.length * 4;
41
- }
42
- const buf = Buffer.allocUnsafe(size);
43
- let off = 0;
44
- buf.writeUInt32LE(entries.length, off); off += 4;
45
- for (const [hash, vec] of entries) {
46
- const hashBytes = Buffer.from(hash, 'utf8');
47
- buf.writeUInt32LE(hashBytes.length, off); off += 4;
48
- hashBytes.copy(buf, off); off += hashBytes.length;
49
- buf.writeUInt32LE(vec.length, off); off += 4;
50
- for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
51
- }
52
- return buf;
53
- }
54
-
55
- /**
56
- * Decode an `.embeddings.bin` file into a Map<content_hash, Float32Array>.
57
- * Standalone (mirrors the instance loader) so the indexer can reuse cached
58
- * vectors across runs regardless of the active storage backend.
59
- */
60
- export function readEmbeddingBinary(filePath) {
61
- const cache = new Map();
62
- if (!fs.existsSync(filePath)) return cache;
63
- const buf = fs.readFileSync(filePath);
64
- let off = 0;
65
- const count = buf.readUInt32LE(off); off += 4;
66
- for (let i = 0; i < count; i++) {
67
- const hashLen = buf.readUInt32LE(off); off += 4;
68
- const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
69
- const dim = buf.readUInt32LE(off); off += 4;
70
- const vec = new Float32Array(dim);
71
- for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
72
- cache.set(hash, vec);
73
- }
74
- return cache;
75
- }
76
-
77
- /**
78
- * Append embedding entries to an `.embeddings.bin` file in place (creating it if
79
- * absent) and return the absolute float-data offset of each appended entry.
80
- * The leading uint32 entry count is updated so readEmbeddingBinary stays valid.
81
- *
82
- * Used by the watch daemon for incremental updates: a full bin rewrite on every
83
- * file change would be O(corpus) — appending is O(changed chunks). Dead entries
84
- * from replaced chunks are compacted away on the next full index run.
85
- *
86
- * @param {string} filePath
87
- * @param {Map<string, Float32Array|number[]>|object} entries key → vector
88
- * @returns {Map<string, {offset:number, dim:number}>}
89
- */
90
- export function appendEmbeddingBinary(filePath, entries) {
91
- const list = entries instanceof Map ? Array.from(entries.entries()) : Object.entries(entries || {});
92
- const offsets = new Map();
93
- if (list.length === 0) return offsets;
94
-
95
- if (!fs.existsSync(filePath)) {
96
- const hdr = Buffer.alloc(4);
97
- hdr.writeUInt32LE(0, 0);
98
- fs.writeFileSync(filePath, hdr);
99
- }
100
- const fd = fs.openSync(filePath, 'r+');
101
- try {
102
- const pos = fs.fstatSync(fd).size;
103
- let size = 0;
104
- for (const [key, vec] of list) size += 4 + Buffer.byteLength(key, 'utf8') + 4 + vec.length * 4;
105
- const buf = Buffer.allocUnsafe(size);
106
- let off = 0;
107
- for (const [key, vec] of list) {
108
- const kb = Buffer.from(key, 'utf8');
109
- buf.writeUInt32LE(kb.length, off); off += 4;
110
- kb.copy(buf, off); off += kb.length;
111
- buf.writeUInt32LE(vec.length, off); off += 4;
112
- offsets.set(key, { offset: pos + off, dim: vec.length });
113
- for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
114
- }
115
- fs.writeSync(fd, buf, 0, buf.length, pos);
116
- const hdr = Buffer.allocUnsafe(4);
117
- fs.readSync(fd, hdr, 0, 4, 0);
118
- hdr.writeUInt32LE(hdr.readUInt32LE(0) + list.length, 0);
119
- fs.writeSync(fd, hdr, 0, 4, 0);
120
- } finally {
121
- fs.closeSync(fd);
122
- }
123
- return offsets;
124
- }
125
-
126
- /**
127
- * Cosine-score EVERY vector in an `.embeddings.bin` against a query vector,
128
- * streaming from disk in bounded buffers, and return the top-N entry keys.
129
- *
130
- * This is the semantic channel for the disk-backed stores. The previous design
131
- * scored vectors only for chunks that already matched lexically, which silently
132
- * disabled semantic search exactly where it matters: conceptual queries that
133
- * share no tokens with the code. A sequential scan of the bin is fast (the OS
134
- * page cache holds it after the first query) and keeps resident RAM flat.
135
- *
136
- * @param {{fd?: number, buffer?: Buffer}} source Open fd or in-memory buffer of the bin.
137
- * @param {Float32Array|number[]} queryVector
138
- * @param {{topN?: number, minScore?: number}} [opts]
139
- * @returns {Array<{key: string, score: number}>} Sorted by score, descending.
140
- */
141
- export function scanEmbeddingBinary({ fd = -1, buffer = null }, queryVector, { topN = 200, minScore = 0 } = {}) {
142
- const qDim = queryVector.length;
143
- let qNorm = 0;
144
- for (let d = 0; d < qDim; d++) qNorm += queryVector[d] * queryVector[d];
145
- qNorm = Math.sqrt(qNorm);
146
- if (qNorm === 0) return [];
147
-
148
- const top = []; // sorted desc, capped at topN
149
- let worst = -Infinity;
150
- const push = (key, score) => {
151
- if (score <= minScore) return;
152
- if (top.length >= topN && score <= worst) return;
153
- let lo = 0, hi = top.length;
154
- while (lo < hi) { const mid = (lo + hi) >> 1; if (top[mid].score >= score) lo = mid + 1; else hi = mid; }
155
- top.splice(lo, 0, { key, score });
156
- if (top.length > topN) top.pop();
157
- worst = top[top.length - 1].score;
158
- };
159
-
160
- // Parses complete entries in buf starting at startOff; returns the offset of
161
- // the first incomplete entry (callers carry the remainder into the next read).
162
- const processEntries = (buf, startOff) => {
163
- let off = startOff;
164
- for (;;) {
165
- if (off + 4 > buf.length) return off;
166
- const keyLen = buf.readUInt32LE(off);
167
- if (off + 4 + keyLen + 4 > buf.length) return off;
168
- const dim = buf.readUInt32LE(off + 4 + keyLen);
169
- const entryEnd = off + 4 + keyLen + 4 + dim * 4;
170
- if (entryEnd > buf.length) return off;
171
- if (dim === qDim) {
172
- const key = buf.toString('utf8', off + 4, off + 4 + keyLen);
173
- let dp = 0, nv = 0;
174
- let p = off + 4 + keyLen + 4;
175
- for (let d = 0; d < dim; d++, p += 4) {
176
- const v = buf.readFloatLE(p);
177
- dp += queryVector[d] * v;
178
- nv += v * v;
179
- }
180
- const n = Math.sqrt(nv);
181
- if (n > 0) push(key, dp / (qNorm * n));
182
- }
183
- off = entryEnd;
184
- }
185
- };
186
-
187
- if (buffer) { processEntries(buffer, 4); return top; }
188
- if (fd < 0) return [];
189
-
190
- const CHUNK = 4 * 1024 * 1024;
191
- let filePos = 0;
192
- let carry = null;
193
- let first = true;
194
- for (;;) {
195
- const buf = Buffer.allocUnsafe(CHUNK);
196
- const read = fs.readSync(fd, buf, 0, CHUNK, filePos);
197
- if (read <= 0) break;
198
- filePos += read;
199
- const work = carry && carry.length
200
- ? Buffer.concat([carry, buf.subarray(0, read)])
201
- : buf.subarray(0, read);
202
- const consumed = processEntries(work, first ? 4 : 0);
203
- first = false;
204
- carry = work.subarray(consumed);
205
- if (read < CHUNK) break;
206
- }
207
- return top;
208
- }
209
-
210
- // ─── Binary-quantized vector sketch ─────────────────────────────────────────────
211
- // The streaming full scan above is exact but O(corpus × dim) per query: ~104 ms
212
- // at 50k chunks and ~519 ms at 200k (measured warm). The sketch removes that
213
- // ceiling while keeping resident RAM bounded: each vector is reduced to its SIGN
214
- // BITS (768 dims → 96 bytes, 0.1% of the float32 data). A query is answered by
215
- // (1) a Hamming-distance pass over the packed bits (XOR + popcount over Uint32
216
- // words — a few ms even at 200k rows), keeping the best `oversample × topN`
217
- // candidates, then (2) an exact cosine rescore of only those candidates, pread
218
- // from the bin. Sign quantization of normalized embeddings preserves cosine
219
- // ordering well enough that a 4× oversampled rescore recovers the exact top-N
220
- // (validated in test/unit.mjs against the exhaustive scan).
221
- //
222
- // The sketch is APPEND-AWARE: the bin only ever grows between full rebuilds
223
- // (the daemon appends), so `updateVectorSketch` re-reads just the tail beyond
224
- // what it has already consumed, keeping refresh O(changed chunks).
225
-
226
- function popcnt32(x) {
227
- x -= (x >> 1) & 0x55555555;
228
- x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
229
- x = (x + (x >> 4)) & 0x0f0f0f0f;
230
- return (x * 0x01010101) >> 24;
231
- }
232
-
233
- /** Pack a float vector's sign bits into `words` Uint32 words. */
234
- function quantizeToBits(vec, out, base, words) {
235
- for (let w = 0; w < words; w++) {
236
- let bits = 0;
237
- const d0 = w * 32;
238
- const dMax = Math.min(d0 + 32, vec.length);
239
- for (let d = d0; d < dMax; d++) {
240
- if (vec[d] > 0) bits |= (1 << (d - d0));
241
- }
242
- out[base + w] = bits;
243
- }
244
- }
245
-
246
- /**
247
- * Build or incrementally extend a binary sketch of an `.embeddings.bin`.
248
- *
249
- * @param {object|null} sketch Existing sketch to extend, or null to create.
250
- * @param {{fd?: number, buffer?: Buffer}} source
251
- * @returns {object} sketch { dim, words, n, keys[], offsets[], bits: Uint32Array,
252
- * consumed, headerCount, firstKey }
253
- */
254
- export function updateVectorSketch(sketch, { fd = -1, buffer = null }) {
255
- const readAt = (buf, pos, len) => {
256
- if (buffer) return buffer.subarray(pos, pos + len);
257
- const b = Buffer.allocUnsafe(len);
258
- const r = fs.readSync(fd, b, 0, len, pos);
259
- return r < len ? b.subarray(0, r) : b;
260
- };
261
- const fileSize = buffer ? buffer.length : (fd >= 0 ? fs.fstatSync(fd).size : 0);
262
- if (fileSize < 4) return sketch;
263
-
264
- const headerCount = readAt(null, 0, 4).readUInt32LE(0);
265
- if (!sketch) {
266
- sketch = {
267
- dim: 0, words: 0, n: 0, keys: [], offsets: [],
268
- bits: new Uint32Array(0), consumed: 4, headerCount: 0, firstKey: null,
269
- };
270
- }
271
- if (fileSize <= sketch.consumed) return sketch;
272
-
273
- // Read the unseen tail in large blocks. No carry buffers / Buffer.concat —
274
- // the next block simply re-reads from the last complete entry boundary
275
- // (at most one partial entry of overlap), so the build leaves no transient
276
- // garbage behind. Sign bits are quantized straight off the block buffer.
277
- const CHUNK = 8 * 1024 * 1024;
278
- const reusable = buffer ? null : Buffer.allocUnsafe(CHUNK);
279
- let pos = sketch.consumed;
280
- while (pos < fileSize) {
281
- const len = Math.min(CHUNK, fileSize - pos);
282
- let work;
283
- if (buffer) {
284
- work = buffer.subarray(pos, pos + len);
285
- } else {
286
- const r = fs.readSync(fd, reusable, 0, len, pos);
287
- work = r < len ? reusable.subarray(0, r) : (len < CHUNK ? reusable.subarray(0, len) : reusable);
288
- }
289
- let off = 0;
290
- let parsedAny = false;
291
- for (;;) {
292
- if (off + 4 > work.length) break;
293
- const keyLen = work.readUInt32LE(off);
294
- if (off + 4 + keyLen + 4 > work.length) break;
295
- const dim = work.readUInt32LE(off + 4 + keyLen);
296
- const entryEnd = off + 4 + keyLen + 4 + dim * 4;
297
- if (entryEnd > work.length) break;
298
-
299
- const key = work.toString('utf8', off + 4, off + 4 + keyLen);
300
- const floatBase = off + 4 + keyLen + 4;
301
- if (sketch.dim === 0) {
302
- sketch.dim = dim;
303
- sketch.words = Math.ceil(dim / 32);
304
- }
305
- if (dim === sketch.dim) {
306
- if ((sketch.n + 1) * sketch.words > sketch.bits.length) {
307
- const grown = new Uint32Array(Math.max(1024 * sketch.words, sketch.bits.length * 2));
308
- grown.set(sketch.bits);
309
- sketch.bits = grown;
310
- }
311
- // Quantize sign bits directly from the block buffer.
312
- const bitBase = sketch.n * sketch.words;
313
- for (let w = 0; w < sketch.words; w++) {
314
- let bits = 0;
315
- const d0 = w * 32;
316
- const dMax = Math.min(d0 + 32, dim);
317
- for (let d = d0; d < dMax; d++) {
318
- if (work.readFloatLE(floatBase + d * 4) > 0) bits |= (1 << (d - d0));
319
- }
320
- sketch.bits[bitBase + w] = bits;
321
- }
322
- sketch.keys.push(key);
323
- sketch.offsets.push(pos + floatBase);
324
- if (sketch.firstKey === null) sketch.firstKey = key;
325
- sketch.n++;
326
- }
327
- off = entryEnd;
328
- parsedAny = true;
329
- }
330
- if (!parsedAny) break; // partial entry larger than the block — corrupt tail
331
- pos += off; // resume exactly at the last complete entry boundary
332
- sketch.consumed = pos;
333
- }
334
- sketch.headerCount = headerCount;
335
- return sketch;
336
- }
337
-
338
- /**
339
- * Approximate top-N search via the sketch + exact cosine rescore from the bin.
340
- * Drop-in replacement for scanEmbeddingBinary (same return shape), ~20–50×
341
- * faster at scale.
342
- *
343
- * @returns {Array<{key: string, score: number}>} sorted by exact cosine, desc.
344
- */
345
- export function searchVectorSketch(sketch, { fd = -1, buffer = null }, queryVector, {
346
- topN = 200, minScore = 0, oversample = 4,
347
- } = {}) {
348
- if (!sketch || sketch.n === 0 || queryVector.length !== sketch.dim) return [];
349
- const words = sketch.words;
350
-
351
- // 1. Quantize the query and rank rows by Hamming distance (bounded top-M).
352
- const qbits = new Uint32Array(words);
353
- quantizeToBits(queryVector, qbits, 0, words);
354
-
355
- const M = Math.min(sketch.n, Math.max(topN * oversample, 64));
356
- const candIdx = new Int32Array(M);
357
- const candHam = new Int32Array(M);
358
- let count = 0, worst = -1;
359
- const bits = sketch.bits;
360
- for (let i = 0; i < sketch.n; i++) {
361
- const base = i * words;
362
- let ham = 0;
363
- for (let w = 0; w < words; w++) ham += popcnt32((bits[base + w] ^ qbits[w]) >>> 0);
364
- if (count >= M && ham >= worst) continue;
365
- // bounded insertion sort (ascending hamming)
366
- let lo = 0, hi = count;
367
- while (lo < hi) { const mid = (lo + hi) >> 1; if (candHam[mid] <= ham) lo = mid + 1; else hi = mid; }
368
- const end = Math.min(count, M - 1);
369
- for (let j = end; j > lo; j--) { candHam[j] = candHam[j - 1]; candIdx[j] = candIdx[j - 1]; }
370
- candHam[lo] = ham; candIdx[lo] = i;
371
- if (count < M) count++;
372
- worst = candHam[count - 1];
373
- }
374
-
375
- // 2. Exact cosine rescore of the candidates only (pread per candidate).
376
- let qNorm = 0;
377
- for (let d = 0; d < queryVector.length; d++) qNorm += queryVector[d] * queryVector[d];
378
- qNorm = Math.sqrt(qNorm);
379
- if (qNorm === 0) return [];
380
-
381
- const byteLen = sketch.dim * 4;
382
- const raw = Buffer.allocUnsafe(byteLen);
383
- const scored = [];
384
- for (let c = 0; c < count; c++) {
385
- const i = candIdx[c];
386
- let view;
387
- if (buffer) {
388
- view = buffer.subarray(sketch.offsets[i], sketch.offsets[i] + byteLen);
389
- if (view.length < byteLen) continue;
390
- } else {
391
- const read = fs.readSync(fd, raw, 0, byteLen, sketch.offsets[i]);
392
- if (read < byteLen) continue;
393
- view = raw;
394
- }
395
- let dp = 0, nv = 0;
396
- for (let d = 0; d < sketch.dim; d++) {
397
- const v = view.readFloatLE(d * 4);
398
- dp += queryVector[d] * v;
399
- nv += v * v;
400
- }
401
- const n = Math.sqrt(nv);
402
- if (n === 0) continue;
403
- const score = dp / (qNorm * n);
404
- if (score > minScore) scored.push({ key: sketch.keys[i], score });
405
- }
406
- scored.sort((a, b) => b.score - a.score);
407
- return scored.slice(0, topN);
408
- }
12
+ isNaturalLanguageQuery, WINDOW_VEC_SUFFIX, EMBEDDING_MAX_WINDOWS, baseEmbeddingKey,
13
+ scoreSparseExpanded,
14
+ } from '../search-core.mjs';
15
+ import { expandSparseQuery } from '../search-sparse.mjs';
16
+ import {
17
+ HierarchicalNSW, HNSW_THRESHOLD, LAZY_VEC_THRESHOLD, SKETCH_THRESHOLD,
18
+ writeEmbeddingBinary, scanEmbeddingBinary, updateVectorSketch, searchVectorSketch,
19
+ } from './binary.mjs';
409
20
 
410
21
  export class MemoryGraphIndex {
411
22
  /**
@@ -461,6 +72,72 @@ export class MemoryGraphIndex {
461
72
 
462
73
  this.rrfK = rrfK;
463
74
  this._saveTimer = null;
75
+
76
+ // ── A4: persistent resolved symbol graph (opt-in; null when not built) ──
77
+ // _edges is the serialized edge list; the two maps index it by endpoint for
78
+ // O(1) getEdges and edge-backed findCallers/findReferers. Cleared on any
79
+ // incremental update (daemon) so a stale graph never serves wrong callers.
80
+ this._edges = null;
81
+ this._edgesByTo = new Map(); // to_chunk_id → edge[]
82
+ this._edgesByFrom = new Map(); // from_chunk_id → edge[]
83
+
84
+ // ── A5: symbol centrality (PageRank over the edges; built with the graph) ──
85
+ // _centrality maps chunk id → { score, rank }; precomputed at index time and
86
+ // serialized, so both backends serve identical numbers. Cleared with the edges.
87
+ this._centrality = null;
88
+ this._centralityTotal = 0;
89
+ this._centralRanked = null; // lazy id-by-rank cache for topCentral
90
+
91
+ // ── C2: serialized taint flows ({ flows, meta }); precomputed at index time with --taint,
92
+ // so the taint tools serve instantly and both backends serve identical flows. Cleared
93
+ // on any incremental file update (a code change invalidates the flow set).
94
+ this._taint = null;
95
+
96
+ // ── A2 v2: SCIP precise references — `{ defChunkId: [refererChunkId, …] }`, built at index
97
+ // time from a locally-generated SCIP index (--resolver scip) and serialized as an
98
+ // isolated artifact so it cannot perturb A4 edges / A5 centrality. find_references reads
99
+ // it for a binding-precise, cross-file `resolved` reference tier. Cleared with the graph.
100
+ this._scipRefs = null;
101
+
102
+ // ── B3: learned-sparse association model — `{ assoc: { term: [[assocTerm, weight], …] } }`,
103
+ // learned from corpus co-occurrence at index time with --learned-sparse and serialized as
104
+ // an isolated artifact. searchHybrid uses it to add an NL-only vocabulary-EXPANSION
105
+ // channel; absent on default indexes → channel inert → byte-identical. Whole-program →
106
+ // cleared on any incremental file update.
107
+ this._sparseModel = null;
108
+ }
109
+
110
+ /** Index the (already deterministically-ordered) edge list by endpoint. */
111
+ _indexEdges(edges) {
112
+ this._edges = edges;
113
+ this._edgesByTo = new Map();
114
+ this._edgesByFrom = new Map();
115
+ for (const e of edges) {
116
+ if (!this._edgesByTo.has(e.to_chunk_id)) this._edgesByTo.set(e.to_chunk_id, []);
117
+ this._edgesByTo.get(e.to_chunk_id).push(e);
118
+ if (!this._edgesByFrom.has(e.from_chunk_id)) this._edgesByFrom.set(e.from_chunk_id, []);
119
+ this._edgesByFrom.get(e.from_chunk_id).push(e);
120
+ }
121
+ }
122
+
123
+ /** Load the precomputed symbol-centrality map (A5). */
124
+ _indexCentrality(centrality) {
125
+ this._centrality = new Map(Object.entries(centrality));
126
+ this._centralityTotal = this._centrality.size;
127
+ this._centralRanked = null;
128
+ }
129
+
130
+ /** Drop the symbol graph + its centrality + serialized taint + SCIP references (an incremental update makes them stale). */
131
+ _clearEdges() {
132
+ this._edges = null;
133
+ this._edgesByTo = new Map();
134
+ this._edgesByFrom = new Map();
135
+ this._centrality = null;
136
+ this._centralityTotal = 0;
137
+ this._centralRanked = null;
138
+ this._taint = null;
139
+ this._scipRefs = null;
140
+ this._sparseModel = null;
464
141
  }
465
142
 
466
143
  // ─── Load ─────────────────────────────────────────────────────────────────
@@ -469,10 +146,23 @@ export class MemoryGraphIndex {
469
146
  if (!fs.existsSync(this.indexPath)) return;
470
147
  const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
471
148
  this.graph = data.graph || { dependencies: {}, importedBy: {} };
149
+ // A4 resolved symbol graph (opt-in; absent on default indexes → no-op).
150
+ if (Array.isArray(data.edges)) this._indexEdges(data.edges);
151
+ // A5 symbol centrality (built with the graph; absent on default indexes → no-op).
152
+ if (data.centrality && typeof data.centrality === 'object') this._indexCentrality(data.centrality);
153
+ // C2 serialized taint flows (opt-in --taint; absent on default indexes → no-op).
154
+ if (data.taint && typeof data.taint === 'object') this._taint = data.taint;
155
+ // A2 v2 SCIP precise references (opt-in --resolver scip; absent on default indexes → no-op).
156
+ if (data.scip_refs && typeof data.scip_refs === 'object') this._scipRefs = data.scip_refs;
157
+ // B3 learned-sparse model (opt-in --learned-sparse; absent on default indexes → no-op).
158
+ if (data.sparse_model && typeof data.sparse_model === 'object') this._sparseModel = data.sparse_model;
159
+ // Does this corpus carry LLM enrichment? Drives the NL vector-channel weight
160
+ // in fuseAndRank (strong summary vectors earn full weight; plain code vectors
161
+ // stay a low-weight rescue). Set during the chunk load loop below.
162
+ this._corpusEnriched = false;
472
163
 
473
164
  const chunkCount = (data.chunks || []).length;
474
165
 
475
- // Decide loading strategy based on corpus size and cacheEmbeddings flag
476
166
  this._lazyMode = (!this._cacheEmbeddings) && chunkCount >= LAZY_VEC_THRESHOLD;
477
167
 
478
168
  if (fs.existsSync(this._embeddingPath)) {
@@ -482,13 +172,10 @@ export class MemoryGraphIndex {
482
172
  // For very large corpora: open persistent fd, release buffer from heap
483
173
  if (chunkCount >= 50000) {
484
174
  try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
485
- // binBuf goes out of scope → GC can collect it
486
175
  } else {
487
- // Medium-large: keep buffer for zero-copy slice access
488
176
  this._embeddingBuf = binBuf;
489
177
  }
490
178
  } else {
491
- // Eager: fill embeddingCache (needed by indexer + small-corpus MCP server)
492
179
  this._loadEmbeddingBinary(binBuf);
493
180
  }
494
181
  } else if (data.embeddingCache) {
@@ -499,6 +186,9 @@ export class MemoryGraphIndex {
499
186
 
500
187
  for (const chunk of (data.chunks || [])) {
501
188
  this.chunks.set(chunk.id, chunk);
189
+ if (!this._corpusEnriched && (chunk.summary || (chunk.concepts && chunk.concepts.length))) {
190
+ this._corpusEnriched = true;
191
+ }
502
192
 
503
193
  // Eager vector population. Vectors are keyed by embeddingKeyFor(chunk)
504
194
  // (content_hash + enrichment digest); plain content_hash is the
@@ -507,10 +197,16 @@ export class MemoryGraphIndex {
507
197
  const vecKey = embeddingKeyFor(chunk);
508
198
  if (chunk.content_hash && this.embeddingCache.has(vecKey)) {
509
199
  this.vectors.set(chunk.id, this.embeddingCache.get(vecKey));
510
- // Summary-only second vector (enriched chunks): stored under a
511
- // pseudo row id; searchHybrid folds hits back onto the chunk id.
200
+ // Summary-only and per-window vectors (enriched / oversized chunks):
201
+ // stored under pseudo row ids; searchHybrid folds hits back onto the
202
+ // chunk id (max-sim across all of the chunk's vectors).
512
203
  const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
513
204
  if (sVec) this.vectors.set(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
205
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) {
206
+ const wVec = this.embeddingCache.get(vecKey + WINDOW_VEC_SUFFIX + i);
207
+ if (!wVec) break;
208
+ this.vectors.set(chunk.id + WINDOW_VEC_SUFFIX + i, wVec);
209
+ }
514
210
  } else if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
515
211
  this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
516
212
  } else if (chunk.embedding) {
@@ -520,12 +216,9 @@ export class MemoryGraphIndex {
520
216
  }
521
217
  }
522
218
 
523
- // Build inverted lexical index from the shared document builder
524
- // (search-core.buildLexicalDocument) — identical text across backends.
525
219
  const deps = this.graph.dependencies[chunk.file_path] || [];
526
220
  this._indexLexical(chunk.id, buildLexicalDocument(chunk, deps), chunk.file_path);
527
221
 
528
- // Build symbol table (Frontier 2)
529
222
  if (chunk.name && chunk.name !== 'anonymous') {
530
223
  const n = chunk.name.toLowerCase();
531
224
  if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
@@ -606,7 +299,7 @@ export class MemoryGraphIndex {
606
299
  // ─── Lexical index (TRUE inverted index) ──────────────────────────────────
607
300
 
608
301
  _indexLexical(chunkId, text, filePath = '') {
609
- const tokens = tokenize(text);
302
+ const tokens = tokenize(text); // raw + additive Porter stems
610
303
  if (tokens.length === 0) return;
611
304
 
612
305
  const termCounts = new Map();
@@ -614,16 +307,16 @@ export class MemoryGraphIndex {
614
307
  termCounts.set(token, (termCounts.get(token) || 0) + 1);
615
308
  }
616
309
 
617
- // Track document length for BM25 length normalization.
618
- // Only content tokens are counted — path tokens are handled separately
619
- // in searchHybrid to avoid BM25 length normalisation amplifying short
620
- // export stubs that share a path with a larger implementation.
621
- this.docLens.set(chunkId, tokens.length);
622
- this.totalDocLen += tokens.length;
310
+ // Additive stem tokens earn their own postings but must NOT inflate docLen —
311
+ // inflated length would perturb BM25 normalisation for exact symbolic matches.
312
+ // raw-based docLen keeps raw-term BM25 byte-identical to the pre-stem index.
313
+ // Path tokens are tallied separately in searchHybrid, not here.
314
+ const rawLen = tokenize(text, false).length;
315
+ this.docLens.set(chunkId, rawLen);
316
+ this.totalDocLen += rawLen;
623
317
 
624
318
  const chunkTokenSet = new Set();
625
319
  for (const [term, count] of termCounts) {
626
- // Store raw term count — BM25 applies saturation at search time
627
320
  this.df.set(term, (this.df.get(term) || 0) + 1);
628
321
  let posting = this.invertedIndex.get(term);
629
322
  if (!posting) { posting = new Map(); this.invertedIndex.set(term, posting); }
@@ -638,7 +331,7 @@ export class MemoryGraphIndex {
638
331
  // Path matching is done multiplicatively in searchHybrid, which avoids
639
332
  // amplifying short stubs that happen to share a path with a long function.
640
333
  if (filePath) {
641
- const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' ')));
334
+ const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' '), false));
642
335
  if (!this.pathTokens) this.pathTokens = new Map();
643
336
  this.pathTokens.set(chunkId, pathTokenSet);
644
337
  }
@@ -659,7 +352,6 @@ export class MemoryGraphIndex {
659
352
  else this.df.set(term, freq - 1);
660
353
  }
661
354
  }
662
- // Update BM25 length accounting
663
355
  const dl = this.docLens.get(chunkId);
664
356
  if (dl !== undefined) {
665
357
  this.totalDocLen = Math.max(0, this.totalDocLen - dl);
@@ -671,7 +363,10 @@ export class MemoryGraphIndex {
671
363
  }
672
364
 
673
365
  _searchLexical(queryText) {
674
- const queryTokens = tokenize(queryText);
366
+ // Asymmetric stemming: the index always carries stems, but only stem the
367
+ // QUERY for natural-language/behavioural queries — symbolic name lookups
368
+ // stay exact so stem collisions never dilute a precise symbol match.
369
+ const queryTokens = tokenize(queryText, isNaturalLanguageQuery(queryText));
675
370
  const scores = new Map();
676
371
  const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
677
372
 
@@ -739,7 +434,6 @@ export class MemoryGraphIndex {
739
434
  this._vecNorms = norms;
740
435
  this._matrixDirty = false;
741
436
 
742
- // HNSW for large eager corpora
743
437
  if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
744
438
  try {
745
439
  const hnsw = new HierarchicalNSW('cosine', dim);
@@ -753,7 +447,7 @@ export class MemoryGraphIndex {
753
447
  }
754
448
  this._hnsw = hnsw;
755
449
  } catch (e) {
756
- process.stderr.write(`[core-engine] HNSW build failed: ${e.message}\n`);
450
+ process.stderr.write(`[engine/memory] HNSW build failed: ${e.message}\n`);
757
451
  this._hnsw = null;
758
452
  }
759
453
  }
@@ -773,7 +467,6 @@ export class MemoryGraphIndex {
773
467
  qNorm = Math.sqrt(qNorm);
774
468
  if (qNorm === 0) return [];
775
469
 
776
- // HNSW fast path
777
470
  if (this._hnsw) {
778
471
  const topK = Math.min(200, n);
779
472
  try {
@@ -788,7 +481,6 @@ export class MemoryGraphIndex {
788
481
  } catch { /* fall through to flat scan */ }
789
482
  }
790
483
 
791
- // Exact flat scan
792
484
  const results = [];
793
485
  for (let i = 0; i < n; i++) {
794
486
  const base = i * dim;
@@ -813,9 +505,14 @@ export class MemoryGraphIndex {
813
505
  if (!this._embeddingBuf && this._vecFd < 0) return [];
814
506
  // Large corpora: binary sketch (built once on first vector query) replaces
815
507
  // the exact O(corpus) scan with a Hamming prefilter + bounded rescore.
816
- // 10k threshold matches the SQLite store so backends stay rank-identical
817
- // wherever the exact scan is still fast.
818
- if (!this._sketch && this._vecOffsets.size >= 10000) {
508
+ // SKETCH_THRESHOLD is shared with the SQLite store so both switch to the
509
+ // sketch at the same vector count. NOTE: this lazy path only runs when the
510
+ // store is in lazy mode (cacheEmbeddings off AND chunkCount ≥ LAZY_VEC_THRESHOLD);
511
+ // a corpus with < LAZY_VEC_THRESHOLD chunks but ≥ SKETCH_THRESHOLD vectors
512
+ // (heavy enrichment/oversized windows) still scans exactly here while SQLite
513
+ // sketches — a narrow band worth a dedicated scale parity test before relying
514
+ // on byte-identical top-5 above the threshold.
515
+ if (!this._sketch && this._vecOffsets.size >= SKETCH_THRESHOLD) {
819
516
  try { this._sketch = updateVectorSketch(null, source); } catch { this._sketch = null; }
820
517
  }
821
518
  const hits = this._sketch
@@ -834,6 +531,7 @@ export class MemoryGraphIndex {
834
531
  const key = embeddingKeyFor(c);
835
532
  put(key, c.id);
836
533
  put(key + SUMMARY_VEC_SUFFIX, c.id); // summary-only vector → same chunk
534
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) put(key + WINDOW_VEC_SUFFIX + i, c.id); // window vectors → same chunk
837
535
  if (key !== c.content_hash) put(c.content_hash, c.id); // legacy bins
838
536
  }
839
537
  this._keyToIds = map;
@@ -858,12 +556,12 @@ export class MemoryGraphIndex {
858
556
  // zero lexical overlap, which the old TF-IDF prefilter never could.
859
557
  vectorResults = this._scanVectorsLazy(queryVector, minScore);
860
558
  } else {
861
- // Eager rows include summary-only pseudo ids (`<id>|s`) — fold each
862
- // back onto its chunk via the shared finalizer (best score per
863
- // chunk, deterministic order, same cap as the disk-backed paths).
559
+ // Eager rows include summary-only and per-window pseudo ids
560
+ // (`<id>|s`, `<id>|wN`) — fold each back onto its chunk via the shared
561
+ // finalizer (best/max-sim score per chunk, deterministic order, same
562
+ // cap as the disk-backed paths).
864
563
  const entries = this._searchVector(queryVector, minScore).map(r => ({
865
- id: r.id.endsWith(SUMMARY_VEC_SUFFIX)
866
- ? r.id.slice(0, -SUMMARY_VEC_SUFFIX.length) : r.id,
564
+ id: baseEmbeddingKey(r.id),
867
565
  score: r.score,
868
566
  }));
869
567
  vectorResults = finalizeVectorCandidates(entries);
@@ -872,12 +570,14 @@ export class MemoryGraphIndex {
872
570
  vectorResults = [];
873
571
  }
874
572
 
875
- // Fusion + boost ladder lives in search-core.mjs so the in-memory engine
876
- // and the SQLite store rank identically. Backend state is reached through
877
- // accessors; the math is measured once and shared.
573
+ // B3 learned-sparse channel: opt-in (model present) + NL-asymmetric (symbolic queries skip
574
+ // it → byte-identical). expandSparseQuery returns null when nothing fires → no channel.
575
+ const sparseResults = this._searchSparseExpanded(queryText);
576
+
878
577
  return fuseAndRank({
879
578
  lexicalResults,
880
579
  vectorResults,
580
+ sparseResults,
881
581
  getChunk: (id) => this.chunks.get(id),
882
582
  getPathTokens: (id) => this.pathTokens.get(id),
883
583
  getDf: (t) => this.df.get(t) || 0,
@@ -886,12 +586,37 @@ export class MemoryGraphIndex {
886
586
  topK,
887
587
  queryText,
888
588
  exactBoostName,
889
- // Equivalent to the former full-scan (symbolTable is keyed by name.toLowerCase()),
890
- // but O(1) and reusable by the SQLite backend.
589
+ corpusEnriched: this._corpusEnriched,
590
+ // O(1) vs the old full-scan; same contract implemented by the SQLite backend.
891
591
  resolveExact: (term) => this.symbolTable.get(term) || [],
892
592
  });
893
593
  }
894
594
 
595
+ /**
596
+ * B3 — the learned-sparse expansion channel: NL queries only, model present only. Returns the
597
+ * ranked sparse candidates, or [] when the channel is inert (no model / symbolic query / no
598
+ * association fired) so fusion is byte-identical to the default path.
599
+ */
600
+ _searchSparseExpanded(queryText) {
601
+ if (!this._sparseModel || !isNaturalLanguageQuery(queryText)) return [];
602
+ const termWeights = expandSparseQuery(queryText, this._sparseModel);
603
+ if (!termWeights) return [];
604
+ const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
605
+ return scoreSparseExpanded(termWeights, {
606
+ docCount: this.docCount,
607
+ avgdl,
608
+ getDf: (t) => this.df.get(t) || 0,
609
+ getPostings:(t) => this.invertedIndex.get(t) || [],
610
+ getDocLen: (id) => this.docLens.get(id),
611
+ });
612
+ }
613
+
614
+ /** Whether opt-in learned-sparse associations (B3 --learned-sparse) are loaded. */
615
+ hasSparseModel() { return this._sparseModel != null; }
616
+
617
+ /** Path to the shared `.embeddings.bin` (B4 late-interaction reranker reads candidate vectors from it). */
618
+ embeddingBinPath() { return this._embeddingPath; }
619
+
895
620
  // ─── Persistence ───────────────────────────────────────────────────────────
896
621
 
897
622
  async save() {
@@ -899,13 +624,20 @@ export class MemoryGraphIndex {
899
624
  id: c.id, file_path: c.file_path, node_type: c.node_type,
900
625
  name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
901
626
  content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
902
- calls: c.calls || [], params: c.params || [],
627
+ calls: c.calls || [], call_sites: c.call_sites || [], params: c.params || [],
903
628
  return_type: c.return_type || '', class_context: c.class_context || '',
904
629
  type_refs: c.type_refs || [], decorators: c.decorators || [],
905
630
  extends: c.extends || [],
906
631
  hyde: c.hyde || '', summary: c.summary || '', concepts: c.concepts || [],
907
632
  }));
908
- const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
633
+ const payload = JSON.stringify({
634
+ chunks: chunksData, graph: this.graph,
635
+ ...(this._edges ? { edges: this._edges } : {}),
636
+ ...(this._centrality ? { centrality: Object.fromEntries(this._centrality) } : {}),
637
+ ...(this._taint ? { taint: this._taint } : {}),
638
+ ...(this._scipRefs ? { scip_refs: this._scipRefs } : {}),
639
+ ...(this._sparseModel ? { sparse_model: this._sparseModel } : {}),
640
+ });
909
641
  const tmpPath = `${this.indexPath}.tmp`;
910
642
  const tmpBinPath = `${this._embeddingPath}.tmp`;
911
643
  await Promise.all([
@@ -923,7 +655,7 @@ export class MemoryGraphIndex {
923
655
  this._saveTimer = setTimeout(async () => {
924
656
  this._saveTimer = null;
925
657
  try { await this.save(); }
926
- catch (err) { process.stderr.write(`[core-engine] ❌ Async save failed: ${err.message}\n`); }
658
+ catch (err) { process.stderr.write(`[engine/memory] ❌ Async save failed: ${err.message}\n`); }
927
659
  }, delayMs);
928
660
  }
929
661
 
@@ -954,6 +686,10 @@ export class MemoryGraphIndex {
954
686
  * @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
955
687
  */
956
688
  applyFileUpdate(filePath, { chunks = [], imports = [], embeddings = null } = {}) {
689
+ // A per-file edit makes the whole-program symbol graph + its centrality stale; drop
690
+ // both so findCallers/findReferers fall back to the always-correct scan until the next
691
+ // full `idx-index` rebuilds them (the daemon never rebuilds the graph).
692
+ if (this._edges || this._centrality || this._taint || this._scipRefs || this._sparseModel) this._clearEdges();
957
693
  this.updateFileGraph(filePath, imports);
958
694
 
959
695
  for (const [id, chunk] of Array.from(this.chunks.entries())) {
@@ -961,6 +697,7 @@ export class MemoryGraphIndex {
961
697
  this._removeLexical(id);
962
698
  this.removeVector(id);
963
699
  this.removeVector(id + SUMMARY_VEC_SUFFIX);
700
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) this.removeVector(id + WINDOW_VEC_SUFFIX + i);
964
701
  this._removeSymbol(chunk);
965
702
  this.chunks.delete(id);
966
703
  }
@@ -977,6 +714,10 @@ export class MemoryGraphIndex {
977
714
  if (vec) this.addVector(chunk.id, vec);
978
715
  const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
979
716
  if (sVec) this.addVector(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
717
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) {
718
+ const wVec = this.embeddingCache.get(vecKey + WINDOW_VEC_SUFFIX + i);
719
+ if (wVec) this.addVector(chunk.id + WINDOW_VEC_SUFFIX + i, wVec);
720
+ }
980
721
  this._indexLexical(chunk.id, buildLexicalDocument(chunk, imports), chunk.file_path);
981
722
  this.chunks.set(chunk.id, chunk);
982
723
  if (chunk.name && chunk.name !== 'anonymous') {
@@ -1058,13 +799,152 @@ export class MemoryGraphIndex {
1058
799
  return out;
1059
800
  }
1060
801
 
802
+ /**
803
+ * Resolved symbol-graph edges incident to a chunk (A4). Returns [] unless the
804
+ * opt-in graph was built. Deterministically ordered (the edge list is pre-sorted).
805
+ * @param {string} chunkId
806
+ * @param {object} [opts]
807
+ * @param {string|null} [opts.kind] Filter to 'calls' | 'extends' | 'type'.
808
+ * @param {'in'|'out'} [opts.direction] 'in' = referrers of chunkId (default), 'out' = its referents.
809
+ * @returns {Array<{from_chunk_id:string,to_chunk_id:string,kind:string,confidence:string,chunk:object|null}>}
810
+ */
811
+ /** Whether the opt-in resolved symbol graph (A4) is loaded. */
812
+ hasSymbolGraph() { return this._edges != null; }
813
+
814
+ getEdges(chunkId, { kind = null, direction = 'in' } = {}) {
815
+ if (!this._edges) return [];
816
+ const src = direction === 'out' ? this._edgesByFrom : this._edgesByTo;
817
+ const list = src.get(chunkId) || [];
818
+ const out = [];
819
+ for (const e of list) {
820
+ if (kind && e.kind !== kind) continue;
821
+ const otherId = direction === 'out' ? e.to_chunk_id : e.from_chunk_id;
822
+ out.push({ ...e, chunk: this.chunks.get(otherId) ?? null });
823
+ }
824
+ return out;
825
+ }
826
+
827
+ /** Whether opt-in symbol centrality (A5) is loaded. */
828
+ hasCentrality() { return this._centrality != null; }
829
+
830
+ /** Whether opt-in serialized taint flows (C2 --taint) are loaded. */
831
+ hasTaint() { return this._taint != null; }
832
+
833
+ /** The serialized taint payload `{ flows, meta }` (C2), or null. */
834
+ getTaintFlows() { return this._taint; }
835
+
836
+ /** Whether opt-in SCIP precise references (A2 v2 --resolver scip) are loaded. */
837
+ hasScipRefs() { return this._scipRefs != null; }
838
+
839
+ /** SCIP-confirmed referer chunk ids for a definition chunk (A2 v2), or [] when none / not loaded. */
840
+ getScipReferers(defChunkId) {
841
+ const refs = this._scipRefs && this._scipRefs[defChunkId];
842
+ return Array.isArray(refs) ? refs : [];
843
+ }
844
+
845
+ /** How many definitions carry SCIP precise references (A2 v2). 0 when not loaded. */
846
+ scipRefCount() { return this._scipRefs ? Object.keys(this._scipRefs).length : 0; }
847
+
848
+ /**
849
+ * Symbol centrality (A5) for one chunk: its PageRank score, dense rank (1 = most central),
850
+ * and the total number of ranked (connected) chunks. Null when the chunk is unranked
851
+ * (isolated) or centrality was not built. @returns {{score:number,rank:number,total:number}|null}
852
+ */
853
+ getCentrality(chunkId) {
854
+ if (!this._centrality) return null;
855
+ const e = this._centrality.get(chunkId);
856
+ return e ? { score: e.score, rank: e.rank, total: this._centralityTotal } : null;
857
+ }
858
+
859
+ /** The most central symbols (A5), rank-ascending. @returns {Array<{chunk,score,rank}>} */
860
+ topCentral(limit = 20) {
861
+ if (!this._centrality) return [];
862
+ if (!this._centralRanked) {
863
+ this._centralRanked = [...this._centrality.entries()]
864
+ .map(([id, v]) => ({ id, score: v.score, rank: v.rank }))
865
+ .sort((a, b) => a.rank - b.rank || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
866
+ }
867
+ const out = [];
868
+ for (const { id, score, rank } of this._centralRanked) {
869
+ const chunk = this.chunks.get(id);
870
+ if (!chunk) continue;
871
+ out.push({ chunk, score, rank });
872
+ if (out.length >= limit) break;
873
+ }
874
+ return out;
875
+ }
876
+
1061
877
  /** Chunks that call the given function name. @returns {object[]} */
1062
878
  findCallers(funcName) {
879
+ // Edge-backed (A4) when the resolved graph is present and the name is defined —
880
+ // returns the SAME set as the scan below (every name-match caller → each def).
881
+ if (this._edges) {
882
+ const defs = this.resolveSymbol(funcName);
883
+ if (defs.length > 0) return this._referrersByEdge(defs, e => e.kind === 'calls');
884
+ }
1063
885
  const out = [];
1064
886
  for (const c of this.chunks.values()) if (c.calls?.includes(funcName)) out.push(c);
1065
887
  return out;
1066
888
  }
1067
889
 
890
+ /** From-chunks of edges of a given kind that point at any of `defs`, id-sorted. */
891
+ _referrersByEdge(defs, keep) {
892
+ const ids = new Set();
893
+ for (const def of defs) for (const e of (this._edgesByTo.get(def.id) || [])) if (keep(e)) ids.add(e.from_chunk_id);
894
+ const out = [];
895
+ for (const id of [...ids].sort()) { const c = this.chunks.get(id); if (c) out.push(c); }
896
+ return out;
897
+ }
898
+
899
+ /**
900
+ * Chunks that reference a symbol *by name* as a type (`type_refs`) or as a
901
+ * base class/interface (`extends`) — the non-call half of symbol-level
902
+ * references. Calls are served by findCallers; mcp/topology.mjs findReferences fuses
903
+ * the two and classifies each by confidence. Case-insensitive exact match.
904
+ * @returns {object[]}
905
+ */
906
+ findReferers(symbol) {
907
+ const key = String(symbol).toLowerCase().trim();
908
+ if (!key) return [];
909
+ if (this._edges) {
910
+ const defs = this.resolveSymbol(symbol);
911
+ if (defs.length > 0) return this._referrersByEdge(defs, e => e.kind === 'extends' || e.kind === 'type');
912
+ }
913
+ const out = [];
914
+ for (const c of this.chunks.values()) {
915
+ // A class/type lists its own name in type_refs (the name node is a
916
+ // type_identifier) — that self-mention is a definition, not a reference.
917
+ if (c.name && c.name.toLowerCase() === key) continue;
918
+ const hit = (c.type_refs && c.type_refs.some(t => t.toLowerCase() === key))
919
+ || (c.extends && c.extends.some(t => t.toLowerCase() === key));
920
+ if (hit) out.push(c);
921
+ }
922
+ return out;
923
+ }
924
+
925
+ /**
926
+ * HTTP routes filtered by method and/or path prefix, each augmented with its
927
+ * resolved handler chunk (`chunk`, looked up by handler_chunk_id, or null).
928
+ * `graph.routes` round-trips verbatim through load(); a route-less index yields
929
+ * []. Method is matched case-insensitively (HTTP verbs are upper-cased); the
930
+ * path prefix match is case-insensitive. Mirrors SqliteGraphStore.findRoutes so
931
+ * both backends return identical records.
932
+ * @returns {object[]}
933
+ */
934
+ findRoutes({ method = null, pathPrefix = null } = {}) {
935
+ const routes = (this.graph && this.graph.routes) || [];
936
+ const m = method ? String(method).toUpperCase().trim() : null;
937
+ const p = pathPrefix ? String(pathPrefix).toLowerCase() : null;
938
+ const out = [];
939
+ for (const r of routes) {
940
+ if (m && String(r.method || '').toUpperCase() !== m) continue;
941
+ if (p && !String(r.path || '').toLowerCase().startsWith(p)) continue;
942
+ const chunk = r.handler_chunk_id ? (this.chunks.get(r.handler_chunk_id) || null) : null;
943
+ out.push({ ...r, chunk });
944
+ }
945
+ return out;
946
+ }
947
+
1068
948
  /** Lazily iterate every chunk (cursor-friendly parity with the SQLite store). */
1069
949
  *iterateChunks() { yield* this.chunks.values(); }
1070
950
 
@@ -1078,11 +958,11 @@ export class MemoryGraphIndex {
1078
958
  vectorCount() {
1079
959
  if (this._lazyMode) {
1080
960
  let n = 0;
1081
- for (const k of this._vecOffsets.keys()) if (!k.endsWith(SUMMARY_VEC_SUFFIX)) n++;
961
+ for (const k of this._vecOffsets.keys()) if (baseEmbeddingKey(k) === k) n++;
1082
962
  return n;
1083
963
  }
1084
964
  let n = 0;
1085
- for (const k of this.vectors.keys()) if (!k.endsWith(SUMMARY_VEC_SUFFIX)) n++;
965
+ for (const k of this.vectors.keys()) if (baseEmbeddingKey(k) === k) n++;
1086
966
  return n;
1087
967
  }
1088
968