UNPKG

@claude-flow/embeddings

Version:

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

201 lines (174 loc) • 7.62 kB
/** * ADR-121 Phase 2 — tests for SearchableEmbeddingCache. * * Tests both code paths: * - HNSW backing via @ruvector/core (when the optional peer dep is * available and constructable) * - Linear-scan fallback (when @ruvector/core isn't usable) * * Whichever path is active in the test runner, the IEmbeddingCache * contract must hold (get/set/has/delete/search) and `stats().degraded` * must accurately reflect which path is live. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; // Force the LinearScanFallback path so tests are deterministic and // don't depend on the monorepo's pinned @ruvector/core (which has a // known dimension-default regression at the workspace-resolved // version). The fallback exercises the same IEmbeddingCache contract. // Real @ruvector/core HNSW backing is covered by the slow-path // benchmark + the CI smoke that runs against `@ruvector/core@latest`. vi.mock('@ruvector/core', () => ({})); import { SearchableEmbeddingCache, createSearchableEmbeddingCache, } from '../searchable-embedding-cache.js'; const DIM = 4; function vec(values: number[]): Float32Array { if (values.length !== DIM) throw new Error(`test bug: vec needs ${DIM} dims`); return new Float32Array(values); } describe('SearchableEmbeddingCache — contract', () => { let cache: SearchableEmbeddingCache; beforeEach(() => { cache = createSearchableEmbeddingCache({ dimension: DIM, metric: 'cosine' }); }); it('set then get returns the same vector', async () => { await cache.set('alpha', vec([1, 0, 0, 0])); const got = await cache.get('alpha'); expect(got).toBeInstanceOf(Float32Array); expect(Array.from(got!)).toEqual([1, 0, 0, 0]); }); it('get on missing key returns undefined and counts a miss', async () => { const got = await cache.get('nope'); expect(got).toBeUndefined(); expect(cache.stats().misses).toBeGreaterThan(0); }); it('has reports membership', async () => { await cache.set('foo', vec([0.5, 0.5, 0, 0])); expect(await cache.has('foo')).toBe(true); expect(await cache.has('bar')).toBe(false); }); it('delete removes the entry', async () => { await cache.set('to-remove', vec([1, 0, 0, 0])); expect(await cache.delete('to-remove')).toBe(true); expect(await cache.has('to-remove')).toBe(false); expect(await cache.delete('to-remove')).toBe(false); // second delete is a no-op }); it('rejects mismatched-dimension vectors on set', async () => { await expect( cache.set('bad', new Float32Array([1, 2, 3])), // 3 dims, cache is 4 ).rejects.toThrow(/dimension/i); }); it('rejects mismatched-dimension queries on search', async () => { await cache.set('valid', vec([1, 0, 0, 0])); await expect( cache.search(new Float32Array([1, 0])), ).rejects.toThrow(/dimension/i); }); it('updating the same key replaces the existing vector', async () => { await cache.set('key', vec([1, 0, 0, 0])); await cache.set('key', vec([0, 1, 0, 0])); expect(Array.from((await cache.get('key'))!)).toEqual([0, 1, 0, 0]); expect(cache.stats().size).toBe(1); }); }); describe('SearchableEmbeddingCache — search', () => { let cache: SearchableEmbeddingCache; beforeEach(async () => { cache = createSearchableEmbeddingCache({ dimension: DIM, metric: 'cosine' }); // Populate with orthogonal-ish vectors so cosine ranking is unambiguous. await cache.set('along-x', vec([1, 0, 0, 0])); await cache.set('along-y', vec([0, 1, 0, 0])); await cache.set('along-z', vec([0, 0, 1, 0])); await cache.set('along-w', vec([0, 0, 0, 1])); await cache.set('diagonal-xy', vec([0.7071, 0.7071, 0, 0])); }); it('returns empty array when the cache is empty', async () => { const empty = createSearchableEmbeddingCache({ dimension: DIM }); const hits = await empty.search(vec([1, 0, 0, 0]), 5); expect(hits).toEqual([]); }); it('ranks identical vectors highest', async () => { const hits = await cache.search(vec([1, 0, 0, 0]), 3); expect(hits.length).toBeGreaterThan(0); expect(hits[0]!.text).toBe('along-x'); // Cosine of identical vectors = 1 (or whatever the metric's "perfect" is). expect(hits[0]!.score).toBeGreaterThan(0.99); }); it('returns the cached vector + the text key alongside the score', async () => { const hits = await cache.search(vec([1, 0, 0, 0]), 1); expect(hits[0]).toMatchObject({ text: 'along-x', }); expect(hits[0]!.vector).toBeInstanceOf(Float32Array); expect(hits[0]!.vector.length).toBe(DIM); }); it('honors the k parameter', async () => { const hits = await cache.search(vec([1, 0, 0, 0]), 2); expect(hits.length).toBe(2); }); it('finds the near-duplicate vector with a higher score than orthogonals', async () => { // Query is similar to diagonal-xy. Expect diagonal-xy on top, then // along-x and along-y to rank above along-z / along-w. const hits = await cache.search(vec([0.6, 0.8, 0, 0]), 5); const topKeys = hits.map(h => h.text); expect(topKeys[0]).toBe('diagonal-xy'); // along-z and along-w should rank below the xy-plane vectors. const zIdx = topKeys.indexOf('along-z'); const xIdx = topKeys.indexOf('along-x'); if (zIdx >= 0 && xIdx >= 0) { expect(xIdx).toBeLessThan(zIdx); } }); it('searchByText embeds the query and routes to search()', async () => { const fakeEmbed = async (text: string) => ({ embedding: text === 'find-x' ? vec([1, 0, 0, 0]) : vec([0, 0, 0, 1]), latencyMs: 0, }); const hits = await cache.searchByText('find-x', 1, fakeEmbed); expect(hits[0]!.text).toBe('along-x'); }); }); describe('SearchableEmbeddingCache — stats + lifecycle', () => { it('stats() reports backing kind via the degraded flag', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM }); await cache.set('warm', vec([1, 0, 0, 0])); const s = cache.stats(); // degraded is true when the linear-scan fallback is active. Either // path is valid in the test env — we just assert the flag is a // boolean and the size reflects what we put in. expect(typeof s.degraded).toBe('boolean'); expect(s.size).toBe(1); expect(s.metric).toBe('cosine'); expect(s.dimension).toBe(DIM); }); it('hit/miss accounting tracks across get calls', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM }); await cache.set('foo', vec([1, 0, 0, 0])); await cache.get('foo'); // hit await cache.get('foo'); // hit await cache.get('missing'); // miss const s = cache.stats(); expect(s.hits).toBe(2); expect(s.misses).toBe(1); expect(s.hitRate).toBeCloseTo(2 / 3, 3); }); it('clear() empties the cache + resets stats', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM }); await cache.set('a', vec([1, 0, 0, 0])); await cache.get('a'); await cache.clear(); expect(cache.stats().size).toBe(0); expect(cache.stats().hits).toBe(0); expect(cache.stats().misses).toBe(0); }); it('respects maxSize via eviction (regression: unbounded growth)', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM, maxSize: 2 }); await cache.set('a', vec([1, 0, 0, 0])); await cache.set('b', vec([0, 1, 0, 0])); await cache.set('c', vec([0, 0, 1, 0])); // triggers eviction of 'a' expect(cache.stats().size).toBe(2); expect(await cache.has('a')).toBe(false); expect(await cache.has('c')).toBe(true); }); });