graph-indexer 2.0.0 → 2.1.1

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.
@@ -10,7 +10,7 @@
10
10
 
11
11
  <rules>
12
12
  <rule name="hidden-calls">Rust inserts calls the source never spells: `?`→From::from, operators→Add::add etc., Deref coercion→deref, scope end→Drop::drop, `.await`→poll. None appear as edges — never call a trait method unused from an empty call graph.</rule>
13
- <rule name="trait-dispatch">Calls through generics (T: Trait) or dyn Trait bind to the method NAME: get_call_graph("execute") mixes every execute. Disambiguate by the impl target in each signature. Enumerate implementors: ONE search_code(query: "impl TraitName for", exact_tokens: "TraitName").</rule>
13
+ <rule name="trait-dispatch">Calls through generics (T: Trait) or dyn Trait bind to the method NAME: get_call_graph("execute") mixes every execute. Disambiguate by the impl target in each signature. Enumerate implementors: ONE search_code(query: "impl TraitName for", exact_tokens: "TraitName"). If your index was built --resolver scip (rust-analyzer --scip), find_references returns a precise 🎯 SCIP-resolved set that separates same-named methods — trust it over name-only.</rule>
14
14
  <rule name="re-exports">`pub use` hubs (crate roots, preludes) produce re_export nodes: internal origin → ONE hop; external crate → stop.</rule>
15
15
  <rule name="closures-and-generics">Logic inside closures passed to combinators has no edges — behavioural search. A generic fn is ONE chunk regardless of instantiations; per-type behaviour comes from its trait bounds — resolve the bound trait's method ONCE, per-type copies don't exist.</rule>
16
16
  <rule name="query-style">snake_case splits well; pin PascalCase trait/type names with exact_tokens. For error flow, search the error variant name or the thiserror/anyhow message string — far more unique than "error". Trust card file paths over guessed `crate::` paths.</rule>
package/seal.mjs ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * @file seal.mjs
3
+ * @description Sealed mode (F1): turn "air-gapped by default" from a behaviour you trust into a
4
+ * guarantee you can verify and enforce. Two opt-in tiers:
5
+ * • strict — zero network egress; lexical-only (the default path).
6
+ * • local — loopback only; local LLM/embedders on this box are allowed, nothing
7
+ * leaves it.
8
+ *
9
+ * Three mechanisms:
10
+ * 1. assertSealCompatible(config) — FAIL-CLOSED validation: refuse to start if any
11
+ * enabled feature would egress beyond the tier, naming the feature + remedy.
12
+ * 2. installEgressGuard({allow}) — a deny-by-default runtime hook on the in-process
13
+ * network paths (net/tls sockets, http(s), fetch) so an *accidental* call is
14
+ * caught, not just the known ones. A best-effort child_process denylist covers
15
+ * the obvious out-of-process egress tools (documented limitation: a child
16
+ * process has its own socket layer the parent cannot hook).
17
+ * 3. sealManifest(config) — a deterministic attestation document (tier, providers,
18
+ * egress posture) an auditor/CI can diff against policy.
19
+ *
20
+ * Sealed mode is itself opt-in and can only REMOVE capability (fail-closed); it never
21
+ * changes ranking, the store, or parity. The default path is already strict-compatible.
22
+ *
23
+ * Language grammars (parse/languages.mjs) are a separate, asset-availability form of
24
+ * fail-closed: they are not bundled dependencies (installed on demand into
25
+ * .graph-indexer/node_modules), so under sealed mode auto-install is disabled and
26
+ * indexer.mjs throws SealViolation if a configured language's grammar isn't already
27
+ * resolvable — pre-install before sealing rather than silently shipping a partial index.
28
+ * @author MaquinaTech <https://github.com/MaquinaTech>
29
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
30
+ * @license MIT
31
+ */
32
+ import http from 'node:http';
33
+ import https from 'node:https';
34
+ import net from 'node:net';
35
+ import cp from 'node:child_process';
36
+ import crypto from 'node:crypto';
37
+
38
+ /** Thrown by the fail-closed validation and by the runtime guard. */
39
+ export class SealViolation extends Error {
40
+ constructor(message) { super(message); this.name = 'SealViolation'; }
41
+ }
42
+
43
+ const LOOPBACK_RE = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|::1|0:0:0:0:0:0:0:1)$/i;
44
+
45
+ /** Is a bare host string a loopback address? (IPv6 brackets tolerated.) */
46
+ export function isLoopbackHost(host) {
47
+ if (!host) return false;
48
+ const h = String(host).replace(/^\[|\]$/g, '').trim().toLowerCase();
49
+ return LOOPBACK_RE.test(h);
50
+ }
51
+
52
+ /** Is a URL string loopback-bound? Unparseable → false (treated as non-loopback). */
53
+ export function isLoopbackUrl(url) {
54
+ if (!url) return false;
55
+ try { return isLoopbackHost(new URL(String(url)).hostname); } catch { return false; }
56
+ }
57
+
58
+ /** The LLM endpoint a feature would hit, given the active provider. */
59
+ function llmHost(config) {
60
+ return config.llmProvider === 'mlx' ? config.mlxLmHost : config.ollamaHost;
61
+ }
62
+
63
+ /**
64
+ * Enumerate the enabled features that make network calls, each tagged with its reach
65
+ * ('loopback' = this box only, 'remote' = off-box) and a remedy. The default config (every
66
+ * optional feature off) returns [].
67
+ *
68
+ * @param {object} config Resolved config.
69
+ * @returns {Array<{feature:string,reach:'loopback'|'remote',target:string,remedy:string}>}
70
+ */
71
+ export function egressingFeatures(config) {
72
+ const out = [];
73
+ const reachOf = (host) => (isLoopbackHost(host) || isLoopbackUrl(host) ? 'loopback' : 'remote');
74
+
75
+ if (config.embeddingsEnabled) {
76
+ const p = config.embedProvider;
77
+ if (p === 'ollama' || p === 'auto') {
78
+ out.push({
79
+ feature: `embeddings (${p === 'auto' ? 'auto→ollama' : 'ollama'})`,
80
+ reach: reachOf(config.ollamaHost), target: config.ollamaHost,
81
+ remedy: 'drop --embeddings, or point OLLAMA_HOST at loopback under --sealed local',
82
+ });
83
+ } else if (p === 'local') {
84
+ out.push({
85
+ feature: 'embeddings (local MiniLM)', reach: 'remote',
86
+ target: 'model CDN (first-run download)',
87
+ remedy: 'pre-warm the model cache offline, or drop --embeddings under sealed mode',
88
+ });
89
+ } else if (p === 'code-local') {
90
+ // Same in-process @huggingface/transformers path as 'local' — the code model is
91
+ // fetched from the model CDN on the FIRST run (air-gapped only once cached).
92
+ out.push({
93
+ feature: 'embeddings (code-local code-specialized)', reach: 'remote',
94
+ target: 'model CDN (first-run download)',
95
+ remedy: 'pre-warm the code model cache offline, or drop --embeddings under sealed mode',
96
+ });
97
+ } else if (p === 'mlx') {
98
+ out.push({
99
+ feature: 'embeddings (mlx subprocess)', reach: 'loopback',
100
+ target: 'local mlx subprocess',
101
+ remedy: 'drop --embeddings under --sealed strict (local compute only under --sealed local)',
102
+ });
103
+ }
104
+ }
105
+ if (config.enrichment?.enabled) {
106
+ out.push({
107
+ feature: 'enrichment (LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
108
+ remedy: 'drop --enrichment under sealed mode',
109
+ });
110
+ }
111
+ if (config.rerank?.enabled) {
112
+ if (config.rerank.provider === 'cross-encoder') {
113
+ out.push({
114
+ feature: 'rerank (cross-encoder)', reach: 'remote',
115
+ target: 'model CDN (first-run download)',
116
+ remedy: 'pre-warm the model cache offline, or drop --rerank under sealed mode',
117
+ });
118
+ } else {
119
+ out.push({
120
+ feature: 'rerank (generative LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
121
+ remedy: 'drop --rerank under sealed mode',
122
+ });
123
+ }
124
+ }
125
+ if (config.hyde?.enabled) {
126
+ out.push({
127
+ feature: 'hyde (LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
128
+ remedy: 'disable hyde under sealed mode',
129
+ });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * FAIL-CLOSED validation. Throws SealViolation (listing every offending feature) if the config
136
+ * would egress beyond its tier. No-op when sealed is 'off'. Called from resolveConfig so a
137
+ * sealed-incompatible config can never be constructed.
138
+ *
139
+ * @param {object} config Resolved config (reads config.sealed ∈ {'off','local','strict'}).
140
+ */
141
+ export function assertSealCompatible(config) {
142
+ const tier = config.sealed || 'off';
143
+ if (tier === 'off') return;
144
+ const feats = egressingFeatures(config);
145
+ // strict forbids ANY egress; local forbids only off-box ('remote') reach.
146
+ const violations = tier === 'strict' ? feats : feats.filter(f => f.reach === 'remote');
147
+ if (violations.length === 0) return;
148
+ const detail = violations.map(v => ` • ${v.feature} → ${v.reach} (${v.target}); ${v.remedy}`).join('\n');
149
+ throw new SealViolation(
150
+ `--sealed ${tier} refuses to start — ${violations.length} enabled feature(s) would egress`
151
+ + (tier === 'strict' ? ' (strict = lexical-only, zero network):\n' : ' beyond loopback:\n')
152
+ + detail);
153
+ }
154
+
155
+ // ── Runtime egress guard ─────────────────────────────────────────────────────────────────────
156
+
157
+ function hostFromHttpArgs(args) {
158
+ const a = args[0];
159
+ if (typeof a === 'string') { try { return new URL(a).hostname; } catch { return 'localhost'; } }
160
+ if (a && typeof a === 'object') {
161
+ if (typeof a.href === 'string') { try { return new URL(a.href).hostname; } catch { /* fall through */ } }
162
+ const h = a.hostname || a.host || 'localhost';
163
+ return String(h).replace(/:\d+$/, '');
164
+ }
165
+ return 'localhost';
166
+ }
167
+
168
+ function hostFromNetArgs(args) {
169
+ const a = args[0];
170
+ if (a && typeof a === 'object') {
171
+ if (a.path) return null; // IPC / unix socket — local, not network
172
+ return a.host || 'localhost';
173
+ }
174
+ if (typeof a === 'number') return typeof args[1] === 'string' ? args[1] : 'localhost';
175
+ if (typeof a === 'string') return null; // IPC path
176
+ return 'localhost';
177
+ }
178
+
179
+ function hostFromFetch(input) {
180
+ if (typeof input === 'string') { try { return new URL(input).hostname; } catch { return null; } }
181
+ if (input && typeof input === 'object') {
182
+ if (typeof input.url === 'string') { try { return new URL(input.url).hostname; } catch { return null; } }
183
+ try { return new URL(String(input)).hostname; } catch { return null; }
184
+ }
185
+ return null;
186
+ }
187
+
188
+ const EGRESS_BINS = /^(curl|wget|nc|ncat|telnet|ssh|scp|sftp|rsync)$/i;
189
+ const GIT_NET = /^(fetch|pull|push|clone|remote|ls-remote|submodule)$/i;
190
+ const PKG_MGRS = /^(npm|pnpm|yarn|pip|pip3|gem|cargo|go)$/i;
191
+ const PKG_NET = /\b(install|add|update|upgrade|ci|fetch|download|get)\b/i;
192
+
193
+ /** Best-effort: would this child process reach the network? (documented as not airtight.) */
194
+ export function commandWouldEgress(cmd, args = []) {
195
+ const base = String(cmd || '').split(/[\\/]/).pop();
196
+ if (EGRESS_BINS.test(base)) return true;
197
+ if (/^git$/i.test(base) && args[0] && GIT_NET.test(String(args[0]))) return true;
198
+ if (PKG_MGRS.test(base) && args.some(a => PKG_NET.test(String(a)))) return true;
199
+ // shell strings (exec/execSync) arrive as the whole command in `cmd`.
200
+ if (!args.length && /\b(curl|wget|nc|ncat|ssh|scp|rsync)\b/i.test(String(cmd))) return true;
201
+ if (!args.length && /\bgit\s+(fetch|pull|push|clone|remote|ls-remote)\b/i.test(String(cmd))) return true;
202
+ return false;
203
+ }
204
+
205
+ let _installed = null;
206
+
207
+ /**
208
+ * Install the deny-by-default egress guard. Idempotent. The in-process socket/http/fetch hooks are
209
+ * airtight for THIS process; the child_process denylist is best-effort (a child has its own socket
210
+ * layer). Returns a handle with restore() (used by tests + an orderly shutdown).
211
+ *
212
+ * @param {object} [opts]
213
+ * @param {Array<'loopback'>} [opts.allow] [] (strict) → block all; ['loopback'] (local) → permit loopback.
214
+ */
215
+ export function installEgressGuard({ allow = [] } = {}) {
216
+ if (_installed) return _installed;
217
+ const allowLoopback = allow.includes('loopback');
218
+ const permit = (host) => host !== null && allowLoopback && isLoopbackHost(host);
219
+ const block = (host, api) => {
220
+ throw new SealViolation(`sealed mode blocked a network connection to ${host || '(unparseable host)'} via ${api}`);
221
+ };
222
+ // A null host from net means IPC/unix socket (local) → allow; from fetch means unparseable → block.
223
+ const guardNet = (host, api, orig, ctx, args) => {
224
+ if (host === null || permit(host)) return orig.apply(ctx, args);
225
+ return block(host, api);
226
+ };
227
+
228
+ const orig = {
229
+ httpRequest: http.request, httpGet: http.get,
230
+ httpsRequest: https.request, httpsGet: https.get,
231
+ netConnect: net.Socket.prototype.connect,
232
+ fetch: globalThis.fetch,
233
+ spawn: cp.spawn, spawnSync: cp.spawnSync,
234
+ exec: cp.exec, execSync: cp.execSync,
235
+ execFile: cp.execFile, execFileSync: cp.execFileSync,
236
+ };
237
+
238
+ http.request = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpRequest.apply(this, args) : block(h, 'http.request'); };
239
+ http.get = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpGet.apply(this, args) : block(h, 'http.get'); };
240
+ https.request = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpsRequest.apply(this, args) : block(h, 'https.request'); };
241
+ https.get = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpsGet.apply(this, args) : block(h, 'https.get'); };
242
+ net.Socket.prototype.connect = function (...args) { return guardNet(hostFromNetArgs(args), 'net.connect', orig.netConnect, this, args); };
243
+ if (orig.fetch) globalThis.fetch = function (input, init) { const h = hostFromFetch(input); return permit(h) ? orig.fetch.call(this, input, init) : block(h, 'fetch'); };
244
+
245
+ const guardChild = (name, origFn) => function (cmd, args, ...rest) {
246
+ const argv = Array.isArray(args) ? args : [];
247
+ if (commandWouldEgress(cmd, argv)) throw new SealViolation(`sealed mode blocked a network-reaching child process via ${name}: ${cmd}`);
248
+ return origFn.call(this, cmd, args, ...rest);
249
+ };
250
+ cp.spawn = guardChild('spawn', orig.spawn);
251
+ cp.spawnSync = guardChild('spawnSync', orig.spawnSync);
252
+ cp.exec = guardChild('exec', orig.exec);
253
+ cp.execSync = guardChild('execSync', orig.execSync);
254
+ cp.execFile = guardChild('execFile', orig.execFile);
255
+ cp.execFileSync = guardChild('execFileSync', orig.execFileSync);
256
+
257
+ _installed = {
258
+ allow,
259
+ restore() {
260
+ http.request = orig.httpRequest; http.get = orig.httpGet;
261
+ https.request = orig.httpsRequest; https.get = orig.httpsGet;
262
+ net.Socket.prototype.connect = orig.netConnect;
263
+ if (orig.fetch) globalThis.fetch = orig.fetch;
264
+ cp.spawn = orig.spawn; cp.spawnSync = orig.spawnSync;
265
+ cp.exec = orig.exec; cp.execSync = orig.execSync;
266
+ cp.execFile = orig.execFile; cp.execFileSync = orig.execFileSync;
267
+ _installed = null;
268
+ },
269
+ };
270
+ return _installed;
271
+ }
272
+
273
+ /** Whether the guard is currently installed (for stats / idempotency checks). */
274
+ export function egressGuardActive() { return _installed != null; }
275
+
276
+ /**
277
+ * Deterministic attestation document — the effective sealed posture an auditor/CI can diff against
278
+ * policy. No timestamps in the body (stamp outside if needed) so it is reproducible.
279
+ *
280
+ * @param {object} config Resolved config.
281
+ */
282
+ export function sealManifest(config) {
283
+ const tier = config.sealed || 'off';
284
+ return {
285
+ sealed: tier,
286
+ egress: tier === 'strict' ? 'none' : tier === 'local' ? 'loopback-only' : 'unrestricted',
287
+ providers: {
288
+ embeddings: config.embeddingsEnabled ? config.embedProvider : 'off',
289
+ enrichment: config.enrichment?.enabled ? config.llmProvider : 'off',
290
+ rerank: config.rerank?.enabled ? config.rerank.provider : 'off',
291
+ llm: config.llmProvider || 'ollama',
292
+ },
293
+ egressing_features: egressingFeatures(config).map(f => ({ feature: f.feature, reach: f.reach, target: f.target })),
294
+ };
295
+ }
296
+
297
+ // ── Attestation signing (F1 hardening) ───────────────────────────────────────────────────────
298
+ // Turn the manifest from a document you trust into one you can VERIFY: a detached signature over
299
+ // its canonical bytes, so an auditor/CI proves the attested posture is authentic and untampered.
300
+ // Pure local crypto (node:crypto) — no dependency, no network; works under --sealed strict.
301
+
302
+ /**
303
+ * Deterministic JSON: object keys sorted recursively → stable bytes for signing/verifying. (The
304
+ * manifest is already emitted in a fixed order, but signing over a canonical form makes the
305
+ * signature robust to any future key-order change and to a round-trip through a file.)
306
+ */
307
+ export function canonicalJson(value) {
308
+ const norm = (v) => {
309
+ if (Array.isArray(v)) return v.map(norm);
310
+ if (v && typeof v === 'object') {
311
+ const out = {};
312
+ for (const k of Object.keys(v).sort()) out[k] = norm(v[k]);
313
+ return out;
314
+ }
315
+ return v;
316
+ };
317
+ return JSON.stringify(norm(value));
318
+ }
319
+
320
+ /** Map an asymmetric key type to its (node digest, label) signing spec. Ed25519 uses a null digest. */
321
+ function signSpec(keyType) {
322
+ switch (keyType) {
323
+ case 'ed25519': return { digest: null, alg: 'ed25519' };
324
+ case 'ed448': return { digest: null, alg: 'ed448' };
325
+ case 'rsa':
326
+ case 'rsa-pss': return { digest: 'sha256', alg: 'rsa-sha256' };
327
+ case 'ec': return { digest: 'sha256', alg: 'ecdsa-sha256' };
328
+ default: throw new Error(`unsupported attestation key type: ${keyType}`);
329
+ }
330
+ }
331
+
332
+ /** SHA-256 fingerprint (first 16 bytes, hex) of a public key's SPKI/DER encoding — a stable key id. */
333
+ export function publicKeyFingerprint(publicKey) {
334
+ const der = publicKey.export({ type: 'spki', format: 'der' });
335
+ return crypto.createHash('sha256').update(der).digest('hex').slice(0, 32);
336
+ }
337
+
338
+ /**
339
+ * Sign a seal manifest with a PEM private key (Ed25519 / RSA / EC). Returns a signed envelope
340
+ * `{ manifest, signature: { alg, keyType, publicKeyFingerprint, value } }`. The signature covers the
341
+ * CANONICAL JSON of the manifest, so it is reproducible and tamper-evident.
342
+ *
343
+ * @param {object} manifest Output of sealManifest(config).
344
+ * @param {string|Buffer} privateKeyPem A PEM-encoded private key.
345
+ */
346
+ export function signManifest(manifest, privateKeyPem) {
347
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
348
+ const spec = signSpec(privateKey.asymmetricKeyType);
349
+ const data = Buffer.from(canonicalJson(manifest), 'utf8');
350
+ const value = crypto.sign(spec.digest, data, privateKey).toString('base64');
351
+ const publicKey = crypto.createPublicKey(privateKey);
352
+ return {
353
+ manifest,
354
+ signature: {
355
+ alg: spec.alg,
356
+ keyType: privateKey.asymmetricKeyType,
357
+ publicKeyFingerprint: publicKeyFingerprint(publicKey),
358
+ value,
359
+ },
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Verify a signed manifest envelope against a PEM public key. Returns `{ valid, reason }` (never
365
+ * throws — a malformed envelope/key is a clean `valid:false`). `valid` is true only if the signature
366
+ * matches the canonical JSON of `envelope.manifest` under the declared algorithm and key.
367
+ *
368
+ * @param {object} envelope Output of signManifest().
369
+ * @param {string|Buffer} publicKeyPem A PEM-encoded public key.
370
+ */
371
+ export function verifySignedManifest(envelope, publicKeyPem) {
372
+ try {
373
+ if (!envelope || !envelope.manifest || !envelope.signature || !envelope.signature.value) {
374
+ return { valid: false, reason: 'not a signed manifest envelope (missing manifest/signature)' };
375
+ }
376
+ const publicKey = crypto.createPublicKey(publicKeyPem);
377
+ const spec = signSpec(publicKey.asymmetricKeyType);
378
+ if (spec.alg !== envelope.signature.alg) {
379
+ return { valid: false, reason: `algorithm mismatch: envelope=${envelope.signature.alg}, key=${spec.alg}` };
380
+ }
381
+ const data = Buffer.from(canonicalJson(envelope.manifest), 'utf8');
382
+ const ok = crypto.verify(spec.digest, data, publicKey, Buffer.from(envelope.signature.value, 'base64'));
383
+ if (!ok) return { valid: false, reason: 'signature does not match (tampered manifest or wrong key)' };
384
+ const fp = publicKeyFingerprint(publicKey);
385
+ if (envelope.signature.publicKeyFingerprint && envelope.signature.publicKeyFingerprint !== fp) {
386
+ return { valid: false, reason: 'public-key fingerprint mismatch' };
387
+ }
388
+ return { valid: true, reason: 'signature valid', publicKeyFingerprint: fp };
389
+ } catch (e) {
390
+ return { valid: false, reason: `verification error: ${e.message}` };
391
+ }
392
+ }
393
+
394
+ /** Generate an Ed25519 attestation keypair as PEM strings (`idx-index --gen-attestation-key`). */
395
+ export function generateAttestationKeyPair() {
396
+ return crypto.generateKeyPairSync('ed25519', {
397
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
398
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
399
+ });
400
+ }
package/search-core.mjs CHANGED
@@ -295,6 +295,43 @@ export function bm25Score(idf, tf, docLen, avgdl, k1 = BM25_K1, b = BM25_B) {
295
295
  return idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgdl));
296
296
  }
297
297
 
298
+ /**
299
+ * B3 — score the learned-sparse expansion channel: a weighted-term retrieval over the EXISTING BM25
300
+ * postings, used identically by both backends so the channel is parity-by-construction.
301
+ *
302
+ * PARITY INVARIANT (do not break): each chunk receives EXACTLY ONE addend per associate term, so a
303
+ * chunk's final float sum is the cross-term accumulation in `termWeights`' iteration order — which is
304
+ * deterministic and identical across backends (expandSparseQuery rebuilds the Map sorted: weight desc,
305
+ * term asc). The INNER getPostings(term) order legitimately differs between backends (memory Map vs
306
+ * SQL rows) and is parity-IRRELEVANT precisely because the keys within one term are distinct (one
307
+ * scores.set per chunk per term). Never accumulate within a term, or emit a chunk twice for one term.
308
+ *
309
+ * The backends differ only in HOW postings are reached (in-memory Map vs SQL), injected as accessors:
310
+ * getDf(term) → document frequency (0 ⇒ skip the term)
311
+ * getPostings(term) → iterable of [chunkId, tf]
312
+ * getDocLen(chunkId) → that chunk's BM25 doc length (falls back to avgdl)
313
+ *
314
+ * @param {Iterable<[string, number]>} termWeights associate term → fusion weight (ordered)
315
+ * @param {{docCount:number, avgdl:number, getDf:Function, getPostings:Function, getDocLen:Function}} acc
316
+ * @returns {Array<{id:string, score:number, rank:number}>} ranked, capped, id-tie-broken
317
+ */
318
+ export function scoreSparseExpanded(termWeights, { docCount, avgdl, getDf, getPostings, getDocLen }) {
319
+ const scores = new Map();
320
+ for (const [term, weight] of termWeights) {
321
+ const dfreq = getDf(term);
322
+ if (!dfreq) continue;
323
+ const idf = okapiIdf(docCount, dfreq);
324
+ for (const [chunkId, tf] of getPostings(term)) {
325
+ const dl = getDocLen(chunkId) ?? avgdl;
326
+ scores.set(chunkId, (scores.get(chunkId) || 0) + weight * bm25Score(idf, tf, dl, avgdl));
327
+ }
328
+ }
329
+ return Array.from(scores.entries())
330
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
331
+ .slice(0, LEXICAL_FUSION_CAP)
332
+ .map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
333
+ }
334
+
298
335
  // ─── Hybrid fusion (RRF + boost ladder) ────────────────────────────────────────
299
336
 
300
337
  // Lexical candidates handed to fusion — shared by BOTH backends. An asymmetric
@@ -368,6 +405,20 @@ const NL_LEXICAL_WEIGHT = 1.5;
368
405
  const NL_VECTOR_WEIGHT_PLAIN = 0.4;
369
406
  const NL_VECTOR_WEIGHT_ENRICHED = 0.6;
370
407
 
408
+ // B3 learned-sparse channel weight. The sparse channel is the corpus-learned vocabulary EXPANSION
409
+ // view (associate terms the literal query never mentions); it only fires for NL queries on a
410
+ // --learned-sparse index, so it never touches symbolic lookups or the default path.
411
+ //
412
+ // HONEST MEASUREMENT (strict test:eval, 5 core suites, in-process A/B — see the weight sweep in
413
+ // IMPROVEMENT_LEARNED_SPARSE.md): the channel does NOT beat the lexical baseline at any weight.
414
+ // Higher weights (0.5/0.3) REGRESS semantic s@5 (the co-occurrence associates add noise that buries
415
+ // correct hits); the best case is ≈neutral at 0.2 (semantic + symbolic rank-1 both unchanged,
416
+ // overall s@5 −0.5). Like BM25F / AST / the code-embedding NL weight before it, this measured
417
+ // net-neutral-to-negative, so the DEFAULT stays lexical (B3 ships opt-in via --learned-sparse only).
418
+ // 0.2 is the measured least-harmful active setting for users who opt in / whose corpus has the
419
+ // vocabulary mismatch the benchmark fixtures lack.
420
+ const NL_SPARSE_WEIGHT = 0.2;
421
+
371
422
  const QUERY_STOPWORDS = new Set([
372
423
  'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
373
424
  'by', 'from', 'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what',
@@ -424,7 +475,7 @@ export const EXAMPLE_DIR_RE = /^(?:[^/\\]+[/\\])?(?:examples?|samples?|demos?|tu
424
475
  export function fuseAndRank({
425
476
  lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
426
477
  docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
427
- corpusEnriched = false,
478
+ corpusEnriched = false, sparseResults = [],
428
479
  }) {
429
480
  const rrfScores = new Map();
430
481
  const K = rrfK;
@@ -501,9 +552,12 @@ export function fuseAndRank({
501
552
  const queryIsStyle = exactBoostName != null ||
502
553
  /[.#]|css|scss|stylesheet|selector|\bstyle\b|\bclass\b|color|margin|padding|font|border|background|hover|keyframe|animation|mixin|breakpoint|responsive|\bwidth\b|\bheight\b/.test(queryLower);
503
554
 
555
+ // B3 learned-sparse channel (opt-in, presence + NL gated by the caller). When the caller passes
556
+ // no sparse candidates the spread contributes nothing → byte-identical to the default path.
504
557
  const allResults = [
505
558
  ...vectorResults.map(r => ({ ...r, _w: wVec })),
506
559
  ...lexicalResults.map(r => ({ ...r, _w: wLex })),
560
+ ...sparseResults.map(r => ({ ...r, _w: NL_SPARSE_WEIGHT })),
507
561
  ];
508
562
 
509
563
  for (const { id, rank, _w } of allResults) {
@@ -593,6 +647,102 @@ export function fuseAndRank({
593
647
  .filter(r => r.chunk !== undefined);
594
648
  }
595
649
 
650
+ // ─── D3: learned re-ranker (opt-in, post-fusion) ─────────────────────────────────
651
+ //
652
+ // A learned linear model that RE-ORDERS the fused top-N using features the engine
653
+ // already computes — the RRF fused score plus PROGRAM-STRUCTURE signals A4/A5/A1 added
654
+ // (symbol centrality, resolved-edge in-degree, SCIP-resolved in-degree) and git recency.
655
+ // It is a *post-fusion* re-rank (like the LLM reranker / git boost — tool layer, never
656
+ // inside fuseAndRank), so the default RRF ordering is byte-identical: this runs ONLY when
657
+ // the caller opts in (config.ranker === 'learned'). Inference is a dot product — zero
658
+ // runtime dependency, no model server, deterministic (ties broken on id) → parity-safe.
659
+ //
660
+ // Honest scope: the discriminating features (centrality / in-degree / resolved-in) are
661
+ // ZERO without --symbol-graph, so on a default index the model degrades to the RRF score
662
+ // + git + node-type and is ≈neutral; its lift is on symbol-graph indexes. Trained offline
663
+ // (bench/train-ranker.mjs) on the benchmark's labelled query→chunk pairs; RRF stays the
664
+ // default. See docs/internals/IMPROVEMENT_LEARNED_RANKER.md for the measured deltas.
665
+
666
+ /** Feature order — the model's weight vector is keyed by these names. */
667
+ export const RANKER_FEATURES = ['rrf', 'rank', 'centrality', 'in_degree', 'resolved_in', 'git', 'is_def', 'is_test'];
668
+
669
+ // Node types that are NOT a definition (a re-export / bare expression / import is a weaker hit).
670
+ const NON_DEF_NODE_RE = /expression|call_expression|re_export|_export_statement|import|comment/;
671
+
672
+ /**
673
+ * Default shipped model — CONSERVATIVE and RRF-DOMINANT by design. The offline trainer
674
+ * (bench/train-ranker.mjs) measured that the program-structure features (centrality / resolved
675
+ * in-degree) carry ~no signal for symbol retrieval on the benchmark — the correct symbol is usually
676
+ * NOT the most central one (the fit assigns them near-zero weight) — and a fully data-fit model only
677
+ * TIES RRF on the held-out split (rank-1 0.579 vs 0.579). So the shipped default keeps RRF in charge
678
+ * (it already
679
+ * encodes lexical+vector+boosts), adds only a whisper of structure as a tie-breaker, and an is_test
680
+ * penalty that aligns with the existing demotion. RRF remains the engine default (config.ranker);
681
+ * this model only runs under the opt-in `--ranker learned`, where it is ≈neutral on the generic
682
+ * benchmark and exists for users to RETRAIN on their own symbol-graph-rich repo. See
683
+ * docs/internals/IMPROVEMENT_LEARNED_RANKER.md for the full measured story.
684
+ */
685
+ export const DEFAULT_RANKER_MODEL = Object.freeze({
686
+ bias: 0,
687
+ weights: Object.freeze({
688
+ rrf: 1.0, rank: 0.30, centrality: 0.04, in_degree: 0.04,
689
+ resolved_in: 0.04, git: 0.03, is_def: 0.0, is_test: -0.20,
690
+ }),
691
+ });
692
+
693
+ /**
694
+ * Extract the feature vector for one fused candidate. Pure; all backend data arrives through
695
+ * accessors so memory and SQLite produce identical features (parity).
696
+ *
697
+ * @param {{score:number, chunk:object}} result
698
+ * @param {number} idx 0-based position in the fused pool.
699
+ * @param {object} ctx { maxScore, getCentrality?, getInEdges?, gitScoreFor? }
700
+ */
701
+ export function extractRankerFeatures(result, idx, ctx) {
702
+ const c = result.chunk || {};
703
+ const cen = ctx.getCentrality ? ctx.getCentrality(c.id) : null;
704
+ const inEdges = ctx.getInEdges ? (ctx.getInEdges(c.id) || []) : [];
705
+ let resolvedIn = 0;
706
+ for (const e of inEdges) if (e.confidence === 'resolved') resolvedIn++;
707
+ const nodeType = String(c.node_type || '');
708
+ return {
709
+ rrf: ctx.maxScore > 0 ? (result.score || 0) / ctx.maxScore : 0,
710
+ rank: 1 / (1 + idx),
711
+ centrality: cen ? cen.score : 0,
712
+ in_degree: Math.min(inEdges.length / 10, 1), // squashed into [0,1]
713
+ resolved_in: Math.min(resolvedIn / 5, 1),
714
+ git: ctx.gitScoreFor ? (ctx.gitScoreFor(c.file_path) || 0) : 0,
715
+ is_def: nodeType && !NON_DEF_NODE_RE.test(nodeType) ? 1 : 0,
716
+ is_test: c.file_path && TEST_FILE_RE.test(c.file_path) ? 1 : 0,
717
+ };
718
+ }
719
+
720
+ /** Linear score of a feature vector under a model. */
721
+ export function scoreLearned(features, model = DEFAULT_RANKER_MODEL) {
722
+ let s = model.bias || 0;
723
+ const w = model.weights || {};
724
+ for (const f of RANKER_FEATURES) s += (w[f] || 0) * (features[f] || 0);
725
+ return s;
726
+ }
727
+
728
+ /**
729
+ * Re-rank fused results with the learned model. Returns a NEW array (score replaced by the learned
730
+ * score), ordered by learned score desc with id tie-break. ≤1 result is returned unchanged.
731
+ * Deterministic → both backends re-rank identically (parity-safe).
732
+ *
733
+ * @param {Array<{score:number, chunk:object}>} results The fused (RRF) pool, over-fetched.
734
+ * @param {object} ctx Accessors: { getCentrality?, getInEdges?, gitScoreFor? }.
735
+ * @param {object} [model]
736
+ */
737
+ export function learnedRerank(results, ctx = {}, model = DEFAULT_RANKER_MODEL) {
738
+ if (!Array.isArray(results) || results.length <= 1) return results;
739
+ const maxScore = results.reduce((m, r) => Math.max(m, r.score || 0), 0);
740
+ const fctx = { ...ctx, maxScore };
741
+ const scored = results.map((r, i) => ({ r, s: scoreLearned(extractRankerFeatures(r, i, fctx), model) }));
742
+ scored.sort((a, b) => (b.s - a.s) || ((a.r.chunk?.id ?? '') < (b.r.chunk?.id ?? '') ? -1 : 1));
743
+ return scored.map(({ r, s }) => ({ ...r, score: s }));
744
+ }
745
+
596
746
  // ─── Graph centrality ──────────────────────────────────────────────────────────
597
747
 
598
748
  /**