@maxgfr/codeindex 2.8.1 → 2.10.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/src/bm25.ts CHANGED
@@ -8,6 +8,14 @@
8
8
  // the query reuses util keywords, so query and haystack always tokenize alike.
9
9
  // Deterministic: files are scored in scan order (sorted by rel), scores are
10
10
  // fixed to 4 decimal places, and ties break by path.
11
+ //
12
+ // Trigram fuzzy fallback (v2.9.0): a query term that matches NOTHING in the
13
+ // corpus (document frequency == 0 — checked STRICTLY, so any term that
14
+ // already matches anywhere is never touched) is expanded against the corpus
15
+ // vocabulary via character-trigram Dice similarity (threshold 0.6, top-3
16
+ // candidates, deterministic tie-break). This keeps every currently-matching
17
+ // query byte-identical: the expansion only ever engages on terms that would
18
+ // otherwise contribute nothing.
11
19
  import type { RepoScan } from "./scan.js";
12
20
  import { foldText, keywords } from "./util.js";
13
21
  import { byStr } from "./sort.js";
@@ -16,10 +24,17 @@ const K1 = 1.2;
16
24
  const B = 0.75;
17
25
  const DEFAULT_LIMIT = 20;
18
26
  const TOP_SYMBOLS = 5;
27
+ const FUZZY_DICE_THRESHOLD = 0.6;
28
+ const FUZZY_CAP = 3;
19
29
 
20
30
  export interface SearchOptions {
21
31
  // Maximum results returned (default 20).
22
32
  limit?: number;
33
+ // Trigram fuzzy fallback for query terms with zero document frequency
34
+ // (default true). Safe as an always-on default: the df==0 gate means it
35
+ // only ever engages on terms that would otherwise match nothing, so a
36
+ // query where every term already hits is completely unaffected.
37
+ fuzzy?: boolean;
23
38
  }
24
39
 
25
40
  export interface SearchResult {
@@ -27,6 +42,10 @@ export interface SearchResult {
27
42
  score: number; // BM25 score, fixed to 4 decimal places
28
43
  matchedTerms: string[]; // query tokens present in this file's document, sorted
29
44
  topSymbols: string[]; // symbols whose name matches the most query tokens (cap 5)
45
+ // Query terms (df==0) resolved via trigram fuzzy fallback that contributed
46
+ // to this result, sorted. Present only when >=1 term used the fallback —
47
+ // purely additive, never present for an all-exact-match result.
48
+ fuzzyTerms?: string[];
30
49
  }
31
50
 
32
51
  // Split an identifier/phrase into lowercase, diacritic-folded subtokens:
@@ -87,6 +106,39 @@ function buildDocs(scan: RepoScan): Doc[] {
87
106
  return docs;
88
107
  }
89
108
 
109
+ // Character trigrams of a token, padded with two boundary sentinels on each
110
+ // side (pg_trgm-style: "^^t…m$$") so short prefix/suffix runs still produce
111
+ // shared grams. Deduplicated into a Set — a repeated gram doesn't inflate
112
+ // Dice similarity.
113
+ export function charTrigrams(term: string): Set<string> {
114
+ const padded = `^^${term}$$`;
115
+ const grams = new Set<string>();
116
+ for (let i = 0; i + 3 <= padded.length; i++) grams.add(padded.slice(i, i + 3));
117
+ return grams;
118
+ }
119
+
120
+ // Dice coefficient between two trigram sets: 2|A∩B| / (|A|+|B|). 0 when
121
+ // either side is empty (no divide-by-zero).
122
+ export function diceCoefficient(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
123
+ if (!a.size || !b.size) return 0;
124
+ let inter = 0;
125
+ for (const g of a) if (b.has(g)) inter++;
126
+ return (2 * inter) / (a.size + b.size);
127
+ }
128
+
129
+ // Trigram index of the corpus vocabulary: every distinct doc token mapped to
130
+ // its trigram set. Built LAZILY by searchIndex — only when >=1 query term has
131
+ // df==0 — so a fully-matched query never pays this cost.
132
+ function buildTrigramIndex(docs: Doc[]): Map<string, Set<string>> {
133
+ const index = new Map<string, Set<string>>();
134
+ for (const d of docs) {
135
+ for (const term of d.tf.keys()) {
136
+ if (!index.has(term)) index.set(term, charTrigrams(term));
137
+ }
138
+ }
139
+ return index;
140
+ }
141
+
90
142
  // Rank the scanned files against a natural-language (or identifier) query.
91
143
  // Pure and deterministic: same scan + query → the same results, byte-for-byte.
92
144
  export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions = {}): SearchResult[] {
@@ -118,36 +170,93 @@ export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions =
118
170
  df.set(t, count);
119
171
  }
120
172
 
173
+ // Fuzzy fallback: STRICT df==0 gate — a term that matches anywhere, even
174
+ // once, is never expanded. The trigram index of the corpus vocabulary is
175
+ // built lazily, only when at least one term needs it, so a fully-matched
176
+ // query (the common case) pays zero extra cost and stays byte-identical.
177
+ const fuzzyEnabled = opts.fuzzy ?? true;
178
+ const fuzzyCandidates = new Map<string, { term: string; dice: number }[]>();
179
+ if (fuzzyEnabled) {
180
+ const unmatched = terms.filter((t) => df.get(t) === 0);
181
+ if (unmatched.length) {
182
+ const trigramIndex = buildTrigramIndex(docs);
183
+ for (const t of unmatched) {
184
+ const grams = charTrigrams(t);
185
+ const candidates: { term: string; dice: number }[] = [];
186
+ for (const [vocabTerm, vocabGrams] of trigramIndex) {
187
+ const dice = diceCoefficient(grams, vocabGrams);
188
+ if (dice >= FUZZY_DICE_THRESHOLD) candidates.push({ term: vocabTerm, dice });
189
+ }
190
+ // Deterministic: similarity desc, then vocab term asc.
191
+ candidates.sort((a, b) => b.dice - a.dice || byStr(a.term, b.term));
192
+ fuzzyCandidates.set(t, candidates.slice(0, FUZZY_CAP));
193
+ }
194
+ }
195
+ }
196
+ // df cache for expanded vocab terms (distinct from query-term df above).
197
+ const vocabDf = new Map<string, number>();
198
+ const dfOfVocabTerm = (term: string): number => {
199
+ const known = df.get(term) ?? vocabDf.get(term);
200
+ if (known !== undefined) return known;
201
+ let count = 0;
202
+ for (const d of docs) if (d.tf.has(term)) count++;
203
+ vocabDf.set(term, count);
204
+ return count;
205
+ };
206
+
121
207
  const results: SearchResult[] = [];
122
208
  for (const d of docs) {
123
209
  let score = 0;
124
210
  const matched: string[] = [];
211
+ const symbolTerms = new Set<string>(); // matched ∪ fuzzy-expanded vocab terms, for topSymbols ranking
212
+ const fuzzyHit = new Set<string>(); // original query terms resolved via fuzzy fallback, for this doc
125
213
  for (const t of terms) {
126
214
  const tf = d.tf.get(t);
127
- if (!tf) continue;
128
- matched.push(t);
129
- const idf = Math.log(1 + (n - df.get(t)! + 0.5) / (df.get(t)! + 0.5));
130
- score += (idf * (tf * (K1 + 1))) / (tf + K1 * (1 - B + (B * d.len) / avgLen));
215
+ if (tf) {
216
+ matched.push(t);
217
+ symbolTerms.add(t);
218
+ const idf = Math.log(1 + (n - df.get(t)! + 0.5) / (df.get(t)! + 0.5));
219
+ score += (idf * (tf * (K1 + 1))) / (tf + K1 * (1 - B + (B * d.len) / avgLen));
220
+ continue;
221
+ }
222
+ // Only ever reached for a term with df==0 (or absent from THIS doc but
223
+ // matched elsewhere — fuzzyCandidates has no entry for those, so the
224
+ // lookup below is a no-op and behavior is identical to before v2.9.0).
225
+ const candidates = fuzzyCandidates.get(t);
226
+ if (!candidates) continue;
227
+ for (const cand of candidates) {
228
+ const ctf = d.tf.get(cand.term);
229
+ if (!ctf) continue;
230
+ const cdf = dfOfVocabTerm(cand.term);
231
+ const idf = Math.log(1 + (n - cdf + 0.5) / (cdf + 0.5));
232
+ const contribution = (idf * (ctf * (K1 + 1))) / (ctf + K1 * (1 - B + (B * d.len) / avgLen));
233
+ score += contribution * cand.dice; // near-miss always scores below an exact hit (dice < 1)
234
+ symbolTerms.add(cand.term);
235
+ fuzzyHit.add(t);
236
+ }
131
237
  }
132
- if (!matched.length) continue;
238
+ if (!matched.length && !fuzzyHit.size) continue;
133
239
 
134
- // Symbols ranked by how many query tokens their name carries, then by name.
240
+ // Symbols ranked by how many query tokens (exact or fuzzy-expanded) their
241
+ // name carries, then by name.
135
242
  const scored = d.symbols
136
243
  .map((name) => {
137
244
  const toks = new Set(subtokens(name));
138
245
  let hits = 0;
139
- for (const t of matched) if (toks.has(t)) hits++;
246
+ for (const t of symbolTerms) if (toks.has(t)) hits++;
140
247
  return { name, hits };
141
248
  })
142
249
  .filter((s) => s.hits > 0)
143
250
  .sort((a, b) => b.hits - a.hits || byStr(a.name, b.name));
144
251
 
145
- results.push({
252
+ const result: SearchResult = {
146
253
  file: d.file,
147
254
  score: Number(score.toFixed(4)),
148
255
  matchedTerms: matched.sort(byStr),
149
256
  topSymbols: scored.slice(0, TOP_SYMBOLS).map((s) => s.name),
150
- });
257
+ };
258
+ if (fuzzyHit.size) result.fuzzyTerms = [...fuzzyHit].sort(byStr);
259
+ results.push(result);
151
260
  }
152
261
 
153
262
  // Rounded score first (so 4-dp ties resolve stably), then path.
@@ -0,0 +1,133 @@
1
+ import { foldText } from "../util.js";
2
+ import type { StaticEmbedModel } from "./model.js";
3
+
4
+ // The pure-JS deterministic encoder — the reason this whole tier can produce
5
+ // goldens. Every step is chosen to be byte-identical on any platform:
6
+ // 1. tokenize : foldText (NFKD, strip combining marks) + camelCase/ACRONYM
7
+ // split + lowercase + split on non-alphanumeric runs.
8
+ // 2. wordpiece : greedy longest-match against the model vocab, with BERT-style
9
+ // "##" continuation pieces; an unsplittable word → the UNK row
10
+ // (or nothing when the model has no UNK).
11
+ // 3. gather : look up each token's row (double precision).
12
+ // 4. mean-pool : sum rows in FIXED token order, divide by token count. IEEE-754
13
+ // double add/div is deterministic for a fixed evaluation order.
14
+ // 5. L2-norm : divide by sqrt(Σ v²) — sqrt is correctly-rounded per IEEE-754,
15
+ // so identical across engines.
16
+ // 6. quantize : multiply the unit vector by 127, round-half-to-EVEN (NOT
17
+ // Math.round, which is half-up), clamp to [-127, 127] → int8.
18
+ // The fixed 1/127 scale means ranking is a PURE INTEGER dot product (no per-vector
19
+ // float scale to store or multiply): for a fixed query every corpus vector shares
20
+ // the same scale, so the integer dot Σ q·c is monotonic with the cosine estimate.
21
+
22
+ const QUANT = 127;
23
+
24
+ // Split raw text into lowercase, diacritic-folded alphanumeric words. camelCase
25
+ // and ACRONYMWord boundaries become spaces FIRST (so "verifyAuthToken" exposes
26
+ // "verify","auth","token" as separate words to wordpiece), then any
27
+ // non-alphanumeric run splits. Mirrors the boundary rules bm25 `subtokens` uses,
28
+ // keeping the two search tiers tokenizing code identifiers alike.
29
+ export function basicTokenize(text: string): string[] {
30
+ const spaced = foldText(text)
31
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
32
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
33
+ const out: string[] = [];
34
+ for (const part of spaced.toLowerCase().split(/[^a-z0-9]+/)) {
35
+ if (part) out.push(part);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Greedy longest-match wordpiece for ONE word. The first piece is looked up as
41
+ // written; every subsequent piece is looked up with a "##" prefix. If no piece
42
+ // matches at some position the whole word is unresolvable → [unkId] (or [] when
43
+ // the model has no UNK row). Deterministic: longest-match is a fixed scan.
44
+ export function wordpiece(word: string, model: StaticEmbedModel): number[] {
45
+ if (!word) return [];
46
+ const ids: number[] = [];
47
+ let start = 0;
48
+ const n = word.length;
49
+ while (start < n) {
50
+ let end = n;
51
+ let match = -1;
52
+ while (end > start) {
53
+ const piece = start === 0 ? word.slice(start, end) : "##" + word.slice(start, end);
54
+ const id = model.vocab.get(piece);
55
+ if (id !== undefined) {
56
+ match = id;
57
+ break;
58
+ }
59
+ end--;
60
+ }
61
+ if (match === -1) return model.unkId >= 0 ? [model.unkId] : []; // whole word is OOV
62
+ ids.push(match);
63
+ start = end;
64
+ }
65
+ return ids;
66
+ }
67
+
68
+ // Full token-id sequence for a text: tokenize → wordpiece each word, concatenated
69
+ // in order. The order is load-bearing for determinism, but mean-pooling is
70
+ // order-independent anyway; the fixed order simply makes reasoning trivial.
71
+ export function tokenize(text: string, model: StaticEmbedModel): number[] {
72
+ const ids: number[] = [];
73
+ for (const word of basicTokenize(text)) {
74
+ for (const id of wordpiece(word, model)) ids.push(id);
75
+ }
76
+ return ids;
77
+ }
78
+
79
+ // Round half to EVEN (banker's rounding). JS `Math.round` rounds half toward
80
+ // +∞, which is not symmetric and would bias quantization; half-to-even is the
81
+ // IEEE-754 default and keeps quantize(x) === -quantize(-x).
82
+ export function roundHalfToEven(x: number): number {
83
+ const f = Math.floor(x);
84
+ const diff = x - f;
85
+ if (diff < 0.5) return f;
86
+ if (diff > 0.5) return f + 1;
87
+ return f % 2 === 0 ? f : f + 1; // exactly .5 → nearest even
88
+ }
89
+
90
+ // Encode a text to an int8 unit-ish vector. Empty / all-OOV / zero-norm inputs
91
+ // yield an all-zero vector (dot-products against it are 0 — it simply ranks
92
+ // last), never NaN. Pure and deterministic: same model + text → same bytes.
93
+ export function encode(model: StaticEmbedModel, text: string): Int8Array {
94
+ const { dim, weights } = model;
95
+ const out = new Int8Array(dim);
96
+ const ids = tokenize(text, model);
97
+ if (ids.length === 0) return out;
98
+
99
+ // mean-pool in double precision, fixed order.
100
+ const pooled = new Float64Array(dim);
101
+ for (const id of ids) {
102
+ const base = id * dim;
103
+ for (let d = 0; d < dim; d++) pooled[d]! += weights[base + d]!;
104
+ }
105
+ const inv = 1 / ids.length;
106
+ for (let d = 0; d < dim; d++) pooled[d]! *= inv;
107
+
108
+ // L2-normalize.
109
+ let sumsq = 0;
110
+ for (let d = 0; d < dim; d++) sumsq += pooled[d]! * pooled[d]!;
111
+ const norm = Math.sqrt(sumsq);
112
+ if (norm === 0) return out; // zero vector — nothing to rank on
113
+
114
+ // quantize to int8 at the fixed 1/127 scale, round-half-to-even, clamp.
115
+ for (let d = 0; d < dim; d++) {
116
+ let q = roundHalfToEven((pooled[d]! / norm) * QUANT);
117
+ if (q > QUANT) q = QUANT;
118
+ else if (q < -QUANT) q = -QUANT;
119
+ out[d] = q;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // Integer dot product of two int8 vectors (the ranking primitive). Widened to a
125
+ // JS number (double), but every operand and partial sum is an exact integer
126
+ // (max |Σ| for dim 512 ≈ 512·127² ≈ 8.3M, far inside 2⁵³) — so this is an EXACT
127
+ // integer computation, deterministic everywhere.
128
+ export function intDot(a: Int8Array, b: Int8Array): number {
129
+ const n = Math.min(a.length, b.length);
130
+ let dot = 0;
131
+ for (let i = 0; i < n; i++) dot += a[i]! * b[i]!;
132
+ return dot;
133
+ }
@@ -0,0 +1,55 @@
1
+ // HTTP embedding endpoint client — the "rich" tier SKELETON for v2.11.0. It is
2
+ // intentionally NOT wired into the CLI or MCP in v2.10.0: the deterministic
3
+ // static tier is the default, and an endpoint's vectors are float and provider-
4
+ // dependent (NOT byte-deterministic), so this stays an explicit, opt-in escape
5
+ // hatch a future release finalizes. Shipped now only so the module surface is
6
+ // stable and the contract is documented.
7
+ //
8
+ // The contract (v2.11): POST { texts: string[] } → { vectors: number[][] } to
9
+ // CODEINDEX_EMBED_ENDPOINT (or opts.url). Node >=18 has global fetch, so this
10
+ // stays zero-dependency. The library never orchestrates docker; running such a
11
+ // server (e.g. ghcr.io/maxgfr/codeindex-embed) is a user/CI concern.
12
+
13
+ export interface EmbedEndpointOptions {
14
+ // Endpoint URL. Falls back to CODEINDEX_EMBED_ENDPOINT when omitted.
15
+ url?: string;
16
+ // Abort the request after this many ms (default 30_000).
17
+ timeoutMs?: number;
18
+ // Extra request headers (e.g. an auth token for a private endpoint).
19
+ headers?: Record<string, string>;
20
+ }
21
+
22
+ // Resolve the configured endpoint URL, or undefined when neither opts.url nor
23
+ // CODEINDEX_EMBED_ENDPOINT is set — the silent "endpoint tier unavailable" signal.
24
+ export function resolveEmbedEndpoint(opts: EmbedEndpointOptions = {}): string | undefined {
25
+ const url = opts.url ?? process.env.CODEINDEX_EMBED_ENDPOINT;
26
+ return url && url.trim() ? url.trim() : undefined;
27
+ }
28
+
29
+ // Request float embeddings for a batch of texts from the endpoint. Throws a
30
+ // clear error when no endpoint is configured or the response is malformed; the
31
+ // caller decides whether to degrade. NOTE: endpoint vectors are float and are
32
+ // NOT part of any determinism guarantee — this path is for the rich tier only.
33
+ export async function embedViaEndpoint(texts: string[], opts: EmbedEndpointOptions = {}): Promise<number[][]> {
34
+ const url = resolveEmbedEndpoint(opts);
35
+ if (!url) throw new Error("no embedding endpoint configured (set CODEINDEX_EMBED_ENDPOINT or pass opts.url)");
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000);
38
+ try {
39
+ const res = await fetch(url, {
40
+ method: "POST",
41
+ headers: { "content-type": "application/json", ...(opts.headers ?? {}) },
42
+ body: JSON.stringify({ texts }),
43
+ signal: controller.signal,
44
+ });
45
+ if (!res.ok) throw new Error(`embedding endpoint ${url} returned HTTP ${res.status}`);
46
+ const data = (await res.json()) as { vectors?: unknown };
47
+ const vectors = data.vectors;
48
+ if (!Array.isArray(vectors) || !vectors.every((v) => Array.isArray(v) && v.every((x) => typeof x === "number"))) {
49
+ throw new Error(`embedding endpoint ${url} returned a malformed { vectors } payload`);
50
+ }
51
+ return vectors as number[][];
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
@@ -0,0 +1,125 @@
1
+ import type { RepoScan } from "../scan.js";
2
+ import { encode } from "./encode.js";
3
+ import { EMBED_VERSION, type StaticEmbedModel } from "./model.js";
4
+
5
+ // The corpus embedding artifact. Granularity is PER-SYMBOL (each symbol gets its
6
+ // own record, so search can surface the exact symbol that matched), with a
7
+ // PER-FILE fallback record for files that declare no symbols (docs, config) so
8
+ // every file with content is still represented. Deterministic: records follow
9
+ // scan order (files sorted by rel) then declaration order within a file.
10
+ export interface EmbeddingRecord {
11
+ file: string; // repo-relative path
12
+ symbol?: string; // the declared symbol this vector represents (absent for a file-level record)
13
+ line?: number; // 1-based symbol line (absent for a file-level record)
14
+ vec: Int8Array; // length === dim
15
+ }
16
+
17
+ export interface EmbeddingIndex {
18
+ embedVersion: number;
19
+ modelId: string;
20
+ dim: number;
21
+ records: EmbeddingRecord[];
22
+ }
23
+
24
+ // The text encoded for one symbol: its name (camelCase-split by the tokenizer),
25
+ // its signature, the owning file's one-line summary, and the file's path segments
26
+ // — the same signal bm25 indexes, but pooled into a single vector. Newline-joined
27
+ // so the tokenizer's non-alphanumeric split cleanly separates the parts.
28
+ function symbolText(rel: string, name: string, signature: string | undefined, summary: string | undefined): string {
29
+ return [name, signature ?? "", summary ?? "", rel.replace(/\//g, " ")].join("\n");
30
+ }
31
+
32
+ // A file-level record's text (symbol-less files): title, summary, headings, path.
33
+ function fileText(rel: string, title: string | undefined, summary: string | undefined, headings: string[]): string {
34
+ return [title ?? "", summary ?? "", ...headings, rel.replace(/\//g, " ")].join("\n");
35
+ }
36
+
37
+ // Build the corpus embedding index from a scan + a loaded model. Pure and
38
+ // deterministic (encode is byte-stable, scan order is fixed) → two builds of an
39
+ // unchanged repo produce byte-identical serialized bytes.
40
+ export function buildEmbeddingIndex(scan: RepoScan, model: StaticEmbedModel): EmbeddingIndex {
41
+ const records: EmbeddingRecord[] = [];
42
+ for (const f of scan.files) {
43
+ const seen = new Set<string>();
44
+ let hadSymbol = false;
45
+ for (const s of f.symbols) {
46
+ if (seen.has(s.name)) continue; // dedupe by name within a file (bm25 parity)
47
+ seen.add(s.name);
48
+ hadSymbol = true;
49
+ records.push({
50
+ file: f.rel,
51
+ symbol: s.name,
52
+ line: s.line,
53
+ vec: encode(model, symbolText(f.rel, s.name, s.signature, f.summary)),
54
+ });
55
+ }
56
+ if (!hadSymbol) {
57
+ const text = fileText(f.rel, f.title, f.summary, f.headings);
58
+ if (text.replace(/\s+/g, "")) {
59
+ records.push({ file: f.rel, vec: encode(model, text) });
60
+ }
61
+ }
62
+ }
63
+ return { embedVersion: EMBED_VERSION, modelId: model.modelId, dim: model.dim, records };
64
+ }
65
+
66
+ const MAGIC = "CIE1"; // codeindex embeddings, format 1
67
+
68
+ // Serialize to embeddings.bin: a fixed ASCII magic, a uint32-LE header length, a
69
+ // UTF-8 JSON header (per-record metadata in build order — carries NO absolute
70
+ // path or timestamp), then the packed int8 body (count × dim signed bytes). The
71
+ // header JSON key order is fixed by construction, and every body byte is written
72
+ // explicitly, so the bytes are fully deterministic. Returns a Uint8Array (a
73
+ // Buffer at runtime) so the public type surface stays free of the Node `Buffer`
74
+ // global — a consumer type-checking without @types/node still resolves it.
75
+ export function serializeEmbeddings(index: EmbeddingIndex): Uint8Array {
76
+ const header = JSON.stringify({
77
+ embedVersion: index.embedVersion,
78
+ modelId: index.modelId,
79
+ dim: index.dim,
80
+ count: index.records.length,
81
+ records: index.records.map((r) => ({ file: r.file, symbol: r.symbol ?? "", line: r.line ?? 0 })),
82
+ });
83
+ const headerBuf = Buffer.from(header, "utf8");
84
+ const body = Buffer.alloc(index.records.length * index.dim);
85
+ let off = 0;
86
+ for (const r of index.records) {
87
+ for (let d = 0; d < index.dim; d++) body.writeInt8(r.vec[d] ?? 0, off++);
88
+ }
89
+ const out = Buffer.alloc(8 + headerBuf.length + body.length);
90
+ out.write(MAGIC, 0, "ascii");
91
+ out.writeUInt32LE(headerBuf.length, 4);
92
+ headerBuf.copy(out, 8);
93
+ body.copy(out, 8 + headerBuf.length);
94
+ return out;
95
+ }
96
+
97
+ // Inverse of serializeEmbeddings. Accepts any Uint8Array (e.g. a fs.readFileSync
98
+ // Buffer) and wraps it as a Buffer VIEW (no copy) for the numeric reads. Throws
99
+ // on a bad magic (a corrupt or foreign file) so a caller fails loudly rather
100
+ // than misreading arbitrary bytes.
101
+ export function deserializeEmbeddings(bytes: Uint8Array): EmbeddingIndex {
102
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
103
+ if (buf.length < 8 || buf.toString("ascii", 0, 4) !== MAGIC) {
104
+ throw new Error("embeddings.bin: bad magic (not a codeindex embeddings artifact)");
105
+ }
106
+ const headerLen = buf.readUInt32LE(4);
107
+ const header = JSON.parse(buf.toString("utf8", 8, 8 + headerLen)) as {
108
+ embedVersion: number;
109
+ modelId: string;
110
+ dim: number;
111
+ count: number;
112
+ records: { file: string; symbol: string; line: number }[];
113
+ };
114
+ const bodyOff = 8 + headerLen;
115
+ const { dim } = header;
116
+ const records: EmbeddingRecord[] = header.records.map((m, i) => {
117
+ const vec = new Int8Array(dim);
118
+ for (let d = 0; d < dim; d++) vec[d] = buf.readInt8(bodyOff + i * dim + d);
119
+ const rec: EmbeddingRecord = { file: m.file, vec };
120
+ if (m.symbol) rec.symbol = m.symbol;
121
+ if (m.line) rec.line = m.line;
122
+ return rec;
123
+ });
124
+ return { embedVersion: header.embedVersion, modelId: header.modelId, dim, records };
125
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ // The deterministic static-embedding tier (v2.10.0). A *static* embedding model
5
+ // is a plain lookup table: token → row vector. Inference is tokenize → gather
6
+ // rows → mean-pool → L2-normalize → int8-quantize (see encode.ts). There is NO
7
+ // neural forward pass, no wasm, no float matmul — so the encode is pure-JS and
8
+ // byte-identical across platforms, which is the whole reason this tier exists.
9
+ //
10
+ // Activation is OPT-IN by asset presence, exactly like the tree-sitter grammar
11
+ // tier (src/ast/loader.ts): no model on disk → the engine silently stays
12
+ // lexical. Models are NEVER shipped in the npm tarball; the asset is fetched by
13
+ // `codeindex embed pull` into CODEINDEX_EMBED_DIR (or <repo>/.codeindex/models/).
14
+
15
+ // Bumped independently of SCHEMA_VERSION / EXTRACTOR_VERSION whenever the
16
+ // embeddings.bin artifact shape or the encode algorithm changes, so a consumer
17
+ // rejects an embeddings.bin written by an incompatible engine (or a different
18
+ // model) instead of misranking with it. SCHEMA_VERSION is deliberately left
19
+ // UNTOUCHED — embeddings are a purely additive sidecar with zero impact on the
20
+ // graph.json / symbols.json consumers.
21
+ export const EMBED_VERSION = 1;
22
+
23
+ // The default model-name a `pull` writes and a `status` reports. The real asset
24
+ // URL is intentionally NOT hard-coded yet (see resolveEmbedPullUrl): the asset
25
+ // is unpublished, so `pull` fails cleanly asking for CODEINDEX_EMBED_URL rather
26
+ // than fetching a phantom.
27
+ export const DEFAULT_EMBED_DIRNAME = "models";
28
+
29
+ // A loaded static-embedding model. `vocab` maps a wordpiece token to its row
30
+ // index; `weights` is the flat row-major (vocabSize × dim) matrix as IEEE-754
31
+ // doubles (float rows widen to double exactly, so mean-pool stays deterministic).
32
+ export interface StaticEmbedModel {
33
+ modelId: string;
34
+ dim: number;
35
+ unk: string; // the OOV token (e.g. "[UNK]"); may be absent from vocab
36
+ unkId: number; // row index of `unk`, or -1 when the model has no UNK row
37
+ vocabSize: number;
38
+ vocab: Map<string, number>;
39
+ weights: Float64Array; // length === vocabSize * dim, row-major
40
+ }
41
+
42
+ // The on-disk model.json header shape. `weights` is an array of `vocabSize`
43
+ // rows, each of length `dim`. Kept human-authorable (plain JSON numbers) so a
44
+ // tiny fixture model is committable and diffable; a real `pull`ed model uses the
45
+ // identical shape at scale.
46
+ interface ModelFile {
47
+ modelId: string;
48
+ dim: number;
49
+ unk?: string;
50
+ vocab: string[];
51
+ weights: number[][];
52
+ }
53
+
54
+ // Where the model asset lives. CODEINDEX_EMBED_DIR wins outright (explicit
55
+ // override, mirrors CODEINDEX_GRAMMAR_DIR); otherwise <repo>/.codeindex/models/
56
+ // then <cwd>/.codeindex/models/. Returns undefined when no model.json is found
57
+ // anywhere — the silent-degradation signal. When CODEINDEX_EMBED_DIR is set but
58
+ // empty of a model, that is still "no model" (undefined), never an error.
59
+ export function resolveEmbedModelDir(repo?: string): string | undefined {
60
+ const env = process.env.CODEINDEX_EMBED_DIR;
61
+ const candidates: string[] = [];
62
+ if (env) candidates.push(env);
63
+ if (repo) candidates.push(join(repo, ".codeindex", DEFAULT_EMBED_DIRNAME));
64
+ candidates.push(join(process.cwd(), ".codeindex", DEFAULT_EMBED_DIRNAME));
65
+ for (const c of candidates) {
66
+ if (existsSync(join(c, "model.json"))) return c;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ // True when an activatable model is present (used by `embed status` and the MCP
72
+ // gate before attempting a semantic search).
73
+ export function hasEmbedModel(repo?: string): boolean {
74
+ return resolveEmbedModelDir(repo) !== undefined;
75
+ }
76
+
77
+ // Load and validate a static model from a directory containing model.json.
78
+ // Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
79
+ // loudly at load rather than silently misranking. Returns undefined only when
80
+ // the directory has no model.json (the not-present case).
81
+ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
82
+ if (!dir) return undefined;
83
+ const path = join(dir, "model.json");
84
+ if (!existsSync(path)) return undefined;
85
+ const raw = JSON.parse(readFileSync(path, "utf8")) as ModelFile;
86
+ const { modelId, dim, vocab, weights } = raw;
87
+ if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
88
+ if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${path}`);
89
+ if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
90
+ throw new Error(`embed model: vocab/weights length mismatch in ${path}`);
91
+ }
92
+ const vocabSize = vocab.length;
93
+ const flat = new Float64Array(vocabSize * dim);
94
+ const vmap = new Map<string, number>();
95
+ for (let i = 0; i < vocabSize; i++) {
96
+ const tok = vocab[i]!;
97
+ if (typeof tok !== "string") throw new Error(`embed model: non-string vocab entry at ${i}`);
98
+ if (!vmap.has(tok)) vmap.set(tok, i); // first occurrence wins (deterministic)
99
+ const row = weights[i]!;
100
+ if (!Array.isArray(row) || row.length !== dim) {
101
+ throw new Error(`embed model: row ${i} has length ${row?.length}, expected ${dim}`);
102
+ }
103
+ for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
104
+ }
105
+ const unk = typeof raw.unk === "string" ? raw.unk : "[UNK]";
106
+ const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
107
+ return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
108
+ }
109
+
110
+ // Resolve the URL `embed pull` fetches from. The official asset is not published
111
+ // yet, so there is no built-in default: the user must point CODEINDEX_EMBED_URL
112
+ // at a model.json (or a directory serving one). Returns undefined when unset —
113
+ // the caller turns that into a clean, actionable failure, never a crash.
114
+ export function resolveEmbedPullUrl(): string | undefined {
115
+ const url = process.env.CODEINDEX_EMBED_URL;
116
+ return url && url.trim() ? url.trim() : undefined;
117
+ }