@aitytech/agentkits-memory 1.0.0 → 2.0.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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -149
  3. package/assets/agentkits-memory-add-memory.png +0 -0
  4. package/assets/agentkits-memory-memory-detail.png +0 -0
  5. package/assets/agentkits-memory-memory-list.png +0 -0
  6. package/assets/logo.svg +24 -0
  7. package/dist/better-sqlite3-backend.d.ts +192 -0
  8. package/dist/better-sqlite3-backend.d.ts.map +1 -0
  9. package/dist/better-sqlite3-backend.js +801 -0
  10. package/dist/better-sqlite3-backend.js.map +1 -0
  11. package/dist/cli/save.js +0 -0
  12. package/dist/cli/setup.d.ts +6 -2
  13. package/dist/cli/setup.d.ts.map +1 -1
  14. package/dist/cli/setup.js +289 -42
  15. package/dist/cli/setup.js.map +1 -1
  16. package/dist/cli/viewer.js +25 -56
  17. package/dist/cli/viewer.js.map +1 -1
  18. package/dist/cli/web-viewer.d.ts +14 -0
  19. package/dist/cli/web-viewer.d.ts.map +1 -0
  20. package/dist/cli/web-viewer.js +1769 -0
  21. package/dist/cli/web-viewer.js.map +1 -0
  22. package/dist/embeddings/embedding-cache.d.ts +131 -0
  23. package/dist/embeddings/embedding-cache.d.ts.map +1 -0
  24. package/dist/embeddings/embedding-cache.js +217 -0
  25. package/dist/embeddings/embedding-cache.js.map +1 -0
  26. package/dist/embeddings/index.d.ts +11 -0
  27. package/dist/embeddings/index.d.ts.map +1 -0
  28. package/dist/embeddings/index.js +11 -0
  29. package/dist/embeddings/index.js.map +1 -0
  30. package/dist/embeddings/local-embeddings.d.ts +140 -0
  31. package/dist/embeddings/local-embeddings.d.ts.map +1 -0
  32. package/dist/embeddings/local-embeddings.js +293 -0
  33. package/dist/embeddings/local-embeddings.js.map +1 -0
  34. package/dist/hooks/context.d.ts +6 -1
  35. package/dist/hooks/context.d.ts.map +1 -1
  36. package/dist/hooks/context.js +12 -2
  37. package/dist/hooks/context.js.map +1 -1
  38. package/dist/hooks/observation.d.ts +6 -1
  39. package/dist/hooks/observation.d.ts.map +1 -1
  40. package/dist/hooks/observation.js +12 -2
  41. package/dist/hooks/observation.js.map +1 -1
  42. package/dist/hooks/service.d.ts +1 -6
  43. package/dist/hooks/service.d.ts.map +1 -1
  44. package/dist/hooks/service.js +33 -85
  45. package/dist/hooks/service.js.map +1 -1
  46. package/dist/hooks/session-init.d.ts +6 -1
  47. package/dist/hooks/session-init.d.ts.map +1 -1
  48. package/dist/hooks/session-init.js +12 -2
  49. package/dist/hooks/session-init.js.map +1 -1
  50. package/dist/hooks/summarize.d.ts +6 -1
  51. package/dist/hooks/summarize.d.ts.map +1 -1
  52. package/dist/hooks/summarize.js +12 -2
  53. package/dist/hooks/summarize.js.map +1 -1
  54. package/dist/index.d.ts +10 -17
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +172 -94
  57. package/dist/index.js.map +1 -1
  58. package/dist/mcp/server.js +17 -3
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/migration.js +3 -3
  61. package/dist/migration.js.map +1 -1
  62. package/dist/search/hybrid-search.d.ts +262 -0
  63. package/dist/search/hybrid-search.d.ts.map +1 -0
  64. package/dist/search/hybrid-search.js +688 -0
  65. package/dist/search/hybrid-search.js.map +1 -0
  66. package/dist/search/index.d.ts +13 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/index.js +13 -0
  69. package/dist/search/index.js.map +1 -0
  70. package/dist/search/token-economics.d.ts +161 -0
  71. package/dist/search/token-economics.d.ts.map +1 -0
  72. package/dist/search/token-economics.js +239 -0
  73. package/dist/search/token-economics.js.map +1 -0
  74. package/dist/types.d.ts +0 -68
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js.map +1 -1
  77. package/package.json +23 -8
  78. package/src/__tests__/better-sqlite3-backend.test.ts +1466 -0
  79. package/src/__tests__/cache-manager.test.ts +499 -0
  80. package/src/__tests__/embedding-integration.test.ts +481 -0
  81. package/src/__tests__/hnsw-index.test.ts +727 -0
  82. package/src/__tests__/index.test.ts +432 -0
  83. package/src/better-sqlite3-backend.ts +1000 -0
  84. package/src/cli/setup.ts +358 -47
  85. package/src/cli/viewer.ts +28 -63
  86. package/src/cli/web-viewer.ts +1956 -0
  87. package/src/embeddings/__tests__/embedding-cache.test.ts +269 -0
  88. package/src/embeddings/__tests__/local-embeddings.test.ts +495 -0
  89. package/src/embeddings/embedding-cache.ts +318 -0
  90. package/src/embeddings/index.ts +20 -0
  91. package/src/embeddings/local-embeddings.ts +419 -0
  92. package/src/hooks/__tests__/handlers.test.ts +58 -17
  93. package/src/hooks/__tests__/integration.test.ts +77 -26
  94. package/src/hooks/context.ts +13 -2
  95. package/src/hooks/observation.ts +13 -2
  96. package/src/hooks/service.ts +39 -100
  97. package/src/hooks/session-init.ts +13 -2
  98. package/src/hooks/summarize.ts +13 -2
  99. package/src/index.ts +210 -116
  100. package/src/mcp/server.ts +20 -3
  101. package/src/search/__tests__/hybrid-search.test.ts +669 -0
  102. package/src/search/__tests__/token-economics.test.ts +276 -0
  103. package/src/search/hybrid-search.ts +968 -0
  104. package/src/search/index.ts +29 -0
  105. package/src/search/token-economics.ts +367 -0
  106. package/src/types.ts +0 -96
  107. package/src/__tests__/sqljs-backend.test.ts +0 -410
  108. package/src/migration.ts +0 -574
  109. package/src/sql.js.d.ts +0 -70
  110. package/src/sqljs-backend.ts +0 -789
@@ -0,0 +1,727 @@
1
+ /**
2
+ * Tests for HNSWIndex
3
+ *
4
+ * Tests HNSW vector index functionality including:
5
+ * - Vector insertion and search
6
+ * - Distance metrics (cosine, euclidean, dot product, manhattan)
7
+ * - Quantization (binary, scalar, product)
8
+ * - Index operations (remove, rebuild, clear)
9
+ * - Statistics and events
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
13
+ import { HNSWIndex } from '../hnsw-index.js';
14
+
15
+ /**
16
+ * Helper to create a random vector
17
+ */
18
+ function randomVector(dimensions: number): Float32Array {
19
+ const vector = new Float32Array(dimensions);
20
+ for (let i = 0; i < dimensions; i++) {
21
+ vector[i] = Math.random() * 2 - 1; // Values between -1 and 1
22
+ }
23
+ return vector;
24
+ }
25
+
26
+ /**
27
+ * Helper to create a normalized vector (unit length)
28
+ */
29
+ function normalizedVector(dimensions: number): Float32Array {
30
+ const vector = randomVector(dimensions);
31
+ let norm = 0;
32
+ for (let i = 0; i < dimensions; i++) {
33
+ norm += vector[i] * vector[i];
34
+ }
35
+ norm = Math.sqrt(norm);
36
+ for (let i = 0; i < dimensions; i++) {
37
+ vector[i] /= norm;
38
+ }
39
+ return vector;
40
+ }
41
+
42
+ /**
43
+ * Helper to create a specific vector
44
+ */
45
+ function createVector(values: number[]): Float32Array {
46
+ return new Float32Array(values);
47
+ }
48
+
49
+ describe('HNSWIndex', () => {
50
+ let index: HNSWIndex;
51
+ const dimensions = 8;
52
+
53
+ beforeEach(() => {
54
+ index = new HNSWIndex({
55
+ dimensions,
56
+ M: 8,
57
+ efConstruction: 50,
58
+ maxElements: 1000,
59
+ metric: 'cosine',
60
+ });
61
+ });
62
+
63
+ describe('basic operations', () => {
64
+ it('should create an empty index', () => {
65
+ expect(index.size).toBe(0);
66
+ expect(index.has('nonexistent')).toBe(false);
67
+ });
68
+
69
+ it('should add a single point', async () => {
70
+ const vector = randomVector(dimensions);
71
+ await index.addPoint('id1', vector);
72
+
73
+ expect(index.size).toBe(1);
74
+ expect(index.has('id1')).toBe(true);
75
+ });
76
+
77
+ it('should add multiple points', async () => {
78
+ for (let i = 0; i < 10; i++) {
79
+ await index.addPoint(`id${i}`, randomVector(dimensions));
80
+ }
81
+
82
+ expect(index.size).toBe(10);
83
+ for (let i = 0; i < 10; i++) {
84
+ expect(index.has(`id${i}`)).toBe(true);
85
+ }
86
+ });
87
+
88
+ it('should reject vectors with wrong dimensions', async () => {
89
+ const wrongVector = randomVector(dimensions + 5);
90
+
91
+ await expect(index.addPoint('id1', wrongVector)).rejects.toThrow(
92
+ /dimension mismatch/
93
+ );
94
+ });
95
+
96
+ it('should reject when index is full', async () => {
97
+ const smallIndex = new HNSWIndex({
98
+ dimensions,
99
+ maxElements: 2,
100
+ });
101
+
102
+ await smallIndex.addPoint('id1', randomVector(dimensions));
103
+ await smallIndex.addPoint('id2', randomVector(dimensions));
104
+
105
+ await expect(
106
+ smallIndex.addPoint('id3', randomVector(dimensions))
107
+ ).rejects.toThrow(/full/);
108
+ });
109
+ });
110
+
111
+ describe('search', () => {
112
+ it('should return empty results for empty index', async () => {
113
+ const query = randomVector(dimensions);
114
+ const results = await index.search(query, 5);
115
+
116
+ expect(results).toHaveLength(0);
117
+ });
118
+
119
+ it('should find the exact vector', async () => {
120
+ const vector = normalizedVector(dimensions);
121
+ await index.addPoint('id1', vector);
122
+
123
+ const results = await index.search(vector, 1);
124
+
125
+ expect(results).toHaveLength(1);
126
+ expect(results[0].id).toBe('id1');
127
+ expect(results[0].distance).toBeCloseTo(0, 5);
128
+ });
129
+
130
+ it('should return k nearest neighbors', async () => {
131
+ // Add 20 points
132
+ for (let i = 0; i < 20; i++) {
133
+ await index.addPoint(`id${i}`, randomVector(dimensions));
134
+ }
135
+
136
+ const query = randomVector(dimensions);
137
+ const results = await index.search(query, 5);
138
+
139
+ expect(results).toHaveLength(5);
140
+ // Results should be sorted by distance
141
+ for (let i = 1; i < results.length; i++) {
142
+ expect(results[i].distance).toBeGreaterThanOrEqual(results[i - 1].distance);
143
+ }
144
+ });
145
+
146
+ it('should respect k limit when index has fewer points', async () => {
147
+ await index.addPoint('id1', randomVector(dimensions));
148
+ await index.addPoint('id2', randomVector(dimensions));
149
+
150
+ const query = randomVector(dimensions);
151
+ const results = await index.search(query, 10);
152
+
153
+ expect(results).toHaveLength(2);
154
+ });
155
+
156
+ it('should reject query with wrong dimensions', async () => {
157
+ await index.addPoint('id1', randomVector(dimensions));
158
+ const wrongQuery = randomVector(dimensions + 3);
159
+
160
+ await expect(index.search(wrongQuery, 5)).rejects.toThrow(
161
+ /dimension mismatch/
162
+ );
163
+ });
164
+
165
+ it('should find similar vectors closer', async () => {
166
+ // Create a base vector
167
+ const base = createVector([1, 0, 0, 0, 0, 0, 0, 0]);
168
+ const similar = createVector([0.9, 0.1, 0, 0, 0, 0, 0, 0]);
169
+ const different = createVector([0, 0, 0, 0, 0, 0, 0, 1]);
170
+
171
+ await index.addPoint('similar', similar);
172
+ await index.addPoint('different', different);
173
+
174
+ const results = await index.search(base, 2);
175
+
176
+ expect(results[0].id).toBe('similar');
177
+ expect(results[1].id).toBe('different');
178
+ expect(results[0].distance).toBeLessThan(results[1].distance);
179
+ });
180
+ });
181
+
182
+ describe('searchWithFilters', () => {
183
+ it('should apply filters to results', async () => {
184
+ for (let i = 0; i < 20; i++) {
185
+ await index.addPoint(`id${i}`, randomVector(dimensions));
186
+ }
187
+
188
+ const query = randomVector(dimensions);
189
+ // Only accept IDs that end with even numbers
190
+ const filter = (id: string) => {
191
+ const num = parseInt(id.replace('id', ''));
192
+ return num % 2 === 0;
193
+ };
194
+
195
+ const results = await index.searchWithFilters(query, 5, filter);
196
+
197
+ expect(results.length).toBeLessThanOrEqual(5);
198
+ for (const result of results) {
199
+ const num = parseInt(result.id.replace('id', ''));
200
+ expect(num % 2).toBe(0);
201
+ }
202
+ });
203
+
204
+ it('should return fewer results if filter eliminates many', async () => {
205
+ for (let i = 0; i < 10; i++) {
206
+ await index.addPoint(`id${i}`, randomVector(dimensions));
207
+ }
208
+
209
+ const query = randomVector(dimensions);
210
+ // Only accept id0
211
+ const filter = (id: string) => id === 'id0';
212
+
213
+ const results = await index.searchWithFilters(query, 5, filter);
214
+
215
+ expect(results.length).toBe(1);
216
+ expect(results[0].id).toBe('id0');
217
+ });
218
+ });
219
+
220
+ describe('removePoint', () => {
221
+ it('should remove an existing point', async () => {
222
+ await index.addPoint('id1', randomVector(dimensions));
223
+ await index.addPoint('id2', randomVector(dimensions));
224
+
225
+ expect(index.size).toBe(2);
226
+ const removed = await index.removePoint('id1');
227
+
228
+ expect(removed).toBe(true);
229
+ expect(index.size).toBe(1);
230
+ expect(index.has('id1')).toBe(false);
231
+ expect(index.has('id2')).toBe(true);
232
+ });
233
+
234
+ it('should return false for non-existent point', async () => {
235
+ const removed = await index.removePoint('nonexistent');
236
+ expect(removed).toBe(false);
237
+ });
238
+
239
+ it('should handle removing entry point', async () => {
240
+ await index.addPoint('id1', randomVector(dimensions));
241
+ await index.addPoint('id2', randomVector(dimensions));
242
+
243
+ // Remove both points
244
+ await index.removePoint('id1');
245
+ await index.removePoint('id2');
246
+
247
+ expect(index.size).toBe(0);
248
+ });
249
+
250
+ it('should still allow search after removal', async () => {
251
+ await index.addPoint('id1', randomVector(dimensions));
252
+ await index.addPoint('id2', randomVector(dimensions));
253
+ await index.addPoint('id3', randomVector(dimensions));
254
+
255
+ await index.removePoint('id2');
256
+
257
+ const query = randomVector(dimensions);
258
+ const results = await index.search(query, 10);
259
+
260
+ expect(results).toHaveLength(2);
261
+ expect(results.map((r) => r.id)).not.toContain('id2');
262
+ });
263
+ });
264
+
265
+ describe('rebuild', () => {
266
+ it('should rebuild index from entries', async () => {
267
+ const entries = [];
268
+ for (let i = 0; i < 10; i++) {
269
+ entries.push({ id: `id${i}`, vector: randomVector(dimensions) });
270
+ }
271
+
272
+ await index.rebuild(entries);
273
+
274
+ expect(index.size).toBe(10);
275
+ for (const entry of entries) {
276
+ expect(index.has(entry.id)).toBe(true);
277
+ }
278
+ });
279
+
280
+ it('should clear existing entries during rebuild', async () => {
281
+ await index.addPoint('old1', randomVector(dimensions));
282
+ await index.addPoint('old2', randomVector(dimensions));
283
+
284
+ await index.rebuild([{ id: 'new1', vector: randomVector(dimensions) }]);
285
+
286
+ expect(index.size).toBe(1);
287
+ expect(index.has('old1')).toBe(false);
288
+ expect(index.has('new1')).toBe(true);
289
+ });
290
+ });
291
+
292
+ describe('clear', () => {
293
+ it('should remove all entries', async () => {
294
+ for (let i = 0; i < 5; i++) {
295
+ await index.addPoint(`id${i}`, randomVector(dimensions));
296
+ }
297
+
298
+ expect(index.size).toBe(5);
299
+ index.clear();
300
+ expect(index.size).toBe(0);
301
+ });
302
+
303
+ it('should reset statistics', async () => {
304
+ await index.addPoint('id1', randomVector(dimensions));
305
+ await index.search(randomVector(dimensions), 5);
306
+
307
+ index.clear();
308
+ const stats = index.getStats();
309
+
310
+ expect(stats.vectorCount).toBe(0);
311
+ });
312
+ });
313
+
314
+ describe('statistics', () => {
315
+ it('should track vector count', async () => {
316
+ await index.addPoint('id1', randomVector(dimensions));
317
+ await index.addPoint('id2', randomVector(dimensions));
318
+
319
+ const stats = index.getStats();
320
+ expect(stats.vectorCount).toBe(2);
321
+ });
322
+
323
+ it('should estimate memory usage', async () => {
324
+ await index.addPoint('id1', randomVector(dimensions));
325
+
326
+ const stats = index.getStats();
327
+ expect(stats.memoryUsage).toBeGreaterThan(0);
328
+ });
329
+
330
+ it('should track search time', async () => {
331
+ await index.addPoint('id1', randomVector(dimensions));
332
+ await index.search(randomVector(dimensions), 5);
333
+ await index.search(randomVector(dimensions), 5);
334
+
335
+ const stats = index.getStats();
336
+ expect(stats.avgSearchTime).toBeGreaterThanOrEqual(0);
337
+ });
338
+ });
339
+
340
+ describe('events', () => {
341
+ it('should emit point:added event', async () => {
342
+ const handler = vi.fn();
343
+ index.on('point:added', handler);
344
+
345
+ await index.addPoint('id1', randomVector(dimensions));
346
+
347
+ expect(handler).toHaveBeenCalledWith(
348
+ expect.objectContaining({
349
+ id: 'id1',
350
+ duration: expect.any(Number),
351
+ })
352
+ );
353
+ });
354
+
355
+ it('should emit point:removed event', async () => {
356
+ const handler = vi.fn();
357
+ index.on('point:removed', handler);
358
+
359
+ await index.addPoint('id1', randomVector(dimensions));
360
+ await index.removePoint('id1');
361
+
362
+ expect(handler).toHaveBeenCalledWith({ id: 'id1' });
363
+ });
364
+
365
+ it('should emit index:rebuilt event', async () => {
366
+ const handler = vi.fn();
367
+ index.on('index:rebuilt', handler);
368
+
369
+ await index.rebuild([{ id: 'id1', vector: randomVector(dimensions) }]);
370
+
371
+ expect(handler).toHaveBeenCalledWith(
372
+ expect.objectContaining({
373
+ vectorCount: 1,
374
+ buildTime: expect.any(Number),
375
+ })
376
+ );
377
+ });
378
+ });
379
+
380
+ describe('distance metrics', () => {
381
+ describe('cosine', () => {
382
+ it('should find identical vectors as distance 0', async () => {
383
+ const cosineIndex = new HNSWIndex({
384
+ dimensions: 4,
385
+ metric: 'cosine',
386
+ });
387
+
388
+ const vector = normalizedVector(4);
389
+ await cosineIndex.addPoint('id1', vector);
390
+
391
+ const results = await cosineIndex.search(vector, 1);
392
+ expect(results[0].distance).toBeCloseTo(0, 5);
393
+ });
394
+
395
+ it('should find opposite vectors as distance close to 2', async () => {
396
+ const cosineIndex = new HNSWIndex({
397
+ dimensions: 4,
398
+ metric: 'cosine',
399
+ });
400
+
401
+ const vector = createVector([1, 0, 0, 0]);
402
+ const opposite = createVector([-1, 0, 0, 0]);
403
+
404
+ await cosineIndex.addPoint('id1', opposite);
405
+
406
+ const results = await cosineIndex.search(vector, 1);
407
+ expect(results[0].distance).toBeCloseTo(2, 1);
408
+ });
409
+ });
410
+
411
+ describe('euclidean', () => {
412
+ it('should compute euclidean distance correctly', async () => {
413
+ const euclideanIndex = new HNSWIndex({
414
+ dimensions: 3,
415
+ metric: 'euclidean',
416
+ });
417
+
418
+ const vector1 = createVector([0, 0, 0]);
419
+ const vector2 = createVector([3, 4, 0]); // Distance should be 5
420
+
421
+ await euclideanIndex.addPoint('id1', vector2);
422
+
423
+ const results = await euclideanIndex.search(vector1, 1);
424
+ expect(results[0].distance).toBeCloseTo(5, 5);
425
+ });
426
+ });
427
+
428
+ describe('dot product', () => {
429
+ it('should compute dot product distance', async () => {
430
+ const dotIndex = new HNSWIndex({
431
+ dimensions: 4,
432
+ metric: 'dot',
433
+ });
434
+
435
+ const vector1 = createVector([1, 2, 3, 4]);
436
+ const vector2 = createVector([1, 1, 1, 1]); // Dot product = 10
437
+
438
+ await dotIndex.addPoint('id1', vector2);
439
+
440
+ const results = await dotIndex.search(vector1, 1);
441
+ // Dot distance is negative (higher dot product = more similar = lower distance)
442
+ expect(results[0].distance).toBeCloseTo(-10, 5);
443
+ });
444
+ });
445
+
446
+ describe('manhattan', () => {
447
+ it('should compute manhattan distance correctly', async () => {
448
+ const manhattanIndex = new HNSWIndex({
449
+ dimensions: 3,
450
+ metric: 'manhattan',
451
+ });
452
+
453
+ const vector1 = createVector([0, 0, 0]);
454
+ const vector2 = createVector([1, 2, 3]); // Manhattan distance = 6
455
+
456
+ await manhattanIndex.addPoint('id1', vector2);
457
+
458
+ const results = await manhattanIndex.search(vector1, 1);
459
+ expect(results[0].distance).toBeCloseTo(6, 5);
460
+ });
461
+ });
462
+ });
463
+
464
+ describe('quantization', () => {
465
+ describe('binary quantization', () => {
466
+ it('should work with binary quantization', async () => {
467
+ const quantizedIndex = new HNSWIndex({
468
+ dimensions: 32,
469
+ quantization: {
470
+ enabled: true,
471
+ type: 'binary',
472
+ bits: 1,
473
+ method: 'scalar',
474
+ },
475
+ });
476
+
477
+ await quantizedIndex.addPoint('id1', randomVector(32));
478
+ await quantizedIndex.addPoint('id2', randomVector(32));
479
+
480
+ const results = await quantizedIndex.search(randomVector(32), 2);
481
+ expect(results.length).toBe(2);
482
+
483
+ const stats = quantizedIndex.getStats();
484
+ expect(stats.compressionRatio).toBe(32);
485
+ });
486
+ });
487
+
488
+ describe('scalar quantization', () => {
489
+ it('should work with scalar quantization', async () => {
490
+ const quantizedIndex = new HNSWIndex({
491
+ dimensions: 16,
492
+ quantization: {
493
+ enabled: true,
494
+ type: 'scalar',
495
+ bits: 8,
496
+ method: 'scalar',
497
+ },
498
+ });
499
+
500
+ await quantizedIndex.addPoint('id1', randomVector(16));
501
+ await quantizedIndex.addPoint('id2', randomVector(16));
502
+
503
+ const results = await quantizedIndex.search(randomVector(16), 2);
504
+ expect(results.length).toBe(2);
505
+
506
+ const stats = quantizedIndex.getStats();
507
+ expect(stats.compressionRatio).toBe(4); // 32/8
508
+ });
509
+ });
510
+
511
+ describe('product quantization', () => {
512
+ it('should work with product quantization', async () => {
513
+ const quantizedIndex = new HNSWIndex({
514
+ dimensions: 32,
515
+ quantization: {
516
+ enabled: true,
517
+ type: 'product',
518
+ bits: 8,
519
+ method: 'product',
520
+ subquantizers: 8,
521
+ },
522
+ });
523
+
524
+ await quantizedIndex.addPoint('id1', randomVector(32));
525
+ await quantizedIndex.addPoint('id2', randomVector(32));
526
+
527
+ const results = await quantizedIndex.search(randomVector(32), 2);
528
+ expect(results.length).toBe(2);
529
+
530
+ const stats = quantizedIndex.getStats();
531
+ expect(stats.compressionRatio).toBe(8);
532
+ });
533
+ });
534
+ });
535
+
536
+ describe('default configuration', () => {
537
+ it('should use default values when not specified', async () => {
538
+ const defaultIndex = new HNSWIndex({});
539
+
540
+ // Should use default dimensions (1536 for OpenAI)
541
+ const vector = randomVector(1536);
542
+ await defaultIndex.addPoint('id1', vector);
543
+
544
+ expect(defaultIndex.size).toBe(1);
545
+ });
546
+ });
547
+
548
+ describe('concurrent operations', () => {
549
+ it('should handle concurrent insertions', async () => {
550
+ const promises = [];
551
+ for (let i = 0; i < 20; i++) {
552
+ promises.push(index.addPoint(`id${i}`, randomVector(dimensions)));
553
+ }
554
+
555
+ await Promise.all(promises);
556
+ expect(index.size).toBe(20);
557
+ });
558
+
559
+ it('should handle concurrent searches', async () => {
560
+ // Add some points first
561
+ for (let i = 0; i < 10; i++) {
562
+ await index.addPoint(`id${i}`, randomVector(dimensions));
563
+ }
564
+
565
+ // Concurrent searches
566
+ const promises = [];
567
+ for (let i = 0; i < 10; i++) {
568
+ promises.push(index.search(randomVector(dimensions), 5));
569
+ }
570
+
571
+ const results = await Promise.all(promises);
572
+ for (const result of results) {
573
+ expect(result.length).toBeLessThanOrEqual(5);
574
+ }
575
+ });
576
+ });
577
+
578
+ describe('edge cases', () => {
579
+ it('should handle single element index', async () => {
580
+ await index.addPoint('only', randomVector(dimensions));
581
+
582
+ const results = await index.search(randomVector(dimensions), 10);
583
+ expect(results).toHaveLength(1);
584
+ expect(results[0].id).toBe('only');
585
+ });
586
+
587
+ it('should handle searching for more results than exist', async () => {
588
+ await index.addPoint('id1', randomVector(dimensions));
589
+ await index.addPoint('id2', randomVector(dimensions));
590
+
591
+ const results = await index.search(randomVector(dimensions), 100);
592
+ expect(results).toHaveLength(2);
593
+ });
594
+
595
+ it('should handle zero vector', async () => {
596
+ const zeroVector = createVector([0, 0, 0, 0, 0, 0, 0, 0]);
597
+ await index.addPoint('zero', zeroVector);
598
+
599
+ expect(index.has('zero')).toBe(true);
600
+ });
601
+
602
+ it('should handle very similar vectors', async () => {
603
+ const base = createVector([1, 0, 0, 0, 0, 0, 0, 0]);
604
+ const almostSame = createVector([0.99, 0.01, 0, 0, 0, 0, 0, 0]);
605
+
606
+ await index.addPoint('base', base);
607
+ await index.addPoint('almost', almostSame);
608
+
609
+ const results = await index.search(base, 2);
610
+ // Both should be found, with base being closer (distance closer to 0)
611
+ expect(results.length).toBe(2);
612
+ // The base vector should have distance very close to 0
613
+ const baseResult = results.find((r) => r.id === 'base');
614
+ expect(baseResult).toBeDefined();
615
+ expect(baseResult!.distance).toBeCloseTo(0, 3);
616
+ });
617
+ });
618
+
619
+ describe('large scale', () => {
620
+ it('should handle 100 vectors efficiently', async () => {
621
+ const largeIndex = new HNSWIndex({
622
+ dimensions: 64,
623
+ M: 16,
624
+ efConstruction: 100,
625
+ maxElements: 10000,
626
+ });
627
+
628
+ const startInsert = performance.now();
629
+ for (let i = 0; i < 100; i++) {
630
+ await largeIndex.addPoint(`id${i}`, randomVector(64));
631
+ }
632
+ const insertTime = performance.now() - startInsert;
633
+
634
+ expect(largeIndex.size).toBe(100);
635
+ expect(insertTime).toBeLessThan(5000); // Should complete in 5 seconds
636
+
637
+ const startSearch = performance.now();
638
+ const results = await largeIndex.search(randomVector(64), 10);
639
+ const searchTime = performance.now() - startSearch;
640
+
641
+ expect(results).toHaveLength(10);
642
+ expect(searchTime).toBeLessThan(100); // Search should be fast
643
+ });
644
+ });
645
+
646
+ describe('quantization edge cases', () => {
647
+ it('should handle no quantization type', async () => {
648
+ const noQuantIndex = new HNSWIndex({
649
+ dimensions: 8,
650
+ quantization: {
651
+ enabled: true,
652
+ type: 'none',
653
+ bits: 8,
654
+ method: 'scalar',
655
+ },
656
+ });
657
+
658
+ await noQuantIndex.addPoint('id1', randomVector(8));
659
+ const results = await noQuantIndex.search(randomVector(8), 1);
660
+ expect(results.length).toBe(1);
661
+
662
+ const stats = noQuantIndex.getStats();
663
+ expect(stats.compressionRatio).toBe(1); // No compression with 'none' type
664
+ });
665
+ });
666
+
667
+ describe('multiple layers', () => {
668
+ it('should build multi-level graph with many insertions', async () => {
669
+ const multiLevelIndex = new HNSWIndex({
670
+ dimensions: 8,
671
+ M: 4,
672
+ efConstruction: 20,
673
+ maxElements: 200,
674
+ });
675
+
676
+ // Insert enough points to likely have multiple levels
677
+ for (let i = 0; i < 50; i++) {
678
+ await multiLevelIndex.addPoint(`id${i}`, randomVector(8));
679
+ }
680
+
681
+ expect(multiLevelIndex.size).toBe(50);
682
+
683
+ // Search should still work
684
+ const results = await multiLevelIndex.search(randomVector(8), 5);
685
+ expect(results.length).toBe(5);
686
+ });
687
+ });
688
+
689
+ describe('connection pruning', () => {
690
+ it('should prune connections when graph is dense', async () => {
691
+ // Use small M to force pruning
692
+ const denseIndex = new HNSWIndex({
693
+ dimensions: 4,
694
+ M: 2,
695
+ efConstruction: 10,
696
+ maxElements: 100,
697
+ });
698
+
699
+ // Insert points that will require pruning
700
+ for (let i = 0; i < 20; i++) {
701
+ await denseIndex.addPoint(`id${i}`, randomVector(4));
702
+ }
703
+
704
+ expect(denseIndex.size).toBe(20);
705
+
706
+ // Should still be searchable after pruning
707
+ const results = await denseIndex.search(randomVector(4), 5);
708
+ expect(results.length).toBe(5);
709
+ });
710
+ });
711
+
712
+ describe('ef parameter', () => {
713
+ it('should use custom ef for search', async () => {
714
+ for (let i = 0; i < 30; i++) {
715
+ await index.addPoint(`id${i}`, randomVector(dimensions));
716
+ }
717
+
718
+ // Search with different ef values
719
+ const lowEf = await index.search(randomVector(dimensions), 5, 10);
720
+ const highEf = await index.search(randomVector(dimensions), 5, 100);
721
+
722
+ // Both should return 5 results
723
+ expect(lowEf.length).toBe(5);
724
+ expect(highEf.length).toBe(5);
725
+ });
726
+ });
727
+ });