From 08c0d742c4afaabeb407e9c4fedffe174d3d21fb Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 14 Jun 2026 18:32:26 -0400 Subject: [PATCH] fix(ruvector-wasm): correct adapter for WASM build's flat-index, distance-score, and metadata gaps (#568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published @ruvector/wasm build behaves differently from its generated .d.ts in three ways that bite consumers: 1. HNSW is not active — the wasm32 target compiles without the `hnsw` feature and falls back to a flat (brute-force) index, so search is O(n). The O(log n) win is latent until the WASM HNSW lands. 2. `result.score` is a cosine distance (lower is better), not the "higher is better" similarity the .d.ts advertises (ordering is correct: a, b before c). 3. Metadata does not round-trip — search/get return {}. Add RuvectorWasmAdapter (@ruvector/wasm/adapter) which wraps VectorDB with: - a metadata sidecar so inserted metadata round-trips - similarity = 1 - distance (generalised per metric) with `.score` aliased to similarity, plus the raw `distance` preserved - indexType/usesHnsw + WASM_HNSW_AVAILABLE so callers don't assume HNSW - client-side metadata filtering with over-fetch Includes TS declarations with corrected doc comments, a node:test suite covering all three findings, README guidance, and package exports. Co-authored-by: Claude --- crates/ruvector-wasm/README.md | 37 +++ crates/ruvector-wasm/package.json | 13 + crates/ruvector-wasm/src/adapter.d.ts | 132 ++++++++ crates/ruvector-wasm/src/adapter.js | 330 ++++++++++++++++++++ crates/ruvector-wasm/tests/adapter.test.mjs | 176 +++++++++++ 5 files changed, 688 insertions(+) create mode 100644 crates/ruvector-wasm/src/adapter.d.ts create mode 100644 crates/ruvector-wasm/src/adapter.js create mode 100644 crates/ruvector-wasm/tests/adapter.test.mjs diff --git a/crates/ruvector-wasm/README.md b/crates/ruvector-wasm/README.md index ab07471b6..5a5d207d6 100644 --- a/crates/ruvector-wasm/README.md +++ b/crates/ruvector-wasm/README.md @@ -115,6 +115,43 @@ results.forEach(result => { }); ``` +> ⚠️ **Read this before trusting the raw bindings.** Three behaviours of the +> current WASM build differ from what the generated `.d.ts` advertises: +> +> 1. **HNSW is not active in the WASM build.** It compiles without the `hnsw` +> cargo feature and silently falls back to a brute-force flat index, so search +> is O(n), not O(log n). The HNSW win is latent until the WASM HNSW lands. +> 2. **`result.score` is a cosine *distance* (lower is better)** — the ordering is +> correct, but it is *not* the "higher is better" similarity the `.d.ts` +> describes. +> 3. **Metadata does not round-trip** — `search`/`get` return `{}`. +> +> Use the bundled **adapter** instead of the raw `VectorDB` to get these handled +> correctly (see below). + +### Recommended: the corrected adapter + +`@ruvector/wasm/adapter` wraps `VectorDB` with a metadata sidecar and a real +`similarity = 1 - distance` so the documented "higher is better" contract holds. + +```javascript +import { RuvectorWasmAdapter } from '@ruvector/wasm/adapter'; + +// Loads + inits the WASM module and constructs the VectorDB for you. +const index = await RuvectorWasmAdapter.create({ dimensions: 384, metric: 'cosine' }); + +index.insert({ id: 'doc_1', vector: embedding, metadata: { title: 'My Document' } }); + +const results = index.search({ vector: query, k: 10 }); +results.forEach(r => { + console.log(r.id, r.similarity); // similarity: higher is better + console.log(r.distance); // raw distance: lower is better + console.log(r.metadata); // round-trips correctly via the sidecar +}); + +console.log(index.indexType); // 'flat' until WASM HNSW lands +``` + ### React Integration ```typescript diff --git a/crates/ruvector-wasm/package.json b/crates/ruvector-wasm/package.json index 94047b2b8..3c2ff9536 100644 --- a/crates/ruvector-wasm/package.json +++ b/crates/ruvector-wasm/package.json @@ -4,8 +4,20 @@ "description": "High-performance Rust vector database for browsers via WASM", "main": "pkg/ruvector_wasm.js", "types": "pkg/ruvector_wasm.d.ts", + "exports": { + ".": { + "types": "./pkg/ruvector_wasm.d.ts", + "default": "./pkg/ruvector_wasm.js" + }, + "./adapter": { + "types": "./src/adapter.d.ts", + "default": "./src/adapter.js" + } + }, "files": [ "pkg", + "src/adapter.js", + "src/adapter.d.ts", "src/worker.js", "src/worker-pool.js", "src/indexeddb.js" @@ -18,6 +30,7 @@ "build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler --release", "build:all": "npm run build && npm run build:node && npm run build:bundler", "test": "wasm-pack test --headless --chrome", + "test:adapter": "node --test tests/adapter.test.mjs", "test:firefox": "wasm-pack test --headless --firefox", "test:node": "wasm-pack test --node", "size": "npm run build && gzip -c pkg/ruvector_wasm_bg.wasm | wc -c && echo 'bytes (gzipped)'", diff --git a/crates/ruvector-wasm/src/adapter.d.ts b/crates/ruvector-wasm/src/adapter.d.ts new file mode 100644 index 000000000..7f3f7fbdc --- /dev/null +++ b/crates/ruvector-wasm/src/adapter.d.ts @@ -0,0 +1,132 @@ +/** + * Type declarations for the RuvectorWasmAdapter. + * + * Unlike the generated `pkg/ruvector_wasm.d.ts`, the `score` documented here is + * a real similarity (higher is better); the raw distance is exposed separately. + * + * @module @ruvector/wasm/adapter + */ + +/** + * Whether the published WASM build ships an active HNSW index. + * `false` today: the WASM `VectorDB` falls back to a flat (brute-force) index. + */ +export const WASM_HNSW_AVAILABLE: boolean; + +/** + * Convert a raw distance (lower is better) into a similarity (higher is better). + * @param metric 'cosine' | 'dot' | 'dotproduct' | 'euclidean' | 'manhattan' + * @param distance Raw score returned by the WASM `search`. + */ +export function distanceToSimilarity(metric: string, distance: number): number; + +/** A single search result, with similarity and metadata corrected. */ +export interface AdapterSearchResult { + /** Vector id. */ + id: string; + /** Similarity score — higher is better. */ + similarity: number; + /** Raw distance from the underlying index — lower is better. */ + distance: number; + /** Alias of `similarity`, so a `.score` read honours "higher is better". */ + score: number; + /** Vector data, when returned by the index. */ + vector?: Float32Array; + /** Round-tripped metadata from the sidecar. */ + metadata?: Record; +} + +/** Minimal shape of the underlying WASM (or test-double) VectorDB. */ +export interface WasmVectorDBLike { + insert( + vector: Float32Array, + id?: string, + metadata?: Record + ): string; + insertBatch( + entries: Array<{ + id?: string; + vector: Float32Array; + metadata?: Record; + }> + ): string[]; + search( + vector: Float32Array, + k: number, + filter?: Record + ): Array<{ + id: string; + score: number; + vector?: Float32Array; + metadata?: Record; + }>; + get( + id: string + ): { id?: string; vector?: Float32Array; metadata?: Record } | null; + delete(id: string): boolean; + len?(): number; + isEmpty?(): boolean; +} + +export interface AdapterOptions { + /** Vector dimensions (informational). */ + dimensions?: number; + /** Distance metric the db was created with; controls similarity conversion. */ + metric?: string; + /** Override the index-type report. Defaults to {@link WASM_HNSW_AVAILABLE}. */ + usesHnsw?: boolean; +} + +export interface CreateOptions { + /** Vector dimensions (required). */ + dimensions: number; + /** Distance metric. Defaults to 'cosine'. */ + metric?: string; + /** Requested at the WASM layer (the build falls back to flat regardless). */ + useHnsw?: boolean; + /** Pre-imported WASM module; if omitted, `@ruvector/wasm` is imported. */ + module?: any; +} + +/** Correct wrapper around the generated WASM `VectorDB`. */ +export class RuvectorWasmAdapter { + constructor(db: WasmVectorDBLike, options?: AdapterOptions); + + static create(options: CreateOptions): Promise; + + /** `false` for the current WASM build — flat O(n) search. */ + readonly usesHnsw: boolean; + /** 'hnsw' | 'flat' — index type backing this adapter. */ + readonly indexType: 'hnsw' | 'flat'; + + insert(entry: { + id?: string; + vector: Float32Array | number[]; + metadata?: Record; + }): string; + + insertBatch( + entries: Array<{ + id?: string; + vector: Float32Array | number[]; + metadata?: Record; + }> + ): string[]; + + search(query: { + vector: Float32Array | number[]; + k: number; + filter?: Record; + }): AdapterSearchResult[]; + + get( + id: string + ): { id: string; vector?: Float32Array; metadata?: Record } | null; + + delete(id: string): boolean; + len(): number; + isEmpty(): boolean; + clearMetadata(): void; +} + +export default RuvectorWasmAdapter; diff --git a/crates/ruvector-wasm/src/adapter.js b/crates/ruvector-wasm/src/adapter.js new file mode 100644 index 000000000..23d85dcb2 --- /dev/null +++ b/crates/ruvector-wasm/src/adapter.js @@ -0,0 +1,330 @@ +/** + * RuvectorWasmAdapter — a correct, ergonomic wrapper around the generated + * `@ruvector/wasm` `VectorDB`. + * + * It exists to paper over three behaviours of the current WASM build that bite + * callers who take the raw bindings (and the generated `.d.ts`) at face value: + * + * 1. **HNSW is not active in the WASM build.** The Rust crate compiles the + * `wasm32` target *without* the `hnsw` feature, so `VectorDB` silently falls + * back to a brute-force flat index (`vector_db.rs`: + * `"HNSW requested but not available (WASM build), using flat index"`). + * Results are still correct, but search is O(n), not O(log n). The win is + * latent until the upstream WASM HNSW lands. This adapter surfaces that fact + * via {@link RuvectorWasmAdapter#indexType} / {@link WASM_HNSW_AVAILABLE} + * instead of letting callers assume a logarithmic index. + * + * 2. **`result.score` is a cosine *distance*, not a similarity.** Lower is + * better and the ordering is correct (a, b before c), but that contradicts + * the generated `.d.ts` which advertises a "higher is better" score. This + * adapter exposes both the raw `distance` and a `similarity = 1 - distance` + * (generalised per metric) so "higher is better" actually holds. + * + * 3. **Metadata does not round-trip.** Inserted metadata comes back as `{}` + * (or `undefined`) from the WASM `search`/`get` getters. This adapter keeps + * an in-process **metadata sidecar** keyed by vector id and re-attaches it + * on the way out, so what you put in is what you get back. + * + * The adapter is dependency-injectable: pass a pre-constructed `VectorDB` (real + * or a test double), or use {@link RuvectorWasmAdapter.create} to load and init + * the WASM module for you. + * + * @module @ruvector/wasm/adapter + */ + +/** + * Whether the published WASM build ships an active HNSW index. + * + * The crate is compiled for `wasm32` without the `hnsw` cargo feature, so this + * is `false` today: the WASM `VectorDB` uses a flat (brute-force) index. Flip + * to `true` once the WASM build enables HNSW. + * + * @type {boolean} + */ +export const WASM_HNSW_AVAILABLE = false; + +/** + * Convert a raw distance score (lower is better) into a similarity where + * higher is better, matching the contract the `.d.ts` advertises. + * + * Mirrors the conversion used by `@ruvector/router` so the whole ecosystem + * agrees on what "score" means. + * + * @param {string} metric - 'cosine' | 'dot' | 'dotproduct' | 'euclidean' | 'manhattan' + * @param {number} distance - Raw score returned by the WASM `search`. + * @returns {number} Similarity, higher is better. + */ +export function distanceToSimilarity(metric, distance) { + switch ((metric || 'cosine').toLowerCase()) { + case 'cosine': + // cosine distance = 1 - cosine_similarity ⇒ similarity = 1 - distance + return 1 - distance; + case 'dot': + case 'dotproduct': + // dot "distance" is stored negated ⇒ similarity = -distance + return -distance; + case 'euclidean': + case 'manhattan': + default: + // unbounded distances: monotonic decreasing map into (0, 1] + return 1 / (1 + distance); + } +} + +/** + * @typedef {Object} AdapterSearchResult + * @property {string} id - Vector id. + * @property {number} similarity - Higher is better (see {@link distanceToSimilarity}). + * @property {number} distance - Raw score from the WASM index (lower is better). + * @property {number} score - Alias of `similarity`, so the documented + * "higher is better" score contract holds for callers reading `.score`. + * @property {Float32Array=} vector - Vector data, when returned by the index. + * @property {Record=} metadata - Round-tripped metadata from the sidecar. + */ + +/** + * Correct wrapper around the generated WASM `VectorDB`. + */ +export class RuvectorWasmAdapter { + /** + * @param {any} db - A constructed WASM `VectorDB` instance (or a compatible + * test double exposing `insert`, `insertBatch`, `search`, `get`, `delete`, + * `len`/`isEmpty`). + * @param {Object} [options] + * @param {number} [options.dimensions] - Vector dimensions (informational). + * @param {string} [options.metric='cosine'] - Distance metric the `db` was + * created with; controls the similarity conversion. + * @param {boolean} [options.usesHnsw] - Override the index-type report. Defaults + * to {@link WASM_HNSW_AVAILABLE}. + */ + constructor(db, options = {}) { + if (!db) { + throw new Error('RuvectorWasmAdapter requires a VectorDB instance'); + } + this._db = db; + this._metric = (options.metric || 'cosine').toLowerCase(); + this._dimensions = options.dimensions; + this._usesHnsw = options.usesHnsw ?? WASM_HNSW_AVAILABLE; + + /** + * Metadata sidecar: id -> metadata. Works around the WASM build not + * round-tripping metadata through `search`/`get`. + * @type {Map>} + */ + this._metadata = new Map(); + } + + /** + * Load the WASM module, construct a `VectorDB`, and wrap it. + * + * @param {Object} [options] + * @param {number} options.dimensions - Vector dimensions (required). + * @param {string} [options.metric='cosine'] - Distance metric. + * @param {boolean} [options.useHnsw=true] - Requested at the WASM layer; note + * the WASM build falls back to flat regardless (see {@link WASM_HNSW_AVAILABLE}). + * @param {any} [options.module] - Pre-imported WASM module (exposing `default` + * init and `VectorDB`). If omitted, `@ruvector/wasm` is imported dynamically. + * @returns {Promise} + */ + static async create(options = {}) { + const { dimensions, metric = 'cosine', useHnsw = true } = options; + if (!dimensions || dimensions <= 0) { + throw new Error('RuvectorWasmAdapter.create requires positive `dimensions`'); + } + + const mod = options.module ?? (await import('@ruvector/wasm')); + // `web`/`bundler` targets export a default init() that must run once before + // any class is constructed. `nodejs` targets have no default export. + if (typeof mod.default === 'function') { + await mod.default(); + } + + const VectorDB = mod.VectorDB; + if (typeof VectorDB !== 'function') { + throw new Error('@ruvector/wasm did not export a VectorDB constructor'); + } + + const db = new VectorDB(dimensions, metric, useHnsw); + return new RuvectorWasmAdapter(db, { dimensions, metric }); + } + + /** + * Whether this index is backed by HNSW. `false` for the current WASM build — + * search is O(n) flat scan until upstream WASM HNSW lands. + * @returns {boolean} + */ + get usesHnsw() { + return this._usesHnsw; + } + + /** + * Index type, for callers that want to reason about search complexity. + * @returns {'hnsw' | 'flat'} + */ + get indexType() { + return this._usesHnsw ? 'hnsw' : 'flat'; + } + + /** + * Insert a single vector, recording its metadata in the sidecar. + * + * @param {Object} entry + * @param {string} [entry.id] - Optional id (auto-generated by WASM if absent). + * @param {Float32Array | number[]} entry.vector + * @param {Record} [entry.metadata] + * @returns {string} The vector id (the WASM-assigned one when not supplied). + */ + insert(entry) { + const vector = toFloat32(entry.vector); + // Still hand metadata to the WASM layer (forward-compat for when it + // round-trips), but the sidecar is the source of truth on the way out. + const id = this._db.insert(vector, entry.id, entry.metadata); + if (entry.metadata !== undefined) { + this._metadata.set(id, entry.metadata); + } + return id; + } + + /** + * Insert vectors in a batch, recording metadata in the sidecar. + * + * @param {Array<{ id?: string, vector: Float32Array | number[], metadata?: Record }>} entries + * @returns {string[]} Vector ids in the same order as `entries`. + */ + insertBatch(entries) { + const nativeEntries = entries.map((e) => ({ + id: e.id, + vector: toFloat32(e.vector), + metadata: e.metadata, + })); + const ids = this._db.insertBatch(nativeEntries); + for (let i = 0; i < ids.length; i++) { + const meta = entries[i] && entries[i].metadata; + if (meta !== undefined) { + this._metadata.set(ids[i], meta); + } + } + return ids; + } + + /** + * Search for the `k` nearest vectors. + * + * Returns results ordered best-first by `similarity` (higher is better), with + * the raw `distance` preserved and metadata re-attached from the sidecar. + * When `filter` is supplied it is applied against the sidecar metadata (the + * WASM filter relies on metadata that does not round-trip), over-fetching as + * needed so `k` results survive the filter where possible. + * + * @param {Object} query + * @param {Float32Array | number[]} query.vector + * @param {number} query.k + * @param {Record} [query.filter] - Exact-match metadata filter. + * @returns {AdapterSearchResult[]} + */ + search(query) { + const k = query.k; + const vector = toFloat32(query.vector); + const hasFilter = query.filter && Object.keys(query.filter).length > 0; + + // Over-fetch when filtering so post-filter results can still reach k. + const fetch = hasFilter ? Math.max(k * 4, k) : k; + const raw = this._db.search(vector, fetch, undefined) || []; + + let mapped = raw.map((r) => { + const distance = r.score; + const metadata = this._metadata.has(r.id) + ? this._metadata.get(r.id) + : r.metadata; + const similarity = distanceToSimilarity(this._metric, distance); + return { + id: r.id, + similarity, + score: similarity, + distance, + vector: r.vector, + metadata, + }; + }); + + if (hasFilter) { + const entries = Object.entries(query.filter); + mapped = mapped.filter((r) => { + const md = r.metadata; + if (!md) return false; + return entries.every(([key, value]) => md[key] === value); + }); + } + + // The flat index already orders by ascending distance, but sort defensively + // so a, b come before c regardless of the underlying index's guarantees. + mapped.sort((a, b) => b.similarity - a.similarity); + + return mapped.slice(0, k); + } + + /** + * Get a vector by id, with metadata re-attached from the sidecar. + * + * @param {string} id + * @returns {{ id: string, vector?: Float32Array, metadata?: Record } | null} + */ + get(id) { + const entry = this._db.get(id); + if (!entry) return null; + return { + id: entry.id ?? id, + vector: entry.vector, + metadata: this._metadata.has(id) ? this._metadata.get(id) : entry.metadata, + }; + } + + /** + * Delete a vector by id, dropping its sidecar metadata. + * @param {string} id + * @returns {boolean} + */ + delete(id) { + const deleted = this._db.delete(id); + if (deleted) { + this._metadata.delete(id); + } + return deleted; + } + + /** + * Number of vectors in the index. + * @returns {number} + */ + len() { + if (typeof this._db.len === 'function') return this._db.len(); + return this._metadata.size; + } + + /** + * Whether the index is empty. + * @returns {boolean} + */ + isEmpty() { + if (typeof this._db.isEmpty === 'function') return this._db.isEmpty(); + return this.len() === 0; + } + + /** + * Drop all sidecar metadata. Call this when you recreate the underlying db. + */ + clearMetadata() { + this._metadata.clear(); + } +} + +/** + * Coerce a vector into a `Float32Array` without copying when already one. + * @param {Float32Array | number[]} v + * @returns {Float32Array} + */ +function toFloat32(v) { + return v instanceof Float32Array ? v : new Float32Array(v); +} + +export default RuvectorWasmAdapter; diff --git a/crates/ruvector-wasm/tests/adapter.test.mjs b/crates/ruvector-wasm/tests/adapter.test.mjs new file mode 100644 index 000000000..3ea4d138b --- /dev/null +++ b/crates/ruvector-wasm/tests/adapter.test.mjs @@ -0,0 +1,176 @@ +/** + * Tests for RuvectorWasmAdapter. + * + * Uses a FakeVectorDB that reproduces the three problematic behaviours of the + * real WASM build: + * 1. flat (no HNSW) — `usesHnsw === false` + * 2. `score` is a cosine *distance* (lower is better) + * 3. metadata does not round-trip (search/get return `{}`) + * + * The adapter must hide all three: similarity higher-is-better with correct + * ordering, and metadata round-tripped via the sidecar. + * + * Run: node --test crates/ruvector-wasm/tests/adapter.test.mjs + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + RuvectorWasmAdapter, + distanceToSimilarity, + WASM_HNSW_AVAILABLE, +} from '../src/adapter.js'; + +function cosineDistance(a, b) { + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom === 0 ? 1 : 1 - dot / denom; +} + +/** Mimics the real WASM VectorDB: flat index, distance score, no metadata round-trip. */ +class FakeVectorDB { + constructor() { + this.store = new Map(); + this._auto = 0; + } + + insert(vector, id /*, metadata */) { + const key = id ?? `auto-${this._auto++}`; + // Note: metadata is intentionally dropped — reproduces the WASM bug. + this.store.set(key, Float32Array.from(vector)); + return key; + } + + insertBatch(entries) { + return entries.map((e) => this.insert(e.vector, e.id, e.metadata)); + } + + search(vector, k /*, filter */) { + const results = []; + for (const [id, vec] of this.store) { + results.push({ id, score: cosineDistance(vector, vec), metadata: {} }); + } + results.sort((a, b) => a.score - b.score); // flat scan, ascending distance + return results.slice(0, k); + } + + get(id) { + const vec = this.store.get(id); + return vec ? { id, vector: vec, metadata: {} } : null; + } + + delete(id) { + return this.store.delete(id); + } + + len() { + return this.store.size; + } + + isEmpty() { + return this.store.size === 0; + } +} + +test('distanceToSimilarity: cosine distance -> higher-is-better similarity', () => { + assert.equal(distanceToSimilarity('cosine', 0), 1); + assert.equal(distanceToSimilarity('cosine', 0.25), 0.75); + assert.ok( + distanceToSimilarity('cosine', 0.1) > distanceToSimilarity('cosine', 0.4) + ); +}); + +test('finding #2: search returns similarity (higher is better) with a, b before c', () => { + const db = new FakeVectorDB(); + const adapter = new RuvectorWasmAdapter(db, { dimensions: 3, metric: 'cosine' }); + + // a and b are close to the query [1,0,0]; c is orthogonal. + adapter.insert({ id: 'a', vector: [1, 0, 0] }); + adapter.insert({ id: 'b', vector: [0.9, 0.1, 0] }); + adapter.insert({ id: 'c', vector: [0, 0, 1] }); + + const results = adapter.search({ vector: [1, 0, 0], k: 3 }); + assert.deepEqual( + results.map((r) => r.id), + ['a', 'b', 'c'] + ); + + // Higher is better, and the best result outscores the worst. + assert.ok(results[0].similarity >= results[1].similarity); + assert.ok(results[1].similarity >= results[2].similarity); + assert.ok(results[0].similarity > results[2].similarity); + // `.score` honours the documented "higher is better" contract. + assert.equal(results[0].score, results[0].similarity); + // Raw distance preserved (lower is better) and consistent with similarity. + assert.ok(results[0].distance <= results[2].distance); + assert.ok(Math.abs(results[0].similarity - (1 - results[0].distance)) < 1e-6); +}); + +test('finding #3: metadata round-trips via the sidecar', () => { + const db = new FakeVectorDB(); + const adapter = new RuvectorWasmAdapter(db, { dimensions: 3, metric: 'cosine' }); + + const meta = { title: 'doc-a', tags: ['x', 'y'] }; + adapter.insert({ id: 'a', vector: [1, 0, 0], metadata: meta }); + adapter.insert({ id: 'b', vector: [0, 1, 0], metadata: { title: 'doc-b' } }); + + // Raw WASM would return {}; the adapter restores the real metadata. + const [top] = adapter.search({ vector: [1, 0, 0], k: 1 }); + assert.deepEqual(top.metadata, meta); + + const got = adapter.get('a'); + assert.deepEqual(got.metadata, meta); +}); + +test('insertBatch round-trips metadata in order', () => { + const db = new FakeVectorDB(); + const adapter = new RuvectorWasmAdapter(db, { dimensions: 2, metric: 'cosine' }); + + const ids = adapter.insertBatch([ + { id: 'one', vector: [1, 0], metadata: { n: 1 } }, + { id: 'two', vector: [0, 1], metadata: { n: 2 } }, + ]); + assert.deepEqual(ids, ['one', 'two']); + assert.deepEqual(adapter.get('two').metadata, { n: 2 }); +}); + +test('filter is applied against sidecar metadata', () => { + const db = new FakeVectorDB(); + const adapter = new RuvectorWasmAdapter(db, { dimensions: 2, metric: 'cosine' }); + + adapter.insert({ id: 'a', vector: [1, 0], metadata: { kind: 'fruit' } }); + adapter.insert({ id: 'b', vector: [0.95, 0.05], metadata: { kind: 'veg' } }); + adapter.insert({ id: 'c', vector: [0.9, 0.1], metadata: { kind: 'fruit' } }); + + const results = adapter.search({ vector: [1, 0], k: 2, filter: { kind: 'fruit' } }); + assert.deepEqual( + results.map((r) => r.id), + ['a', 'c'] + ); +}); + +test('finding #1: index type reports flat (HNSW not active in WASM build)', () => { + const db = new FakeVectorDB(); + const adapter = new RuvectorWasmAdapter(db, { dimensions: 2 }); + assert.equal(WASM_HNSW_AVAILABLE, false); + assert.equal(adapter.usesHnsw, false); + assert.equal(adapter.indexType, 'flat'); +}); + +test('delete drops sidecar metadata and updates length', () => { + const db = new FakeVectorDB(); + const adapter = new RuvectorWasmAdapter(db, { dimensions: 2 }); + adapter.insert({ id: 'a', vector: [1, 0], metadata: { keep: false } }); + assert.equal(adapter.len(), 1); + assert.equal(adapter.delete('a'), true); + assert.equal(adapter.len(), 0); + assert.equal(adapter.get('a'), null); + assert.equal(adapter.isEmpty(), true); +});