sweet-search 2.5.13 → 2.6.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 (52) hide show
  1. package/README.md +36 -9
  2. package/core/cli.js +41 -3
  3. package/core/embedding/embedding-local-model.js +106 -10
  4. package/core/embedding/embedding-service.js +59 -1
  5. package/core/embedding/model-client.mjs +257 -0
  6. package/core/embedding/model-server.mjs +217 -0
  7. package/core/incremental-indexing/application/maintenance-handlers.mjs +19 -98
  8. package/core/incremental-indexing/application/maintenance-worker.mjs +46 -9
  9. package/core/incremental-indexing/application/operator-cli.mjs +14 -5
  10. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +40 -0
  11. package/core/incremental-indexing/application/production-reconciler.mjs +718 -54
  12. package/core/incremental-indexing/application/reconciler.mjs +87 -15
  13. package/core/incremental-indexing/domain/cutoff-cache.mjs +191 -0
  14. package/core/incremental-indexing/domain/interval-autotune.mjs +84 -1
  15. package/core/incremental-indexing/domain/reconcile-counters.mjs +0 -4
  16. package/core/incremental-indexing/domain/watermark-scheduler.mjs +0 -24
  17. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +2 -26
  18. package/core/incremental-indexing/infrastructure/manifest.mjs +1 -9
  19. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +72 -0
  20. package/core/indexing/artifact-builder.js +1 -1
  21. package/core/indexing/dedup/dedup-phase.js +36 -17
  22. package/core/indexing/dedup/exemplar-selector.js +5 -0
  23. package/core/indexing/index-codebase-v21.js +37 -14
  24. package/core/indexing/index-maintainer.mjs +337 -6
  25. package/core/indexing/indexer-ann.js +27 -434
  26. package/core/indexing/indexer-build.js +30 -14
  27. package/core/indexing/indexer-manifest.js +0 -3
  28. package/core/indexing/indexer-phases.js +101 -25
  29. package/core/indexing/maintainer-launcher.mjs +22 -0
  30. package/core/indexing/maintainer-watcher.mjs +397 -0
  31. package/core/indexing/os-priority.mjs +160 -0
  32. package/core/indexing/rss-budget.mjs +425 -0
  33. package/core/indexing/streaming-vectors.js +450 -0
  34. package/core/infrastructure/config/platform.js +14 -10
  35. package/core/infrastructure/onnx-session-utils.js +37 -0
  36. package/core/infrastructure/sparse-gram-delta-reader.js +11 -1
  37. package/core/ranking/late-interaction-index.js +58 -7
  38. package/core/search/daemon-registry.js +199 -0
  39. package/core/search/search-read-semantic.js +9 -3
  40. package/core/search/search-semantic.js +6 -29
  41. package/core/search/search-server.js +527 -27
  42. package/core/search/session-daemon-prewarm.mjs +110 -1
  43. package/core/search/sweet-search.js +0 -38
  44. package/core/vector-store/binary-hnsw-index.js +692 -78
  45. package/core/vector-store/index.js +1 -4
  46. package/eval/agent-read-workflows/bin/_ss-argparse.mjs +51 -5
  47. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +95 -44
  48. package/eval/agent-read-workflows/bin/ss-read +2 -0
  49. package/mcp/tool-handlers.js +1 -2
  50. package/package.json +11 -8
  51. package/scripts/uninstall.js +2 -0
  52. package/core/vector-store/hnsw-index.js +0 -751
@@ -27,7 +27,10 @@
27
27
  */
28
28
 
29
29
  import fs from 'fs/promises';
30
- import { existsSync, statSync } from 'fs';
30
+ import {
31
+ existsSync, statSync,
32
+ openSync, readSync, closeSync, fstatSync,
33
+ } from 'fs';
31
34
  import path from 'path';
32
35
  import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
33
36
  import {
@@ -44,6 +47,161 @@ import { loadBitmap, isSet } from '../infrastructure/tombstone-bitmap-reader.js'
44
47
  // different version are incompatible and must be rebuilt.
45
48
  const PIPELINE_VERSION = 2;
46
49
 
50
+ // =============================================================================
51
+ // G9 — PACKED MMAP FORMAT (gated, NO MIGRATION)
52
+ // =============================================================================
53
+ //
54
+ // The default on-disk format is the JSON sidecar tuple
55
+ // (.meta.json / .vectors.json / .graph.json / .int8.json / .calibration.json),
56
+ // written by save() and read by load(). That format is preserved BYTE-FOR-BYTE
57
+ // when SWEET_SEARCH_HNSW_MMAP !== '1' — this whole subsystem is inert by
58
+ // default. Existing on-disk indexes (the 200 bench repos) keep loading via the
59
+ // JSON path regardless of the flag: format is detected by inspecting the .idx
60
+ // file's magic header (`_indexFormatOnDisk`), never by the flag.
61
+ //
62
+ // When SWEET_SEARCH_HNSW_MMAP === '1', NEWLY written indexes are packed into a
63
+ // single flat-binary `.idx` file (magic-prefixed) whose vectors / graph
64
+ // adjacency / int8 sections are laid out so the read/search path can fault in
65
+ // only the pages it touches (lazy fd reads → OS page cache). The packed format
66
+ // preserves the deterministic levels (G1) and deterministic entry point (FIX-B)
67
+ // because those are properties of the in-memory graph/entryPoint that get
68
+ // serialized verbatim — packing does not re-run construction.
69
+ //
70
+ // MMAP_MAGIC is 8 ASCII bytes so a packed file is trivially and cheaply
71
+ // distinguishable from a JSON sidecar's `.idx` (which today is never written
72
+ // with content — only the sidecars carry data) without parsing.
73
+ const MMAP_MAGIC = Buffer.from('SSHNSWP\0', 'ascii'); // 8 bytes
74
+ const MMAP_FORMAT_VERSION = 1;
75
+
76
+ function hnswMmapEnabled() {
77
+ return process.env.SWEET_SEARCH_HNSW_MMAP === '1';
78
+ }
79
+
80
+ /**
81
+ * Lazy, fd-backed graph adjacency for the packed (mmap) read path.
82
+ *
83
+ * Behaves like `graph[level][nodeIndex] -> number[]` (the JSON-loaded shape)
84
+ * but reads each (level, node) neighbor list from the open file descriptor on
85
+ * first touch and caches it, so a cold search faults in only the pages it
86
+ * visits. `?.[idx]` / `.length` / iteration over a returned neighbor list all
87
+ * work identically to a plain array because each level proxies an array-like
88
+ * via numeric-index getters that materialize lazily.
89
+ *
90
+ * The on-disk graph section layout (all little-endian uint32):
91
+ * [levelCount]
92
+ * per level: [nodeCount] then nodeCount × [neighborCount, n0, n1, ...]
93
+ * Per-(level,node) byte offsets are precomputed once into a flat index at load
94
+ * so a touch is an O(1) seek + a single readSync of just that list.
95
+ */
96
+ class MmapGraphLevel {
97
+ constructor(fd, sectionBase, nodeOffsets, nodeCount) {
98
+ this._fd = fd;
99
+ this._base = sectionBase;
100
+ this._nodeOffsets = nodeOffsets; // Int32Array of byte offsets (relative) per node, -1 if empty
101
+ this._cache = new Array(nodeCount);
102
+ this.length = nodeCount;
103
+ }
104
+
105
+ _materialize(i) {
106
+ if (i < 0 || i >= this.length) return undefined;
107
+ const cached = this._cache[i];
108
+ if (cached !== undefined) return cached;
109
+ const rel = this._nodeOffsets[i];
110
+ if (rel < 0) {
111
+ this._cache[i] = [];
112
+ return this._cache[i];
113
+ }
114
+ const head = Buffer.allocUnsafe(4);
115
+ readSync(this._fd, head, 0, 4, this._base + rel);
116
+ const count = head.readUInt32LE(0);
117
+ const list = new Array(count);
118
+ if (count > 0) {
119
+ const body = Buffer.allocUnsafe(count * 4);
120
+ readSync(this._fd, body, 0, count * 4, this._base + rel + 4);
121
+ for (let j = 0; j < count; j += 1) list[j] = body.readUInt32LE(j * 4);
122
+ }
123
+ this._cache[i] = list;
124
+ return list;
125
+ }
126
+ }
127
+
128
+ // Numeric-index access (graph[level][idx]) is the hot path; back each level
129
+ // with a Proxy that maps integer keys to lazy materialization and forwards
130
+ // `length`. Optional-chaining (`graph[level]?.[idx]`) and `.length` both work.
131
+ function makeMmapGraphLevelProxy(level) {
132
+ return new Proxy(level, {
133
+ get(target, prop) {
134
+ if (prop === 'length') return target.length;
135
+ if (typeof prop === 'string') {
136
+ const n = Number(prop);
137
+ if (Number.isInteger(n) && n >= 0) return target._materialize(n);
138
+ }
139
+ return target[prop];
140
+ },
141
+ has(target, prop) {
142
+ if (prop === 'length') return true;
143
+ const n = Number(prop);
144
+ if (Number.isInteger(n) && n >= 0 && n < target.length) return true;
145
+ return prop in target;
146
+ },
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Lazy, fd-backed vectors store for the packed read path.
152
+ *
153
+ * Behaves like `vectors[idx] -> { id, binary, metadata }`. `id` and `metadata`
154
+ * are kept fully resident (they live in the small header JSON, parsed once),
155
+ * but the `binary` Uint8Array is read from the flat vector blob on first touch
156
+ * so cold ticks hold near-zero binary bytes in heap. Each materialized record
157
+ * is cached so repeated touches in a search don't re-read.
158
+ */
159
+ class MmapVectorStore {
160
+ constructor(fd, blobBase, dimension, ids, metas) {
161
+ this._fd = fd;
162
+ this._base = blobBase;
163
+ this._dim = dimension;
164
+ this._ids = ids; // string[]
165
+ this._metas = metas; // object[]
166
+ this._cache = new Array(ids.length);
167
+ this.length = ids.length;
168
+ }
169
+
170
+ _materialize(i) {
171
+ if (i < 0 || i >= this.length) return undefined;
172
+ const cached = this._cache[i];
173
+ if (cached !== undefined) return cached;
174
+ const buf = Buffer.allocUnsafe(this._dim);
175
+ readSync(this._fd, buf, 0, this._dim, this._base + i * this._dim);
176
+ // Copy into a standalone Uint8Array so callers (hammingDistance) see a
177
+ // tight, stable view independent of the scratch Buffer.
178
+ const binary = new Uint8Array(this._dim);
179
+ binary.set(buf);
180
+ const rec = { id: this._ids[i], binary, metadata: this._metas[i] };
181
+ this._cache[i] = rec;
182
+ return rec;
183
+ }
184
+ }
185
+
186
+ function makeMmapVectorProxy(store) {
187
+ return new Proxy(store, {
188
+ get(target, prop) {
189
+ if (prop === 'length') return target.length;
190
+ if (typeof prop === 'string') {
191
+ const n = Number(prop);
192
+ if (Number.isInteger(n) && n >= 0) return target._materialize(n);
193
+ }
194
+ return target[prop];
195
+ },
196
+ has(target, prop) {
197
+ if (prop === 'length') return true;
198
+ const n = Number(prop);
199
+ if (Number.isInteger(n) && n >= 0 && n < target.length) return true;
200
+ return prop in target;
201
+ },
202
+ });
203
+ }
204
+
47
205
  // =============================================================================
48
206
  // BINARY HNSW INDEX CLASS
49
207
  // =============================================================================
@@ -85,10 +243,17 @@ export class BinaryHNSWIndex {
85
243
  this.useAsymmetric = false; // Enabled after calibration
86
244
  this._staleBitmapCache = null;
87
245
  this._cleanBuild = false;
246
+
247
+ // G9 mmap: set when this instance is backed by an open, packed .idx fd
248
+ // (loadMmap). `vectors`/`graph` are then lazy proxies and the index is
249
+ // read/search-only. Null/false on the default JSON path.
250
+ this._mmapBacked = false;
251
+ this._mmapFd = null;
88
252
  }
89
253
 
90
254
  /** Reset to empty state for a fresh build (skips loading from disk). */
91
255
  resetForBuild() {
256
+ this._closeMmap();
92
257
  this.vectors = [];
93
258
  this.idToIndex = new Map();
94
259
  this.int8Vectors.clear();
@@ -103,6 +268,15 @@ export class BinaryHNSWIndex {
103
268
  this._cleanBuild = true;
104
269
  }
105
270
 
271
+ /** Close an open packed-file descriptor if this index is mmap-backed. */
272
+ _closeMmap() {
273
+ if (this._mmapFd != null) {
274
+ try { closeSync(this._mmapFd); } catch { /* best-effort */ }
275
+ }
276
+ this._mmapFd = null;
277
+ this._mmapBacked = false;
278
+ }
279
+
106
280
  _stalePathForIndex(indexPath = this.indexPath) {
107
281
  return indexPath === this.indexPath ? this.stalePath : `${indexPath}.stale.bin`;
108
282
  }
@@ -113,9 +287,22 @@ export class BinaryHNSWIndex {
113
287
  async init() {
114
288
  if (this.initialized) return;
115
289
 
116
- // Try to load existing index
290
+ // Try to load an existing index. Detect the on-disk format by inspecting
291
+ // the artifacts (NOT the flag): a JSON sidecar tuple is identified by its
292
+ // .meta.json descriptor; a packed (mmap) index is identified by the magic
293
+ // header on the .idx file itself. Old indexes always take the JSON path
294
+ // regardless of SWEET_SEARCH_HNSW_MMAP (no migration, ever).
117
295
  const metaPath = this.indexPath.replace('.idx', '.meta.json');
118
- if (existsSync(metaPath)) {
296
+ const onDisk = _indexFormatOnDisk(this.indexPath);
297
+ if (onDisk === 'packed') {
298
+ try {
299
+ await this.load(); // load() routes to loadMmap() for packed files
300
+ this.initialized = true;
301
+ return;
302
+ } catch (err) {
303
+ console.log(`BinaryHNSW: Failed to load packed index, creating new: ${err.message}`);
304
+ }
305
+ } else if (existsSync(metaPath)) {
119
306
  try {
120
307
  await this.load();
121
308
  this.initialized = true;
@@ -136,7 +323,15 @@ export class BinaryHNSWIndex {
136
323
  }
137
324
 
138
325
  /**
139
- * Calculate random level for new node (exponential distribution)
326
+ * Calculate random level for new node (exponential distribution).
327
+ *
328
+ * Draws from the global, unseeded Math.random() at insert time, so the
329
+ * level a node receives depends on insertion order / RNG-stream
330
+ * interleaving (per-file reload-and-insert vs. batched load-once-insert-all
331
+ * vs. compaction rebuild all consume the stream differently). This is the
332
+ * default path and is intentionally left unchanged. For an
333
+ * insertion-order-independent level, see `levelForId(id)` (gated on
334
+ * SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS).
140
335
  */
141
336
  getRandomLevel() {
142
337
  const mL = 1 / Math.log(this.M);
@@ -144,6 +339,40 @@ export class BinaryHNSWIndex {
144
339
  return Math.min(level, 10); // Cap at 10 levels
145
340
  }
146
341
 
342
+ /**
343
+ * Deterministic level for a node id (insertion-order-independent).
344
+ *
345
+ * Pure function of the string id (FNV-1a 32-bit hash → uniform u∈(0,1])
346
+ * fed through the SAME exponential CDF as `getRandomLevel()`
347
+ * (`floor(-ln(u) * (1/ln(M)))`, capped at 10). Because the level depends
348
+ * only on the id (not on Math.random() or insertion order), batched,
349
+ * per-file, and compaction construction paths all assign the same node the
350
+ * same level — the prerequisite for a byte-identical graph.
351
+ *
352
+ * Used only when SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS === '1'; the default
353
+ * path (`getRandomLevel`) is unchanged.
354
+ *
355
+ * @param {string} id - node id (coerced to string)
356
+ * @returns {number} level in [0, 10]
357
+ */
358
+ levelForId(id) {
359
+ // FNV-1a 32-bit over the id's UTF-16 code units. Stable across calls and
360
+ // across a fresh process (no global state, no RNG).
361
+ const s = String(id);
362
+ let h = 0x811c9dc5; // FNV offset basis
363
+ for (let i = 0; i < s.length; i += 1) {
364
+ h ^= s.charCodeAt(i);
365
+ // FNV prime multiply via shifts, kept in unsigned 32-bit space.
366
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
367
+ }
368
+ // Map hash → u ∈ (0,1]. Use (h+1)/2^32 so u is never 0 (avoids -ln(0)=∞)
369
+ // and never exceeds 1; h ranges over [0, 2^32-1] so (h+1) ∈ [1, 2^32].
370
+ const u = (h + 1) / 4294967296; // 2^32
371
+ const mL = 1 / Math.log(this.M);
372
+ const level = Math.floor(-Math.log(u) * mL);
373
+ return Math.min(level, 10); // Cap at 10 levels (same as getRandomLevel)
374
+ }
375
+
147
376
  /**
148
377
  * Add a vector to the index
149
378
  *
@@ -155,6 +384,19 @@ export class BinaryHNSWIndex {
155
384
  async add(id, binaryVector, metadata = {}, int8Vector = null) {
156
385
  await this.init();
157
386
 
387
+ // The packed (mmap) layout is a read/search-only representation: `vectors`
388
+ // and `graph` are lazy fd-backed proxies, not mutable plain arrays. Build
389
+ // paths must construct on plain arrays and persist via save()/saveMmap().
390
+ // Mutating a packed-loaded index would silently corrupt the graph, so fail
391
+ // loudly instead. (This is unreachable on the default path; the reconciler
392
+ // builds in memory and only the read side mmap-loads.)
393
+ if (this._mmapBacked) {
394
+ throw new Error(
395
+ 'BinaryHNSW: cannot add() to an mmap-backed (packed) index — '
396
+ + 'it is a read/search-only view. Rebuild on a fresh in-memory index.'
397
+ );
398
+ }
399
+
158
400
  // Convert float to binary if needed
159
401
  let binary;
160
402
  if (binaryVector instanceof Uint8Array) {
@@ -185,8 +427,17 @@ export class BinaryHNSWIndex {
185
427
  this.int8Vectors.set(id, int8Vector);
186
428
  }
187
429
 
188
- // Add to HNSW graph
189
- const level = this.getRandomLevel();
430
+ // Add to HNSW graph.
431
+ // Gate: deterministic per-id levels are DEFAULT-ON (disable with
432
+ // SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS=0). When on, assign the level from a
433
+ // stable hash of the id (insertion-order-independent) so all construction
434
+ // paths (incremental add, end-of-tick batch, compaction rebuild) agree and
435
+ // the graph is reproducible. Verified recall-neutral on GCSN (+0.01pp MRR)
436
+ // and byte-identical via the determinism harness. Set the flag to '0' to
437
+ // restore the legacy random-level (insertion-order-dependent) path.
438
+ const level = process.env.SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS !== '0'
439
+ ? this.levelForId(id)
440
+ : this.getRandomLevel();
190
441
  this.addToGraph(idx, level);
191
442
 
192
443
  return idx;
@@ -248,6 +499,37 @@ export class BinaryHNSWIndex {
248
499
  return selected;
249
500
  }
250
501
 
502
+ /**
503
+ * Whether insertion-order-independent entry-point selection is active.
504
+ *
505
+ * Mirrors the gate on level assignment in `add()`. When ON, the entry
506
+ * point is chosen deterministically (min id at the max level) so batched,
507
+ * per-file, and compaction construction paths agree on `entryPoint` even
508
+ * when several nodes share the max level — the last byte-identity gap left
509
+ * by deterministic levels (E.1 entry-point divergence). DEFAULT-ON (disable
510
+ * with SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS=0) → mirrors the level-assignment
511
+ * gate in `add()`. Set the flag to '0' to restore today's
512
+ * first-inserted-at-max-level behavior.
513
+ */
514
+ _deterministicEntryPoint() {
515
+ return process.env.SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS !== '0';
516
+ }
517
+
518
+ /**
519
+ * Tie-break two graph node indices by their string id (deterministic,
520
+ * insertion-order-independent). Returns the index whose id sorts first
521
+ * lexicographically. Lexicographic comparison of the stable string id is
522
+ * used (not the FNV-1a hash) so the choice is human-auditable and never
523
+ * subject to hash collisions.
524
+ */
525
+ _entryPointWinner(idxA, idxB) {
526
+ if (idxA === -1) return idxB;
527
+ if (idxB === -1) return idxA;
528
+ const idA = String(this.vectors[idxA].id);
529
+ const idB = String(this.vectors[idxB].id);
530
+ return idA <= idB ? idxA : idxB;
531
+ }
532
+
251
533
  /**
252
534
  * Add node to HNSW graph.
253
535
  * Uses heuristic selection + level-aware M0=2*M.
@@ -310,10 +592,23 @@ export class BinaryHNSWIndex {
310
592
  }
311
593
  }
312
594
 
313
- // Update entry point if new level is higher
595
+ // Update entry point.
596
+ //
597
+ // Default path (flag OFF): unchanged — the entry point only moves when a
598
+ // strictly higher level appears, so the first node inserted at the running
599
+ // max level keeps it (insertion-order-dependent, today's behavior).
600
+ //
601
+ // Deterministic path (flag ON): a strictly higher level still wins, but on
602
+ // a TIE at the current max level the entry point is refined to the node
603
+ // with the smallest id. Because `maxLevel` only ever rises during a build
604
+ // (no deletions here) and equals the max id-assigned level, this converges
605
+ // to `min(id)` over all nodes at the final max level regardless of
606
+ // insertion order — closing the E.1 entry-point byte-identity gap.
314
607
  if (level > this.maxLevel) {
315
608
  this.entryPoint = idx;
316
609
  this.maxLevel = level;
610
+ } else if (level === this.maxLevel && this._deterministicEntryPoint()) {
611
+ this.entryPoint = this._entryPointWinner(this.entryPoint, idx);
317
612
  }
318
613
  }
319
614
 
@@ -738,6 +1033,17 @@ export class BinaryHNSWIndex {
738
1033
  * versioned manifest paths.
739
1034
  */
740
1035
  async save(indexPath = this.indexPath) {
1036
+ // G9 gate. Default OFF → the JSON-sidecar writer below runs UNCHANGED, so
1037
+ // every existing index keeps its exact byte format and there is no
1038
+ // migration. ONLY when SWEET_SEARCH_HNSW_MMAP === '1' do NEW writes pack
1039
+ // into the mmap-friendly flat-binary .idx (a distinct on-disk format with
1040
+ // its own magic/version). The deterministic levels (G1) and entry point
1041
+ // (FIX-B) live in `this.graph`/`this.entryPoint`, which both writers
1042
+ // serialize verbatim — packing preserves them.
1043
+ if (hnswMmapEnabled()) {
1044
+ return this.saveMmap(indexPath);
1045
+ }
1046
+
741
1047
  await fs.mkdir(path.dirname(indexPath), { recursive: true });
742
1048
 
743
1049
  const meta = {
@@ -833,6 +1139,14 @@ export class BinaryHNSWIndex {
833
1139
  * search crash.
834
1140
  */
835
1141
  async load(indexPath = this.indexPath) {
1142
+ // Route by DETECTED on-disk format, never by the flag: a packed .idx
1143
+ // (magic header) is read via the mmap path even with the flag off, and a
1144
+ // JSON-sidecar index is read via the JSON path even with the flag on. This
1145
+ // is what lets old and new coexist with zero migration.
1146
+ if (_indexFormatOnDisk(indexPath) === 'packed') {
1147
+ return this.loadMmap(indexPath);
1148
+ }
1149
+
836
1150
  const metaPath = indexPath.replace('.idx', '.meta.json');
837
1151
  const vectorsPath = indexPath.replace('.idx', '.vectors.json');
838
1152
  const graphPath = indexPath.replace('.idx', '.graph.json');
@@ -927,13 +1241,338 @@ export class BinaryHNSWIndex {
927
1241
  console.log(`BinaryHNSW: Loaded ${this.vectors.length} vectors from ${indexPath} (asymmetric=${this.useAsymmetric})`);
928
1242
  }
929
1243
 
1244
+ /**
1245
+ * Save the index in the packed, mmap-friendly flat-binary format (G9).
1246
+ *
1247
+ * Activated only via save() under SWEET_SEARCH_HNSW_MMAP === '1'. The whole
1248
+ * index is serialized into a SINGLE `.idx` file (so the read path can fault
1249
+ * in only touched pages) and the legacy JSON sidecars for this path are
1250
+ * removed so a stale JSON descriptor can never shadow the packed file. The
1251
+ * graph + entryPoint are written verbatim, preserving G1/FIX-B determinism.
1252
+ *
1253
+ * On-disk layout (all integers little-endian):
1254
+ * magic(8) | formatVersion(u32) | headerLen(u32) | header(JSON utf-8) | sections...
1255
+ * The header JSON carries the meta fields, the id/metadata arrays, the
1256
+ * asymmetric calibration (inlined — small), and { offset, len } descriptors
1257
+ * for the three binary sections (vectors, graph, int8), each section offset
1258
+ * being relative to the start of the section region (right after the header).
1259
+ *
1260
+ * Publish is atomic: write to `<path>.tmp.<pid>` then rename over `<path>`.
1261
+ */
1262
+ async saveMmap(indexPath = this.indexPath) {
1263
+ await fs.mkdir(path.dirname(indexPath), { recursive: true });
1264
+
1265
+ const dim = this.dimension;
1266
+ const n = this.vectors.length;
1267
+
1268
+ // --- Section 1: vectors blob (flat n*dim bytes) ---
1269
+ const vectorsBlob = Buffer.allocUnsafe(n * dim);
1270
+ const ids = new Array(n);
1271
+ const metas = new Array(n);
1272
+ for (let i = 0; i < n; i += 1) {
1273
+ const v = this.vectors[i];
1274
+ ids[i] = v.id;
1275
+ metas[i] = v.metadata;
1276
+ vectorsBlob.set(v.binary, i * dim);
1277
+ }
1278
+
1279
+ // --- Section 2: graph blob ---
1280
+ // [levelCount] then per level [nodeCount] then per node [count, n0, n1...]
1281
+ const graphParts = [];
1282
+ const lvlHead = Buffer.allocUnsafe(4);
1283
+ lvlHead.writeUInt32LE(this.graph.length, 0);
1284
+ graphParts.push(lvlHead);
1285
+ for (let l = 0; l < this.graph.length; l += 1) {
1286
+ const level = this.graph[l] || [];
1287
+ const nodeHead = Buffer.allocUnsafe(4);
1288
+ nodeHead.writeUInt32LE(level.length, 0);
1289
+ graphParts.push(nodeHead);
1290
+ for (let i = 0; i < level.length; i += 1) {
1291
+ const neighbors = level[i] || [];
1292
+ const buf = Buffer.allocUnsafe(4 + neighbors.length * 4);
1293
+ buf.writeUInt32LE(neighbors.length, 0);
1294
+ for (let j = 0; j < neighbors.length; j += 1) {
1295
+ buf.writeUInt32LE(neighbors[j], 4 + j * 4);
1296
+ }
1297
+ graphParts.push(buf);
1298
+ }
1299
+ }
1300
+ const graphBlob = Buffer.concat(graphParts);
1301
+
1302
+ // --- Section 3: int8 blob (only live ids, matching JSON save() semantics) ---
1303
+ // [entryCount] then per entry [idIndex(u32), len(u32), bytes...]
1304
+ const liveIds = new Set(ids);
1305
+ const int8Parts = [];
1306
+ const idToIdx = new Map();
1307
+ for (let i = 0; i < n; i += 1) idToIdx.set(ids[i], i);
1308
+ let int8Count = 0;
1309
+ const int8Body = [];
1310
+ for (const [id, vec] of this.int8Vectors) {
1311
+ if (!liveIds.has(id)) continue;
1312
+ const idIndex = idToIdx.get(id);
1313
+ if (idIndex === undefined) continue;
1314
+ const entry = Buffer.allocUnsafe(8 + vec.length);
1315
+ entry.writeUInt32LE(idIndex, 0);
1316
+ entry.writeUInt32LE(vec.length, 4);
1317
+ // Int8Array → bytes (two's complement, identical layout)
1318
+ Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength).copy(entry, 8);
1319
+ int8Body.push(entry);
1320
+ int8Count += 1;
1321
+ }
1322
+ const int8Head = Buffer.allocUnsafe(4);
1323
+ int8Head.writeUInt32LE(int8Count, 0);
1324
+ int8Parts.push(int8Head, ...int8Body);
1325
+ const int8Blob = Buffer.concat(int8Parts);
1326
+
1327
+ // --- Header ---
1328
+ let vOff = 0;
1329
+ const gOff = vOff + vectorsBlob.length;
1330
+ const iOff = gOff + graphBlob.length;
1331
+ const header = {
1332
+ magic: 'SSHNSWP',
1333
+ formatVersion: MMAP_FORMAT_VERSION,
1334
+ dimension: this.dimension,
1335
+ floatDimension: this.floatDimension,
1336
+ M: this.M,
1337
+ efConstruction: this.efConstruction,
1338
+ efSearch: this.efSearch,
1339
+ maxElements: this.maxElements,
1340
+ vectorCount: n,
1341
+ maxLevel: this.maxLevel,
1342
+ entryPoint: this.entryPoint,
1343
+ useAsymmetric: this.useAsymmetric,
1344
+ pipelineVersion: PIPELINE_VERSION,
1345
+ savedAt: new Date().toISOString(),
1346
+ ids,
1347
+ metas,
1348
+ calibration: (this.useAsymmetric && this.centroid && this.signVector)
1349
+ ? { centroid: Array.from(this.centroid), signVector: Array.from(this.signVector) }
1350
+ : null,
1351
+ sections: {
1352
+ vectors: { offset: vOff, len: vectorsBlob.length },
1353
+ graph: { offset: gOff, len: graphBlob.length },
1354
+ int8: { offset: iOff, len: int8Blob.length },
1355
+ },
1356
+ };
1357
+ const headerBuf = Buffer.from(JSON.stringify(header), 'utf-8');
1358
+ const prefix = Buffer.allocUnsafe(MMAP_MAGIC.length + 8);
1359
+ MMAP_MAGIC.copy(prefix, 0);
1360
+ prefix.writeUInt32LE(MMAP_FORMAT_VERSION, MMAP_MAGIC.length);
1361
+ prefix.writeUInt32LE(headerBuf.length, MMAP_MAGIC.length + 4);
1362
+
1363
+ const full = Buffer.concat([prefix, headerBuf, vectorsBlob, graphBlob, int8Blob]);
1364
+
1365
+ const tmpPath = `${indexPath}.tmp.${process.pid}`;
1366
+ await fs.writeFile(tmpPath, full);
1367
+ await fs.rename(tmpPath, indexPath);
1368
+
1369
+ // Retire any legacy JSON sidecars at this path so a stale descriptor can
1370
+ // never shadow the packed file on the next load. This is NOT a migration
1371
+ // of other indexes — it only cleans up the path we just wrote.
1372
+ await Promise.all([
1373
+ fs.rm(indexPath.replace('.idx', '.meta.json'), { force: true }),
1374
+ fs.rm(indexPath.replace('.idx', '.vectors.json'), { force: true }),
1375
+ fs.rm(indexPath.replace('.idx', '.graph.json'), { force: true }),
1376
+ fs.rm(indexPath.replace('.idx', '.int8.json'), { force: true }),
1377
+ fs.rm(indexPath.replace('.idx', '.calibration.json'), { force: true }),
1378
+ ]);
1379
+
1380
+ if (this._cleanBuild) {
1381
+ await fs.rm(this._stalePathForIndex(indexPath), { force: true });
1382
+ this._staleBitmapCache = null;
1383
+ this._cleanBuild = false;
1384
+ }
1385
+
1386
+ console.log(`BinaryHNSW: Saved ${n} vectors to ${indexPath} (packed/mmap, asymmetric=${this.useAsymmetric})`);
1387
+ }
1388
+
1389
+ /**
1390
+ * Load a packed (mmap) index for read/search (G9).
1391
+ *
1392
+ * Keeps the `.idx` file descriptor OPEN and backs `vectors`/`graph` with
1393
+ * lazy fd-readers so a cold tick faults in only the pages a search visits
1394
+ * (touched-pages-only, near-zero heap until traversed). The header (ids,
1395
+ * metadata, calibration, section offsets) is small and parsed eagerly. The
1396
+ * resulting in-memory graph/entryPoint are byte-equivalent to what a
1397
+ * JSON-sidecar build of the same ids would hold, so search results are
1398
+ * identical.
1399
+ */
1400
+ async loadMmap(indexPath = this.indexPath) {
1401
+ this._closeMmap();
1402
+ const fd = openSync(indexPath, 'r');
1403
+ try {
1404
+ const stat = fstatSync(fd);
1405
+ const prefix = Buffer.allocUnsafe(MMAP_MAGIC.length + 8);
1406
+ readSync(fd, prefix, 0, prefix.length, 0);
1407
+ if (!prefix.subarray(0, MMAP_MAGIC.length).equals(MMAP_MAGIC)) {
1408
+ throw new Error(`BinaryHNSW: not a packed index (bad magic): ${indexPath}`);
1409
+ }
1410
+ const fmt = prefix.readUInt32LE(MMAP_MAGIC.length);
1411
+ if (fmt !== MMAP_FORMAT_VERSION) {
1412
+ throw new Error(
1413
+ `BinaryHNSW: packed format version mismatch: file=${fmt}, current=${MMAP_FORMAT_VERSION}.`
1414
+ );
1415
+ }
1416
+ const headerLen = prefix.readUInt32LE(MMAP_MAGIC.length + 4);
1417
+ const headerBuf = Buffer.allocUnsafe(headerLen);
1418
+ readSync(fd, headerBuf, 0, headerLen, prefix.length);
1419
+ const header = JSON.parse(headerBuf.toString('utf-8'));
1420
+
1421
+ const storedVersion = header.pipelineVersion || 1;
1422
+ if (storedVersion !== PIPELINE_VERSION) {
1423
+ throw new Error(
1424
+ `Pipeline version mismatch: index=${storedVersion}, current=${PIPELINE_VERSION}. `
1425
+ + 'Index must be rebuilt (quantization pipeline changed).'
1426
+ );
1427
+ }
1428
+
1429
+ this.dimension = header.dimension;
1430
+ this.floatDimension = header.floatDimension;
1431
+ this.M = header.M;
1432
+ this.efConstruction = header.efConstruction;
1433
+ this.efSearch = header.efSearch;
1434
+ this.maxElements = header.maxElements;
1435
+ this.maxLevel = header.maxLevel;
1436
+ this.entryPoint = header.entryPoint;
1437
+ this.useAsymmetric = header.useAsymmetric || false;
1438
+ this.centroid = null;
1439
+ this.signVector = null;
1440
+ if (this.useAsymmetric && header.calibration) {
1441
+ this.centroid = new Float32Array(header.calibration.centroid);
1442
+ this.signVector = new Float32Array(header.calibration.signVector);
1443
+ }
1444
+
1445
+ const sectionsBase = prefix.length + headerLen;
1446
+ const sec = header.sections;
1447
+ const n = header.vectorCount;
1448
+ const ids = header.ids;
1449
+ const metas = header.metas;
1450
+
1451
+ // Consistency probe mirroring the JSON load() invariant.
1452
+ if (ids.length !== n || (this.entryPoint !== -1 && this.entryPoint >= n)) {
1453
+ throw new Error(
1454
+ `BinaryHNSW: packed index inconsistency at ${indexPath} `
1455
+ + `(vectorCount=${n}, ids=${ids.length}, entryPoint=${this.entryPoint})`
1456
+ );
1457
+ }
1458
+
1459
+ // id → index map (resident; needed by getInt8Vector and searches).
1460
+ this.idToIndex.clear();
1461
+ for (let i = 0; i < n; i += 1) this.idToIndex.set(ids[i], i);
1462
+
1463
+ // Lazy vectors store (binary bytes faulted in per touch).
1464
+ const vectorStore = new MmapVectorStore(
1465
+ fd, sectionsBase + sec.vectors.offset, this.dimension, ids, metas
1466
+ );
1467
+ this.vectors = makeMmapVectorProxy(vectorStore);
1468
+
1469
+ // Build the per-(level,node) offset table for the graph section so each
1470
+ // touch is an O(1) seek. We read the section's structural integers once
1471
+ // (small relative to the neighbor bodies); neighbor LISTS stay on disk
1472
+ // until touched.
1473
+ const graphBase = sectionsBase + sec.graph.offset;
1474
+ const graphLevels = this._buildMmapGraphOffsets(fd, graphBase, sec.graph.len);
1475
+ this.graph = graphLevels;
1476
+
1477
+ // int8 vectors: small Int8Arrays, read eagerly into the resident Map
1478
+ // (getInt8Vector is an O(1) Map lookup on the hot rescore path; keeping
1479
+ // them resident matches the JSON path and avoids per-lookup seeks).
1480
+ this.int8Vectors.clear();
1481
+ if (sec.int8 && sec.int8.len >= 4) {
1482
+ const int8Base = sectionsBase + sec.int8.offset;
1483
+ const head = Buffer.allocUnsafe(4);
1484
+ readSync(fd, head, 0, 4, int8Base);
1485
+ const entryCount = head.readUInt32LE(0);
1486
+ let cursor = int8Base + 4;
1487
+ for (let e = 0; e < entryCount; e += 1) {
1488
+ const eh = Buffer.allocUnsafe(8);
1489
+ readSync(fd, eh, 0, 8, cursor);
1490
+ const idIndex = eh.readUInt32LE(0);
1491
+ const len = eh.readUInt32LE(4);
1492
+ const body = Buffer.allocUnsafe(len);
1493
+ readSync(fd, body, 0, len, cursor + 8);
1494
+ const vec = new Int8Array(len);
1495
+ for (let b = 0; b < len; b += 1) vec[b] = body.readInt8(b);
1496
+ this.int8Vectors.set(ids[idIndex], vec);
1497
+ cursor += 8 + len;
1498
+ }
1499
+ }
1500
+
1501
+ this._mmapFd = fd;
1502
+ this._mmapBacked = true;
1503
+ this._visitedList.ensureCapacity(n);
1504
+ this.initialized = true;
1505
+
1506
+ // Touch the file size to keep the consistency contract auditable.
1507
+ void stat.size;
1508
+ console.log(`BinaryHNSW: Loaded ${n} vectors from ${indexPath} (packed/mmap, asymmetric=${this.useAsymmetric})`);
1509
+ } catch (err) {
1510
+ try { closeSync(fd); } catch { /* best-effort */ }
1511
+ this._mmapFd = null;
1512
+ this._mmapBacked = false;
1513
+ throw err;
1514
+ }
1515
+ }
1516
+
1517
+ /**
1518
+ * Scan the graph section's structural integers once and build a flat
1519
+ * per-(level,node) byte-offset table, returning an array of lazy level
1520
+ * proxies. Only the [levelCount]/[nodeCount]/[neighborCount] integers are
1521
+ * read here; the neighbor id lists themselves remain on disk until a level
1522
+ * proxy materializes a node on touch.
1523
+ */
1524
+ _buildMmapGraphOffsets(fd, base, sectionLen) {
1525
+ const lvlHead = Buffer.allocUnsafe(4);
1526
+ readSync(fd, lvlHead, 0, 4, base);
1527
+ const levelCount = lvlHead.readUInt32LE(0);
1528
+ let cursor = 4; // relative to base
1529
+ const levels = new Array(levelCount);
1530
+ const four = Buffer.allocUnsafe(4);
1531
+ for (let l = 0; l < levelCount; l += 1) {
1532
+ readSync(fd, four, 0, 4, base + cursor);
1533
+ const nodeCount = four.readUInt32LE(0);
1534
+ cursor += 4;
1535
+ const nodeOffsets = new Int32Array(nodeCount);
1536
+ for (let i = 0; i < nodeCount; i += 1) {
1537
+ // Record this node's neighbor-list offset (points at its [count] word).
1538
+ nodeOffsets[i] = cursor;
1539
+ readSync(fd, four, 0, 4, base + cursor);
1540
+ const neighborCount = four.readUInt32LE(0);
1541
+ cursor += 4 + neighborCount * 4;
1542
+ }
1543
+ const level = new MmapGraphLevel(fd, base, nodeOffsets, nodeCount);
1544
+ levels[l] = makeMmapGraphLevelProxy(level);
1545
+ }
1546
+ if (cursor !== sectionLen) {
1547
+ // Structural mismatch → corrupt/truncated section.
1548
+ throw new Error(
1549
+ `BinaryHNSW: packed graph section length mismatch (read ${cursor}, expected ${sectionLen})`
1550
+ );
1551
+ }
1552
+ return levels;
1553
+ }
1554
+
930
1555
  /**
931
1556
  * Get index statistics
932
1557
  */
933
1558
  getStats() {
934
- const graphNodes = this.graph.reduce((sum, level) => sum + level.filter(n => n).length, 0);
935
- const graphEdges = this.graph.reduce((sum, level) =>
936
- sum + level.reduce((s, neighbors) => s + (neighbors?.length || 0), 0), 0);
1559
+ // On the packed mmap path each `this.graph[l]` is a lazy level proxy, not a
1560
+ // real Array, so Array.prototype.filter/reduce are unavailable and walking
1561
+ // it would also fault in every neighbor list (defeating mmap). Iterate by
1562
+ // index instead, which works for both representations.
1563
+ let graphNodes = 0;
1564
+ let graphEdges = 0;
1565
+ for (let l = 0; l < this.graph.length; l += 1) {
1566
+ const level = this.graph[l];
1567
+ if (!level) continue;
1568
+ for (let i = 0; i < level.length; i += 1) {
1569
+ const neighbors = level[i];
1570
+ if (neighbors) {
1571
+ graphNodes += 1;
1572
+ graphEdges += neighbors.length || 0;
1573
+ }
1574
+ }
1575
+ }
937
1576
 
938
1577
  return {
939
1578
  dimension: this.dimension,
@@ -957,6 +1596,7 @@ export class BinaryHNSWIndex {
957
1596
  * Clear all data
958
1597
  */
959
1598
  async clear() {
1599
+ this._closeMmap();
960
1600
  this.vectors = [];
961
1601
  this.idToIndex.clear();
962
1602
  this.int8Vectors.clear();
@@ -967,6 +1607,14 @@ export class BinaryHNSWIndex {
967
1607
  this._staleBitmapCache = null;
968
1608
  this._cleanBuild = false;
969
1609
  }
1610
+
1611
+ /**
1612
+ * Release the packed (mmap) file descriptor, if any. The OS page cache may
1613
+ * retain touched pages; this only drops the fd. Safe no-op on the JSON path.
1614
+ */
1615
+ close() {
1616
+ this._closeMmap();
1617
+ }
970
1618
  }
971
1619
 
972
1620
  // =============================================================================
@@ -991,11 +1639,44 @@ export async function createBinaryHNSWIndex(options = {}) {
991
1639
  }
992
1640
 
993
1641
  function binaryHnswArtifactsExist(indexPath) {
1642
+ // A packed (mmap) index is a single .idx file; the JSON format is the sidecar
1643
+ // tuple. Either is loadable, so report presence of EITHER. Detection is by
1644
+ // on-disk content (magic header) — never the flag — so old + new coexist.
1645
+ if (_indexFormatOnDisk(indexPath) === 'packed') return true;
994
1646
  return existsSync(indexPath.replace('.idx', '.meta.json'))
995
1647
  && existsSync(indexPath.replace('.idx', '.vectors.json'))
996
1648
  && existsSync(indexPath.replace('.idx', '.graph.json'));
997
1649
  }
998
1650
 
1651
+ /**
1652
+ * Detect the on-disk format of an HNSW index WITHOUT loading it, by content:
1653
+ * - 'packed' : the `.idx` file exists and begins with the MMAP magic header.
1654
+ * - 'json' : the JSON-sidecar descriptor (`.meta.json`) exists.
1655
+ * - 'none' : neither.
1656
+ * Reads only the leading magic bytes of the `.idx` (cheap), so the JSON path is
1657
+ * chosen for every legacy index regardless of SWEET_SEARCH_HNSW_MMAP — the
1658
+ * guarantee that existing on-disk indexes load unchanged with no migration.
1659
+ */
1660
+ function _indexFormatOnDisk(indexPath) {
1661
+ try {
1662
+ if (existsSync(indexPath)) {
1663
+ const st = statSync(indexPath);
1664
+ if (st.isFile() && st.size >= MMAP_MAGIC.length) {
1665
+ const fd = openSync(indexPath, 'r');
1666
+ try {
1667
+ const head = Buffer.allocUnsafe(MMAP_MAGIC.length);
1668
+ readSync(fd, head, 0, MMAP_MAGIC.length, 0);
1669
+ if (head.equals(MMAP_MAGIC)) return 'packed';
1670
+ } finally {
1671
+ closeSync(fd);
1672
+ }
1673
+ }
1674
+ }
1675
+ } catch { /* fall through to JSON detection */ }
1676
+ if (existsSync(indexPath.replace('.idx', '.meta.json'))) return 'json';
1677
+ return 'none';
1678
+ }
1679
+
999
1680
  // =============================================================================
1000
1681
  // CLI
1001
1682
  // =============================================================================
@@ -1085,75 +1766,8 @@ Options:
1085
1766
  console.log(` Equivalent float: ${(numVectors * floatDim * 4 / 1024 / 1024).toFixed(2)} MB`);
1086
1767
  console.log(` Compression: ${(floatDim * 4 / binaryDim).toFixed(0)}x`);
1087
1768
 
1088
- } else if (command === 'compare') {
1089
- console.log('\n=== Binary vs Float HNSW Comparison ===\n');
1090
-
1091
- const { HNSWIndex } = await import('./hnsw-index.js');
1092
-
1093
- const numVectors = 5000;
1094
- const floatDim = 512;
1095
-
1096
- console.log(`Testing with ${numVectors} vectors, ${floatDim} dimensions\n`);
1097
-
1098
- // Generate vectors
1099
- const vectors = [];
1100
- for (let i = 0; i < numVectors; i++) {
1101
- const float = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
1102
- vectors.push({ id: `vec-${i}`, float });
1103
- }
1104
-
1105
- // Binary index
1106
- const binaryIndex = new BinaryHNSWIndex({ dimension: Math.ceil(floatDim / 8), floatDimension: floatDim });
1107
- await binaryIndex.init();
1108
-
1109
- console.log('Building binary index...');
1110
- let start = performance.now();
1111
- for (const v of vectors) {
1112
- await binaryIndex.add(v.id, floatToBinary(v.float));
1113
- }
1114
- const binaryBuildTime = performance.now() - start;
1115
-
1116
- // Float index
1117
- const floatIndex = new HNSWIndex({ dimension: floatDim });
1118
- await floatIndex.init();
1119
-
1120
- console.log('Building float index...');
1121
- start = performance.now();
1122
- for (const v of vectors) {
1123
- await floatIndex.add(v.id, v.float);
1124
- }
1125
- const floatBuildTime = performance.now() - start;
1126
-
1127
- console.log(`\nBuild time: Binary ${binaryBuildTime.toFixed(0)}ms, Float ${floatBuildTime.toFixed(0)}ms`);
1128
-
1129
- // Search comparison
1130
- const numQueries = 50;
1131
- const binaryLatencies = [];
1132
- const floatLatencies = [];
1133
-
1134
- for (let i = 0; i < numQueries; i++) {
1135
- const queryFloat = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
1136
-
1137
- const binaryResult = await binaryIndex.search(floatToBinary(queryFloat), 10);
1138
- binaryLatencies.push(binaryResult.latency_us);
1139
-
1140
- const floatResult = await floatIndex.search(queryFloat, 10);
1141
- floatLatencies.push(floatResult.latency_us);
1142
- }
1143
-
1144
- const binaryP50 = binaryLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
1145
- const floatP50 = floatLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
1146
-
1147
- console.log(`\nSearch latency p50: Binary ${binaryP50}μs, Float ${floatP50}μs`);
1148
- console.log(`Speedup: ${(floatP50 / binaryP50).toFixed(1)}x`);
1149
-
1150
- const binaryMem = numVectors * Math.ceil(floatDim / 8);
1151
- const floatMem = numVectors * floatDim * 4;
1152
- console.log(`\nMemory: Binary ${(binaryMem / 1024).toFixed(0)} KB, Float ${(floatMem / 1024).toFixed(0)} KB`);
1153
- console.log(`Compression: ${(floatMem / binaryMem).toFixed(0)}x`);
1154
-
1155
1769
  } else {
1156
- console.log('Unknown command. Use: stats, test, or compare');
1770
+ console.log('Unknown command. Use: stats, test');
1157
1771
  }
1158
1772
  } catch (err) {
1159
1773
  console.error('Error:', err.message);