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
package/SECURITY.md ADDED
@@ -0,0 +1,85 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ We take the security and privacy of this project seriously. If you discover a
6
+ security vulnerability, please do **not** open a public issue. Instead, report it
7
+ privately by emailing the maintainer at **nicolopezdelerma@gmail.com**.
8
+
9
+ Please include:
10
+
11
+ - A description of the vulnerability and its impact.
12
+ - Steps to reproduce (a minimal proof of concept if possible).
13
+ - The affected version (`graph-indexer --version` or the `version` field in `package.json`).
14
+
15
+ You can expect an initial acknowledgement within **5 business days**. Coordinated
16
+ disclosure is appreciated — we will work with you on a fix and credit before any
17
+ public discussion.
18
+
19
+ ## Supported versions
20
+
21
+ Security fixes are applied to the latest published `2.x` release. Older versions
22
+ are not maintained; please upgrade to the latest version.
23
+
24
+ ## Security posture
25
+
26
+ graph-indexer is designed to run locally as a developer tool. Its threat model is
27
+ shaped by a few deliberate choices:
28
+
29
+ - **Air-gapped by default.** In normal operation the only outbound network call is to
30
+ a *local* Ollama endpoint (`OLLAMA_HOST`, default `http://localhost:11434`) for
31
+ embeddings. With `INDEXER_EMBEDDINGS=off`, even that call is skipped and the tool is
32
+ fully offline. There are no analytics, telemetry, crash reporters, or third-party API
33
+ calls. **One exception:** the optional in-process embedding provider
34
+ (`embedProvider: "local"`, or `"auto"` when no Ollama is found) downloads its model
35
+ weights from the Hugging Face CDN **once** on first index, then runs fully offline
36
+ from the local cache. Your source code is never sent anywhere — only model weights
37
+ are fetched. For strictly air-gapped installs, set `embedProvider` to `"ollama"` or
38
+ `"off"`, or pre-populate the model cache on a connected machine.
39
+ - **Sealed mode (opt-in, fail-closed enforcement).** For regulated or strictly
40
+ air-gapped installs, `--sealed strict` (`INDEXER_SEALED`) enforces **zero** network
41
+ egress (lexical-only), and `--sealed local` permits **loopback only** (a local
42
+ Ollama/MLX on this box is allowed, nothing leaves it). It is *fail-closed*: the build
43
+ refuses to start if any enabled feature (embeddings/enrichment/rerank/HyDE) would
44
+ egress beyond the tier, and it installs a **deny-by-default runtime egress guard** on
45
+ the in-process network paths (`net`/`tls` sockets, `http(s)`, `fetch`) so even an
46
+ accidental call is blocked — turning "we don't call out" into "we *can't*."
47
+ `idx-index --attest` prints a deterministic attestation manifest (optionally
48
+ Ed25519/RSA-**signed** and CI-verifiable) of the egress posture. See `seal.mjs`.
49
+ - **No code execution.** Source files are parsed into ASTs with Tree-sitter. The
50
+ indexer never imports, evaluates, or runs the code it indexes. The one subprocess
51
+ it spawns is a local, read-only `git log` (see "git signals" below) — never the
52
+ indexed code.
53
+ - **Local git signals.** At index time the indexer optionally reads the repository's
54
+ *local* commit log (`git log` on `MCP_PROJECT_ROOT`) to derive churn / recency /
55
+ co-change ranking hints. No remote is contacted and no source content leaves the
56
+ machine — only commit metadata (paths, timestamps) is read locally. Disable it
57
+ entirely with `INDEXER_GIT_SIGNALS=off`, `--no-git-signals`, or
58
+ `"gitSignals": false`.
59
+ - **Path-traversal guard.** The `get_file_skeleton` MCP tool resolves and normalizes
60
+ the requested path and rejects anything outside `MCP_PROJECT_ROOT`. Beyond the
61
+ textual check it also resolves symlinks with `realpath` on both the root and the
62
+ target and re-verifies containment, so a symlink *inside* the project that points
63
+ outside it cannot be used to read arbitrary files on the host.
64
+ - **Local artifacts only.** The index (`code-index.json`) and embeddings
65
+ (`code-index.embeddings.bin`) are written inside the project and are git-ignored by
66
+ `init`. They contain snippets of your source code — treat them with the same
67
+ sensitivity as the repository itself and do not commit or share them.
68
+ - **MCP transport.** The server communicates over stdio with the local MCP client
69
+ (your IDE/agent). It does not open a network socket.
70
+ - **Taint analysis is advisory, not a guarantee.** The opt-in `trace_taint` /
71
+ `find_tainted_sinks` MCP tools statically trace untrusted sources to dangerous sinks
72
+ over the call graph using pure regex catalogs + graph traversal (no model, no network,
73
+ read-only). They are a **finder, not a verifier**: they favour precision and miss flows
74
+ through dynamic dispatch, reflection, ORM/query-builder indirection, and untyped
75
+ collections — **"0 findings" is not proof of safety.** Use them for orientation, not as
76
+ a security gate.
77
+
78
+ ## Operational guidance
79
+
80
+ - Point `MCP_PROJECT_ROOT` only at repositories you trust and intend to index.
81
+ - If you set `OLLAMA_HOST` to a remote host, that host will receive code snippets as
82
+ embedding input — only do so on a network and host you control.
83
+ - The background watch daemon respects `.gitignore` and skips `node_modules`, build
84
+ output, and dot-directories, so secrets in ignored paths are not indexed. Keep
85
+ sensitive files git-ignored.
package/TRADEMARK.md ADDED
@@ -0,0 +1,31 @@
1
+ # Trademark Policy
2
+
3
+ This document outlines the trademark policy for the **graph-indexer** project and the **MaquinaTech** brand.
4
+
5
+ While the software source code is freely available under the [MIT License](LICENSE), the trademarks, service marks, and logos associated with the project are the intellectual property of MaquinaTech. The open-source license does not grant you the right to use these trademarks in a way that suggests endorsement, affiliation, or origin.
6
+
7
+ ## Our Trademarks
8
+
9
+ The following are trademarks of MaquinaTech:
10
+ * **graph-indexer™**
11
+ * **Graph Indexer™**
12
+ * **MaquinaTech™**
13
+
14
+ ## Permitted Uses
15
+
16
+ You may use our trademarks without prior written permission in the following cases:
17
+ * **Descriptive Use:** To truthfully describe the origin of the software. For example, stating "This product uses graph-indexer."
18
+ * **Compatibility:** To indicate compatibility with our project. For example, "Custom tool for graph-indexer," provided it does not confuse users into thinking it is an official tool created by us.
19
+ * **Community Discussion:** In blog posts, tutorials, videos, and discussions about the project, provided the context is accurate and not misleading.
20
+
21
+ ## Restricted Uses
22
+
23
+ You **may not** use our trademarks without explicit, prior written permission in the following ways:
24
+ * **Product Naming:** You cannot name your product, commercial service, or company "graph-indexer", "Graph Indexer", or anything confusingly similar (e.g., "Graph Indexer Enterprise", "Graph-Indexer Pro", or "graph-indexer-cloud").
25
+ * **Domain Names:** You cannot use our trademarks in a domain name (e.g., `graph-indexer-hosting.com` or `maquinatech-solutions.net`).
26
+ * **False Endorsement:** You cannot use our trademarks in any way that implies an official affiliation, partnership, sponsorship, or endorsement by MaquinaTech.
27
+ * **Modified Versions (Forks):** If you fork the repository and make modifications to the source code to distribute it as your own product, you must rename the project. You cannot distribute your modified version under the name "graph-indexer".
28
+
29
+ ## Contact and Permissions
30
+
31
+ If you wish to use our trademarks in a way that is not explicitly permitted by this policy, or if you are unsure whether your intended commercial use is permitted, please contact us for written authorization.
package/cli-ui.mjs ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @file cli-ui.mjs
3
+ * @description Tiny, dependency-free console styling shared by the `graph-indexer`
4
+ * CLIs (init + daemon control). ANSI colour with graceful degradation
5
+ * when stdout is not a TTY or NO_COLOR is set, plus a few drawing
6
+ * helpers (boxed banner, rules, status glyphs) for a consistent look.
7
+ * @author MaquinaTech <https://github.com/MaquinaTech>
8
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
9
+ * @license MIT
10
+ */
11
+
12
+ export const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
13
+
14
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : `${s}`);
15
+
16
+ export const c = {
17
+ bold: paint('1'),
18
+ dim: paint('2'),
19
+ red: paint('31'),
20
+ green: paint('32'),
21
+ yellow: paint('33'),
22
+ blue: paint('34'),
23
+ magenta: paint('35'),
24
+ cyan: paint('36'),
25
+ };
26
+
27
+ export const glyph = {
28
+ ok: c.green('✓'),
29
+ upd: c.cyan('↻'),
30
+ keep: c.dim('•'),
31
+ move: c.yellow('↪'),
32
+ skip: c.dim('–'),
33
+ warn: c.yellow('⚠'),
34
+ err: c.red('✗'),
35
+ run: c.green('●'),
36
+ stop: c.dim('○'),
37
+ arrow: c.cyan('→'),
38
+ };
39
+
40
+ export const log = (msg = '') => process.stdout.write(msg + '\n');
41
+
42
+ /** A horizontal rule, capped to the terminal width so it never wraps. */
43
+ export const rule = (width = Math.min(60, (process.stdout.columns || 80))) => c.dim('─'.repeat(Math.max(1, width)));
44
+
45
+ const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
46
+
47
+ /** ANSI-aware truncate to `limit` visible columns; closes any open colour + adds an ellipsis. */
48
+ function clipVisible(s, limit) {
49
+ if (limit < 1) return '';
50
+ if (visibleLen(s) <= limit) return s;
51
+ let out = '', w = 0, i = 0, ansi = false;
52
+ while (i < s.length && w < limit - 1) {
53
+ if (s[i] === '\x1b') {
54
+ const m = s.slice(i).match(/^\x1b\[[0-9;]*m/);
55
+ if (m) { out += m[0]; i += m[0].length; ansi = true; continue; }
56
+ }
57
+ out += s[i++]; w++;
58
+ }
59
+ return out + (ansi ? '\x1b[0m' : '') + '…';
60
+ }
61
+
62
+ /**
63
+ * Draws a bordered box. ANSI escapes are stripped before measuring width so colour
64
+ * never breaks alignment, and the box (plus each line) is capped to the terminal
65
+ * width so the border stays intact on narrow terminals instead of wrapping.
66
+ */
67
+ export function box(lines, { pad = 1, color = c.cyan } = {}) {
68
+ const maxInner = Math.max(1, (process.stdout.columns || 80) - 2); // 2 border columns
69
+ const shown = lines.map((line) => clipVisible(line, maxInner - pad * 2));
70
+ const inner = Math.min(maxInner, Math.max(...shown.map(visibleLen)) + pad * 2);
71
+ const top = color('╭' + '─'.repeat(inner) + '╮');
72
+ const bot = color('╰' + '─'.repeat(inner) + '╯');
73
+ const body = shown.map((line) => {
74
+ const gap = ' '.repeat(Math.max(0, inner - visibleLen(line) - pad));
75
+ return color('│') + ' '.repeat(pad) + line + gap + color('│');
76
+ });
77
+ return [top, ...body, bot].join('\n');
78
+ }
package/colbert.mjs ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @file colbert.mjs
3
+ * @description B4 — ColBERT-style LATE-INTERACTION reranker (multi-vector MaxSim). A third,
4
+ * opt-in reranker provider (rerank.provider === 'late-interaction'), sibling to the
5
+ * generative judge and the cross-encoder (B2). Where a single-vector dense channel
6
+ * pools the query and the document each into ONE vector (losing which part matched
7
+ * where), late interaction keeps MULTIPLE vectors per side and scores them with MaxSim:
8
+ *
9
+ * score(q, d) = Σ_{qi} max_{dj} cos(qi, dj)
10
+ *
11
+ * i.e. every query sub-vector finds its best-matching document sub-vector and the hits
12
+ * sum — the compositional-query win ColBERT is known for. It reuses the document
13
+ * sub-vectors the engine ALREADY stores (the base + `|s` summary + `|wN` window vectors
14
+ * of each chunk), so there is **no storage blow-up** — the canonical ColBERT cost. It is
15
+ * deployed as a RERANKER over the over-fetched candidate pool (the standard
16
+ * retrieve-then-late-interact pattern), so it is a post-retrieval re-order — the default
17
+ * ranking is untouched and parity is preserved (both backends read the SAME bin → the
18
+ * SAME doc vectors → the SAME MaxSim → the SAME order).
19
+ *
20
+ * AIR-GAPPED / HONEST SCOPE: the query multi-vectors are produced by the SAME local
21
+ * embedder the dense channel already uses (no new model, no network). A true ColBERT
22
+ * uses a token-level CONTEXTUAL encoder; a general sentence embedder over query
23
+ * sub-units only APPROXIMATES that — so this is "late interaction over the existing
24
+ * encoder," with a real token-level ColBERT model a clean future drop-in behind
25
+ * `encodeMultiVector`. Requires --embeddings (the doc vectors + the embedder); a no-op
26
+ * (returns the original order) when either is absent.
27
+ * @author MaquinaTech <https://github.com/MaquinaTech>
28
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
29
+ * @license MIT
30
+ */
31
+ import { collectVectorsByKey } from './engine/binary.mjs';
32
+ import { embeddingKeyFor, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, EMBEDDING_MAX_WINDOWS } from './search-core.mjs';
33
+
34
+ // Query sub-unit extraction is intentionally light (no stemming — surface forms, like the dense
35
+ // channel). A tiny stopword guard keeps function words from spending a query vector slot.
36
+ const STOP = new Set([
37
+ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from',
38
+ 'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what', 'when', 'where', 'which',
39
+ 'who', 'why', 'does', 'do', 'it', 'its', 'into', 'if', 'then', 'there', 'all', 'any',
40
+ ]);
41
+
42
+ /** Cosine similarity of two equal-length vectors (0 when either is degenerate). */
43
+ export function cosine(a, b) {
44
+ if (!a || !b || a.length !== b.length) return 0;
45
+ let dot = 0, na = 0, nb = 0;
46
+ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
47
+ if (na === 0 || nb === 0) return 0;
48
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
49
+ }
50
+
51
+ /**
52
+ * MaxSim late-interaction score: Σ over query vectors of the max cosine to any document vector.
53
+ * Order-independent (a sum of maxes) → no parity dependence on vector ordering.
54
+ * @param {Float32Array[]} qVecs query sub-vectors
55
+ * @param {Float32Array[]} dVecs document sub-vectors
56
+ * @returns {number} 0 when either side is empty
57
+ */
58
+ export function maxSimScore(qVecs, dVecs) {
59
+ if (!qVecs?.length || !dVecs?.length) return 0;
60
+ let total = 0;
61
+ for (const q of qVecs) {
62
+ let best = -Infinity;
63
+ for (const d of dVecs) { const s = cosine(q, d); if (s > best) best = s; }
64
+ if (best > -Infinity) total += best;
65
+ }
66
+ return total;
67
+ }
68
+
69
+ /** The embedding keys that hold a chunk's stored sub-vectors (base + summary + windows). */
70
+ function chunkVectorKeys(chunk) {
71
+ const vecKey = embeddingKeyFor(chunk);
72
+ const keys = [vecKey, chunk.content_hash, vecKey + SUMMARY_VEC_SUFFIX];
73
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) keys.push(vecKey + WINDOW_VEC_SUFFIX + i);
74
+ return { vecKey, keys };
75
+ }
76
+
77
+ /**
78
+ * Load each chunk's stored sub-vectors from the shared `.embeddings.bin` in a SINGLE pass (one bin
79
+ * read per rerank, not per candidate). Identical for both backends (same file, same keys) →
80
+ * parity-by-construction. @returns {Map<chunkId, Float32Array[]>}
81
+ */
82
+ export function loadChunkVectors(binPath, chunks) {
83
+ const meta = new Map(); // chunkId → { vecKey, hash }
84
+ const allKeys = new Set();
85
+ for (const c of chunks) {
86
+ if (!c || !c.content_hash) { meta.set(c?.id, null); continue; }
87
+ const { vecKey, keys } = chunkVectorKeys(c);
88
+ meta.set(c.id, { vecKey, hash: c.content_hash });
89
+ for (const k of keys) allKeys.add(k);
90
+ }
91
+ const found = collectVectorsByKey(binPath, allKeys);
92
+ const out = new Map();
93
+ for (const [id, m] of meta) {
94
+ if (!m) { out.set(id, []); continue; }
95
+ const vecs = [];
96
+ const base = found.get(m.vecKey) || found.get(m.hash); // deterministic order: base, summary, windows
97
+ if (base) vecs.push(base);
98
+ const s = found.get(m.vecKey + SUMMARY_VEC_SUFFIX); if (s) vecs.push(s);
99
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) { const w = found.get(m.vecKey + WINDOW_VEC_SUFFIX + i); if (w) vecs.push(w); }
100
+ out.set(id, vecs);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Encode a query into MULTIPLE vectors (the whole query plus its content sub-units) with the SAME
107
+ * local embedder the dense channel uses. Best-effort: returns [] on any failure / no embedder.
108
+ * @param {{embedDocuments?:Function, embedQuery?:Function}} embedder
109
+ * @param {string} text
110
+ * @param {object} [opts]
111
+ * @returns {Promise<Float32Array[]>}
112
+ */
113
+ export async function encodeMultiVector(embedder, text, { maxVecs = 8 } = {}) {
114
+ if (!embedder || typeof embedder.embedDocuments !== 'function') return [];
115
+ const words = String(text).toLowerCase().split(/[\s\W_]+/).filter(Boolean);
116
+ const units = [];
117
+ const seen = new Set();
118
+ for (const w of words) {
119
+ if (w.length < 3 || STOP.has(w) || seen.has(w)) continue;
120
+ seen.add(w); units.push(w);
121
+ if (units.length >= maxVecs - 1) break;
122
+ }
123
+ units.unshift(String(text)); // the holistic query vector always leads
124
+ let vecs;
125
+ try { vecs = await embedder.embedDocuments(units); }
126
+ catch { return []; }
127
+ return (vecs || []).filter(v => v && v.length).map(v => v instanceof Float32Array ? v : new Float32Array(v));
128
+ }
129
+
130
+ /**
131
+ * Late-interaction RERANK of the over-fetched pool. Re-orders the top-`topM` by MaxSim of the query
132
+ * multi-vectors against each candidate's stored doc sub-vectors; the tail is preserved. Best-effort
133
+ * and never throws: any shortfall (no embedder, <2 candidates with vectors, empty query vectors)
134
+ * returns the ORIGINAL order, so the channel can only help, never corrupt. Never mutates `r.score`
135
+ * (protects the downstream git-boost / formatting), exactly like rerankCrossEncoder.
136
+ *
137
+ * @param {Array<{chunk:object, score:number}>} results
138
+ * @param {object} opts
139
+ * @param {Float32Array[]} opts.qVecs query multi-vectors (from encodeMultiVector)
140
+ * @param {string} opts.binPath the `.embeddings.bin` path (db.embeddingBinPath())
141
+ * @param {number} [opts.topM]
142
+ * @returns {Array}
143
+ */
144
+ export function rerankLateInteraction(results, { qVecs, binPath, topM = 12 } = {}) {
145
+ const head = results.slice(0, topM);
146
+ if (head.length < 2 || !qVecs?.length || !binPath) return results;
147
+ const vecsById = loadChunkVectors(binPath, head.map(r => r.chunk));
148
+ const scored = head.map((r, i) => {
149
+ const dVecs = vecsById.get(r.chunk.id) || [];
150
+ return { i, id: r.chunk.id, s: dVecs.length ? maxSimScore(qVecs, dVecs) : NaN };
151
+ });
152
+ // "Can only help, never corrupt": treat no-signal as a shortfall. Re-order only when at least two
153
+ // candidates carry a FINITE and DISTINCT MaxSim — so all-zero scores (e.g. a dim mismatch making
154
+ // every cosine 0) or undifferentiated ties fall through to the original order instead of being
155
+ // scrambled into id-lexicographic order by the tie-break.
156
+ const finite = scored.filter(x => Number.isFinite(x.s));
157
+ if (finite.length < 2 || new Set(finite.map(x => x.s)).size < 2) return results;
158
+ const order = scored
159
+ // finite MaxSim first (desc); candidates without vectors sink but keep a stable id order
160
+ .sort((a, b) => {
161
+ const af = Number.isFinite(a.s), bf = Number.isFinite(b.s);
162
+ if (af && bf) return (b.s - a.s) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
163
+ if (af) return -1; if (bf) return 1;
164
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
165
+ })
166
+ .map(x => x.i);
167
+ return [...order.map(i => head[i]), ...results.slice(topM)];
168
+ }