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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @file search-sparse.mjs
3
+ * @description B3 — opt-in LEARNED-SPARSE vocabulary-expansion channel. A learned-sparse retriever
4
+ * (SPLADE / uniCOIL family) expands a query into a WEIGHTED term set — original terms
5
+ * plus learned associates — and fuses that as an extra sparse channel, closing
6
+ * lexical-gap misses the Porter-stemming bridge structurally cannot (stemming only
7
+ * normalises morphology; it cannot connect "auth" → "credential"/"token").
8
+ *
9
+ * THE AIR-GAPPED, ZERO-DEPENDENCY REALISATION: rather than require a neural MLM
10
+ * (which needs a downloaded model + @huggingface/transformers and cannot be measured
11
+ * air-gapped), the SHIPPED provider LEARNS the term associations FROM THE CORPUS at
12
+ * index time via positive pointwise mutual information (PMI) over chunk co-occurrence.
13
+ * It is genuine learned-sparse — learned per-term weights + vocabulary expansion — with
14
+ * ZERO new dependency, fully deterministic, and measurable on the honest `test:eval`.
15
+ * A neural SPLADE provider is a clean future drop-in behind the same `expandSparseQuery`
16
+ * seam (see docs/internals/IMPROVEMENT_LEARNED_SPARSE.md).
17
+ *
18
+ * SOUNDNESS / SACRED DEFAULT: the model is built ONLY with --learned-sparse and
19
+ * serialized as an ISOLATED index artifact (like centrality/taint/scip_refs), so it
20
+ * cannot perturb the lexical postings or the default path. The channel is
21
+ * NL-ASYMMETRIC — it fires only for natural-language queries with a real expansion —
22
+ * so symbolic/exact lookups stay byte-identical. Scoring is delegated to a SHARED
23
+ * core (search-core.scoreSparseExpanded) that both backends call through their own
24
+ * postings accessors → parity by construction.
25
+ * @author MaquinaTech <https://github.com/MaquinaTech>
26
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
27
+ * @license MIT
28
+ */
29
+ import { tokenize } from './search-core.mjs';
30
+
31
+ // ── Model-build knobs (structural, not tuned to any query) ──────────────────────────────────
32
+ const MIN_TOKEN_LEN = 3; // ignore 1-2 char noise
33
+ const MAX_TOKENS_PER_CHUNK = 40; // cap co-occurrence fan-out per chunk (most-discriminative first)
34
+ const MIN_DF = 2; // a term must occur in ≥2 chunks to carry association signal
35
+ const MAX_DF_RATIO = 0.30; // skip ultra-common terms (in >30% of chunks → no discriminative value)
36
+ const MIN_COOC = 2; // a pair must co-occur in ≥2 chunks (kills incidental pairs)
37
+ const TOPK_ASSOC = 8; // keep the strongest K associates per term
38
+ const PMI_NORM = 6; // PMI (log2) normaliser → stored weight = clamp(pmi/6, 0..1)
39
+
40
+ // ── Query-expansion knobs ───────────────────────────────────────────────────────────────────
41
+ const EXPANSION_SCALE = 0.35; // an associate term contributes ≤0.35× a real query term
42
+ const MAX_QUERY_EXPANSIONS = 12; // total associate terms added across the whole query
43
+ const MIN_EXPANSION_WEIGHT = 0.05;// drop near-zero associates (no point fusing them)
44
+
45
+ /** Distinct, length-filtered RAW tokens of a text (no stems — associations are surface-form). */
46
+ function distinctTokens(text) {
47
+ const out = new Set();
48
+ for (const t of tokenize(text, false)) if (t.length >= MIN_TOKEN_LEN) out.add(t);
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Learn the sparse association model from the corpus. Two passes:
54
+ * 1. document frequency per token (over distinct per-chunk tokens);
55
+ * 2. co-occurrence counts over each chunk's top-MAX_TOKENS_PER_CHUNK most-discriminative tokens
56
+ * (rarest first by df), then positive PMI per pair, keeping the top-K associates per term.
57
+ *
58
+ * Deterministic: every map is materialised through SORTED keys, and stored weights are rounded, so
59
+ * the serialized model is byte-identical run to run and the two backends read identical bytes.
60
+ *
61
+ * @param {Iterable<{code_snippet?:string,name?:string,docstring?:string}>} chunks the index chunks
62
+ * @param {object} [opts]
63
+ * @returns {{ assoc: Record<string, Array<[string, number]>>, meta: object } | null} null if no signal
64
+ */
65
+ export function buildSparseModel(chunks, opts = {}) {
66
+ const minDf = opts.minDf ?? MIN_DF;
67
+ const maxDfRatio = opts.maxDfRatio ?? MAX_DF_RATIO;
68
+ const topK = opts.topKAssoc ?? TOPK_ASSOC;
69
+
70
+ // The text a chunk contributes its vocabulary from — name + docstring + the snippet.
71
+ const chunkText = (c) => `${c.name || ''} ${c.docstring || ''} ${c.summary || ''} ${c.code_snippet || ''}`;
72
+
73
+ // Pass 1: per-chunk distinct tokens + document frequency.
74
+ const perChunk = [];
75
+ const df = new Map();
76
+ for (const c of chunks) {
77
+ const toks = distinctTokens(chunkText(c));
78
+ if (toks.size < 2) { perChunk.push(null); continue; }
79
+ perChunk.push(toks);
80
+ for (const t of toks) df.set(t, (df.get(t) || 0) + 1);
81
+ }
82
+ const docCount = perChunk.length;
83
+ if (!docCount) return null;
84
+ const maxDf = Math.max(minDf, Math.floor(maxDfRatio * docCount));
85
+
86
+ // A token carries association signal only inside the df band [minDf, maxDf].
87
+ const eligible = (t) => { const d = df.get(t) || 0; return d >= minDf && d <= maxDf; };
88
+
89
+ // Pass 2: co-occurrence over each chunk's most-discriminative (rarest) eligible tokens.
90
+ const cooc = new Map(); // term → Map<term, count>
91
+ const bump = (a, b) => {
92
+ let m = cooc.get(a); if (!m) { m = new Map(); cooc.set(a, m); }
93
+ m.set(b, (m.get(b) || 0) + 1);
94
+ };
95
+ for (const toks of perChunk) {
96
+ if (!toks) continue;
97
+ const elig = [...toks].filter(eligible);
98
+ if (elig.length < 2) continue;
99
+ // rarest-first (df asc, then lexical) → cap fan-out deterministically
100
+ elig.sort((a, b) => (df.get(a) - df.get(b)) || (a < b ? -1 : a > b ? 1 : 0));
101
+ const top = elig.slice(0, MAX_TOKENS_PER_CHUNK);
102
+ for (let i = 0; i < top.length; i++) {
103
+ for (let j = i + 1; j < top.length; j++) {
104
+ bump(top[i], top[j]); bump(top[j], top[i]); // symmetric
105
+ }
106
+ }
107
+ }
108
+
109
+ // Positive PMI per pair → top-K associates per term, weight = clamp(pmi / PMI_NORM, 0..1).
110
+ const log2 = (x) => Math.log(x) / Math.LN2;
111
+ const assoc = {};
112
+ let pairs = 0;
113
+ for (const term of [...cooc.keys()].sort()) {
114
+ const dfa = df.get(term);
115
+ const row = [];
116
+ for (const [other, c] of cooc.get(term)) {
117
+ if (c < MIN_COOC) continue;
118
+ const dfb = df.get(other);
119
+ // PMI = log2( P(a,b) / (P(a) P(b)) ) = log2( c·N / (dfa·dfb) )
120
+ const pmi = log2((c * docCount) / (dfa * dfb));
121
+ if (pmi <= 0) continue;
122
+ const w = Math.min(1, pmi / PMI_NORM);
123
+ if (w < MIN_EXPANSION_WEIGHT) continue;
124
+ row.push([other, Number(w.toFixed(4))]);
125
+ }
126
+ if (!row.length) continue;
127
+ // strongest first; lexical tie-break for determinism; keep top-K
128
+ row.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1));
129
+ assoc[term] = row.slice(0, topK);
130
+ pairs += assoc[term].length;
131
+ }
132
+ if (!pairs) return null;
133
+
134
+ return {
135
+ assoc,
136
+ meta: { terms: Object.keys(assoc).length, pairs, docs: docCount, metric: 'pmi', version: 1 },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Expand a natural-language query into a WEIGHTED term set for the sparse channel: the learned
142
+ * ASSOCIATE terms only (the original query terms already drive the lexical channel, so the sparse
143
+ * channel is a pure vocabulary-EXPANSION view — it surfaces chunks that match the semantically
144
+ * associated vocabulary the literal query never mentions). Deterministic insertion order (query-token
145
+ * order, then per-token associate order) so both backends accumulate scores identically.
146
+ *
147
+ * Returns null when nothing fires (no model, no eligible token, no association) — the caller then
148
+ * runs ZERO sparse channel and the result is byte-identical to the default path.
149
+ *
150
+ * @param {string} queryText
151
+ * @param {{assoc:Record<string,Array<[string,number]>>}} model
152
+ * @param {object} [opts]
153
+ * @returns {Map<string, number> | null} associate term → fusion weight, or null
154
+ */
155
+ export function expandSparseQuery(queryText, model, opts = {}) {
156
+ if (!model || !model.assoc) return null;
157
+ const scale = opts.scale ?? EXPANSION_SCALE;
158
+ const maxExp = opts.maxExpansions ?? MAX_QUERY_EXPANSIONS;
159
+ const assoc = model.assoc;
160
+
161
+ const qTokens = [...distinctTokens(queryText)]; // raw surface forms, query order via Set
162
+ const weights = new Map(); // associate term → best weight seen
163
+ for (const qt of qTokens) {
164
+ const row = assoc[qt];
165
+ if (!row) continue;
166
+ for (const [term, w] of row) {
167
+ if (qTokens.includes(term)) continue; // don't re-add a literal query term
168
+ const ew = scale * w;
169
+ if (ew < MIN_EXPANSION_WEIGHT) continue;
170
+ const prev = weights.get(term);
171
+ if (prev === undefined || ew > prev) weights.set(term, ew);
172
+ }
173
+ }
174
+ if (!weights.size) return null;
175
+
176
+ // Keep the strongest MAX_QUERY_EXPANSIONS, but REBUILD the map in deterministic order
177
+ // (weight desc, then term asc) so both backends iterate — and float-accumulate — identically.
178
+ const ranked = [...weights.entries()]
179
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
180
+ .slice(0, maxExp);
181
+ if (!ranked.length) return null;
182
+ return new Map(ranked);
183
+ }
package/storage.mjs CHANGED
@@ -18,6 +18,11 @@
18
18
  * resolveSymbol(name) → chunk[] (exact, case-insensitive)
19
19
  * findCallers(funcName) → chunk[]
20
20
  * findReferers(symbol) → chunk[] (type_refs / extends matches)
21
+ * getEdges(chunkId, {kind?, direction}) → edge[] (A4 resolved symbol graph; [] when off)
22
+ * hasSymbolGraph() / hasCentrality() → boolean (opt-in A4 / A5 layers present?)
23
+ * getCentrality(chunkId) → { score, rank, total } | null (A5 symbol PageRank)
24
+ * topCentral(limit) → [{ chunk, score, rank }] (A5, rank-ascending)
25
+ * hasTaint() / getTaintFlows() → boolean / { flows, meta } | null (C2 serialized taint, --taint)
21
26
  * findRoutes({method, pathPrefix}) → route[] (HTTP route → handler chunk)
22
27
  * iterateChunks() → Iterable<chunk> (cursor on SQLite)
23
28
  * getDependencies(path) / getImportedBy(path) → string[]
package/watch-daemon.mjs CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  summaryEmbeddingText, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, embeddingWindows,
36
36
  } from './search-core.mjs';
37
37
  import { MAX_FILE_SIZE_BYTES, buildIgnoreFilter, extractImportsFromAST, extractSemanticChunks } from './parse/extractor.mjs';
38
- import { getParserForFile } from './parse/languages.mjs';
38
+ import { getParserForFile, ensureLanguagesReady } from './parse/languages.mjs';
39
39
  import { resolveLocalImports, buildEmbeddingPayload, fullBodyForEmbedding } from './parse/imports.mjs';
40
40
  import { createEmbedder, readEmbedMeta } from './embeddings.mjs';
41
41
  import {
@@ -239,7 +239,12 @@ async function processFileChange(absolutePath) {
239
239
 
240
240
  // Serialize updates: chokidar can fire bursts (git checkout, format-on-save across
241
241
  // files); per-file work must not interleave inside the store.
242
- let queue = Promise.resolve();
242
+ // Seeds the update chain: the daemon inherits its language set from whatever the
243
+ // last `idx-index` run resolved, and won't autoInstall (grammars are provisioned
244
+ // at index time, not silently mid-watch) — a language added after the daemon
245
+ // started is picked up on the next full `idx-index`, same as other whole-program
246
+ // passes (see interprocedural.mjs).
247
+ let queue = ensureLanguagesReady({ projectRoot: PROJECT_ROOT, enabledLangs: config.languages, autoInstall: false, log: (msg) => process.stderr.write(msg + '\n') });
243
248
  function enqueue(absolutePath) {
244
249
  queue = queue.then(() => processFileChange(absolutePath)).catch(() => {});
245
250
  }