UNPKG

@claude-flow/embeddings

Version:

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

176 lines • 8.09 kB
/** * ADR-121 Phase 3 — quantization tests. * * Covers: * - int8 round-trip + recall (≥0.98 on unit-normalized vectors — the * Phase 3 acceptance gate) * - SearchableEmbeddingCache + `quantize: 'int8'` end-to-end * - rabitqMemoryReduction math * - RabitqSnapshot graceful error when @ruvector/rabitq-wasm absent * * Real RaBitQ end-to-end search runs against an actual `.build` index * when the optional peer dep is installable; otherwise we assert the * named-error contract. */ import { describe, it, expect, vi } from 'vitest'; import { int8Encode, int8Decode, measureInt8RoundTripRecall, rabitqMemoryReduction, RabitqSnapshot, } from '../quantization.js'; import { createSearchableEmbeddingCache } from '../searchable-embedding-cache.js'; // Same mocking convention as the searchable-cache tests — force the // linear-scan fallback so the int8 wiring is exercised against a // deterministic backing. vi.mock('@ruvector/core', () => ({})); // And mock @ruvector/rabitq-wasm absent so the Phase 3 "graceful // error when missing" path is exercised in CI without depending on // the WASM module loading. vi.mock('@ruvector/rabitq-wasm', () => ({})); const DIM = 16; /** Build a random unit-normalized Float32Array of length `DIM`. */ function randomUnitVector(seed) { // Tiny LCG so tests are deterministic. let s = seed >>> 0; const next = () => { s = (s * 1664525 + 1013904223) >>> 0; return (s / 0xffffffff) * 2 - 1; }; const v = new Float32Array(DIM); let sumSq = 0; for (let i = 0; i < DIM; i++) { v[i] = next(); sumSq += v[i] * v[i]; } const norm = Math.sqrt(sumSq); if (norm === 0) v[0] = 1; else for (let i = 0; i < DIM; i++) v[i] = v[i] / norm; return v; } describe('int8 quantization', () => { it('encodes a unit vector to int8 + scale and round-trips with high recall', () => { const v = randomUnitVector(42); const enc = int8Encode(v); expect(enc.bytes).toBeInstanceOf(Int8Array); expect(enc.bytes.length).toBe(DIM); expect(enc.scale).toBeGreaterThan(0); const back = int8Decode(enc); // Cosine similarity between original and round-tripped should be // close to 1 (the Phase 3 acceptance: ≥0.98 mean recall). let dot = 0, na = 0, nb = 0; for (let i = 0; i < DIM; i++) { dot += v[i] * back[i]; na += v[i] * v[i]; nb += back[i] * back[i]; } const sim = dot / (Math.sqrt(na) * Math.sqrt(nb)); expect(sim).toBeGreaterThan(0.98); }); it('handles the all-zero vector without dividing by zero', () => { const z = new Float32Array(DIM); const enc = int8Encode(z); expect(enc.scale).toBe(1); expect(Array.from(enc.bytes)).toEqual(Array.from(new Int8Array(DIM))); const back = int8Decode(enc); expect(Array.from(back)).toEqual(Array.from(new Float32Array(DIM))); }); it('measureInt8RoundTripRecall hits ≥0.98 mean recall across a corpus (acceptance gate)', () => { // 100 unit-normalized random vectors — the same shape the // benchmark uses on real embeddings. const corpus = Array.from({ length: 100 }, (_, i) => randomUnitVector(i + 1)); const result = measureInt8RoundTripRecall(corpus); expect(result.count).toBe(100); expect(result.meanRecall).toBeGreaterThanOrEqual(0.98); // Bound the variance — no single vector should fall below 0.95. expect(result.minRecall).toBeGreaterThanOrEqual(0.95); }); it('measureInt8RoundTripRecall handles empty corpus', () => { const result = measureInt8RoundTripRecall([]); expect(result.count).toBe(0); expect(result.meanRecall).toBe(1); }); }); describe('SearchableEmbeddingCache + quantize: "int8"', () => { it('round-trips a vector through the cache with int8 storage', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' }); const original = randomUnitVector(123); await cache.set('key', original); const back = await cache.get('key'); expect(back).toBeInstanceOf(Float32Array); expect(back.length).toBe(DIM); // Cosine sim between original and dequantized should be ≥0.98. let dot = 0, na = 0, nb = 0; for (let i = 0; i < DIM; i++) { dot += original[i] * back[i]; na += original[i] ** 2; nb += back[i] ** 2; } expect(dot / (Math.sqrt(na) * Math.sqrt(nb))).toBeGreaterThan(0.98); }); it('reports the quantize mode + estimated bytes in stats()', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' }); await cache.set('a', randomUnitVector(1)); await cache.set('b', randomUnitVector(2)); const s = cache.stats(); expect(s.quantize).toBe('int8'); expect(s.size).toBe(2); // int8 estimated bytes: 2 vectors * (DIM=16 + 4 scale) = 40 expect(s.estimatedBytes).toBe(2 * (DIM + 4)); }); it('default (no quantize) reports estimatedBytes as full float32', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM }); await cache.set('a', randomUnitVector(1)); const s = cache.stats(); expect(s.quantize).toBe('none'); expect(s.estimatedBytes).toBe(DIM * 4); }); it('delete drops the int8 entry alongside the index entry', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' }); await cache.set('a', randomUnitVector(1)); expect((await cache.delete('a'))).toBe(true); expect(await cache.get('a')).toBeUndefined(); expect(cache.stats().size).toBe(0); expect(cache.stats().estimatedBytes).toBe(0); }); it('clear drops all int8 entries', async () => { const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' }); await cache.set('a', randomUnitVector(1)); await cache.set('b', randomUnitVector(2)); await cache.clear(); expect(cache.stats().size).toBe(0); expect(cache.stats().estimatedBytes).toBe(0); }); }); describe('rabitqMemoryReduction', () => { it('computes the theoretical 32× reduction for typical embeddings', () => { // 10k vectors × 384 dim const r = rabitqMemoryReduction(10_000, 384); expect(r.rawBytes).toBe(10_000 * 384 * 4); // 15.36 MB expect(r.rabitqBytes).toBe(Math.ceil(10_000 * 384 / 8)); // 480 KB // Reduction is N×dim×4 vs ceil(N×dim/8). For N=10k dim=384, // that's 15,360,000 / 480,000 = exactly 32. expect(r.reductionFactor).toBeCloseTo(32, 1); }); it('handles single-vector case', () => { const r = rabitqMemoryReduction(1, 8); expect(r.rawBytes).toBe(32); expect(r.rabitqBytes).toBe(1); // ceil(8/8) = 1 expect(r.reductionFactor).toBe(32); }); it('handles empty corpus gracefully', () => { const r = rabitqMemoryReduction(0, 384); expect(r.rawBytes).toBe(0); expect(r.rabitqBytes).toBe(0); // 0/1 (the Math.max guard) → 0 reduction factor; not divide-by-zero expect(r.reductionFactor).toBe(0); }); }); describe('RabitqSnapshot — peer-dep error contract', () => { it('build() throws a clear, named error when @ruvector/rabitq-wasm is missing', async () => { const entries = Array.from({ length: 5 }, (_, i) => ({ id: `e${i}`, vector: randomUnitVector(i + 1), })); await expect(RabitqSnapshot.build(entries, { dimension: DIM })).rejects.toThrow(/@ruvector\/rabitq-wasm/); // Acceptance: error gives the operator the install command + a // fallback path so they're not stuck. await expect(RabitqSnapshot.build(entries, { dimension: DIM })).rejects.toThrow(/npm install/); }); }); //# sourceMappingURL=quantization.test.js.map