@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
JavaScript
/**
* 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