sweet-search 0.0.1 → 2.3.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 (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,1004 @@
1
+
2
+ /**
3
+ * Binary HNSW Index (P0: 32x memory reduction, 10x faster search)
4
+ *
5
+ * Specialized HNSW index for binary vectors using Hamming distance.
6
+ * Part of the 3-stage retrieval pipeline:
7
+ * Stage 1: Binary HNSW (1000 candidates, ~100μs)
8
+ * Stage 2: Int8 rescore (100 candidates, ~1ms)
9
+ * Stage 3: Rerank (top 20 → k)
10
+ *
11
+ * CANONICAL INT8 STORAGE (Workstream H resolution):
12
+ * Int8 vectors for stage-2 rescoring are stored in this index's .int8.json sidecar.
13
+ * This file is saved/loaded alongside the binary HNSW index artifacts.
14
+ * - save(): Writes .int8.json with { id: Int8Array[], ... } format
15
+ * - load(): Populates this.int8Vectors Map from .int8.json
16
+ * - getInt8Vector(id): O(1) lookup used by sweet-search.js during stage-2
17
+ *
18
+ * This is the ONLY source of int8 vectors. The SQLite approach (codebase-int8.db)
19
+ * was removed as redundant - it was created but never used by search.
20
+ *
21
+ * Reference: https://huggingface.co/blog/embedding-quantization
22
+ *
23
+ * Performance:
24
+ * - Memory: 32x smaller than float HNSW (512d → 64 bytes)
25
+ * - Search: ~10x faster (Hamming distance via SIMD-friendly popcount)
26
+ * - Throughput: ~10,000 queries/sec on 100k vectors
27
+ */
28
+
29
+ import fs from 'fs/promises';
30
+ import { existsSync } from 'fs';
31
+ import path from 'path';
32
+ import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
33
+ import {
34
+ floatToBinary,
35
+ asymmetricDocEncode, asymmetricQueryEncode,
36
+ computeCentroid, generateSignVector,
37
+ } from '../infrastructure/quantization.js';
38
+ import { wasmHammingDistance as hammingDistance } from '../infrastructure/simd-distance.js';
39
+ import { TypedMinHeap, TypedMaxHeap, VisitedList } from './binary-heap.js';
40
+
41
+ // Current quantization pipeline version. Bump when the encoding pipeline changes
42
+ // (centroid subtraction, rotation, quantization scheme). Indexes built with a
43
+ // different version are incompatible and must be rebuilt.
44
+ const PIPELINE_VERSION = 2;
45
+
46
+ // =============================================================================
47
+ // BINARY HNSW INDEX CLASS
48
+ // =============================================================================
49
+
50
+ export class BinaryHNSWIndex {
51
+ constructor(options = {}) {
52
+ // Binary dimension (bytes, not bits)
53
+ this.dimension = options.dimension || BINARY_HNSW_CONFIG.dimension;
54
+ this.floatDimension = options.floatDimension || BINARY_HNSW_CONFIG.floatDimension;
55
+
56
+ // HNSW parameters (more aggressive since Hamming is cheap)
57
+ this.M = options.M || BINARY_HNSW_CONFIG.M;
58
+ this.efConstruction = options.efConstruction || BINARY_HNSW_CONFIG.efConstruction;
59
+ this.efSearch = options.efSearch || BINARY_HNSW_CONFIG.efSearch;
60
+ this.maxElements = options.maxElements || BINARY_HNSW_CONFIG.maxElements;
61
+
62
+ this.indexPath = options.indexPath || DB_PATHS.binaryHnswIndex;
63
+
64
+ // Storage
65
+ this.vectors = []; // Array of { id, binary: Uint8Array, metadata }
66
+ this.idToIndex = new Map(); // id → array index
67
+ this.initialized = false;
68
+
69
+ // For int8 rescoring
70
+ this.int8Vectors = new Map(); // id → Int8Array
71
+
72
+ // Graph structure (simplified HNSW for pure JS)
73
+ this.graph = []; // Array of neighbor lists per level
74
+ this.entryPoint = -1;
75
+ this.maxLevel = 0;
76
+
77
+ // Pre-allocated visited list (generation-stamped, reused across searches)
78
+ this._visitedList = new VisitedList(this.maxElements);
79
+
80
+ // Asymmetric binary quantization calibration data
81
+ this.centroid = null; // Float32Array — dataset centroid
82
+ this.signVector = null; // Float32Array — random ±1 for WHT rotation
83
+ this.useAsymmetric = false; // Enabled after calibration
84
+ }
85
+
86
+ /** Reset to empty state for a fresh build (skips loading from disk). */
87
+ resetForBuild() {
88
+ this.vectors = [];
89
+ this.idToIndex = new Map();
90
+ this.graph = [];
91
+ this.entryPoint = -1;
92
+ this.maxLevel = 0;
93
+ this.centroid = null;
94
+ this.signVector = null;
95
+ this.useAsymmetric = false;
96
+ this.initialized = true;
97
+ }
98
+
99
+ /**
100
+ * Initialize the index
101
+ */
102
+ async init() {
103
+ if (this.initialized) return;
104
+
105
+ // Try to load existing index
106
+ const metaPath = this.indexPath.replace('.idx', '.meta.json');
107
+ if (existsSync(metaPath)) {
108
+ try {
109
+ await this.load();
110
+ this.initialized = true;
111
+ return;
112
+ } catch (err) {
113
+ console.log(`BinaryHNSW: Failed to load, creating new index: ${err.message}`);
114
+ }
115
+ }
116
+
117
+ this.vectors = [];
118
+ this.idToIndex = new Map();
119
+ this.graph = [];
120
+ this.entryPoint = -1;
121
+ this.maxLevel = 0;
122
+ this.initialized = true;
123
+
124
+ console.log(`BinaryHNSW: Initialized (${this.dimension} bytes, M=${this.M})`);
125
+ }
126
+
127
+ /**
128
+ * Calculate random level for new node (exponential distribution)
129
+ */
130
+ getRandomLevel() {
131
+ const mL = 1 / Math.log(this.M);
132
+ let level = Math.floor(-Math.log(Math.random()) * mL);
133
+ return Math.min(level, 10); // Cap at 10 levels
134
+ }
135
+
136
+ /**
137
+ * Add a vector to the index
138
+ *
139
+ * @param {string} id - Unique identifier
140
+ * @param {Uint8Array|number[]} binaryVector - Binary vector (or float to convert)
141
+ * @param {object} metadata - Optional metadata
142
+ * @param {Int8Array} int8Vector - Optional int8 vector for rescoring
143
+ */
144
+ async add(id, binaryVector, metadata = {}, int8Vector = null) {
145
+ await this.init();
146
+
147
+ // Convert float to binary if needed
148
+ let binary;
149
+ if (binaryVector instanceof Uint8Array) {
150
+ binary = binaryVector;
151
+ } else if (Array.isArray(binaryVector) && binaryVector.length > this.dimension) {
152
+ // Assume it's a float vector, convert to binary
153
+ binary = floatToBinary(binaryVector.slice(0, this.floatDimension));
154
+ } else {
155
+ binary = new Uint8Array(binaryVector);
156
+ }
157
+
158
+ // Check if already exists
159
+ if (this.idToIndex.has(id)) {
160
+ const idx = this.idToIndex.get(id);
161
+ this.vectors[idx] = { id, binary, metadata };
162
+ if (int8Vector) {
163
+ this.int8Vectors.set(id, int8Vector);
164
+ }
165
+ return idx;
166
+ }
167
+
168
+ // Add new vector
169
+ const idx = this.vectors.length;
170
+ this.vectors.push({ id, binary, metadata });
171
+ this.idToIndex.set(id, idx);
172
+
173
+ if (int8Vector) {
174
+ this.int8Vectors.set(id, int8Vector);
175
+ }
176
+
177
+ // Add to HNSW graph
178
+ const level = this.getRandomLevel();
179
+ this.addToGraph(idx, level);
180
+
181
+ return idx;
182
+ }
183
+
184
+ /**
185
+ * Level-aware max degree: M0=2*M on layer 0, M on higher layers.
186
+ */
187
+ getMaxM(level) {
188
+ return level === 0 ? this.M * 2 : this.M;
189
+ }
190
+
191
+ /**
192
+ * Heuristic neighbor selection (Algorithm 4, Malkov & Yashunin 2016).
193
+ * Selects diverse neighbors that cover different angular directions,
194
+ * avoiding clusters of nearby nodes pointing at each other.
195
+ */
196
+ selectNeighborsHeuristic(nodeIdx, candidates, maxM) {
197
+ const selected = [];
198
+ const nodeBinary = this.vectors[nodeIdx].binary;
199
+
200
+ // Sort candidates by distance ascending
201
+ candidates.sort((a, b) => a.dist - b.dist);
202
+
203
+ for (const candidate of candidates) {
204
+ if (selected.length >= maxM) break;
205
+
206
+ // Check if this candidate is closer to any already-selected neighbor
207
+ // than it is to the node itself — if so, it's redundant (same direction)
208
+ let tooClose = false;
209
+ for (const s of selected) {
210
+ const interDist = hammingDistance(
211
+ this.vectors[candidate.idx].binary,
212
+ this.vectors[s.idx].binary
213
+ );
214
+ if (interDist < candidate.dist) {
215
+ tooClose = true;
216
+ break;
217
+ }
218
+ }
219
+
220
+ if (!tooClose) {
221
+ selected.push(candidate);
222
+ }
223
+ }
224
+
225
+ // Backfill with closest if heuristic was too aggressive
226
+ if (selected.length < maxM) {
227
+ const selectedSet = new Set(selected.map(s => s.idx));
228
+ for (const candidate of candidates) {
229
+ if (selected.length >= maxM) break;
230
+ if (!selectedSet.has(candidate.idx)) {
231
+ selected.push(candidate);
232
+ selectedSet.add(candidate.idx);
233
+ }
234
+ }
235
+ }
236
+
237
+ return selected;
238
+ }
239
+
240
+ /**
241
+ * Add node to HNSW graph.
242
+ * Uses heuristic selection + level-aware M0=2*M.
243
+ */
244
+ addToGraph(idx, level) {
245
+ // Ensure graph has enough levels
246
+ while (this.graph.length <= level) {
247
+ this.graph.push([]);
248
+ }
249
+
250
+ // Initialize neighbor lists for new node
251
+ for (let l = 0; l <= level; l++) {
252
+ if (!this.graph[l][idx]) {
253
+ this.graph[l][idx] = [];
254
+ }
255
+ }
256
+
257
+ // If first node, set as entry point
258
+ if (this.entryPoint === -1) {
259
+ this.entryPoint = idx;
260
+ this.maxLevel = level;
261
+ return;
262
+ }
263
+
264
+ // Find neighbors at each level and connect
265
+ let currentNode = this.entryPoint;
266
+
267
+ // Traverse from top to target level
268
+ for (let l = this.maxLevel; l > level; l--) {
269
+ currentNode = this.greedySearch(currentNode, idx, l);
270
+ }
271
+
272
+ // At each level from level down to 0, find neighbors and connect
273
+ for (let l = level; l >= 0; l--) {
274
+ const maxM = this.getMaxM(l);
275
+ const neighbors = this.searchLayer(currentNode, idx, this.efConstruction, l);
276
+
277
+ // Heuristic selection (Algorithm 4) for angular diversity
278
+ const selectedNeighbors = this.selectNeighborsHeuristic(idx, neighbors, maxM);
279
+
280
+ // Connect both directions
281
+ this.graph[l][idx] = selectedNeighbors.map(n => n.idx);
282
+
283
+ for (const neighbor of selectedNeighbors) {
284
+ if (!this.graph[l][neighbor.idx]) {
285
+ this.graph[l][neighbor.idx] = [];
286
+ }
287
+ if (!this.graph[l][neighbor.idx].includes(idx)) {
288
+ this.graph[l][neighbor.idx].push(idx);
289
+
290
+ // Prune if too many neighbors (threshold = 2 * maxM for the level)
291
+ if (this.graph[l][neighbor.idx].length > maxM * 2) {
292
+ this.pruneNeighbors(neighbor.idx, l);
293
+ }
294
+ }
295
+ }
296
+
297
+ if (selectedNeighbors.length > 0) {
298
+ currentNode = selectedNeighbors[0].idx;
299
+ }
300
+ }
301
+
302
+ // Update entry point if new level is higher
303
+ if (level > this.maxLevel) {
304
+ this.entryPoint = idx;
305
+ this.maxLevel = level;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Greedy search to find closest node at a given level
311
+ */
312
+ greedySearch(startNode, targetIdx, level) {
313
+ const targetBinary = this.vectors[targetIdx].binary;
314
+ let currentNode = startNode;
315
+ let currentDist = hammingDistance(this.vectors[currentNode].binary, targetBinary);
316
+
317
+ let improved = true;
318
+ while (improved) {
319
+ improved = false;
320
+ const neighbors = this.graph[level]?.[currentNode] || [];
321
+
322
+ for (const neighborIdx of neighbors) {
323
+ const neighborDist = hammingDistance(this.vectors[neighborIdx].binary, targetBinary);
324
+ if (neighborDist < currentDist) {
325
+ currentNode = neighborIdx;
326
+ currentDist = neighborDist;
327
+ improved = true;
328
+ }
329
+ }
330
+ }
331
+
332
+ return currentNode;
333
+ }
334
+
335
+ /**
336
+ * Search layer returning ef closest candidates (construction-time).
337
+ * Heap-based with generation-stamped visited list.
338
+ */
339
+ searchLayer(startNode, targetIdx, ef, level) {
340
+ const targetBinary = this.vectors[targetIdx].binary;
341
+ const visited = this._visitedList;
342
+ visited.ensureCapacity(this.vectors.length);
343
+ visited.reset();
344
+ visited.mark(startNode);
345
+
346
+ const startDist = hammingDistance(this.vectors[startNode].binary, targetBinary);
347
+
348
+ // candidates = min-heap (explore closest first)
349
+ const candidates = new TypedMinHeap(ef * 4);
350
+ candidates.insert(startNode, startDist);
351
+
352
+ // results = max-heap of size ef (peek furthest, replaceMax when closer found)
353
+ const results = new TypedMaxHeap(ef + 1);
354
+ results.insert(startNode, startDist);
355
+
356
+ while (candidates.size > 0) {
357
+ const currentDist = candidates.peekVal();
358
+
359
+ // If closest candidate is further than furthest result, stop
360
+ if (results.size >= ef && currentDist > results.peekVal()) {
361
+ break;
362
+ }
363
+
364
+ const currentIdx = candidates.extractMin();
365
+
366
+ const neighbors = this.graph[level]?.[currentIdx] || [];
367
+ for (let i = 0; i < neighbors.length; i++) {
368
+ const neighborIdx = neighbors[i];
369
+ if (visited.isVisited(neighborIdx)) continue;
370
+ visited.mark(neighborIdx);
371
+
372
+ const dist = hammingDistance(this.vectors[neighborIdx].binary, targetBinary);
373
+
374
+ if (results.size < ef) {
375
+ candidates.insert(neighborIdx, dist);
376
+ results.insert(neighborIdx, dist);
377
+ } else if (dist < results.peekVal()) {
378
+ candidates.insert(neighborIdx, dist);
379
+ results.replaceMax(neighborIdx, dist);
380
+ }
381
+ }
382
+ }
383
+
384
+ // Drain results heap into sorted array (ascending by distance)
385
+ const sorted = results.drainSorted();
386
+ const out = [];
387
+ for (let i = 0; i < sorted.length; i++) {
388
+ out.push({ idx: sorted.keys[i], dist: sorted.vals[i] });
389
+ }
390
+ return out;
391
+ }
392
+
393
+ /**
394
+ * Prune neighbors using heuristic selection.
395
+ * Level-aware: M0=2*M on layer 0, M on higher layers.
396
+ */
397
+ pruneNeighbors(nodeIdx, level) {
398
+ const neighbors = this.graph[level][nodeIdx];
399
+ const nodeBinary = this.vectors[nodeIdx].binary;
400
+ const maxM = this.getMaxM(level);
401
+
402
+ const withDist = neighbors.map(idx => ({
403
+ idx,
404
+ dist: hammingDistance(this.vectors[idx].binary, nodeBinary),
405
+ }));
406
+
407
+ const selected = this.selectNeighborsHeuristic(nodeIdx, withDist, maxM);
408
+ this.graph[level][nodeIdx] = selected.map(n => n.idx);
409
+ }
410
+
411
+ /**
412
+ * Search for k nearest neighbors.
413
+ * Supports asymmetric binary quantization.
414
+ * Fix 7: Adaptive ef based on greedy descent quality.
415
+ *
416
+ * @param {Uint8Array|number[]} queryVector - Binary query vector (or float to convert)
417
+ * @param {number} k - Number of results
418
+ * @param {object} opts - Optional { floatQuery: Float32Array } for asymmetric mode
419
+ * @returns {Promise<{results: Array, latency_us: number}>}
420
+ */
421
+ async search(queryVector, k = 10, opts = {}) {
422
+ await this.init();
423
+
424
+ const start = performance.now();
425
+
426
+ if (this.vectors.length === 0) {
427
+ return { results: [], latency_us: 0, k, total: 0 };
428
+ }
429
+
430
+ // Convert float to binary if needed
431
+ let queryBinary;
432
+ if (queryVector instanceof Uint8Array) {
433
+ queryBinary = queryVector;
434
+ } else if (Array.isArray(queryVector) && queryVector.length > this.dimension) {
435
+ queryBinary = floatToBinary(queryVector.slice(0, this.floatDimension));
436
+ } else {
437
+ queryBinary = new Uint8Array(queryVector);
438
+ }
439
+
440
+ // When asymmetric mode is active (future: 1024d+ providers), re-encode
441
+ // query binary through center→rotate→sign-bit for Hamming consistency.
442
+ if (this.useAsymmetric && opts.floatQuery) {
443
+ queryBinary = this.encodeDocument(opts.floatQuery);
444
+ }
445
+ let currentNode = this.entryPoint;
446
+
447
+ for (let l = this.maxLevel; l >= 1; l--) {
448
+ currentNode = this.greedySearchQuery(currentNode, queryBinary, l);
449
+ }
450
+
451
+ // Adaptive ef: easy queries get a smaller budget, hard queries get more
452
+ let ef = Math.max(k, this.efSearch);
453
+ const greedyDist = hammingDistance(this.vectors[currentNode].binary, queryBinary);
454
+ const maxDist = this.dimension * 8;
455
+ const greedyQuality = 1 - (greedyDist / maxDist);
456
+ if (greedyQuality > 0.85) {
457
+ ef = Math.max(k, Math.round(ef * 0.6));
458
+ } else if (greedyQuality < 0.55) {
459
+ ef = Math.round(ef * 1.5);
460
+ }
461
+
462
+ // Level 0 search — pure Hamming, no asymmetric in the traversal loop
463
+ const searchResult = this.searchLayerQuery(currentNode, queryBinary, ef, 0);
464
+ let candidates = searchResult.candidates;
465
+
466
+ // Return top k
467
+ const results = candidates.slice(0, k).map(c => ({
468
+ id: this.vectors[c.idx].id,
469
+ score: 1 - (c.dist / maxDist),
470
+ hammingDistance: c.dist,
471
+ metadata: this.vectors[c.idx].metadata,
472
+ }));
473
+
474
+ const latency = performance.now() - start;
475
+
476
+ return {
477
+ results,
478
+ latency_us: Math.round(latency * 1000),
479
+ latency_ms: latency.toFixed(3),
480
+ k,
481
+ total: this.vectors.length,
482
+ visitedNodes: searchResult.visitedCount,
483
+ adaptiveEf: ef,
484
+ useAsymmetric: this.useAsymmetric,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Greedy search for query vector
490
+ */
491
+ greedySearchQuery(startNode, queryBinary, level) {
492
+ let currentNode = startNode;
493
+ let currentDist = hammingDistance(this.vectors[currentNode].binary, queryBinary);
494
+
495
+ let improved = true;
496
+ while (improved) {
497
+ improved = false;
498
+ const neighbors = this.graph[level]?.[currentNode] || [];
499
+
500
+ for (const neighborIdx of neighbors) {
501
+ const neighborDist = hammingDistance(this.vectors[neighborIdx].binary, queryBinary);
502
+ if (neighborDist < currentDist) {
503
+ currentNode = neighborIdx;
504
+ currentDist = neighborDist;
505
+ improved = true;
506
+ }
507
+ }
508
+ }
509
+
510
+ return currentNode;
511
+ }
512
+
513
+ /**
514
+ * Search layer for query vector.
515
+ * Pure Hamming distance — heaps are integer-native, no float packing.
516
+ * Asymmetric rescoring happens in search() after candidates are returned.
517
+ */
518
+ searchLayerQuery(startNode, queryBinary, ef, level) {
519
+ const visited = this._visitedList;
520
+ visited.ensureCapacity(this.vectors.length);
521
+ visited.reset();
522
+ visited.mark(startNode);
523
+
524
+ const startDist = hammingDistance(this.vectors[startNode].binary, queryBinary);
525
+ const candidates = new TypedMinHeap(ef * 4);
526
+ const results = new TypedMaxHeap(ef + 1);
527
+ candidates.insert(startNode, startDist);
528
+ results.insert(startNode, startDist);
529
+
530
+ const etConfig = BINARY_HNSW_CONFIG.earlyTermination || {};
531
+ const windowSize = etConfig.windowSize || 16;
532
+ const etThresholds = etConfig.thresholds || [[0.3, 0.05], [0.6, 0.10]];
533
+ let visitedCount = 0;
534
+ let recentDiscoveries = 0;
535
+ let recentVisits = 0;
536
+
537
+ while (candidates.size > 0) {
538
+ if (results.size >= ef && candidates.peekVal() > results.peekVal()) break;
539
+
540
+ const currentIdx = candidates.extractMin();
541
+ visitedCount++;
542
+
543
+ const neighbors = this.graph[level]?.[currentIdx] || [];
544
+ let foundNew = false;
545
+ for (let i = 0; i < neighbors.length; i++) {
546
+ const neighborIdx = neighbors[i];
547
+ if (visited.isVisited(neighborIdx)) continue;
548
+ visited.mark(neighborIdx);
549
+
550
+ const dist = hammingDistance(this.vectors[neighborIdx].binary, queryBinary);
551
+
552
+ if (results.size < ef) {
553
+ candidates.insert(neighborIdx, dist);
554
+ results.insert(neighborIdx, dist);
555
+ foundNew = true;
556
+ } else if (dist < results.peekVal()) {
557
+ candidates.insert(neighborIdx, dist);
558
+ results.replaceMax(neighborIdx, dist);
559
+ foundNew = true;
560
+ }
561
+ }
562
+
563
+ recentVisits++;
564
+ if (foundNew) recentDiscoveries++;
565
+ if (recentVisits > windowSize) {
566
+ recentVisits >>= 1;
567
+ recentDiscoveries >>= 1;
568
+ }
569
+ if (recentVisits >= windowSize) {
570
+ const progress = visitedCount / ef;
571
+ const rate = recentDiscoveries / recentVisits;
572
+ if (etThresholds.some(([p, r]) => progress > p && rate < r)) break;
573
+ }
574
+ }
575
+
576
+ const sorted = results.drainSorted();
577
+ const out = new Array(sorted.length);
578
+ for (let i = 0; i < sorted.length; i++) {
579
+ out[i] = { idx: sorted.keys[i], dist: sorted.vals[i] };
580
+ }
581
+ return { candidates: out, visitedCount };
582
+ }
583
+
584
+ /**
585
+ * Calibrate asymmetric quantization from float embeddings.
586
+ * Must be called once per index build, before adding vectors.
587
+ * Computes centroid and generates sign vector for WHT rotation.
588
+ */
589
+ calibrateAsymmetric(floatEmbeddings) {
590
+ if (!floatEmbeddings || floatEmbeddings.length === 0) return;
591
+ this.centroid = computeCentroid(floatEmbeddings);
592
+ this.signVector = generateSignVector(this.centroid.length);
593
+ this.useAsymmetric = true;
594
+ }
595
+
596
+ /**
597
+ * Encode a float embedding using the asymmetric pipeline (center→rotate→binarize).
598
+ * Falls back to simple sign-bit if not calibrated.
599
+ */
600
+ encodeDocument(floatEmbedding) {
601
+ if (this.useAsymmetric && this.centroid && this.signVector) {
602
+ return asymmetricDocEncode(floatEmbedding, this.centroid, this.signVector);
603
+ }
604
+ return floatToBinary(floatEmbedding);
605
+ }
606
+
607
+ /**
608
+ * Encode a query using the asymmetric pipeline (center→rotate→int4).
609
+ * Returns { int4, norm } for asymmetric distance, or null if not calibrated.
610
+ */
611
+ encodeQuery(floatEmbedding) {
612
+ if (this.useAsymmetric && this.centroid && this.signVector) {
613
+ return asymmetricQueryEncode(floatEmbedding, this.centroid, this.signVector);
614
+ }
615
+ return null;
616
+ }
617
+
618
+ /**
619
+ * Batch add vectors
620
+ */
621
+ async addBatch(items) {
622
+ await this.init();
623
+
624
+ const results = [];
625
+ for (const item of items) {
626
+ const idx = await this.add(item.id, item.binary || item.vector, item.metadata, item.int8);
627
+ results.push(idx);
628
+ }
629
+ return results;
630
+ }
631
+
632
+ /**
633
+ * Get int8 vector for rescoring
634
+ */
635
+ getInt8Vector(id) {
636
+ return this.int8Vectors.get(id);
637
+ }
638
+
639
+ /**
640
+ * Save index to disk
641
+ */
642
+ async save(indexPath = this.indexPath) {
643
+ await fs.mkdir(path.dirname(indexPath), { recursive: true });
644
+
645
+ // Save metadata
646
+ const meta = {
647
+ dimension: this.dimension,
648
+ floatDimension: this.floatDimension,
649
+ M: this.M,
650
+ efConstruction: this.efConstruction,
651
+ efSearch: this.efSearch,
652
+ maxElements: this.maxElements,
653
+ vectorCount: this.vectors.length,
654
+ maxLevel: this.maxLevel,
655
+ entryPoint: this.entryPoint,
656
+ useAsymmetric: this.useAsymmetric,
657
+ pipelineVersion: PIPELINE_VERSION,
658
+ savedAt: new Date().toISOString(),
659
+ };
660
+
661
+ const metaPath = indexPath.replace('.idx', '.meta.json');
662
+ await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
663
+
664
+ // Save vectors (binary + metadata)
665
+ const vectorsData = this.vectors.map(v => ({
666
+ id: v.id,
667
+ binary: Array.from(v.binary),
668
+ metadata: v.metadata,
669
+ }));
670
+
671
+ const vectorsPath = indexPath.replace('.idx', '.vectors.json');
672
+ await fs.writeFile(vectorsPath, JSON.stringify(vectorsData));
673
+
674
+ // Save graph structure
675
+ const graphPath = indexPath.replace('.idx', '.graph.json');
676
+ await fs.writeFile(graphPath, JSON.stringify(this.graph));
677
+
678
+ // Save int8 vectors if any
679
+ if (this.int8Vectors.size > 0) {
680
+ const int8Data = {};
681
+ for (const [id, vec] of this.int8Vectors) {
682
+ int8Data[id] = Array.from(vec);
683
+ }
684
+ const int8Path = indexPath.replace('.idx', '.int8.json');
685
+ await fs.writeFile(int8Path, JSON.stringify(int8Data));
686
+ }
687
+
688
+ // Save asymmetric calibration data (centroid + rotation signs)
689
+ if (this.useAsymmetric && this.centroid && this.signVector) {
690
+ const calibPath = indexPath.replace('.idx', '.calibration.json');
691
+ await fs.writeFile(calibPath, JSON.stringify({
692
+ centroid: Array.from(this.centroid),
693
+ signVector: Array.from(this.signVector),
694
+ }));
695
+ }
696
+
697
+ console.log(`BinaryHNSW: Saved ${this.vectors.length} vectors to ${indexPath} (asymmetric=${this.useAsymmetric})`);
698
+ }
699
+
700
+ /**
701
+ * Load index from disk
702
+ */
703
+ async load(indexPath = this.indexPath) {
704
+ const metaPath = indexPath.replace('.idx', '.meta.json');
705
+ const vectorsPath = indexPath.replace('.idx', '.vectors.json');
706
+ const graphPath = indexPath.replace('.idx', '.graph.json');
707
+ const int8Path = indexPath.replace('.idx', '.int8.json');
708
+
709
+ if (!existsSync(metaPath)) {
710
+ throw new Error(`Index metadata not found: ${metaPath}`);
711
+ }
712
+
713
+ // Load metadata
714
+ const meta = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
715
+
716
+ // Validate pipeline version — mismatched indexes must be rebuilt
717
+ const storedVersion = meta.pipelineVersion || 1;
718
+ if (storedVersion !== PIPELINE_VERSION) {
719
+ throw new Error(
720
+ `Pipeline version mismatch: index=${storedVersion}, current=${PIPELINE_VERSION}. ` +
721
+ `Index must be rebuilt (quantization pipeline changed).`
722
+ );
723
+ }
724
+
725
+ this.dimension = meta.dimension;
726
+ this.floatDimension = meta.floatDimension;
727
+ this.M = meta.M;
728
+ this.efConstruction = meta.efConstruction;
729
+ this.efSearch = meta.efSearch;
730
+ this.maxLevel = meta.maxLevel;
731
+ this.entryPoint = meta.entryPoint;
732
+ this.useAsymmetric = meta.useAsymmetric || false;
733
+
734
+ // Load vectors
735
+ const vectorsData = JSON.parse(await fs.readFile(vectorsPath, 'utf-8'));
736
+ this.vectors = vectorsData.map(v => ({
737
+ id: v.id,
738
+ binary: new Uint8Array(v.binary),
739
+ metadata: v.metadata,
740
+ }));
741
+
742
+ // Rebuild id map
743
+ this.idToIndex.clear();
744
+ for (let i = 0; i < this.vectors.length; i++) {
745
+ this.idToIndex.set(this.vectors[i].id, i);
746
+ }
747
+
748
+ // Load graph
749
+ this.graph = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
750
+
751
+ // Load int8 vectors if available
752
+ if (existsSync(int8Path)) {
753
+ const int8Data = JSON.parse(await fs.readFile(int8Path, 'utf-8'));
754
+ this.int8Vectors.clear();
755
+ for (const [id, vec] of Object.entries(int8Data)) {
756
+ this.int8Vectors.set(id, new Int8Array(vec));
757
+ }
758
+ }
759
+
760
+ // Load asymmetric calibration data
761
+ const calibPath = indexPath.replace('.idx', '.calibration.json');
762
+ if (this.useAsymmetric && existsSync(calibPath)) {
763
+ const calibData = JSON.parse(await fs.readFile(calibPath, 'utf-8'));
764
+ this.centroid = new Float32Array(calibData.centroid);
765
+ this.signVector = new Float32Array(calibData.signVector);
766
+ }
767
+
768
+ // Resize visited list to actual vector count
769
+ this._visitedList.ensureCapacity(this.vectors.length);
770
+
771
+ this.initialized = true;
772
+ console.log(`BinaryHNSW: Loaded ${this.vectors.length} vectors from ${indexPath} (asymmetric=${this.useAsymmetric})`);
773
+ }
774
+
775
+ /**
776
+ * Get index statistics
777
+ */
778
+ getStats() {
779
+ const graphNodes = this.graph.reduce((sum, level) => sum + level.filter(n => n).length, 0);
780
+ const graphEdges = this.graph.reduce((sum, level) =>
781
+ sum + level.reduce((s, neighbors) => s + (neighbors?.length || 0), 0), 0);
782
+
783
+ return {
784
+ dimension: this.dimension,
785
+ floatDimension: this.floatDimension,
786
+ totalVectors: this.vectors.length,
787
+ maxElements: this.maxElements,
788
+ M: this.M,
789
+ efConstruction: this.efConstruction,
790
+ efSearch: this.efSearch,
791
+ maxLevel: this.maxLevel,
792
+ graphLevels: this.graph.length,
793
+ graphNodes,
794
+ graphEdges,
795
+ int8VectorCount: this.int8Vectors.size,
796
+ memorySizeBytes: this.vectors.length * this.dimension, // Just binary vectors
797
+ memorySizeMB: (this.vectors.length * this.dimension / 1024 / 1024).toFixed(2),
798
+ };
799
+ }
800
+
801
+ /**
802
+ * Clear all data
803
+ */
804
+ async clear() {
805
+ this.vectors = [];
806
+ this.idToIndex.clear();
807
+ this.int8Vectors.clear();
808
+ this.graph = [];
809
+ this.entryPoint = -1;
810
+ this.maxLevel = 0;
811
+ }
812
+ }
813
+
814
+ // =============================================================================
815
+ // FACTORY FUNCTION
816
+ // =============================================================================
817
+
818
+ export async function createBinaryHNSWIndex(options = {}) {
819
+ const index = new BinaryHNSWIndex(options);
820
+
821
+ if (options.load !== false && existsSync(options.indexPath || DB_PATHS.binaryHnswIndex)) {
822
+ try {
823
+ await index.load(options.indexPath);
824
+ } catch (err) {
825
+ console.log(`BinaryHNSW: Could not load existing index: ${err.message}`);
826
+ await index.init();
827
+ }
828
+ } else {
829
+ await index.init();
830
+ }
831
+
832
+ return index;
833
+ }
834
+
835
+ // =============================================================================
836
+ // CLI
837
+ // =============================================================================
838
+
839
+ if (import.meta.url === `file://${process.argv[1]}`) {
840
+ const args = process.argv.slice(2);
841
+
842
+ console.log(`
843
+ Binary HNSW Index CLI
844
+
845
+ Usage:
846
+ binary-hnsw-index.js stats Show index statistics
847
+ binary-hnsw-index.js test Run performance benchmark
848
+ binary-hnsw-index.js compare Compare binary vs float HNSW
849
+
850
+ Options:
851
+ --vectors <n> Number of test vectors (default: 10000)
852
+ --dim <n> Float dimension (default: 512)
853
+ `);
854
+
855
+ const command = args[0];
856
+
857
+ (async () => {
858
+ const index = new BinaryHNSWIndex();
859
+
860
+ try {
861
+ if (command === 'stats') {
862
+ await index.load();
863
+ console.log('\nBinary HNSW Index Statistics:');
864
+ console.log(JSON.stringify(index.getStats(), null, 2));
865
+
866
+ } else if (command === 'test' || command === 'benchmark') {
867
+ console.log('\n=== Binary HNSW Performance Benchmark ===\n');
868
+
869
+ const numVectors = parseInt(args.find((_, i) => args[i - 1] === '--vectors') || '10000', 10);
870
+ const floatDim = parseInt(args.find((_, i) => args[i - 1] === '--dim') || '512', 10);
871
+ const binaryDim = Math.ceil(floatDim / 8);
872
+
873
+ console.log(`Generating ${numVectors} random ${floatDim}-dim vectors...`);
874
+ console.log(`Binary dimension: ${binaryDim} bytes (${floatDim} bits)\n`);
875
+
876
+ // Generate random vectors
877
+ const vectors = [];
878
+ for (let i = 0; i < numVectors; i++) {
879
+ const float = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
880
+ const binary = floatToBinary(float);
881
+ vectors.push({ id: `vec-${i}`, float, binary });
882
+ }
883
+
884
+ // Add to index
885
+ console.log('Adding vectors to index...');
886
+ const addStart = performance.now();
887
+ for (const v of vectors) {
888
+ await index.add(v.id, v.binary, { index: parseInt(v.id.split('-')[1]) });
889
+ }
890
+ const addTime = performance.now() - addStart;
891
+ console.log(`Added ${numVectors} vectors in ${addTime.toFixed(2)}ms (${(numVectors / addTime * 1000).toFixed(0)} vec/s)\n`);
892
+
893
+ // Search benchmark
894
+ const numQueries = 100;
895
+ console.log(`Running ${numQueries} searches...`);
896
+ const latencies = [];
897
+
898
+ for (let i = 0; i < numQueries; i++) {
899
+ const queryFloat = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
900
+ const queryBinary = floatToBinary(queryFloat);
901
+ const result = await index.search(queryBinary, 10);
902
+ latencies.push(result.latency_us);
903
+ }
904
+
905
+ // Stats
906
+ latencies.sort((a, b) => a - b);
907
+ const p50 = latencies[Math.floor(numQueries * 0.5)];
908
+ const p95 = latencies[Math.floor(numQueries * 0.95)];
909
+ const p99 = latencies[Math.floor(numQueries * 0.99)];
910
+ const avg = latencies.reduce((a, b) => a + b, 0) / numQueries;
911
+
912
+ console.log(`\nSearch Latency (μs):`);
913
+ console.log(` p50: ${p50.toFixed(0)}μs`);
914
+ console.log(` p95: ${p95.toFixed(0)}μs`);
915
+ console.log(` p99: ${p99.toFixed(0)}μs`);
916
+ console.log(` avg: ${avg.toFixed(0)}μs`);
917
+
918
+ console.log(`\nMemory usage:`);
919
+ const stats = index.getStats();
920
+ console.log(` Binary vectors: ${stats.memorySizeMB} MB`);
921
+ console.log(` Equivalent float: ${(numVectors * floatDim * 4 / 1024 / 1024).toFixed(2)} MB`);
922
+ console.log(` Compression: ${(floatDim * 4 / binaryDim).toFixed(0)}x`);
923
+
924
+ } else if (command === 'compare') {
925
+ console.log('\n=== Binary vs Float HNSW Comparison ===\n');
926
+
927
+ const { HNSWIndex } = await import('./hnsw-index.js');
928
+
929
+ const numVectors = 5000;
930
+ const floatDim = 512;
931
+
932
+ console.log(`Testing with ${numVectors} vectors, ${floatDim} dimensions\n`);
933
+
934
+ // Generate vectors
935
+ const vectors = [];
936
+ for (let i = 0; i < numVectors; i++) {
937
+ const float = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
938
+ vectors.push({ id: `vec-${i}`, float });
939
+ }
940
+
941
+ // Binary index
942
+ const binaryIndex = new BinaryHNSWIndex({ dimension: Math.ceil(floatDim / 8), floatDimension: floatDim });
943
+ await binaryIndex.init();
944
+
945
+ console.log('Building binary index...');
946
+ let start = performance.now();
947
+ for (const v of vectors) {
948
+ await binaryIndex.add(v.id, floatToBinary(v.float));
949
+ }
950
+ const binaryBuildTime = performance.now() - start;
951
+
952
+ // Float index
953
+ const floatIndex = new HNSWIndex({ dimension: floatDim });
954
+ await floatIndex.init();
955
+
956
+ console.log('Building float index...');
957
+ start = performance.now();
958
+ for (const v of vectors) {
959
+ await floatIndex.add(v.id, v.float);
960
+ }
961
+ const floatBuildTime = performance.now() - start;
962
+
963
+ console.log(`\nBuild time: Binary ${binaryBuildTime.toFixed(0)}ms, Float ${floatBuildTime.toFixed(0)}ms`);
964
+
965
+ // Search comparison
966
+ const numQueries = 50;
967
+ const binaryLatencies = [];
968
+ const floatLatencies = [];
969
+
970
+ for (let i = 0; i < numQueries; i++) {
971
+ const queryFloat = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
972
+
973
+ const binaryResult = await binaryIndex.search(floatToBinary(queryFloat), 10);
974
+ binaryLatencies.push(binaryResult.latency_us);
975
+
976
+ const floatResult = await floatIndex.search(queryFloat, 10);
977
+ floatLatencies.push(floatResult.latency_us);
978
+ }
979
+
980
+ const binaryP50 = binaryLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
981
+ const floatP50 = floatLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
982
+
983
+ console.log(`\nSearch latency p50: Binary ${binaryP50}μs, Float ${floatP50}μs`);
984
+ console.log(`Speedup: ${(floatP50 / binaryP50).toFixed(1)}x`);
985
+
986
+ const binaryMem = numVectors * Math.ceil(floatDim / 8);
987
+ const floatMem = numVectors * floatDim * 4;
988
+ console.log(`\nMemory: Binary ${(binaryMem / 1024).toFixed(0)} KB, Float ${(floatMem / 1024).toFixed(0)} KB`);
989
+ console.log(`Compression: ${(floatMem / binaryMem).toFixed(0)}x`);
990
+
991
+ } else {
992
+ console.log('Unknown command. Use: stats, test, or compare');
993
+ }
994
+ } catch (err) {
995
+ console.error('Error:', err.message);
996
+ if (args.includes('-v') || args.includes('--verbose')) {
997
+ console.error(err.stack);
998
+ }
999
+ process.exit(1);
1000
+ }
1001
+ })();
1002
+ }
1003
+
1004
+ export default BinaryHNSWIndex;