UNPKG

@claude-flow/embeddings

Version:

V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking

157 lines (141 loc) • 6.73 kB
/** * ADR-121 Phase 1 — tests for RuvectorOnnxEmbeddingService. * * Fast unit tests that don't require downloading the 23MB MiniLM model: * - Service construction + provider tag * - Lazy load: optional peer dep missing → clean error naming the dep * - Factory wiring: createEmbeddingService({ provider: 'ruvector-onnx' }) * returns the right concrete class * - Mocked-embedder round-trip: embed/embedBatch shape, normalization, * cache behavior * * Live model parity vs Xenova baseline runs in a separate slow-path * test (gated by an env flag) and in the CI smoke — keeps the * default test suite under 1s. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RuvectorOnnxEmbeddingService, createRuvectorOnnxEmbeddingService, } from '../ruvector-onnx-embedding-service.js'; import { createEmbeddingService } from '../embedding-service.js'; describe('RuvectorOnnxEmbeddingService — construction + factory wiring', () => { it('constructs with provider tag "ruvector-onnx"', () => { const svc = createRuvectorOnnxEmbeddingService(); expect(svc).toBeInstanceOf(RuvectorOnnxEmbeddingService); expect(svc.provider).toBe('ruvector-onnx'); }); it('factory routes provider: "ruvector-onnx" to the right class', () => { const svc = createEmbeddingService({ provider: 'ruvector-onnx' }); expect(svc).toBeInstanceOf(RuvectorOnnxEmbeddingService); }); it('accepts a custom model name on the config', () => { const svc = createRuvectorOnnxEmbeddingService({ model: 'bge-small-en-v1.5' }); // Internal field — assert it survived through the constructor. expect((svc as unknown as { modelName: string }).modelName).toBe('bge-small-en-v1.5'); }); it('defaults normalize=true when omitted', () => { const svc = createRuvectorOnnxEmbeddingService(); expect((svc as unknown as { normalizeOutput: boolean }).normalizeOutput).toBe(true); }); it('honors normalize=false when supplied', () => { const svc = createRuvectorOnnxEmbeddingService({ normalize: false }); expect((svc as unknown as { normalizeOutput: boolean }).normalizeOutput).toBe(false); }); }); describe('RuvectorOnnxEmbeddingService — optional peer dep contract', () => { it('surfaces a clear, named error when the WASM runtime fails to initialize', async () => { // The dep is installed via the @ruvector workspace, but the // upstream wasm-bindgen output currently mismatches ESM vs CJS in // its packaging (the .js wrapper references `module` from an ESM // context). The service must catch the runtime failure and // surface a *named* error so operators don't waste time // diagnosing a generic "module is not defined" trace. // // This test asserts the error message: // 1. names the dep // 2. names the model that failed // 3. recommends the `transformers` fallback so callers aren't stuck const svc = createRuvectorOnnxEmbeddingService({ model: 'all-MiniLM-L6-v2' }); await expect(svc.embed('hello world')).rejects.toThrow(/ruvector-onnx-embeddings-wasm/); await expect(svc.embed('hello world')).rejects.toThrow(/all-MiniLM-L6-v2/); await expect(svc.embed('hello world')).rejects.toThrow(/provider: 'transformers'/); }); }); describe('RuvectorOnnxEmbeddingService — mocked WASM embedder round-trip', () => { // Build a synthetic embedder that returns deterministic vectors, // then inject it into the service's lazy-initialize path so we can // exercise embed / embedBatch / cache / normalization without a real // WASM module loaded. let svc: RuvectorOnnxEmbeddingService; const DIM = 4; beforeEach(() => { svc = createRuvectorOnnxEmbeddingService(); // Inject a synthetic embedder. Each text → vector = [textLen, 0, 0, 0] // (deterministic, distinct per length, easy to assert). const fakeEmbedder = { embedBatch(texts: string[]): Float32Array { const flat = new Float32Array(texts.length * DIM); for (let i = 0; i < texts.length; i++) { flat[i * DIM] = texts[i]!.length; } return flat; }, dimension() { return DIM; }, maxLength() { return 256; }, similarity() { return 0; }, free() { /* no-op */ }, }; // Reach in via the same shape the real initialize() would set. (svc as unknown as { embedder: unknown }).embedder = fakeEmbedder; (svc as unknown as { dimensionCache: number }).dimensionCache = DIM; }); it('embed() returns a Float32Array of the model dimension', async () => { const result = await svc.embed('hi'); expect(result.embedding).toBeInstanceOf(Float32Array); expect(result.embedding.length).toBe(DIM); }); it('embed() L2-normalizes by default (norm === 1)', async () => { const result = await svc.embed('hi'); let sumSq = 0; for (let i = 0; i < result.embedding.length; i++) { sumSq += result.embedding[i]! * result.embedding[i]!; } expect(Math.sqrt(sumSq)).toBeCloseTo(1.0, 5); }); it('embed() caches the result so a second call is a hit', async () => { const first = await svc.embed('hello'); expect(first.cached).toBeUndefined(); const second = await svc.embed('hello'); expect(second.cached).toBe(true); expect(second.latencyMs).toBe(0); // Same Float32Array instance (cache returns reference, not copy). expect(second.embedding).toBe(first.embedding); }); it('embedBatch() returns one Float32Array per input text', async () => { const result = await svc.embedBatch(['a', 'bb', 'ccc']); expect(result.embeddings).toHaveLength(3); for (const emb of result.embeddings) { expect(emb).toBeInstanceOf(Float32Array); expect(emb.length).toBe(DIM); } }); it('embedBatch() separates cached from uncached + reports cache stats', async () => { await svc.embed('cached-already'); const result = await svc.embedBatch(['cached-already', 'fresh-1', 'fresh-2']); expect(result.cacheStats?.hits).toBe(1); expect(result.cacheStats?.misses).toBe(2); }); it('embedBatch() preserves input order', async () => { // length-of-text encoded in slot 0 of the vector pre-normalize; // post-normalize the order is preserved (single non-zero slot). await svc.embed('aa'); // pre-cache length-2 const result = await svc.embedBatch(['ccc', 'aa', 'dddd']); // ccc → length 3 → vector [3, 0, 0, 0] / 3 = [1, 0, 0, 0] // aa → cached from earlier // dddd → length 4 → [1, 0, 0, 0] (normalized) expect(result.embeddings[0]![0]).toBeCloseTo(1.0, 5); expect(result.embeddings[1]![0]).toBeCloseTo(1.0, 5); expect(result.embeddings[2]![0]).toBeCloseTo(1.0, 5); }); });