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