UNPKG

@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
/** * 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, }); }); });