@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
147 lines (126 loc) • 5.41 kB
text/typescript
/**
* ADR-121 Phase 8 — AnnRouter tests.
*
* Two layers:
* 1. `decideBacking()` — pure-function routing rules. Tested
* against the documented table without instantiating anything.
* 2. `AnnRouter.create` + `search` — full lifecycle for the small/
* mutable case (HNSW path). The RaBitQ + DiskANN paths are
* exercised in CI smoke against the real native backends.
*/
import { describe, it, expect, vi } from 'vitest';
// Force the linear-scan fallback for HNSW so tests don't depend on the
// monorepo's pinned @ruvector/core. Also mock diskann absent so the
// HNSW fallback path triggers for the persistent-routing test.
vi.mock('@ruvector/core', () => ({}));
vi.mock('@ruvector/diskann', () => ({}));
import { AnnRouter, decideBacking } from '../ann-router.js';
const DIM = 8;
function vec(values: number[]): Float32Array {
if (values.length !== DIM) throw new Error('test bug');
return new Float32Array(values);
}
describe('decideBacking — routing rules (pure)', () => {
it('persistent=true → diskann (size irrelevant)', () => {
const d = decideBacking({ corpusSize: 10, dimension: 384, persistent: true });
expect(d.backing).toBe('diskann');
expect(d.reason).toMatch(/persistent/);
});
it('corpusSize ≥ 1M → diskann', () => {
const d = decideBacking({ corpusSize: 1_000_000, dimension: 384 });
expect(d.backing).toBe('diskann');
expect(d.reason).toMatch(/1M/);
});
it('corpusSize ≥ 100k AND !mutable → rabitq', () => {
const d = decideBacking({ corpusSize: 250_000, dimension: 384 });
expect(d.backing).toBe('rabitq');
expect(d.reason).toMatch(/RaBitQ/);
});
it('corpusSize ≥ 100k AND mutable=true → hnsw (RaBitQ is batch-only)', () => {
const d = decideBacking({ corpusSize: 250_000, dimension: 384, mutable: true });
expect(d.backing).toBe('hnsw');
expect(d.reason).toMatch(/mutable=true/);
});
it('default (small) → hnsw', () => {
const d = decideBacking({ corpusSize: 100, dimension: 384 });
expect(d.backing).toBe('hnsw');
expect(d.reason).toMatch(/small/);
});
it('reason is always a non-empty string', () => {
for (const wl of [
{ corpusSize: 10, dimension: 8 },
{ corpusSize: 10_000_000, dimension: 8 },
{ corpusSize: 500_000, dimension: 8 },
{ corpusSize: 10, dimension: 8, persistent: true },
]) {
const d = decideBacking(wl as any);
expect(typeof d.reason).toBe('string');
expect(d.reason.length).toBeGreaterThan(0);
}
});
});
describe('AnnRouter.create — peer-dep degradation', () => {
it('persistent=true throws when storagePath missing', async () => {
await expect(
AnnRouter.create({ corpusSize: 10, dimension: DIM, persistent: true }),
).rejects.toThrow(/storagePath/);
});
it('persistent=true degrades to HNSW when DiskANN unavailable', async () => {
const r = await AnnRouter.create({ corpusSize: 10, dimension: DIM, persistent: true, storagePath: '/tmp/nope' });
expect(r.decision.backing).toBe('hnsw');
expect(r.decision.degraded).toBe(true);
expect(r.decision.degradationReason).toMatch(/diskann/i);
});
it('corpusSize ≥ 1M degrades to HNSW when DiskANN unavailable', async () => {
const r = await AnnRouter.create({ corpusSize: 2_000_000, dimension: DIM });
expect(r.decision.backing).toBe('hnsw');
expect(r.decision.degraded).toBe(true);
});
it('small workload → HNSW directly (no degradation)', async () => {
const r = await AnnRouter.create({ corpusSize: 100, dimension: DIM });
expect(r.decision.backing).toBe('hnsw');
expect(r.decision.degraded).toBe(false);
});
});
describe('AnnRouter — HNSW end-to-end lifecycle', () => {
it('add → search returns hits with score + id', async () => {
const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
await r.add([
{ id: 'a', vector: vec([1, 0, 0, 0, 0, 0, 0, 0]) },
{ id: 'b', vector: vec([0, 1, 0, 0, 0, 0, 0, 0]) },
{ id: 'c', vector: vec([0, 0, 1, 0, 0, 0, 0, 0]) },
]);
await r.build(); // no-op for HNSW
const hits = await r.search(vec([1, 0, 0, 0, 0, 0, 0, 0]), 2);
expect(hits.length).toBe(2);
expect(hits[0]!.id).toBe('a');
expect(hits[0]!.score).toBeGreaterThan(0.99); // cosine sim ≈ 1
});
it('count() reports HNSW cache size', async () => {
const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
await r.add([
{ id: 'a', vector: vec([1, 0, 0, 0, 0, 0, 0, 0]) },
{ id: 'b', vector: vec([0, 1, 0, 0, 0, 0, 0, 0]) },
]);
expect(r.count()).toBe(2);
});
it('save() returns false for HNSW backing (in-memory only)', async () => {
const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
expect(r.save()).toBe(false);
});
it('search before add returns empty array', async () => {
const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
const hits = await r.search(vec([1, 0, 0, 0, 0, 0, 0, 0]), 3);
expect(hits).toEqual([]);
});
});
describe('AnnRouter — decision shape', () => {
it('decision exposes backing + reason + degraded fields', async () => {
const r = await AnnRouter.create({ corpusSize: 50, dimension: DIM });
expect(r.decision).toMatchObject({
backing: 'hnsw',
reason: expect.any(String),
degraded: false,
});
});
});