Files
proffesor-for-testing__agen…/tests/unit/kernel/native-hnsw-backend.test.ts
Dragan Spiridonov da638f9079 feat(kernel): migrate NativeHnswBackend from @ruvector/router to hnswlib-node (#399)
@ruvector/router 0.1.28 was found to have four serious bugs that
together broke production code-intelligence semantic search and
forced the v3.9.5 hotfix to disable native HNSW entirely:

  1. HNSW search returned essentially random results — recall@10 was
     0% on the project's FNV fixture and 10% on textbook unit-Gaussian
     random vectors. The graph walk could not even find self-vectors
     when querying with a stored vector. Reproduced deterministically
     on linux-arm64; matches the linux-x64 GitHub Actions failures
     reported in #399.
  2. The VectorDb constructor unconditionally wrote a `vectors.db`
     redb file to the current working directory, polluting users'
     project roots with multi-MB files outside .agentic-qe/, in a
     non-SQLite format that no AQE migration or backup tooling knew
     about. Direct violation of the unified memory architecture.
  3. The redb file held a process-wide exclusive lock — only ONE
     VectorDb instance could exist per process. Subsequent constructors
     threw "Database error: Database already open. Cannot acquire lock."
  4. NAPI dispose did not synchronously release the redb lock (NAPI GC
     is not synchronous). The lock outlived dispose() and caused the
     v3.9.5 futex deadlock when the indexer tried to recreate after
     reset.

NativeHnswBackend is rewritten to wrap hnswlib-node@^3.0.0 — the
canonical Node.js binding for Yury Malkov's reference C++ Hnswlib
implementation, used by Pinecone, Weaviate, Qdrant, LangChain, and
ChromaDB. Empirical verification on the same fixture
(`scripts/diagnose-issue-399*.mjs`):

  - hnswlib-node:        recall@10 = 100%, top-1 = id 42 ✓
  - @ruvector/router:    recall@10 =  10%, top-1 = id 999 (random)

Insert is also faster (~324ms vs ~430ms for 1k vectors) and search
is comparable (0.35ms vs 0.21ms with correct vs wrong results).

No new dependency was added — `hnswlib-node` was already a direct
dependency in `package.json` from the legacy code path in
`src/integrations/embeddings/index/HNSWIndex.ts`.

Implementation notes:

  - Drops the local `vectorStore` mirror that the previous backend
    needed to re-score wrong native results — hnswlib-node returns
    correct distances directly.
  - Drops the `bruteForceSearch` private fallback — there is no
    fallback path because hnswlib-node always returns correct results.
  - `clear()` now recreates the underlying HierarchicalNSW from
    scratch instead of just markDelete-ing every label. The
    tombstone-only approach leaks state across long test runs that
    share the singleton HnswAdapter registry, producing duplicate
    labels from `searchKnn` after many cycles.
  - `search()` overshoots k by 2x and dedups by id as a defensive
    measure against any future hnswlib-node duplicate-label edge
    case, then truncates back to k.
  - `add()` doubles maxElements via `resizeIndex()` when the live id
    count fills the current capacity, so callers don't need to know
    the final index size at construction time.
  - `useNativeHNSW` default flipped back to `true`. Users with large
    codebases get sublinear HNSW search by default again.
  - All four `@ruvector/router` failure modes have explicit regression
    guards in the unit and integration test suites.

The two #399-skipped tests in `phase1-integration.test.ts`
(line 222 and 1185) are un-skipped and now pass green against the
new backend. The three flag-default tests that asserted
`useNativeHNSW: false` (broken by v3.9.5's hotfix) are updated to
assert `true` again.

Stale comments referencing `@ruvector/router`'s lock behavior are
updated in `src/kernel/unified-memory.ts`, `src/init/phases/06-code-intelligence.ts`,
`src/init/phases/10-workers.ts`, and `src/cli/index.ts`. The CLI
status output (`aqe ruvector status`) and the `useNativeHNSW` flag
description are updated to mention `hnswlib-node` instead of
`@ruvector/router`.

Refs #399, ADR-090.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:22:24 +00:00

740 lines
26 KiB
TypeScript

/**
* Native HNSW Backend Unit Tests
*
* Tests for the NativeHnswBackend which wraps hnswlib-node HierarchicalNSW
* (post #399 / ADR-090 — previously wrapped @ruvector/router VectorDb).
*
* These tests verify:
* 1. Module loading and HierarchicalNSW creation
* 2. Feature flag integration (useNativeHNSW)
* 3. The NativeHnswUnavailableError is thrown correctly
* 4. HnswAdapter backend selection based on feature flags
* 5. IHnswIndexProvider contract compliance
* 6. Concurrent access safety (multiple instances coexist)
*
* @module tests/unit/kernel/native-hnsw-backend
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
NativeHnswBackend,
NativeHnswUnavailableError,
resetNativeModuleLoader,
isNativeModuleAvailable,
} from '../../../src/kernel/native-hnsw-backend';
import type { NativeHnswMetrics } from '../../../src/kernel/native-hnsw-backend';
import { HnswAdapter } from '../../../src/kernel/hnsw-adapter';
import { ProgressiveHnswBackend } from '../../../src/kernel/progressive-hnsw-backend';
import {
setRuVectorFeatureFlags,
resetRuVectorFeatureFlags,
getRuVectorFeatureFlags,
isNativeHNSWEnabled,
} from '../../../src/integrations/ruvector/feature-flags';
import type { IHnswIndexProvider, SearchResult } from '../../../src/kernel/hnsw-index-provider';
// ============================================================================
// Test Helpers
// ============================================================================
/**
* Create a random Float32Array vector of the given dimension.
*/
function randomVector(dim: number): Float32Array {
const v = new Float32Array(dim);
for (let i = 0; i < dim; i++) {
v[i] = Math.random() * 2 - 1;
}
return v;
}
/**
* Create a deterministic vector for reproducible tests.
* The vector is normalized to unit length.
*/
function deterministicVector(dim: number, seed: number): Float32Array {
const v = new Float32Array(dim);
let norm = 0;
for (let i = 0; i < dim; i++) {
// Simple deterministic pseudo-random based on seed and index
v[i] = Math.sin(seed * 1000 + i * 7.3) * Math.cos(i * 3.1 + seed);
norm += v[i] * v[i];
}
// Normalize to unit length
norm = Math.sqrt(norm);
if (norm > 0) {
for (let i = 0; i < dim; i++) {
v[i] /= norm;
}
}
return v;
}
// ============================================================================
// NativeHnswBackend Tests
// ============================================================================
describe('NativeHnswBackend', () => {
beforeEach(() => {
resetNativeModuleLoader();
resetRuVectorFeatureFlags();
});
afterEach(() => {
resetNativeModuleLoader();
resetRuVectorFeatureFlags();
HnswAdapter.closeAll();
});
// ===========================================================================
// NativeHnswUnavailableError Tests
// ===========================================================================
describe('NativeHnswUnavailableError', () => {
it('should have correct error name', () => {
const error = new NativeHnswUnavailableError('test reason');
expect(error.name).toBe('NativeHnswUnavailableError');
});
it('should include reason in message', () => {
const error = new NativeHnswUnavailableError('binary not found');
expect(error.message).toContain('binary not found');
expect(error.message).toContain('Native HNSW backend unavailable');
});
it('should be instance of Error', () => {
const error = new NativeHnswUnavailableError('test');
expect(error).toBeInstanceOf(Error);
});
});
// ===========================================================================
// Constructor / Native Module Loading Tests
// ===========================================================================
describe('Constructor', () => {
it('should create a NativeHnswBackend successfully', () => {
// hnswlib-node has no process-wide lock, no shared file, no NAPI
// singleton — multiple instances can coexist freely. The previous
// try/catch around lock contention was a workaround for an
// @ruvector/router 0.1.28 bug (issue #399) that no longer applies.
const backend = new NativeHnswBackend();
expect(backend).toBeInstanceOf(NativeHnswBackend);
expect(backend.isNativeAvailable()).toBe(true);
backend.dispose();
});
it('should have correct NativeHnswUnavailableError type', () => {
const error = new NativeHnswUnavailableError('test');
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('NativeHnswUnavailableError');
});
});
// ===========================================================================
// Module Availability Tests
// ===========================================================================
describe('isNativeModuleAvailable', () => {
it('should return true when hnswlib-node is installed', () => {
expect(isNativeModuleAvailable()).toBe(true);
});
it('should cache the result of module loading', () => {
const first = isNativeModuleAvailable();
const second = isNativeModuleAvailable();
expect(first).toBe(second);
expect(first).toBe(true);
});
it('should return true after reset and retry', () => {
isNativeModuleAvailable();
resetNativeModuleLoader();
expect(isNativeModuleAvailable()).toBe(true);
});
});
// ===========================================================================
// resetNativeModuleLoader Tests
// ===========================================================================
describe('resetNativeModuleLoader', () => {
it('should allow retry after reset', () => {
expect(isNativeModuleAvailable()).toBe(true);
resetNativeModuleLoader();
expect(isNativeModuleAvailable()).toBe(true);
});
it('should not throw', () => {
expect(() => resetNativeModuleLoader()).not.toThrow();
});
});
// ===========================================================================
// Feature Flag Integration Tests
// ===========================================================================
describe('Feature Flag: useNativeHNSW', () => {
// History:
// - v3.9.5: useNativeHNSW default flipped from true to false because
// the @ruvector/router VectorDb deadlocked (futex wait, never resolved)
// when inserting certain vector content shapes — observed against
// examples/ruview_live.py from the RuView project.
// - issue #399 / ADR-090 (April 2026): NativeHnswBackend was rewritten
// to wrap hnswlib-node (the canonical Hnswlib reference implementation
// by Yury Malkov) instead of @ruvector/router. Empirical verification
// showed @ruvector/router 0.1.28 had four bugs: (1) HNSW returned
// essentially random results, recall ~0% on textbook fixtures;
// (2) auto-created vectors.db in CWD; (3) process-wide singleton
// lock; (4) unreleased file lock on dispose (root cause of v3.9.5
// deadlock). hnswlib-node fixes all four. Default flipped back to
// true so users with large codebases get real sublinear HNSW search.
it('should default to true after the hnswlib-node migration (#399)', () => {
const flags = getRuVectorFeatureFlags();
expect(flags.useNativeHNSW).toBe(true);
});
it('should be checkable via convenience function', () => {
expect(isNativeHNSWEnabled()).toBe(true);
});
it('should be settable via setRuVectorFeatureFlags', () => {
setRuVectorFeatureFlags({ useNativeHNSW: false });
expect(isNativeHNSWEnabled()).toBe(false);
setRuVectorFeatureFlags({ useNativeHNSW: true });
expect(isNativeHNSWEnabled()).toBe(true);
});
it('should not affect other flags when set', () => {
const before = getRuVectorFeatureFlags();
setRuVectorFeatureFlags({ useNativeHNSW: false });
const after = getRuVectorFeatureFlags();
expect(after.useQESONA).toBe(before.useQESONA);
expect(after.useQEFlashAttention).toBe(before.useQEFlashAttention);
expect(after.useQEGNNIndex).toBe(before.useQEGNNIndex);
expect(after.logMigrationMetrics).toBe(before.logMigrationMetrics);
});
it('should reset to true on resetRuVectorFeatureFlags (#399 default)', () => {
setRuVectorFeatureFlags({ useNativeHNSW: false });
resetRuVectorFeatureFlags();
expect(isNativeHNSWEnabled()).toBe(true);
});
it('should support environment variable RUVECTOR_USE_NATIVE_HNSW', async () => {
// Dynamically import to test env var parsing
const { initFeatureFlagsFromEnv } = await import(
'../../../src/integrations/ruvector/feature-flags'
);
process.env.RUVECTOR_USE_NATIVE_HNSW = 'false';
initFeatureFlagsFromEnv();
expect(isNativeHNSWEnabled()).toBe(false);
process.env.RUVECTOR_USE_NATIVE_HNSW = 'true';
initFeatureFlagsFromEnv();
expect(isNativeHNSWEnabled()).toBe(true);
delete process.env.RUVECTOR_USE_NATIVE_HNSW;
});
});
// ===========================================================================
// HnswAdapter Backend Selection Tests
// ===========================================================================
describe('HnswAdapter Backend Selection', () => {
it('should use ProgressiveHnswBackend when useNativeHNSW is false', () => {
setRuVectorFeatureFlags({ useNativeHNSW: false });
const adapter = HnswAdapter.create('test-js-backend');
expect(adapter.isNativeBackend()).toBe(false);
});
it('should attempt native backend when useNativeHNSW is true', () => {
setRuVectorFeatureFlags({ useNativeHNSW: true });
const adapter = HnswAdapter.create('test-native-enabled');
// @ruvector/router is available but VectorDb may have lock contention
// Either native or JS fallback is acceptable
expect(adapter.size()).toBe(0);
expect(adapter.dimensions()).toBe(384);
});
it('should create working adapter with JS backend when flag is off', () => {
setRuVectorFeatureFlags({ useNativeHNSW: false });
const adapter = HnswAdapter.create('test-working-js');
adapter.add(0, deterministicVector(384, 1));
adapter.add(1, deterministicVector(384, 2));
expect(adapter.size()).toBe(2);
expect(adapter.dimensions()).toBe(384);
const results = adapter.search(deterministicVector(384, 1), 1);
expect(results).toHaveLength(1);
expect(results[0].id).toBe(0);
});
});
// ===========================================================================
// IHnswIndexProvider Contract Compliance
// (Tests via ProgressiveHnswBackend fallback to verify the adapter contract)
// ===========================================================================
describe('IHnswIndexProvider Contract (via fallback)', () => {
let adapter: HnswAdapter;
beforeEach(() => {
// Use the default JS backend for contract tests
setRuVectorFeatureFlags({ useNativeHNSW: false });
adapter = new HnswAdapter('contract-test');
});
afterEach(() => {
adapter.clear();
});
it('should add and search vectors correctly', () => {
const v1 = deterministicVector(384, 1);
const v2 = deterministicVector(384, 2);
const v3 = deterministicVector(384, 3);
adapter.add(0, v1, { label: 'first' });
adapter.add(1, v2, { label: 'second' });
adapter.add(2, v3, { label: 'third' });
expect(adapter.size()).toBe(3);
// Search for nearest to v1, should return v1 as top result
const results = adapter.search(v1, 2);
expect(results).toHaveLength(2);
expect(results[0].id).toBe(0);
expect(results[0].score).toBeGreaterThan(0.9); // Self-similarity ~1.0
});
it('should return correct dimensions', () => {
expect(adapter.dimensions()).toBe(384);
});
it('should return recall between 0 and 1', () => {
const recall = adapter.recall();
expect(recall).toBeGreaterThanOrEqual(0);
expect(recall).toBeLessThanOrEqual(1);
});
it('should remove vectors', () => {
adapter.add(0, deterministicVector(384, 1));
adapter.add(1, deterministicVector(384, 2));
expect(adapter.size()).toBe(2);
const removed = adapter.remove(0);
expect(removed).toBe(true);
expect(adapter.size()).toBe(1);
// Removing non-existent should return false
expect(adapter.remove(999)).toBe(false);
});
it('should return empty results for empty index', () => {
const results = adapter.search(deterministicVector(384, 1), 5);
expect(results).toHaveLength(0);
});
it('should handle k larger than index size', () => {
adapter.add(0, deterministicVector(384, 1));
const results = adapter.search(deterministicVector(384, 1), 100);
expect(results).toHaveLength(1);
});
it('should return results sorted by descending score', () => {
for (let i = 0; i < 10; i++) {
adapter.add(i, deterministicVector(384, i));
}
const results = adapter.search(deterministicVector(384, 0), 5);
expect(results).toHaveLength(5);
// Verify descending score order
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
}
});
it('should handle dimension mismatch via auto-resize', () => {
// Add a 384-dim vector
adapter.add(0, deterministicVector(384, 1));
// Search with a different-dimension vector (should auto-resize)
const shortQuery = deterministicVector(128, 1);
const results = adapter.search(shortQuery, 1);
expect(results).toHaveLength(1);
});
it('should support metadata on search results', () => {
adapter.add(0, deterministicVector(384, 1), { type: 'test', count: 42 });
const results = adapter.search(deterministicVector(384, 1), 1);
expect(results[0].metadata).toEqual({ type: 'test', count: 42 });
});
});
// ===========================================================================
// Search Accuracy Tests
// ===========================================================================
describe('Search Accuracy', () => {
let adapter: HnswAdapter;
beforeEach(() => {
adapter = new HnswAdapter('accuracy-test');
});
afterEach(() => {
adapter.clear();
});
it('should find exact match with highest score', () => {
const target = deterministicVector(384, 42);
adapter.add(0, target);
// Add other vectors
for (let i = 1; i < 20; i++) {
adapter.add(i, deterministicVector(384, i * 100));
}
const results = adapter.search(target, 1);
expect(results[0].id).toBe(0);
// Cosine similarity of identical vectors should be ~1.0
expect(results[0].score).toBeCloseTo(1.0, 2);
});
it('should distinguish between similar and dissimilar vectors', () => {
// Create a base vector and a slightly perturbed version
const base = deterministicVector(384, 1);
const similar = new Float32Array(base);
similar[0] += 0.01; // Small perturbation
const dissimilar = deterministicVector(384, 999);
adapter.add(0, base);
adapter.add(1, similar);
adapter.add(2, dissimilar);
const results = adapter.search(base, 3);
// Base should be most similar to itself
expect(results[0].id).toBe(0);
// Similar should rank higher than dissimilar
const similarResult = results.find((r) => r.id === 1);
const dissimilarResult = results.find((r) => r.id === 2);
expect(similarResult).toBeDefined();
expect(dissimilarResult).toBeDefined();
expect(similarResult!.score).toBeGreaterThan(dissimilarResult!.score);
});
});
// ===========================================================================
// Concurrent Access Tests
// ===========================================================================
describe('Concurrent Access', () => {
let adapter: HnswAdapter;
beforeEach(() => {
adapter = new HnswAdapter('concurrent-test');
});
afterEach(() => {
adapter.clear();
});
it('should handle rapid sequential add/search/remove', () => {
const dim = 384;
// Rapid adds
for (let i = 0; i < 100; i++) {
adapter.add(i, randomVector(dim));
}
expect(adapter.size()).toBe(100);
// Concurrent-like searches
const searchPromises: SearchResult[][] = [];
for (let i = 0; i < 10; i++) {
searchPromises.push(adapter.search(randomVector(dim), 5));
}
// All searches should return valid results
for (const results of searchPromises) {
expect(results.length).toBeGreaterThan(0);
expect(results.length).toBeLessThanOrEqual(5);
}
// Rapid removes
for (let i = 0; i < 50; i++) {
adapter.remove(i);
}
expect(adapter.size()).toBe(50);
});
it('should handle interleaved add and search operations', () => {
const dim = 384;
for (let round = 0; round < 5; round++) {
// Add batch
for (let i = round * 10; i < (round + 1) * 10; i++) {
adapter.add(i, randomVector(dim));
}
// Search should reflect current state
const results = adapter.search(randomVector(dim), 3);
expect(results.length).toBeGreaterThan(0);
expect(results.length).toBeLessThanOrEqual(3);
}
expect(adapter.size()).toBe(50);
});
it('should handle add with same ID (update)', () => {
const v1 = deterministicVector(384, 1);
const v2 = deterministicVector(384, 2);
adapter.add(0, v1);
expect(adapter.size()).toBe(1);
// Update same ID with different vector
adapter.add(0, v2);
expect(adapter.size()).toBe(1);
// Search should find the updated vector
const results = adapter.search(v2, 1);
expect(results[0].id).toBe(0);
expect(results[0].score).toBeCloseTo(1.0, 2);
});
});
// ===========================================================================
// Backward Compatibility Tests
// ===========================================================================
describe('Backward Compatibility', () => {
it('should support string-based ID operations', () => {
const adapter = HnswAdapter.create('compat-test');
adapter.addByStringId('key-1', Array.from(deterministicVector(384, 1)));
adapter.addByStringId('key-2', Array.from(deterministicVector(384, 2)));
expect(adapter.size()).toBe(2);
const results = adapter.searchByArray(
Array.from(deterministicVector(384, 1)),
1
);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('key-1');
adapter.removeByStringId('key-1');
expect(adapter.size()).toBe(1);
});
it('should work with singleton registry', () => {
const a1 = HnswAdapter.create('singleton-test');
const a2 = HnswAdapter.create('singleton-test');
expect(a1).toBe(a2);
// Different name should create different instance
const a3 = HnswAdapter.create('singleton-test-2');
expect(a3).not.toBe(a1);
});
it('should list registered indexes', () => {
HnswAdapter.create('idx-a');
HnswAdapter.create('idx-b');
const indexes = HnswAdapter.listIndexes();
expect(indexes).toContain('idx-a');
expect(indexes).toContain('idx-b');
});
it('should close individual indexes', () => {
HnswAdapter.create('close-test');
expect(HnswAdapter.get('close-test')).toBeDefined();
HnswAdapter.close('close-test');
expect(HnswAdapter.get('close-test')).toBeUndefined();
});
});
// ===========================================================================
// NativeHnswMetrics Fallback Tracking Tests
// ===========================================================================
describe('NativeHnswMetrics Fallback Tracking', () => {
it('should define all fallback tracking fields on NativeHnswMetrics type', () => {
// Verify the NativeHnswMetrics interface includes the new fallback fields
// by creating a conformant object literal
const metrics: NativeHnswMetrics = {
totalSearches: 0,
totalAdds: 0,
totalRemoves: 0,
avgSearchLatencyMs: 0,
maxSearchLatencyMs: 0,
lastSearchLatencyMs: 0,
fallbackSearchCount: 0,
bruteForceSearchCount: 0,
nativeSearchCount: 0,
fallbackRate: 0,
allSearchesBruteForce: false,
};
expect(metrics.fallbackSearchCount).toBe(0);
expect(metrics.bruteForceSearchCount).toBe(0);
expect(metrics.nativeSearchCount).toBe(0);
expect(metrics.fallbackRate).toBe(0);
expect(metrics.allSearchesBruteForce).toBe(false);
});
it('should compute fallbackRate as ratio of fallback to total searches', () => {
const metrics: NativeHnswMetrics = {
totalSearches: 20,
totalAdds: 0,
totalRemoves: 0,
avgSearchLatencyMs: 0,
maxSearchLatencyMs: 0,
lastSearchLatencyMs: 0,
fallbackSearchCount: 10,
bruteForceSearchCount: 10,
nativeSearchCount: 10,
fallbackRate: 10 / 20,
allSearchesBruteForce: false,
};
expect(metrics.fallbackRate).toBeCloseTo(0.5);
expect(metrics.allSearchesBruteForce).toBe(false);
});
it('should set allSearchesBruteForce true when nativeSearchCount is zero', () => {
const metrics: NativeHnswMetrics = {
totalSearches: 5,
totalAdds: 0,
totalRemoves: 0,
avgSearchLatencyMs: 0,
maxSearchLatencyMs: 0,
lastSearchLatencyMs: 0,
fallbackSearchCount: 5,
bruteForceSearchCount: 5,
nativeSearchCount: 0,
fallbackRate: 1.0,
allSearchesBruteForce: true,
};
expect(metrics.nativeSearchCount).toBe(0);
expect(metrics.totalSearches).toBeGreaterThan(0);
expect(metrics.allSearchesBruteForce).toBe(true);
expect(metrics.fallbackRate).toBe(1.0);
});
it('should keep bruteForceSearchCount as alias for fallbackSearchCount', () => {
const metrics: NativeHnswMetrics = {
totalSearches: 10,
totalAdds: 0,
totalRemoves: 0,
avgSearchLatencyMs: 0,
maxSearchLatencyMs: 0,
lastSearchLatencyMs: 0,
fallbackSearchCount: 7,
bruteForceSearchCount: 7,
nativeSearchCount: 3,
fallbackRate: 0.7,
allSearchesBruteForce: false,
};
expect(metrics.bruteForceSearchCount).toBe(metrics.fallbackSearchCount);
});
});
// ===========================================================================
// NativeHnswUnavailableError Behavior Tests
// ===========================================================================
describe('NativeHnswUnavailableError behavior', () => {
it('should preserve error prototype chain', () => {
const error = new NativeHnswUnavailableError('test reason');
expect(error).toBeInstanceOf(NativeHnswUnavailableError);
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('NativeHnswUnavailableError');
expect(error.message).toBe('Native HNSW backend unavailable: test reason');
});
it('should have a stack trace', () => {
const error = new NativeHnswUnavailableError('stack test');
expect(error.stack).toBeDefined();
expect(error.stack).toContain('NativeHnswUnavailableError');
});
it('should be catchable by Error type', () => {
let caught = false;
try {
throw new NativeHnswUnavailableError('catch test');
} catch (e) {
if (e instanceof Error) {
caught = true;
expect(e.message).toContain('catch test');
}
}
expect(caught).toBe(true);
});
});
// ===========================================================================
// Edge Cases
// ===========================================================================
describe('Edge Cases', () => {
it('should handle zero vector', () => {
const adapter = new HnswAdapter('edge-zero');
const zeroVector = new Float32Array(384); // All zeros
adapter.add(0, zeroVector);
const results = adapter.search(zeroVector, 1);
// Zero vector has undefined cosine similarity, should handle gracefully
expect(results).toHaveLength(1);
expect(results[0].id).toBe(0);
});
it('should handle single-element index', () => {
const adapter = new HnswAdapter('edge-single');
adapter.add(0, deterministicVector(384, 1));
const results = adapter.search(deterministicVector(384, 2), 1);
expect(results).toHaveLength(1);
});
it('should handle k=0 search', () => {
const adapter = new HnswAdapter('edge-k0');
adapter.add(0, deterministicVector(384, 1));
const results = adapter.search(deterministicVector(384, 1), 0);
expect(results).toHaveLength(0);
});
it('should handle large batch add', () => {
const adapter = new HnswAdapter('edge-large');
const count = 500;
for (let i = 0; i < count; i++) {
adapter.add(i, randomVector(384));
}
expect(adapter.size()).toBe(count);
const results = adapter.search(randomVector(384), 10);
expect(results).toHaveLength(10);
adapter.clear();
expect(adapter.size()).toBe(0);
});
});
});