graph-indexer 2.0.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.
package/engine/memory.mjs CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
11
11
  SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
12
12
  isNaturalLanguageQuery, WINDOW_VEC_SUFFIX, EMBEDDING_MAX_WINDOWS, baseEmbeddingKey,
13
+ scoreSparseExpanded,
13
14
  } from '../search-core.mjs';
15
+ import { expandSparseQuery } from '../search-sparse.mjs';
14
16
  import {
15
17
  HierarchicalNSW, HNSW_THRESHOLD, LAZY_VEC_THRESHOLD, SKETCH_THRESHOLD,
16
18
  writeEmbeddingBinary, scanEmbeddingBinary, updateVectorSketch, searchVectorSketch,
@@ -70,6 +72,72 @@ export class MemoryGraphIndex {
70
72
 
71
73
  this.rrfK = rrfK;
72
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;
73
141
  }
74
142
 
75
143
  // ─── Load ─────────────────────────────────────────────────────────────────
@@ -78,6 +146,16 @@ export class MemoryGraphIndex {
78
146
  if (!fs.existsSync(this.indexPath)) return;
79
147
  const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
80
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;
81
159
  // Does this corpus carry LLM enrichment? Drives the NL vector-channel weight
82
160
  // in fuseAndRank (strong summary vectors earn full weight; plain code vectors
83
161
  // stay a low-weight rescue). Set during the chunk load loop below.
@@ -492,9 +570,14 @@ export class MemoryGraphIndex {
492
570
  vectorResults = [];
493
571
  }
494
572
 
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
+
495
577
  return fuseAndRank({
496
578
  lexicalResults,
497
579
  vectorResults,
580
+ sparseResults,
498
581
  getChunk: (id) => this.chunks.get(id),
499
582
  getPathTokens: (id) => this.pathTokens.get(id),
500
583
  getDf: (t) => this.df.get(t) || 0,
@@ -509,6 +592,31 @@ export class MemoryGraphIndex {
509
592
  });
510
593
  }
511
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
+
512
620
  // ─── Persistence ───────────────────────────────────────────────────────────
513
621
 
514
622
  async save() {
@@ -522,7 +630,14 @@ export class MemoryGraphIndex {
522
630
  extends: c.extends || [],
523
631
  hyde: c.hyde || '', summary: c.summary || '', concepts: c.concepts || [],
524
632
  }));
525
- 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
+ });
526
641
  const tmpPath = `${this.indexPath}.tmp`;
527
642
  const tmpBinPath = `${this._embeddingPath}.tmp`;
528
643
  await Promise.all([
@@ -571,6 +686,10 @@ export class MemoryGraphIndex {
571
686
  * @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
572
687
  */
573
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();
574
693
  this.updateFileGraph(filePath, imports);
575
694
 
576
695
  for (const [id, chunk] of Array.from(this.chunks.entries())) {
@@ -680,13 +799,103 @@ export class MemoryGraphIndex {
680
799
  return out;
681
800
  }
682
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
+
683
877
  /** Chunks that call the given function name. @returns {object[]} */
684
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
+ }
685
885
  const out = [];
686
886
  for (const c of this.chunks.values()) if (c.calls?.includes(funcName)) out.push(c);
687
887
  return out;
688
888
  }
689
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
+
690
899
  /**
691
900
  * Chunks that reference a symbol *by name* as a type (`type_refs`) or as a
692
901
  * base class/interface (`extends`) — the non-call half of symbol-level
@@ -697,6 +906,10 @@ export class MemoryGraphIndex {
697
906
  findReferers(symbol) {
698
907
  const key = String(symbol).toLowerCase().trim();
699
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
+ }
700
913
  const out = [];
701
914
  for (const c of this.chunks.values()) {
702
915
  // A class/type lists its own name in type_refs (the name node is a
package/engine/sqlite.mjs CHANGED
@@ -29,8 +29,9 @@ import fs from 'fs';
29
29
  import {
30
30
  tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
31
31
  LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
32
- isNaturalLanguageQuery, baseEmbeddingKey,
32
+ isNaturalLanguageQuery, baseEmbeddingKey, scoreSparseExpanded,
33
33
  } from '../search-core.mjs';
34
+ import { expandSparseQuery } from '../search-sparse.mjs';
34
35
  import {
35
36
  writeEmbeddingBinary, appendEmbeddingBinary, scanEmbeddingBinary,
36
37
  updateVectorSketch, searchVectorSketch, SKETCH_THRESHOLD,
@@ -60,6 +61,11 @@ CREATE TABLE IF NOT EXISTS chunks (
60
61
  CREATE TABLE IF NOT EXISTS postings (term TEXT, chunk_id TEXT, tf INTEGER);
61
62
  CREATE TABLE IF NOT EXISTS terms (term TEXT PRIMARY KEY, df INTEGER);
62
63
  CREATE TABLE IF NOT EXISTS call_edges (callee TEXT, chunk_id TEXT);
64
+ CREATE TABLE IF NOT EXISTS edges (from_chunk_id TEXT, to_chunk_id TEXT, kind TEXT, confidence TEXT);
65
+ CREATE TABLE IF NOT EXISTS centrality (chunk_id TEXT, score REAL, rank INTEGER);
66
+ CREATE TABLE IF NOT EXISTS taint (id INTEGER PRIMARY KEY, payload TEXT);
67
+ CREATE TABLE IF NOT EXISTS scip_refs (id INTEGER PRIMARY KEY, payload TEXT);
68
+ CREATE TABLE IF NOT EXISTS sparse_model (id INTEGER PRIMARY KEY, payload TEXT);
63
69
  CREATE TABLE IF NOT EXISTS deps (file TEXT, dep TEXT);
64
70
  CREATE TABLE IF NOT EXISTS routes (
65
71
  id INTEGER PRIMARY KEY,
@@ -81,6 +87,10 @@ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(content_hash);
81
87
  CREATE INDEX IF NOT EXISTS idx_postings_term ON postings(term);
82
88
  CREATE INDEX IF NOT EXISTS idx_postings_chunk ON postings(chunk_id);
83
89
  CREATE INDEX IF NOT EXISTS idx_calledges_callee ON call_edges(callee);
90
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_chunk_id);
91
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_chunk_id);
92
+ CREATE INDEX IF NOT EXISTS idx_centrality_chunk ON centrality(chunk_id);
93
+ CREATE INDEX IF NOT EXISTS idx_centrality_rank ON centrality(rank);
84
94
  CREATE INDEX IF NOT EXISTS idx_deps_file ON deps(file);
85
95
  CREATE INDEX IF NOT EXISTS idx_deps_dep ON deps(dep);
86
96
  CREATE INDEX IF NOT EXISTS idx_routes_method ON routes(method);
@@ -129,6 +139,14 @@ export class SqliteGraphStore {
129
139
  this._dataVersion = -1;
130
140
  this._writeReady = false;
131
141
  this._sketch = null; // binary-quantized vector sketch (built above SKETCH_THRESHOLD)
142
+ this._hasEdges = false; // A4: resolved symbol graph present (opt-in)
143
+ this._hasCentrality = false; // A5: symbol centrality present (opt-in, built with the graph)
144
+ this._centralityTotal = 0;
145
+ this._hasTaint = false; // C2: serialized taint flows present (opt-in --taint)
146
+ this._hasScipRefs = false; // A2 v2: SCIP precise references present (opt-in --resolver scip)
147
+ this._scipRefsObj = null; // lazily-parsed `{ defId: [refId,…] }` payload (memoized)
148
+ this._hasSparseModel = false; // B3: learned-sparse model present (opt-in --learned-sparse)
149
+ this._sparseModelObj = null; // lazily-parsed `{ assoc: {…} }` payload (memoized)
132
150
  }
133
151
 
134
152
  get backend() { return 'sqlite'; }
@@ -177,10 +195,56 @@ export class SqliteGraphStore {
177
195
  this._openVecFd();
178
196
  this._refreshSketch();
179
197
  this._prepare();
198
+ this._refreshHasEdges();
199
+ this._refreshHasCentrality();
200
+ this._refreshHasTaint();
201
+ this._refreshHasScipRefs();
202
+ this._refreshHasSparseModel();
180
203
  this._dataVersion = this._readDataVersion();
181
204
  return this;
182
205
  }
183
206
 
207
+ /** Whether the opt-in resolved symbol graph (A4) has any edges. */
208
+ _refreshHasEdges() {
209
+ try { this._hasEdges = Boolean(this.db.prepare('SELECT 1 FROM edges LIMIT 1').get()); }
210
+ catch { this._hasEdges = false; }
211
+ }
212
+
213
+ /** Whether opt-in symbol centrality (A5) is present, and how many chunks are ranked. */
214
+ _refreshHasCentrality() {
215
+ try {
216
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM centrality').get();
217
+ this._centralityTotal = row ? row.n : 0;
218
+ this._hasCentrality = this._centralityTotal > 0;
219
+ } catch { this._hasCentrality = false; this._centralityTotal = 0; }
220
+ }
221
+
222
+ /** Whether opt-in serialized taint flows (C2 --taint) are present. */
223
+ _refreshHasTaint() {
224
+ try {
225
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM taint').get();
226
+ this._hasTaint = Boolean(row && row.n > 0);
227
+ } catch { this._hasTaint = false; }
228
+ }
229
+
230
+ /** Whether opt-in SCIP precise references (A2 v2 --resolver scip) are present. */
231
+ _refreshHasScipRefs() {
232
+ try {
233
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM scip_refs').get();
234
+ this._hasScipRefs = Boolean(row && row.n > 0);
235
+ } catch { this._hasScipRefs = false; }
236
+ this._scipRefsObj = null; // drop any memoized payload — the row may have changed
237
+ }
238
+
239
+ /** Whether opt-in learned-sparse associations (B3 --learned-sparse) are present. */
240
+ _refreshHasSparseModel() {
241
+ try {
242
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM sparse_model').get();
243
+ this._hasSparseModel = Boolean(row && row.n > 0);
244
+ } catch { this._hasSparseModel = false; }
245
+ this._sparseModelObj = null; // drop any memoized payload — the row may have changed
246
+ }
247
+
184
248
  _openVecFd() {
185
249
  if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
186
250
  if (fs.existsSync(this._embeddingPath)) {
@@ -306,6 +370,27 @@ export class SqliteGraphStore {
306
370
  'SELECT method, path, handler_name, handler_chunk_id, file_path, line, framework '
307
371
  + 'FROM routes ORDER BY id'
308
372
  );
373
+ // A4 symbol graph. ORDER mirrors the in-memory engine's per-endpoint slice of the
374
+ // globally-sorted edge list (fixed `to` → sort by from,kind,confidence; fixed `from`
375
+ // → sort by to,kind,confidence) so getEdges is byte-identical cross-backend.
376
+ this._stmtEdgesIn = this.db.prepare(
377
+ 'SELECT from_chunk_id, to_chunk_id, kind, confidence FROM edges WHERE to_chunk_id = ? '
378
+ + 'ORDER BY from_chunk_id, kind, confidence'
379
+ );
380
+ this._stmtEdgesOut = this.db.prepare(
381
+ 'SELECT from_chunk_id, to_chunk_id, kind, confidence FROM edges WHERE from_chunk_id = ? '
382
+ + 'ORDER BY to_chunk_id, kind, confidence'
383
+ );
384
+ // A5 symbol centrality. rank/score are precomputed at index time; topCentral orders by
385
+ // the stored rank (chunk_id tie-break mirrors the in-memory ranked cache).
386
+ this._stmtCentrality = this.db.prepare('SELECT score, rank FROM centrality WHERE chunk_id = ?');
387
+ this._stmtTopCentral = this.db.prepare(
388
+ 'SELECT chunk_id, score, rank FROM centrality ORDER BY rank ASC, chunk_id ASC LIMIT ?'
389
+ );
390
+ // C2 serialized taint flows — a single JSON payload row (id = 0).
391
+ this._stmtTaint = this.db.prepare('SELECT payload FROM taint WHERE id = 0');
392
+ this._stmtScipRefs = this.db.prepare('SELECT payload FROM scip_refs WHERE id = 0');
393
+ this._stmtSparseModel = this.db.prepare('SELECT payload FROM sparse_model WHERE id = 0');
309
394
  }
310
395
 
311
396
  _prepareWrite() {
@@ -365,11 +450,124 @@ export class SqliteGraphStore {
365
450
  return this._stmtByName.all(key).map(r => this._rowToChunk(r));
366
451
  }
367
452
 
368
- findCallers(funcName) { return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r)); }
453
+ /**
454
+ * Resolved symbol-graph edges incident to a chunk (A4). Mirrors MemoryGraphIndex.getEdges,
455
+ * including deterministic order. Returns [] unless the opt-in graph was built.
456
+ */
457
+ /** Whether the opt-in resolved symbol graph (A4) is loaded. */
458
+ hasSymbolGraph() { return this._hasEdges; }
459
+
460
+ getEdges(chunkId, { kind = null, direction = 'in' } = {}) {
461
+ if (!this._hasEdges) return [];
462
+ const rows = direction === 'out' ? this._stmtEdgesOut.all(chunkId) : this._stmtEdgesIn.all(chunkId);
463
+ const out = [];
464
+ for (const e of rows) {
465
+ if (kind && e.kind !== kind) continue;
466
+ const otherId = direction === 'out' ? e.to_chunk_id : e.from_chunk_id;
467
+ out.push({ from_chunk_id: e.from_chunk_id, to_chunk_id: e.to_chunk_id, kind: e.kind, confidence: e.confidence, chunk: this.getChunk(otherId) });
468
+ }
469
+ return out;
470
+ }
471
+
472
+ /** Whether opt-in symbol centrality (A5) is loaded. */
473
+ hasCentrality() { return this._hasCentrality; }
474
+
475
+ /** Whether opt-in serialized taint flows (C2 --taint) are loaded. */
476
+ hasTaint() { return this._hasTaint; }
477
+
478
+ /** The serialized taint payload `{ flows, meta }` (C2), or null. Mirrors MemoryGraphIndex. */
479
+ getTaintFlows() {
480
+ if (!this._hasTaint) return null;
481
+ const row = this._stmtTaint.get();
482
+ if (!row || !row.payload) return null;
483
+ try { return JSON.parse(row.payload); } catch { return null; }
484
+ }
485
+
486
+ /** Whether opt-in SCIP precise references (A2 v2 --resolver scip) are loaded. */
487
+ hasScipRefs() { return this._hasScipRefs; }
488
+
489
+ /** Parse the single JSON payload `{ defId: [refId,…] }` once, then memoize. */
490
+ _loadScipRefs() {
491
+ if (!this._hasScipRefs) return {};
492
+ if (!this._scipRefsObj) {
493
+ const row = this._stmtScipRefs.get();
494
+ try { this._scipRefsObj = row && row.payload ? JSON.parse(row.payload) : {}; }
495
+ catch { this._scipRefsObj = {}; }
496
+ }
497
+ return this._scipRefsObj;
498
+ }
499
+
500
+ /** SCIP-confirmed referer chunk ids for a definition chunk (A2 v2), or []. Mirrors MemoryGraphIndex. */
501
+ getScipReferers(defChunkId) {
502
+ const refs = this._loadScipRefs()[defChunkId];
503
+ return Array.isArray(refs) ? refs : [];
504
+ }
505
+
506
+ /** How many definitions carry SCIP precise references (A2 v2). 0 when not loaded. */
507
+ scipRefCount() { return this._hasScipRefs ? Object.keys(this._loadScipRefs()).length : 0; }
508
+
509
+ /** Whether opt-in learned-sparse associations (B3 --learned-sparse) are loaded. */
510
+ hasSparseModel() { return this._hasSparseModel; }
511
+
512
+ /** Path to the shared `.embeddings.bin` (B4 late-interaction reranker reads candidate vectors from it). */
513
+ embeddingBinPath() { return this._embeddingPath; }
514
+
515
+ /** Parse the single learned-sparse payload once, then memoize. Mirrors MemoryGraphIndex._sparseModel. */
516
+ _loadSparseModel() {
517
+ if (!this._hasSparseModel) return null;
518
+ if (!this._sparseModelObj) {
519
+ const row = this._stmtSparseModel.get();
520
+ try { this._sparseModelObj = row && row.payload ? JSON.parse(row.payload) : null; }
521
+ catch { this._sparseModelObj = null; }
522
+ }
523
+ return this._sparseModelObj;
524
+ }
525
+
526
+ /**
527
+ * Symbol centrality (A5) for one chunk. Mirrors MemoryGraphIndex.getCentrality — same
528
+ * { score, rank, total } shape; null when unranked or centrality was not built.
529
+ */
530
+ getCentrality(chunkId) {
531
+ if (!this._hasCentrality) return null;
532
+ const row = this._stmtCentrality.get(chunkId);
533
+ return row ? { score: row.score, rank: row.rank, total: this._centralityTotal } : null;
534
+ }
535
+
536
+ /** The most central symbols (A5), rank-ascending. @returns {Array<{chunk,score,rank}>} */
537
+ topCentral(limit = 20) {
538
+ if (!this._hasCentrality) return [];
539
+ const out = [];
540
+ for (const row of this._stmtTopCentral.all(limit)) {
541
+ const chunk = this.getChunk(row.chunk_id);
542
+ if (chunk) out.push({ chunk, score: row.score, rank: row.rank });
543
+ }
544
+ return out;
545
+ }
546
+
547
+ /** From-chunks of edges of a kept kind pointing at any of `defs`, id-sorted. */
548
+ _referrersByEdge(defs, keepKind) {
549
+ const ids = new Set();
550
+ for (const def of defs) for (const e of this._stmtEdgesIn.all(def.id)) if (keepKind(e.kind)) ids.add(e.from_chunk_id);
551
+ const out = [];
552
+ for (const id of [...ids].sort()) { const c = this.getChunk(id); if (c) out.push(c); }
553
+ return out;
554
+ }
555
+
556
+ findCallers(funcName) {
557
+ if (this._hasEdges) {
558
+ const defs = this.resolveSymbol(funcName);
559
+ if (defs.length > 0) return this._referrersByEdge(defs, k => k === 'calls');
560
+ }
561
+ return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r));
562
+ }
369
563
 
370
564
  findReferers(symbol) {
371
565
  const key = String(symbol).toLowerCase().trim();
372
566
  if (!key) return [];
567
+ if (this._hasEdges) {
568
+ const defs = this.resolveSymbol(symbol);
569
+ if (defs.length > 0) return this._referrersByEdge(defs, k => k === 'extends' || k === 'type');
570
+ }
373
571
  // Escape LIKE metacharacters in the symbol, then bracket with JSON quotes so
374
572
  // the prefilter only fires on a whole array element.
375
573
  const esc = key.replace(/[\\%_]/g, m => '\\' + m);
@@ -513,6 +711,11 @@ export class SqliteGraphStore {
513
711
  .slice(0, LEXICAL_FUSION_CAP)
514
712
  .map(([id, score], i) => ({ id, score, rank: i + 1 }));
515
713
 
714
+ // B3 learned-sparse channel: opt-in (model present) + NL-asymmetric. Shares scoreSparseExpanded
715
+ // with the in-memory engine over identically-ordered terms → byte-identical to memory. [] when
716
+ // inert (no model / symbolic query / no association) → fusion byte-identical to the default path.
717
+ const sparseResults = this._searchSparseExpanded(queryText);
718
+
516
719
  // Stream the FULL embeddings bin (bounded buffers) so conceptual queries
517
720
  // that share no tokens with the code can still match — scoring only lexical
518
721
  // candidates silently disabled semantic search where it mattered most.
@@ -574,13 +777,36 @@ export class SqliteGraphStore {
574
777
  };
575
778
 
576
779
  return fuseAndRank({
577
- lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
780
+ lexicalResults, vectorResults, sparseResults, getChunk, getPathTokens, getDf,
578
781
  docCount: this._docCount, rrfK: this.rrfK, topK, queryText, exactBoostName,
579
782
  corpusEnriched: this._corpusEnriched,
580
783
  resolveExact: (term) => this._stmtIdByName.all(term).map(r => r.id),
581
784
  });
582
785
  }
583
786
 
787
+ /**
788
+ * B3 — the learned-sparse expansion channel (NL queries, model present). Mirrors
789
+ * MemoryGraphIndex._searchSparseExpanded: identical expansion + the shared scoreSparseExpanded
790
+ * scorer over identically-ordered terms → byte-identical to memory. Returns [] when inert.
791
+ */
792
+ _searchSparseExpanded(queryText) {
793
+ if (!this._hasSparseModel || !isNaturalLanguageQuery(queryText)) return [];
794
+ const model = this._loadSparseModel();
795
+ const termWeights = model && expandSparseQuery(queryText, model);
796
+ if (!termWeights) return [];
797
+ const avgdl = this._avgdl;
798
+ const docLenCache = new Map();
799
+ return scoreSparseExpanded(termWeights, {
800
+ docCount: this._docCount,
801
+ avgdl,
802
+ getDf: (t) => { const r = this._stmtDf.get(t); return r ? r.df : 0; },
803
+ // .map() materialises the postings AND populates the doc-len cache before scoring iterates,
804
+ // so getDocLen always resolves without a second query.
805
+ getPostings: (t) => this._stmtPosting.all(t).map(r => { docLenCache.set(r.id, r.doc_len); return [r.id, r.tf]; }),
806
+ getDocLen: (id) => docLenCache.get(id) ?? avgdl,
807
+ });
808
+ }
809
+
584
810
  close() {
585
811
  if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
586
812
  if (this.db) { try { this.db.close(); } catch {} this.db = null; }
@@ -657,6 +883,15 @@ export class SqliteGraphStore {
657
883
 
658
884
  this.db.exec('BEGIN');
659
885
  try {
886
+ // A per-file edit makes the whole-program symbol graph (A4) stale; clear it so
887
+ // findCallers/findReferers fall back to the always-correct scan until the next
888
+ // full `idx-index` rebuilds it (the daemon never rebuilds the graph).
889
+ if (this._hasEdges) { this.db.exec('DELETE FROM edges'); this._hasEdges = false; }
890
+ if (this._hasCentrality) { this.db.exec('DELETE FROM centrality'); this._hasCentrality = false; this._centralityTotal = 0; }
891
+ if (this._hasTaint) { this.db.exec('DELETE FROM taint'); this._hasTaint = false; }
892
+ if (this._hasScipRefs) { this.db.exec('DELETE FROM scip_refs'); this._hasScipRefs = false; this._scipRefsObj = null; }
893
+ if (this._hasSparseModel) { this.db.exec('DELETE FROM sparse_model'); this._hasSparseModel = false; this._sparseModelObj = null; }
894
+
660
895
  // Capture reusable vector offsets BEFORE deleting old rows, which may
661
896
  // be the only rows that hold those offsets.
662
897
  const reuse = new Map();
@@ -749,7 +984,7 @@ export class SqliteGraphStore {
749
984
  * @param {{dependencies:Object<string,string[]>}} payload.graph
750
985
  * @param {Map<string,Float32Array>|object} [payload.embeddingCache] Keyed by embeddingKeyFor(chunk).
751
986
  */
752
- buildFrom({ chunks, graph, embeddingCache }) {
987
+ buildFrom({ chunks, graph, embeddingCache, edges = null, centrality = null, taint = null, scipRefs = null, sparseModel = null }) {
753
988
  this._open();
754
989
  this.db.exec('PRAGMA synchronous = OFF;'); // safe only for a full rebuild; not used for incremental writes
755
990
 
@@ -781,7 +1016,9 @@ export class SqliteGraphStore {
781
1016
  DROP TABLE IF EXISTS chunks; DROP TABLE IF EXISTS postings;
782
1017
  DROP TABLE IF EXISTS terms; DROP TABLE IF EXISTS call_edges;
783
1018
  DROP TABLE IF EXISTS deps; DROP TABLE IF EXISTS meta;
784
- DROP TABLE IF EXISTS routes;
1019
+ DROP TABLE IF EXISTS routes; DROP TABLE IF EXISTS edges;
1020
+ DROP TABLE IF EXISTS centrality; DROP TABLE IF EXISTS taint;
1021
+ DROP TABLE IF EXISTS scip_refs; DROP TABLE IF EXISTS sparse_model;
785
1022
  `);
786
1023
  this.db.exec(SCHEMA_TABLES);
787
1024
  this.db.exec(SCHEMA_INDEXES);
@@ -848,6 +1085,42 @@ export class SqliteGraphStore {
848
1085
  );
849
1086
  }
850
1087
 
1088
+ // A4 resolved symbol graph (opt-in). Inserted in the deterministic order produced
1089
+ // by buildSymbolGraph so the table mirrors the in-memory edge list.
1090
+ if (Array.isArray(edges) && edges.length) {
1091
+ const insEdge = this.db.prepare(
1092
+ 'INSERT INTO edges (from_chunk_id, to_chunk_id, kind, confidence) VALUES (?, ?, ?, ?)'
1093
+ );
1094
+ for (const e of edges) insEdge.run(e.from_chunk_id, e.to_chunk_id, e.kind, e.confidence);
1095
+ }
1096
+
1097
+ // A5 symbol centrality (opt-in). Precomputed score/rank per chunk id; both backends
1098
+ // read these exact numbers → parity-free. Inserted in rank order for tidy storage.
1099
+ if (centrality && typeof centrality === 'object') {
1100
+ const insCen = this.db.prepare('INSERT INTO centrality (chunk_id, score, rank) VALUES (?, ?, ?)');
1101
+ const rows = Object.entries(centrality).sort((a, b) => a[1].rank - b[1].rank);
1102
+ for (const [id, v] of rows) insCen.run(id, v.score, v.rank);
1103
+ }
1104
+
1105
+ // C2 serialized taint flows (opt-in). A single JSON payload (id = 0); both backends parse
1106
+ // the identical bytes → parity-free, exactly like edges/centrality.
1107
+ if (taint && typeof taint === 'object') {
1108
+ this.db.prepare('INSERT INTO taint (id, payload) VALUES (0, ?)').run(JSON.stringify(taint));
1109
+ }
1110
+
1111
+ // A2 v2 SCIP precise references (opt-in --resolver scip). A single JSON payload (id = 0) of
1112
+ // `{ defChunkId: [refererChunkId, …] }`; both backends parse identical bytes → parity-free,
1113
+ // exactly like taint. find_references reads it for the cross-file `resolved` reference tier.
1114
+ if (scipRefs && typeof scipRefs === 'object' && Object.keys(scipRefs).length) {
1115
+ this.db.prepare('INSERT INTO scip_refs (id, payload) VALUES (0, ?)').run(JSON.stringify(scipRefs));
1116
+ }
1117
+
1118
+ // B3 learned-sparse model (opt-in --learned-sparse). A single JSON payload (id = 0); both
1119
+ // backends parse identical bytes → parity-free, exactly like taint/scip_refs.
1120
+ if (sparseModel && typeof sparseModel === 'object' && sparseModel.assoc) {
1121
+ this.db.prepare('INSERT INTO sparse_model (id, payload) VALUES (0, ?)').run(JSON.stringify(sparseModel));
1122
+ }
1123
+
851
1124
  const insMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
852
1125
  insMeta.run('doc_count', String(docCount));
853
1126
  insMeta.run('total_doc_len', String(totalDocLen));