diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2d095d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Unit tests (Node) + run: npm test + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Browser tests (Chromium, WebGPU via SwiftShader) + run: npm run test:browser + + - name: Build library + run: npm run build + + - name: Build site + run: npx vite build --config vite.site.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c034..f59212d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ the public API may still shift between minor versions). ## [Unreleased] ### Added +- Metadata filtering (FR-7): `query()`/`queryText()`/`queryBatch()` accept a + Mongo-ish `filter` in `QueryOptions` — bare values for equality plus `$eq`, + `$ne`, `$in`, `$gt`/`$gte`/`$lt`/`$lte` (AND across fields; unknown operators + throw). Execution is selectivity-aware: tiny match sets (≤ ~4k rows) are + scored exactly on the CPU straight from the store's fp32 vectors (exact on + every index type); on flat stores (fp32 and quantized) larger filters run + **in-index on the GPU** — a new score-mask kernel (`engine/wgsl/mask.ts` + + `index/scoreMask.ts`) stamps non-matching rows to the `-FLT_MAX` sentinel + between the distance dispatch and top-k, so filtered queries return exactly + the top-k matching rows at full GPU speed with no over-fetch (mask = 1 + bit/row). IVF/HNSW/CPU-fallback stores use the exact CPU scan when selective + and tombstone-style over-fetch + post-filter when the filter matches nearly + everything; masked IVF scans and filtered HNSW traversal are future work. + New public types: `MetadataFilter`, `FilterOps`, `FilterValue`. Validated in + headless Chrome on a real GPU (masked flat exact vs brute force on both + top-k paths, chunked corpora, int8 re-rank, tombstones, k > matches). - `stats()` now reports a per-query time breakdown: `lastQueryGpuMs` (measured wait on submitted GPU work + readback) and `lastQueryCpuMs` (JS-side prep, candidate gather, re-rank, top-k merge). 0/total respectively on the CPU diff --git a/README.md b/README.md index ee7537d..1f279bb 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,13 @@ client-side, no server round-trip. ## Status -M1–M5 complete, M6 mostly complete (encryption, CPU/WASM fallback done; -cross-device tuning in progress). In short: flat brute-force + IVF approximate -search, fp32/int8/int4/1-bit quantization (and every combination of the two), -OPFS/IndexedDB persistence with optional AES-256-GCM encryption, an on-device -text embedder, Worker-offloaded ingest, and a WASM-SIMD CPU fallback for +M1–M5 and M7 complete, M6 mostly complete (encryption, CPU/WASM fallback done; +cross-device tuning in progress). In short: flat brute-force + two ANN families — +IVF clustering and an HNSW graph index (with an optional GPU beam-search kernel +and batched queries) — fp32/int8/int4/1-bit quantization (and every IVF×quant +combination), OPFS/IndexedDB persistence (HNSW graphs persist too, so loads skip +the rebuild) with optional AES-256-GCM encryption, an on-device text embedder, +Worker-offloaded ingest and index builds, and a WASM-SIMD CPU fallback for devices without WebGPU. See [CHANGELOG.md](./CHANGELOG.md) for the release history and [Not yet here](#not-yet-here) below for open milestone work. @@ -32,8 +34,9 @@ import { BrowserVec } from 'browservec'; ``` Requires a browser with [WebGPU](https://caniuse.com/webgpu) for the GPU-accelerated -path; falls back to a WASM-SIMD/scalar CPU path (exact fp32 flat search) where WebGPU -is unavailable — see [CPU fallback](./docs/features.md#cpu-fallback--no-webgpu-nfr-7--m6). +path; falls back to a WASM-SIMD/scalar CPU path (exact fp32 flat search, plus the +HNSW graph index) where WebGPU is unavailable — see +[CPU fallback](./docs/features.md#cpu-fallback--no-webgpu-nfr-7--m6). ## Quick start @@ -63,6 +66,12 @@ await db.addBatch([ const hits = await db.query(queryVec, { k: 5 }); // → [{ id, score, metadata? }, ...] (higher score = closer) +await db.query(queryVec, { k: 5, filter: { lang: 'en', year: { $gte: 2020 } } }); +// metadata pre-filtering: $eq (bare value), $ne, $in, $gt/$gte/$lt/$lte + +const batches = await db.queryBatch([q1, q2, q3], { k: 5 }); +// → QueryResult[][] — one GPU dispatch on an HNSW store with search: 'gpu' + db.get('a'); // → { id, vector, metadata? } | null db.delete('a'); // tombstone by id → true/false (compacted on save) await db.update({ id: 'a', vector: v2 }); // replace/upsert a vector @@ -79,11 +88,14 @@ Each links to a short guide with runnable code: | Feature | What it does | |---|---| +| [Metadata filtering](./docs/api-reference.md#metadatafilter) | Mongo-ish `filter` on queries (`$eq`/`$ne`/`$in`/ranges). Flat stores filter **in-index on the GPU** via a score-mask kernel (exact top-k of matching rows, any selectivity, no over-fetch); tiny match sets take an exact CPU scan, exact on every index type. | | [Deleting vectors](./docs/features.md#deleting-vectors) | Tombstone-based delete/update/compact — cheap deletes, GPU memory reclaimed on compact or reload. | | [Persistence](./docs/features.md#persistence-m2) | Versioned binary snapshots to OPFS (or IndexedDB), auto-load on `create()`, export/import as a `Blob`. | | [Encryption at rest](./docs/features.md#encryption-at-rest-m6) | AES-256-GCM + PBKDF2 passphrase envelope for persisted/exported snapshots. | | [Quantization (TurboQuant)](./docs/features.md#quantization--turboquant-int8-m3a) | int8/int4/1-bit codes via randomized Hadamard rotation + exact fp32 re-rank — ~4×/8×/32× less memory. | | [Approximate search (IVF)](./docs/features.md#approximate-search--ivf-m4) | GPU-assisted k-means clustering; queries scan only the nearest `nprobe` clusters. Combines with quantization for the ~1M-row path. | +| [Graph search (HNSW)](./docs/features.md#graph-search--hnsw-m7) | Layered proximity graph with O(log N) beam search — incremental inserts (no rebuild), all metrics, works without WebGPU. Graph persists in snapshots, so loads skip the rebuild (~180×). | +| [GPU graph search](./docs/features.md#gpu-graph-search-m7b) | CAGRA-style WGSL kernel: the whole beam search in one dispatch, one workgroup per query — `queryBatch()` searches many queries concurrently (~4× the CPU walk at 60k×768×128). | | [Text retrieval / embedder](./docs/features.md#text-retrieval--on-device-embedder-m5) | `addText`/`queryText` via a zero-dep hashing embedder or an optional real semantic model (transformers.js). | | [Worker ingest offload](./docs/features.md#worker-ingest-offload-nfr-8) | Rotate+quantize and IVF k-means mean-updates run off the main thread so ingest doesn't freeze the UI. | | [Corpus chunking](./docs/features.md#corpus-chunking-nfr-10) | Corpus spreads across multiple GPU buffers once it would exceed the device's per-buffer limit — transparent, same results. | @@ -137,9 +149,10 @@ browsers are WebKit under the hood, so WebGPU isn't exposed), no OPFS (§NFR-10) triggers off the device's actual reported limit, so this needs no configuration — but where the chunking crossover happens is device-dependent. - **iOS is CPU-fallback-only today**: no WebGPU means quantization/IVF are - unavailable and only exact fp32-flat search runs, over IndexedDB persistence - (no OPFS on iOS). Pass `fallback: 'wasm'` explicitly when targeting iOS Chrome - or Safari, or `BrowserVec.create()` will throw. + unavailable; exact fp32-flat search and the HNSW graph index (M7 — CPU-native, + so it works here) run over IndexedDB persistence (no OPFS on iOS). Pass + `fallback: 'wasm'` explicitly when targeting iOS Chrome or Safari, or + `BrowserVec.create()` will throw. Still missing from the matrix: Android Chrome (has WebGPU — a real gap), Windows + NVIDIA/AMD (likely a much smaller buffer-size cap, which would @@ -159,7 +172,11 @@ device-report JSON block from any of these are welcome — see paste-back JSON block per device, feeding the [Benchmarks](#benchmarks) table above. -Everything else (M1–M5, plus M6's other pieces) is done — see +- **HNSW × quantization** — the graph index is fp32-only for now; combining it + with the TurboQuant codes (quantized distances inside the traversal) is + future work. IVF×quant remains the memory-constrained ~1M path. + +Everything else (M1–M5, M7 graph search, plus M6's other pieces) is done — see [CHANGELOG.md](./CHANGELOG.md) for what shipped in each release. ## Docs & further reading diff --git a/docs/README.md b/docs/README.md index 22e86e9..91d423b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,8 @@ Open any via `npm run dev`: | [`examples/02-quantization.html`](../examples/02-quantization.html) | int8 / int4 quantization comparison | | [`examples/03-ivf-index.html`](../examples/03-ivf-index.html) | IVF approximate search with nprobe sweep | | [`examples/04-ivf-quant-combo.html`](../examples/04-ivf-quant-combo.html) | IVF × int8/int4 combo — the 1M path | +| [`examples/18-hnsw-index.html`](../examples/18-hnsw-index.html) | HNSW graph index — efSearch sweep, recall vs speed, works without WebGPU | +| [`examples/19-hnsw-gpu.html`](../examples/19-hnsw-gpu.html) | GPU graph search — one-dispatch beam kernel, queryBatch, CPU vs GPU crossover | | [`examples/05-text-retrieval.html`](../examples/05-text-retrieval.html) | End-to-end text → search | | [`examples/06-persistence.html`](../examples/06-persistence.html) | Save/load to OPFS/IndexedDB | | [`examples/07-encryption.html`](../examples/07-encryption.html) | AES-256-GCM encrypted snapshots | diff --git a/docs/api-reference.md b/docs/api-reference.md index 9ad5306..38faf54 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -28,7 +28,8 @@ linked from each entry below. | Member | Signature | Notes | |---|---|---| -| `query(vector, opts?)` | `(vector: Vector, opts?: QueryOptions) => Promise` | Core search. Handles tombstone over-fetch, quantized re-rank, and per-query `nprobe` override internally. | +| `query(vector, opts?)` | `(vector: Vector, opts?: QueryOptions) => Promise` | Core search. Handles tombstone over-fetch, quantized re-rank, and per-query `nprobe`/`efSearch` overrides internally. | +| `queryBatch(vectors, opts?)` | `(vectors: Vector[], opts?: QueryOptions) => Promise` | Many queries at once. On an HNSW store with `search: 'gpu'` this is ONE compute dispatch (one workgroup per query); elsewhere it loops `query()`. Results are per-query, input order. | | `queryText(text, opts?)` | `(text: string, opts?: QueryOptions) => Promise` | Requires `config.embedder`. Embeds then `query()`s. | | `get(id)` | `(id: string) => VectorRecord \| null` | Fetch a stored record (including its vector) by id. | @@ -64,20 +65,24 @@ linked from each entry below. | Field | Type | Default | Notes | |---|---|---|---| | `dimension` | `number` | required | Positive integer. 384/768/1024/1536 are fast-pathed. | -| `metric` | `'cosine' \| 'dot' \| 'l2'` | `'cosine'` | IVF and quantization currently require cosine/dot (`l2` is flat-only). | +| `metric` | `'cosine' \| 'dot' \| 'l2'` | `'cosine'` | IVF and quantization currently require cosine/dot (`l2` works on flat and HNSW). | | `normalize` | `boolean` | `metric === 'cosine'` | Normalize vectors on insert. | | `device` | `GPUDevice` | — | Reuse an existing device instead of requesting a new one. | -| `fallback` | `'wasm' \| 'error'` | `'error'` | `'wasm'` degrades to an exact CPU scan (fp32 flat only) when WebGPU is unavailable, instead of throwing. | +| `fallback` | `'wasm' \| 'error'` | `'error'` | `'wasm'` degrades to a CPU path (exact fp32 flat scan, or HNSW) when WebGPU is unavailable, instead of throwing. | | `persist` | `PersistConfig` | — | Enables `save()` / auto-load. | | `embedder` | `Embedder` | — | Enables `addText`/`addTexts`/`queryText`. Its `dimension` must match. | | `quantBits` | `0 \| 1 \| 4 \| 8` | `0` | `0` = fp32. `8`/`4`/`1` = TurboQuant int8/int4/binary. Requires cosine/dot. | | `quant` | `QuantConfig` | — | Tuning for quantized mode (seed, rounds, rerank factor). | -| `ann` | `ANNConfig` | — | Enables IVF. Omit for exact flat search. | +| `ann` | `ANNConfig` | — | Enables an approximate index — IVF (`type` omitted or `'ivf'`) or HNSW (`type: 'hnsw'`). Omit for exact flat search. | | `chunkRows` | `number` | auto | Force corpus chunking below the auto-trigger threshold (mainly for tests/demos). | -### `ANNConfig` +### `ANNConfig` = `IVFConfig | HNSWConfig` -`nlist` (default ≈ `sqrt(count)`, clamped `[16, 4096]`), `nprobe` (default ≈ 5% of `nlist`), `sampleSize` (default `50_000`, reservoir sample for k-means training), `iters` (default `8` Lloyd iterations), `seed`. +A discriminated union on `type`. Omitting `type` (or `'ivf'`) selects IVF, so pre-M7 configs are unchanged. + +**`IVFConfig`** — `type?: 'ivf'`, `nlist` (default ≈ `sqrt(count)`, clamped `[16, 4096]`), `nprobe` (default ≈ 5% of `nlist`), `sampleSize` (default `50_000`, reservoir sample for k-means training), `iters` (default `8` Lloyd iterations), `seed`. + +**`HNSWConfig`** — `type: 'hnsw'`, `M` (graph out-degree per layer, layer 0 keeps 2·M; default `16`), `efConstruction` (build beam width; default `200`), `efSearch` (query beam width, clamped ≥ k; default `64`), `seed` (level RNG, reproducible builds), `search` (`'cpu' | 'gpu'`, default `'cpu'` — `'gpu'` runs the single-dispatch beam-search kernel; needs WebGPU, `M ≤ 32`, `efSearch ≤ 256`, corpus within one storage buffer, and shines on `queryBatch`). ### `QuantConfig` @@ -89,7 +94,38 @@ linked from each entry below. ### `QueryOptions` -`k` (default `10`), `rerank` (override the store's default re-rank behavior, quantized stores only), `nprobe` (per-query override, IVF stores only). +`k` (default `10`), `filter` (metadata predicate — see [`MetadataFilter`](#metadatafilter)), `rerank` (override the store's default re-rank behavior, quantized stores only), `nprobe` (per-query override, IVF stores only), `efSearch` (per-query beam-width override, HNSW stores only — higher = better recall, slower). + +### `MetadataFilter` + +Mongo-ish predicate over record metadata (FR-7). Fields AND together; a bare +value is shorthand for `{ $eq: value }`: + +```ts +await db.query(q, { k: 8, filter: { lang: 'en', year: { $gte: 2020, $lt: 2025 } } }); +``` + +Operators: `$eq`, `$ne` (also matches records missing the field), `$in` (array +of values), and numeric `$gt` / `$gte` / `$lt` / `$lte` (only match stored +numbers). Unknown operators throw. + +Execution picks a strategy per query: + +- **Tiny match set (≤ ~4k rows)** — bypasses the index: matching rows are scored + exactly against the store's CPU-side fp32 vectors (`O(matches · dim)`, + independent of corpus size). Exact on *every* index type, including + quantized/IVF/HNSW. +- **Flat stores (fp32 or quantized), larger match sets** — **in-index GPU + filtering**: a mask pass stamps non-matching rows' scores to `-FLT_MAX` + between the distance kernel and the top-k reduction, so the GPU returns + exactly the top-k *matching* rows with no over-fetch. Full GPU speed at any + selectivity; exact for fp32, and quantized stores keep their usual exact + re-rank. Mask upload is 1 bit/row (~125 KB per query at 1M rows). +- **IVF/HNSW/CPU-fallback stores, larger match sets** — selective filters take + the exact CPU scan; filters matching nearly everything stay on the index + path, over-fetching by the excluded count and post-filtering (the tombstone + mechanism). In-index filtering for IVF and HNSW (masked cluster scans / + filtered graph traversal) is future work. ## Result / status types @@ -99,7 +135,7 @@ linked from each entry below. ### `Stats` (from `stats()`) -`count`, `deleted?`, `dimension`, `metric`, `device: 'webgpu' | 'wasm'`, `lastQueryMs?`, `lastQueryGpuMs?` (GPU kernel portion), `lastQueryCpuMs?` (CPU re-rank/filtering portion), `persist?: 'opfs' | 'indexeddb'`, `quantBits`, `nlist?` (IVF cluster count once built), `chunks?` (>1 once the corpus spans multiple GPU buffers), `ingest?: 'worker' | 'main-thread'` (where quantized rotate+quantize ran), `train?: 'worker' | 'main-thread'` (where IVF k-means centroid updates ran). +`count`, `deleted?`, `dimension`, `metric`, `device: 'webgpu' | 'wasm'`, `lastQueryMs?`, `lastQueryGpuMs?` (GPU kernel portion), `lastQueryCpuMs?` (CPU re-rank/filtering portion), `persist?: 'opfs' | 'indexeddb'`, `quantBits`, `nlist?` (IVF cluster count once built), `maxLevel?` (HNSW top graph layer once non-empty), `graphSearch?: 'gpu' | 'cpu'` (which engine answers HNSW queries), `chunks?` (>1 once the corpus spans multiple GPU buffers), `ingest?: 'worker' | 'main-thread'` (where quantized rotate+quantize ran), `train?: 'worker' | 'main-thread'` (where the ANN index build ran — IVF k-means updates, or HNSW graph construction). ### `SupportInfo` (from `isSupported()`) diff --git a/docs/architecture.md b/docs/architecture.md index b836927..f2852bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,8 @@ BrowserVec (src/index.ts) orchestration: config → device/index select │ ├── QuantIndex fp32 → int8/int4/1-bit (src/index/quant.ts) │ ├── IVFIndex fp32 clustered (src/index/ivf.ts) │ ├── IVFQuantIndex clustered + quantized (src/index/ivfquant.ts) + │ ├── HNSWIndex graph ANN, CPU/Worker-built (src/index/hnsw.ts) + │ │ └── HNSWGpuSearcher optional GPU beam search (src/index/hnswGpu.ts) │ └── CpuIndex WASM-SIMD/scalar fallback (src/fallback/cpu.ts) └── PersistenceBackend OPFS / IndexedDB (src/persist/*) ``` @@ -55,13 +57,17 @@ export interface VectorIndex { |---|---|---|---| | GPU | no | `0` | `FlatIndex` | | GPU | no | `1\|4\|8` | `QuantIndex` | -| GPU | yes | `0` | `IVFIndex` | -| GPU | yes | `1\|4\|8` | `IVFQuantIndex` | -| `null` (CPU fallback) | — | `0` | `CpuIndex` | -| `null` (CPU fallback) | any | `1\|4\|8` or `ann` set | throws — CPU fallback is fp32-flat only | - -`l2` metric is rejected for `ann` and quantized modes at this same point -(cosine/dot only) — it's a validation error, not a silent fallback to flat. +| GPU | `type: 'ivf'` (or omitted `type`) | `0` | `IVFIndex` | +| GPU | `type: 'ivf'` (or omitted `type`) | `1\|4\|8` | `IVFQuantIndex` | +| GPU | `type: 'hnsw'` | `0` | `HNSWIndex` (CPU/Worker graph; + `HNSWGpuSearcher` when `search: 'gpu'`) | +| any | `type: 'hnsw'` | `1\|4\|8` | throws — HNSW × quant is future work | +| `null` (CPU fallback) | no | `0` | `CpuIndex` | +| `null` (CPU fallback) | `type: 'hnsw'` | `0` | `HNSWIndex` — the graph index is CPU-native, so it needs no GPU | +| `null` (CPU fallback) | IVF, or `1\|4\|8` | — | throws — quant/IVF are GPU-throughput features | + +`l2` metric is rejected for IVF and quantized modes at this same point +(cosine/dot only; flat and HNSW accept all three metrics) — it's a validation +error, not a silent fallback to flat. ## Ingest path @@ -122,7 +128,11 @@ only way to reclaim GPU memory without a save/reload round-trip. `PersistenceBackend` (`src/persist/backend.ts`) selected by `{ backend: 'opfs' | 'indexeddb' | 'auto' }`. Loading is the same pipeline in reverse: decrypt-if-needed → deserialize → `Store.insert()` each row in order -→ one `index.append()` of the whole snapshot. See +→ one `index.append()` of the whole snapshot. HNSW stores embed their graph in +the snapshot (format v2, M7c); when the loaded config matches (`type: 'hnsw'`, +same `M`), load calls `index.loadWithGraph()` instead of `append()`, restoring +the graph directly and skipping the O(N·efConstruction) rebuild — any mismatch, +or a snapshot taken with pending tombstones, falls back to the append path. See [internals.md](./internals.md#persistence-format) for the byte layout. ## CPU / WASM fallback @@ -137,14 +147,16 @@ details. ## Worker offload -Two CPU-heavy operations can run on a Worker instead of the main thread — +Three CPU-heavy operations can run on a Worker instead of the main thread — quantized ingest (rotate+quantize, `src/quant/encoder.ts` + -`src/quant/quantize.worker.ts`) and IVF k-means centroid mean-updates -(`src/index/kmeansTrainer.ts` + `src/index/kmeans.worker.ts`). Both follow the -same seam pattern: try to spin up a Worker, transparently fall back to running -in-thread if Workers aren't available, and report which path ran via -`Stats.ingest` / `Stats.train`. See -[internals.md](./internals.md#worker-offload-seam). +`src/quant/quantize.worker.ts`), IVF k-means centroid mean-updates +(`src/index/kmeansTrainer.ts` + `src/index/kmeans.worker.ts`), and the HNSW +graph (`src/index/hnsw.ts` + `src/index/hnsw.worker.ts` — here the Worker *owns* +the graph and corpus copy: appends stream in, only top-k results cross back, so +build **and** CPU search stay off the main thread). All follow the same seam +pattern: try to spin up a Worker, transparently fall back to running in-thread +if Workers aren't available, and report which path ran via `Stats.ingest` / +`Stats.train`. See [internals.md](./internals.md#worker-offload-seam). ## How it maps to the design @@ -185,3 +197,8 @@ implements it. | [src/quant/encode.ts](../src/quant/encode.ts) | §NFR-8 — shared BatchEncoder (rotate+quantize), one impl for both threads | | [src/quant/quantize.worker.ts](../src/quant/quantize.worker.ts) | §NFR-8 — ingest Worker; inlined into the bundle via `?worker&inline` | | [src/quant/encoder.ts](../src/quant/encoder.ts) | §NFR-8 — encoder seam: Worker offload + in-thread fallback | +| [src/index/hnswGraph.ts](../src/index/hnswGraph.ts) | M7 (post-spec) — HNSW graph core: typed-array adjacency, beam search, diversity heuristic, serialize/load | +| [src/index/hnsw.worker.ts](../src/index/hnsw.worker.ts) | M7 / §NFR-8 — graph Worker (owns graph + corpus copy; build and CPU search off-thread) | +| [src/index/hnsw.ts](../src/index/hnsw.ts) | M7 — HNSWIndex seam: Worker/in-thread/GPU dispatch, batch queries, graph persistence hooks | +| [src/engine/wgsl/graphSearch.ts](../src/engine/wgsl/graphSearch.ts) | M7b — CAGRA-style beam-search kernel: whole search in one dispatch, one workgroup per query | +| [src/index/hnswGpu.ts](../src/index/hnswGpu.ts) | M7b — GPU graph executor: corpus/adjacency mirrors, batched dispatch, readback dedup | diff --git a/docs/features.md b/docs/features.md index 1f789b0..eef3e16 100644 --- a/docs/features.md +++ b/docs/features.md @@ -51,7 +51,9 @@ const copy = await BrowserVec.import(blob); // rebuild from a blob The snapshot is a versioned binary blob (`BVEC` magic + header + metadata JSON + packed Float32 vectors). Vectors are persisted **already normalized** (as searched), so a reload reproduces query results exactly. A dimension/metric -mismatch on load throws rather than silently corrupting results. Byte layout: +mismatch on load throws rather than silently corrupting results. HNSW stores +additionally embed their graph so loads skip the index rebuild — see +[Graph persistence](#graph-persistence-m7c). Byte layout: [internals.md#persistence-format](./internals.md#persistence-format-srcpersistformatts). > **Try it:** [`examples/06-persistence.html`](../examples/06-persistence.html) @@ -215,6 +217,110 @@ tracks the exact scan of the probed set to within ~0.005 recall. > **Try it:** [`examples/04-ivf-quant-combo.html`](../examples/04-ivf-quant-combo.html) +## Graph search — HNSW (M7) + +```ts +const db = await BrowserVec.create({ + dimension: 768, + metric: 'cosine', // HNSW supports cosine, dot, AND l2 + ann: { type: 'hnsw' }, // defaults: M: 16, efConstruction: 200, efSearch: 64 +}); + +await db.addBatch(records); // graph built incrementally, in a Worker +const hits = await db.query(q, { k: 10 }); // default efSearch +const wide = await db.query(q, { k: 10, efSearch: 200 }); // wider beam → higher recall, slower +``` + +The second ANN family, alongside IVF (`ann` is a discriminated union — omit +`type` or pass `'ivf'` for clustering, `'hnsw'` for the graph). Every vector +becomes a node in a **layered proximity graph** (Hierarchical Navigable Small +World): layer 0 links each node to ≤ 2·M near neighbors, and each higher layer +keeps an exponentially thinner subset as an express lane. A query greedily +descends from the sparse top layer, then runs a best-first **beam search** of +width `efSearch` on the bottom layer — visiting O(log N) nodes instead of +scanning all N. On a 20k clustered corpus, recall@10 is ~0.98 at `efSearch: 10` +and 1.0 by `efSearch: 40`, at ~10× the speed of the exact CPU scan. + +Where it differs from IVF, and when to pick it: + +- **Incremental inserts.** IVF rebuilds its clustering lazily after appends; + HNSW inserts extend the graph directly, so continuously-growing stores pay no + rebuild on the next query. The flip side: the build cost lives *at ingest + time* (`efConstruction` beam per insert — seconds for tens of thousands of + rows), which is why it runs **off the main thread** (below). +- **CPU-resident.** Graph traversal is sequential pointer-chasing — the + opposite shape from the batch-parallel scan kernels — so build *and* search + run on the CPU, hosted in an inlined Web Worker when available (same seam + pattern as [Worker offload](#worker-ingest-offload-nfr-8); `stats().train` + reports `'worker'` or `'main-thread'`). Because it needs no GPU at all, HNSW + is **the one ANN index that works on the [CPU fallback](#cpu-fallback--no-webgpu-nfr-7--m6)** + (`fallback: 'wasm'`) — and it supports `metric: 'l2'`, which IVF doesn't. +- **fp32 only for now** — `quantBits` + HNSW is future work; IVF×quant remains + the memory-constrained 1M path. + +Builds are deterministic: the level RNG is seeded (`seed`), so the same inserts +in the same order produce the same graph whether it was built in the Worker or +in-thread. + +> **Try it:** [`examples/18-hnsw-index.html`](../examples/18-hnsw-index.html) + +### GPU graph search (M7b) + +```ts +const db = await BrowserVec.create({ + dimension: 768, + metric: 'cosine', + ann: { type: 'hnsw', search: 'gpu' }, // default 'cpu' +}); +await db.addBatch(records); + +// The GPU's regime: many queries, ONE dispatch (one workgroup per query). +const perQuery = await db.queryBatch(queries, { k: 10, efSearch: 64 }); +db.stats().graphSearch; // 'gpu' — or 'cpu' after a capacity fallback +``` + +Graph ANN is usually written off for GPUs because a naive port needs one +dispatch + readback **per hop** (~ms each in a browser). `search: 'gpu'` instead +runs the *entire* beam search inside **one compute dispatch**, CAGRA-style: a +single workgroup owns one query and keeps the whole search state in workgroup +shared memory — the candidate beam, a hashed visited set, and the argmin/argmax +reductions — expanding the best unexpanded node and fanning its K neighbors +across lanes each iteration, with no per-hop round-trip. The CPU (Worker) still +*builds* the graph; the GPU mirrors the corpus and the flat layer-0 adjacency +and searches it. + +**The honest trade** (measured, Apple M-series): a *single* query pays fixed +dispatch + `mapAsync` readback latency (~3–4 ms) that the CPU walk (~0.1–0.4 ms) +doesn't — so for one-at-a-time queries, keep the default `search: 'cpu'`. Batches +flip it: at 60k×768, `queryBatch` of 128 queries completes in ~13.5 ms — **0.105 +ms/query at 0.97 recall@10, ~4× faster than the CPU walk** — and the margin grows +with corpus size, dimension, and batch size. `queryBatch` exists on every store +type (it degrades to a sequential loop elsewhere), so code written against it +picks up the GPU win when the config allows. + +Constraints: `M ≤ 32`, `efSearch ≤ 256` on the GPU path, and the corpus must fit +one storage buffer (graph hops can't be chunked across bind groups the way linear +scans are — past the device limit the store falls back to CPU search permanently +and `stats().graphSearch` reports `'cpu'`). + +> **Try it:** [`examples/19-hnsw-gpu.html`](../examples/19-hnsw-gpu.html) + +### Graph persistence (M7c) + +Nothing to configure: `save()`/`export()` on an HNSW store embed the graph +structure in the snapshot, and a load restores it directly instead of +re-inserting every row — **~180× faster** in practice (20k×128: 19 ms load vs +3.5 s rebuild), with query results identical to the never-saved store. Post-load +inserts stay deterministic too (the level RNG is fast-forwarded to where it +would have been). + +Graph-carrying snapshots use format **v2**; snapshots *without* a graph (flat, +IVF, quantized stores) still write v1, so older builds keep reading everything +they could before. The fallbacks all rebuild instead of erroring: snapshots +taken with pending tombstones (compaction renumbers rows), imports with a +different `M`, or imports into a non-HNSW store simply ignore the graph section. +Byte layout: [internals.md#persistence-format](./internals.md#persistence-format-srcpersistformatts). + ## Text retrieval — on-device embedder (M5) Pass an `embedder` and you get `addText`/`addTexts`/`queryText` — text in, results @@ -408,9 +514,12 @@ scores in place with no copy. Engines lacking WASM/SIMD transparently drop to th loop — identical results, just slower. The 526-byte module ships as a base64 constant, so there's no build-time or runtime WASM toolchain and the single-file inlined dist is preserved. -Scope for now: the CPU path serves **fp32 flat only** — `quantBits`/`ann` are -GPU-throughput optimizations that add nothing to an exact CPU scan, so requesting -them without a GPU is a clear error rather than a silent accuracy change. The demo's +Scope for now: the CPU path serves **fp32 flat and HNSW** (`ann: { type: 'hnsw' }` +— the graph index is CPU-native anyway, making it the one ANN option available +without a GPU; see [Graph search](#graph-search--hnsw-m7)). `quantBits` and IVF +are GPU-throughput optimizations that add nothing to an exact CPU scan, so +requesting them without a GPU is a clear error rather than a silent accuracy +change. The demo's **CPU fallback** button flips an internal force-CPU seam to run the fallback in a WebGPU browser and verifies it returns the same neighbors as the GPU on identical data. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 68c8ac5..ce1fe08 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -6,8 +6,9 @@ How to choose the right index type and parameters for your use case. ``` Is `fallback: 'wasm'` needed? (iOS, locked-down browser, etc.) -├── YES → CPU fallback. fp32 flat only. -│ quantBits: 0, ann: omit +├── YES → CPU fallback. fp32 flat, or HNSW for sub-linear queries. +│ exact: quantBits: 0, ann: omit +│ ANN: quantBits: 0, ann: { type: 'hnsw' } │ See [CPU fallback](../features.md#cpu-fallback--no-webgpu-nfr-7--m6) │ └── NO (WebGPU available) → @@ -26,12 +27,17 @@ Is `fallback: 'wasm'` needed? (iOS, locked-down browser, etc.) │ [Quantization example](../../examples/02-quantization.html) │ ├── Large corpus, speed over exact recall - │ └── IVF fp32 - │ quantBits: 0, ann: { nlist: 1024 } - │ [IVF example](../../examples/03-ivf-index.html) + │ ├── Batch/rebuild-tolerant ingest → IVF fp32 + │ │ quantBits: 0, ann: { nlist: 1024 } + │ │ [IVF example](../../examples/03-ivf-index.html) + │ └── Continuous inserts, or l2 metric → HNSW + │ quantBits: 0, ann: { type: 'hnsw' } + │ add search: 'gpu' if queries arrive in batches (queryBatch) + │ [HNSW example](../../examples/18-hnsw-index.html) · + │ [GPU graph search](../../examples/19-hnsw-gpu.html) │ └── Large corpus + memory constrained - └── IVF × quant (the 1M path) + └── IVF × quant (the 1M path — HNSW is fp32-only for now) quantBits: 8 (or 4), ann: { nlist: 4096 } [IVF×quant example](../../examples/04-ivf-quant-combo.html) ``` @@ -58,7 +64,7 @@ too but the kernel falls back to a general loop — slightly slower per row. |---|---|---| | `'cosine'` (default) | higher = closer | General semantic similarity. Vectors are auto-normalized. | | `'dot'` | higher = closer | When vectors are already normalized or you want raw dot product. | -| `'l2'` | higher = closer (negated squared distance) | When Euclidean distance matters. **Flat only** — IVF and quantized modes don't support l2 yet. | +| `'l2'` | higher = closer (negated squared distance) | When Euclidean distance matters. **Flat and HNSW only** — IVF and quantized modes don't support l2 yet. | ### `quantBits` @@ -109,6 +115,30 @@ Tuning advice: (e.g., at 1M rows, `nlist: 4096` gives ~244 rows/cluster on average). - IVF requires `metric: 'cosine'` or `'dot'` — l2 is not supported. +### `ann` (HNSW) + +Select with `ann: { type: 'hnsw' }` (fp32 only — `quantBits` must be 0): + +| Parameter | Default | Effect | +|---|---|---| +| `M` | 16 | Out-degree per graph layer (layer 0 keeps 2·M). Higher = better recall and more robust graphs, but more memory and slower builds. 12–48 is the practical range; ≤ 32 required for `search: 'gpu'`. | +| `efConstruction` | 200 | Beam width while inserting. Higher = better graph quality, slower ingest. | +| `efSearch` | 64 | Beam width at query time (clamped to ≥ k; ≤ 256 on `search: 'gpu'`). The main recall/latency knob — overridable per query. | +| `seed` | fixed | Level RNG seed; same inserts + same seed = identical graph. | +| `search` | `'cpu'` | `'gpu'` runs the one-dispatch beam-search kernel — pick it when queries arrive in **batches** (`queryBatch`); single queries are faster on `'cpu'`. | + +Tuning advice: + +- **`efSearch`** is the knob to sweep first (like IVF's `nprobe`). On clustered + data, recall@10 is typically ≳0.98 by `efSearch: 10–40`. +- Build cost is paid **at ingest** (in a Worker, so the UI stays responsive) — + unlike IVF there is no rebuild after appends, so HNSW suits continuously + growing stores; IVF suits bulk-load-then-query. +- HNSW supports **all metrics** including `l2`, and runs without WebGPU + (`fallback: 'wasm'`). +- With `persist`/`export`, the graph is stored in the snapshot — reloads + restore it directly (~180× faster than rebuilding) as long as `M` matches. + ### `chunkRows` GPU storage buffers have a device-dependent size cap @@ -143,6 +173,11 @@ const db = await BrowserVec.create({ db.query(q, { k: 20, // return 20 neighbors (default 10) nprobe: 64, // scan 64 clusters (IVF only) + efSearch: 128, // widen the search beam (HNSW only) rerank: false, // skip exact re-rank (quantized only) }); + +// Many queries in one call — a single GPU dispatch on an HNSW store +// with search: 'gpu'; a sequential loop everywhere else. +await db.queryBatch([q1, q2, q3], { k: 10, efSearch: 64 }); ``` diff --git a/docs/guide/integration.md b/docs/guide/integration.md index c3b689c..44fa278 100644 --- a/docs/guide/integration.md +++ b/docs/guide/integration.md @@ -53,7 +53,7 @@ const info = BrowserVec.isSupported(); if (info.webgpu) { // GPU-accelerated path, all configs available } else if (info.wasm) { - // CPU fallback — fp32 flat only, no quant/IVF + // CPU fallback — fp32 flat or HNSW (ann: { type: 'hnsw' }); no quant/IVF } else { // Neither WebGPU nor WASM — the library won't work } @@ -63,7 +63,7 @@ if (info.webgpu) { `browservec` uses three features that may interact with CSP: -1. **Web Workers** (quantized ingest, IVF k-means) — inlined via +1. **Web Workers** (quantized ingest, IVF k-means, HNSW graph) — inlined via `?worker&inline` at build time, so no `worker-src` exception is needed. The worker blob is created from a base64 data URL. 2. **WASM module** (CPU fallback) — requires `'wasm-unsafe-eval'` in @@ -113,7 +113,7 @@ handle: | Error | When | How to handle | |---|---|---| | `WebGPUUnavailableError` | `create()` when WebGPU is absent and `fallback: 'error'` (default) | Fall back to CPU or show a message. Pass `fallback: 'wasm'` to auto-degrade. | -| `"CPU fallback supports fp32 flat only"` | `create()` with `fallback: 'wasm'` + `quantBits` or `ann` | Drop quantization/IVF when targeting CPU-only environments. | +| `"CPU fallback supports fp32 flat and HNSW only"` | `create()` with `fallback: 'wasm'` + `quantBits` or an IVF `ann` | Drop quantization/IVF when targeting CPU-only environments (HNSW — `ann: { type: 'hnsw' }` — is fine there). | | `"dimension must be a positive integer"` | `create()` with invalid `dimension` | Validate input. | | `"duplicate id: …"` | `add()`/`addBatch()` with an id already in the store | Check `db.get(id)` first, or use `update()` for upsert. | | `"query dim X != store dim Y"` | `query()` with wrong-dimension vector | Ensure query dimension matches store dimension. | diff --git a/docs/internals.md b/docs/internals.md index 3e5c660..700e348 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -39,24 +39,40 @@ format, or the Worker-offload seams. For the high-level flow, read ## Persistence format (`src/persist/format.ts`) -Magic `0x43455642` ("BVEC" LE), `FORMAT_VERSION = 1`, `HEADER_BYTES = 32`. +Magic `0x43455642` ("BVEC" LE), `FORMAT_VERSION = 2`, `HEADER_BYTES = 32`. | Offset | Field | |---|---| | `0..4` | magic | -| `4..8` | version | +| `4..8` | version (`1`, or `2` when a graph section is present) | | `8..12` | dimension | | `12..16` | metric code (`0`=cosine, `1`=dot, `2`=l2) | | `16..20` | flags (bit 0 = normalize) | | `20..24` | count | | `24..28` | metadata JSON byte length | -| `28..32` | reserved (0) | +| `28..32` | v1: reserved (0). v2: byte offset of the HNSW graph section (0 = none) | | `32..` | metadata JSON (`Array<{id, metadata?}>`, row order), 4-byte padded | | after that | row-major `Float32Array` vectors (`count * dim * 4` bytes) | -`serialize()` builds this; `deserialize()` validates magic/version/count -consistency and truncation, and returns a `Snapshot`. The vector region is -`.slice()`d out on read so it doesn't pin the whole input buffer in memory. +**v2 graph section (M7c)** — starts 4-aligned right after the vectors, and holds +the HNSW graph so loads restore it instead of rebuilding: + +| Field | Size | +|---|---| +| header: magic `"HNSW"` (`0x57534e48`), graph version (=1), `M`, `entry`, `top` (maxLevel), `upperLen`, 2× reserved | 8 × u32 | +| `levels` — per-node top layer | i32 × count | +| `links0` — layer-0 adjacency, count-prefixed `(2M+1)`-wide blocks | i32 × count·(2M+1) | +| `upper` — upper-layer blocks, concatenated in row order (node *n* owns `levels[n]·(M+1)`) | i32 × upperLen | + +`serialize()` writes **v1 whenever there is no graph** (flat/IVF/quant stores), +so older builds keep reading every snapshot they could before — only the new +feature pays the version bump; `deserialize()` reads 1..2. The graph is skipped +at write time when tombstones are pending (compaction renumbers rows), and +ignored at read time on any config mismatch — both fall back to the ordinary +rebuild-via-append load path. `deserialize()` validates magic/version/count +consistency and truncation, and returns a `Snapshot`. The vector and graph +regions are `.slice()`d out on read so they don't pin the whole input buffer +in memory. ## Encryption envelope (`src/persist/crypto.ts`) @@ -157,6 +173,46 @@ cluster assignment uses a bit-width-specific quantized assign kernel (`encoder.rotateQuery`) before probing/scanning, and the scan kernel is also bit-width-specific. +## HNSW (`src/index/hnswGraph.ts`, `hnsw.ts`, `hnsw.worker.ts`, `hnswGpu.ts`) + +The graph core (`hnswGraph.ts`) is allocation-light by design: adjacency lives +in flat typed arrays (layer 0 is one `Int32Array` of count-prefixed `(2M+1)`-wide +blocks; upper layers get a per-node `Int32Array` only for the ~1/M of nodes that +have them), the visited set is a generation-stamped `Int32Array` (bump a counter +instead of clearing), and the two beam heaps are reused scratch (`MinHeap` over +parallel dist/id arrays; the results heap stores negated distances to act as a +max-heap). Neighbor selection uses the paper's diversity heuristic — keep a +candidate only if it's closer to the query than to every already-kept neighbor — +which is what keeps the graph navigable *between* clusters. Distances share the +house unrolled-×4 loops; internally smaller = closer (negated dot, or squared +L2), so `score = -dist` matches every metric's public convention. + +The seam (`hnsw.ts`) mirrors `kmeansTrainer.ts`, with one difference: the Worker +**owns** the graph and a packed corpus copy. Appends transfer vectors in; +searches send back only the top-k arrays — so both build and CPU search run +off the main thread. Builds are seeded-deterministic on either path. + +The GPU path (`graphSearch.ts` + `hnswGpu.ts`) is CAGRA-style: no hierarchy on +the GPU — the kernel walks the flat layer-0 graph (fixed `2M` slots per row, +`0xFFFFFFFF`-padded) seeded from the entry node plus evenly-spread rows. One +workgroup per query keeps the whole beam state in ~12 KB of workgroup shared +memory: a 256-slot candidate beam (dist/id/expanded), a 2048-slot open-addressed +atomic visited hash (id+1, 0 = empty; sheds under pressure rather than stalling — +the CPU dedups the readback), and reduction scratch. Loop-control reads go +through `workgroupUniformLoad` so barriers stay in uniform control flow. +Termination is exhaustion: expansion clears flags and inserts only add +unexpanded slots when they beat the current worst, so a converged beam ends up +fully expanded (the classic "best unexpanded > worst kept" check is a no-op in +a fused beam — the best unexpanded *is* a kept entry). The kernel emits the raw +beam; the CPU dedups, sorts, and takes k. Batching is free: `queryBatch` packs +queries row-major and dispatches `nQ` workgroups in one submit. + +Persistence (M7c): `serializeGraph()`/`loadGraph()` snapshot and restore the +structure (see [persistence format](#persistence-format-srcpersistformatts)); +`loadGraph` fast-forwards the level RNG one draw per restored row so post-load +inserts draw the same levels a never-saved store would — save/load doesn't fork +determinism. + ## Worker-offload seam (`src/quant/encoder.ts`, `src/index/kmeansTrainer.ts`) Both follow the identical pattern: wrap a synchronous in-thread diff --git a/examples/18-hnsw-index.html b/examples/18-hnsw-index.html new file mode 100644 index 0000000..44c139e --- /dev/null +++ b/examples/18-hnsw-index.html @@ -0,0 +1,99 @@ + + + + + +BrowserVec — 18. HNSW graph index + + + +

← All examples · Home

+

18. HNSW graph index (M7)

+

An HNSW graph links each vector to its near neighbors across layered "express lanes"; a query greedily descends the layers and beam-searches the bottom one, visiting O(log N) nodes instead of scanning all N. Unlike IVF there is no rebuild on append — inserts extend the graph incrementally — and it runs on the CPU in a Web Worker, so it works with or without WebGPU and on any metric. Tune efSearch to trade recall for speed.

+ +
ready.
+ +

+ + + +

+ + + + diff --git a/examples/19-hnsw-gpu.html b/examples/19-hnsw-gpu.html new file mode 100644 index 0000000..d47dc34 --- /dev/null +++ b/examples/19-hnsw-gpu.html @@ -0,0 +1,106 @@ + + + + + +BrowserVec — 19. GPU graph search + + + +

← All examples · Home

+

19. GPU graph search (M7b)

+

The HNSW graph from example 18, but searched by a CAGRA-style WGSL kernel: the whole beam search runs inside one compute dispatch — candidate beam, visited set, and reductions all live in workgroup shared memory, so there is no per-hop readback. One workgroup per query means queryBatch() searches every query concurrently. The honest trade: a single query pays fixed dispatch+readback latency (~ms) that the CPU walk doesn't — batches are where the GPU wins. This example measures both, plus recall. GPU-only (falls back to CPU search without WebGPU).

+ +
ready.
+ +

+ + + + +

+ + + + diff --git a/examples/index.html b/examples/index.html index ff8bd10..92bfa95 100644 --- a/examples/index.html +++ b/examples/index.html @@ -47,6 +47,8 @@

Core concepts

  • M3 TurboQuant quantization int8 / int4 quantized storage with asymmetric query + exact re-rank
  • M4 IVF approximate index cluster-based ANN, nprobe sweep, recall vs speed trade-off
  • M4 IVF × quant combo clustered + quantized — the 1M-vector path with int8 or int4
  • +
  • M7 HNSW graph index graph-based ANN, efSearch sweep, incremental inserts, works without WebGPU
  • +
  • M7b GPU graph search CAGRA-style beam search in one dispatch — queryBatch, CPU vs GPU crossover
  • React React hooks useVectorStore, useSimilaritySearch, useEmbedding, useRetriever — reusable React patterns
  • Vue Vue composables useVectorStore, useSimilaritySearch, useEmbedding, useRetriever — reusable Vue patterns
  • diff --git a/package-lock.json b/package-lock.json index 8f6095e..0afc0cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,56 @@ "license": "MIT", "devDependencies": { "@orama/orama": "^3.1.18", + "@vitest/browser": "^2.1.9", "@webgpu/types": "^0.1.40", "@xenova/transformers": "^2.17.2", "hnswlib-wasm": "^0.8.2", + "playwright": "^1.61.1", "typescript": "~5.6.0", "vectorious": "^6.1.14", "vite": "^5.2.0", + "vitest": "^2.1.9", "voy-search": "^0.6.3" }, "engines": { "node": ">=18" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -423,6 +461,150 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@orama/orama": { "version": "3.1.18", "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-3.1.18.tgz", @@ -433,6 +615,13 @@ "node": ">= 20.0.0" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -856,6 +1045,47 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -880,6 +1110,173 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/browser": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-2.1.9.tgz", + "integrity": "sha512-AHDanTP4Ed6J5R6wRBcWRQ+AxgMnNJxsbaa229nFQz5KOMFZqlW11QkIDoLgCjBOpQ1+c78lTN5jVxO8ME+S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.5.2", + "@vitest/mocker": "2.1.9", + "@vitest/utils": "2.1.9", + "magic-string": "^0.30.12", + "msw": "^2.6.4", + "sirv": "^3.0.0", + "tinyrainbow": "^1.2.0", + "ws": "^8.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "2.1.9", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.71", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", @@ -902,6 +1299,49 @@ "onnxruntime-node": "1.14.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -1073,6 +1513,43 @@ "ieee754": "^1.1.13" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1080,6 +1557,31 @@ "dev": true, "license": "ISC" }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1125,7 +1627,39 @@ "simple-swizzle": "^0.2.2" } }, - "node_modules/decompress-response": { + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", @@ -1141,6 +1675,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -1151,6 +1695,16 @@ "node": ">=4.0.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1161,6 +1715,20 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1171,6 +1739,13 @@ "once": "^1.4.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1210,6 +1785,26 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -1230,6 +1825,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -1237,6 +1842,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/flatbuffers": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", @@ -1266,6 +1898,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -1273,6 +1915,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", @@ -1280,6 +1932,17 @@ "dev": true, "license": "ISC" }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/hnswlib-wasm": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/hnswlib-wasm/-/hnswlib-wasm-0.8.2.tgz", @@ -1329,6 +1992,30 @@ "dev": true, "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -1336,6 +2023,33 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1366,6 +2080,78 @@ "dev": true, "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -1488,6 +2274,37 @@ "platform": "^1.3.6" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1502,6 +2319,53 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -1589,6 +2453,21 @@ "node": ">=6" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/protobufjs": { "version": "6.11.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", @@ -1643,6 +2522,13 @@ "rc": "cli.js" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -1658,6 +2544,23 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1737,6 +2640,13 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, "node_modules/sharp": { "version": "0.32.6", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", @@ -1761,6 +2671,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -1818,6 +2748,21 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1828,6 +2773,30 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamx": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", @@ -1840,6 +2809,13 @@ "text-decoder": "^1.1.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1850,6 +2826,34 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1860,6 +2864,19 @@ "node": ">=0.10.0" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar-fs": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", @@ -1908,6 +2925,93 @@ "b4a": "^1.6.4" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1921,6 +3025,22 @@ "node": "*" } }, + "node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -1942,6 +3062,16 @@ "dev": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2020,6 +3150,95 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/voy-search": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/voy-search/-/voy-search-0.6.3.tgz", @@ -2027,12 +3246,124 @@ "dev": true, "license": "MIT OR Apache 2.0" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } } } } diff --git a/package.json b/package.json index b271997..50e4c82 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,12 @@ "scripts": { "dev": "vite", "build": "vite build && tsc -p tsconfig.build.json", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tests/tsconfig.json", "preview": "vite preview", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "test:browser": "vitest run --project browser", + "test:all": "vitest run", "prepublishOnly": "npm run typecheck && npm run build" }, "keywords": [ @@ -52,12 +56,15 @@ ], "devDependencies": { "@orama/orama": "^3.1.18", + "@vitest/browser": "^2.1.9", "@webgpu/types": "^0.1.40", "@xenova/transformers": "^2.17.2", "hnswlib-wasm": "^0.8.2", + "playwright": "^1.61.1", "typescript": "~5.6.0", "vectorious": "^6.1.14", "vite": "^5.2.0", + "vitest": "^2.1.9", "voy-search": "^0.6.3" } } diff --git a/src/engine/wgsl/graphSearch.ts b/src/engine/wgsl/graphSearch.ts new file mode 100644 index 0000000..4fd06d9 --- /dev/null +++ b/src/engine/wgsl/graphSearch.ts @@ -0,0 +1,259 @@ +// GPU graph-search kernel (M7b) — CAGRA-style best-first beam search. +// +// HNSW-on-CPU walks the graph hop by hop; a naive GPU port would need one +// dispatch + readback per hop (~ms each in a browser), which is why graph ANN +// is usually written off for WebGPU. This kernel instead runs the ENTIRE beam +// search inside one dispatch: a single workgroup owns one query and keeps the +// whole search state in workgroup shared memory — the candidate beam (dist/id/ +// expanded flags), a hashed visited set, and reduction scratch. Each iteration +// it (1) argmin-reduces the beam for the best unexpanded node, (2) fans the +// node's K neighbors across lanes to compute distances in parallel, and +// (3) folds improving neighbors back into the beam via argmax reductions. +// No hierarchy: like CAGRA, the search runs on the flat degree-K bottom layer +// seeded from a few well-spread entry points — the upper HNSW layers exist to +// find a good start, and E entries do that job well enough on a dense beam. +// +// One workgroup per query means batches are free: dispatchWorkgroups(nQueries) +// searches every query concurrently — where the GPU actually beats the CPU walk +// (a single query is latency-bound by dispatch+readback overhead; see hnswGpu). +// +// The kernel emits the raw beam (ef dist/id pairs, unsorted); the CPU dedups +// (the visited hash sheds entries under pressure rather than stalling, so rare +// duplicates are possible) and selects the final top-k. +// +// Uniformity notes: loop-control decisions read from shared memory go through +// workgroupUniformLoad (implicit barrier, provably uniform), and every +// reduction has a fixed trip count so barriers stay in uniform control flow. + +import type { Metric } from '../../types.js'; + +// Beam capacity (shared-array size). Runtime ef ≤ EF via uniform; 256 keeps the +// whole state near ~12 KB, inside the 16 KB baseline workgroup-storage limit. +export const GRAPH_EF_CAP = 256; +// Visited hash slots (u32, id+1, 0 = empty). ~8 KB. Linear probing, bounded. +const HASH_SIZE = 2048; +const HASH_PROBES = 16; +// Fibonacci hashing constant (Knuth) — spreads sequential row ids well. +const HASH_MULT = '2654435761u'; + +const INF = '3.4e38'; + +export interface GraphSearchShaderKey { + dim: number; + /** Fixed out-degree of the graph (slots per row, empty = 0xFFFFFFFF). ≤ workgroupSize. */ + k: number; + metric: Metric; + workgroupSize: number; +} + +export function buildGraphSearchShader(key: GraphSearchShaderKey): string { + const { dim, k, metric, workgroupSize } = key; + if ((workgroupSize & (workgroupSize - 1)) !== 0) { + throw new Error(`graph-search workgroupSize must be a power of two, got ${workgroupSize}`); + } + if (k > workgroupSize) { + throw new Error(`graph degree ${k} exceeds workgroupSize ${workgroupSize}`); + } + + // Internal distance: smaller = closer (negated dot for cosine/dot, squared L2). + const distExpr = + metric === 'l2' + ? `let d = corpus[base + i] - queries[qBase + i]; acc = acc + d * d;` + : `acc = acc + corpus[base + i] * queries[qBase + i];`; + const distReturn = metric === 'l2' ? 'return acc;' : 'return -acc;'; + + return /* wgsl */ ` +// AUTO-GENERATED graph beam search, dim=${dim} K=${k} metric=${metric} wg=${workgroupSize} +override WG: u32 = ${workgroupSize}u; +const DIM: u32 = ${dim}u; +const K: u32 = ${k}u; +const EF: u32 = ${GRAPH_EF_CAP}u; +const SLOTS_PER_LANE: u32 = ${Math.ceil(GRAPH_EF_CAP / workgroupSize)}u; +const HASH_MASK: u32 = ${HASH_SIZE - 1}u; +const EMPTY: u32 = 0xffffffffu; +const INF: f32 = ${INF}; + +@group(0) @binding(0) var corpus: array; // rows*DIM +@group(0) @binding(1) var graph: array; // rows*K, EMPTY-padded +@group(0) @binding(2) var queries: array; // nQ*DIM +@group(0) @binding(3) var entries: array; // entryCount seed rows +@group(0) @binding(4) var outDist: array; // nQ*EF +@group(0) @binding(5) var outId: array; // nQ*EF +// x = ef (beam width, ≤ EF), y = iterCap (max expansions), z = entryCount +@group(0) @binding(6) var params: vec4; + +// The beam: candidate distances/ids and an "already expanded" flag per slot. +// Slots start at INF/expanded so they can never win the argmin until filled. +var candDist: array; +var candId: array; +var candExp: array; +// Visited set: open-addressed hash of id+1 (0 = empty). Sheds on overflow — +// a shed id may re-enter the beam; the CPU dedups the readback. +var visited: array, ${HASH_SIZE}>; +// Per-expansion staging: lane j's neighbor distance/id (INF = skip). +var stgDist: array; +var stgId: array; +// Argmin/argmax reduction scratch. +var redVal: array; +var redIdx: array; + +fn distanceTo(row: u32, qBase: u32) -> f32 { + let base = row * DIM; + var acc: f32 = 0.0; + for (var i: u32 = 0u; i < DIM; i = i + 1u) { + ${distExpr} + } + ${distReturn} +} + +// Mark \`id\` visited; returns true if it was already seen. Lock-free linear +// probe with a bounded budget: under table pressure we give up and report +// "not seen" (costing a duplicate visit) rather than spinning. +fn markVisited(id: u32) -> bool { + var h = (id * ${HASH_MULT}) & HASH_MASK; + var probes: u32 = 0u; + loop { + if (probes >= ${HASH_PROBES}u) { return false; } + let cur = atomicLoad(&visited[h]); + if (cur == id + 1u) { return true; } + if (cur == 0u) { + let r = atomicCompareExchangeWeak(&visited[h], 0u, id + 1u); + if (r.exchanged) { return false; } + if (r.old_value == id + 1u) { return true; } + if (r.old_value == 0u) { continue; } // spurious failure — retry this slot + } + h = (h + 1u) & HASH_MASK; + probes = probes + 1u; + } +} + +// Fold the staged (dist,id) pairs 0..count into the beam: for each, argmax-find +// the worst active slot and replace it if the staged entry is better. Runs with +// all lanes (the reductions need them); lane 0 applies the swap. +fn foldStaged(count: u32, t: u32, efA: u32) { + for (var j: u32 = 0u; j < count; j = j + 1u) { + // Parallel argmax over the active beam. + var worst: f32 = -INF; + var worstIdx: u32 = 0u; + for (var s = t; s < efA; s = s + WG) { + if (candDist[s] > worst) { worst = candDist[s]; worstIdx = s; } + } + redVal[t] = worst; + redIdx[t] = worstIdx; + workgroupBarrier(); + var stride: u32 = WG >> 1u; + loop { + if (stride == 0u) { break; } + if (t < stride) { + if (redVal[t + stride] > redVal[t]) { + redVal[t] = redVal[t + stride]; + redIdx[t] = redIdx[t + stride]; + } + } + workgroupBarrier(); + stride = stride >> 1u; + } + if (t == 0u) { + if (stgDist[j] < redVal[0]) { + let slot = redIdx[0]; + candDist[slot] = stgDist[j]; + candId[slot] = stgId[j]; + candExp[slot] = 0u; + } + } + workgroupBarrier(); + } +} + +@compute @workgroup_size(WG) +fn main(@builtin(workgroup_id) wid: vec3, + @builtin(local_invocation_id) lid: vec3) { + let efA = min(params.x, EF); + let iterCap = params.y; + let entryCount = min(params.z, WG); + let t = lid.x; + let qBase = wid.x * DIM; + + // Init the beam: INF + expanded, so empty slots never win the argmin. + for (var s = t; s < EF; s = s + WG) { + candDist[s] = INF; + candId[s] = EMPTY; + candExp[s] = 1u; + } + // (visited[] is zero-initialized per dispatch by WebGPU.) + + // Seed: stage the entry rows exactly like a neighbor batch, then fold. + if (t < entryCount) { + let e = entries[t]; + stgDist[t] = INF; + if (!markVisited(e)) { + stgDist[t] = distanceTo(e, qBase); + stgId[t] = e; + } + } + workgroupBarrier(); + foldStaged(entryCount, t, efA); + + // Beam loop: expand the best unvisited candidate until the beam is exhausted. + var iter: u32 = 0u; + loop { + if (iter >= iterCap) { break; } + + // Parallel argmin over unexpanded beam slots. + var best: f32 = INF; + var bestIdx: u32 = 0u; + for (var s = t; s < efA; s = s + WG) { + if (candExp[s] == 0u && candDist[s] < best) { best = candDist[s]; bestIdx = s; } + } + redVal[t] = best; + redIdx[t] = bestIdx; + workgroupBarrier(); + var stride: u32 = WG >> 1u; + loop { + if (stride == 0u) { break; } + if (t < stride) { + if (redVal[t + stride] < redVal[t]) { + redVal[t] = redVal[t + stride]; + redIdx[t] = redIdx[t + stride]; + } + } + workgroupBarrier(); + stride = stride >> 1u; + } + let bestDist = workgroupUniformLoad(&redVal[0]); + if (bestDist >= INF) { break; } // nothing left to expand + let bestSlot = workgroupUniformLoad(&redIdx[0]); + // Termination is exhaustion: expansion clears flags and inserts only add + // unexpanded slots when they beat the current worst, so a converged beam + // ends up fully expanded and the INF argmin above breaks the loop. (The + // classic "best unexpanded > worst kept" check is a no-op in a fused beam — + // the best unexpanded IS a kept entry, so it never exceeds the beam max.) + if (t == 0u) { candExp[bestSlot] = 1u; } + let node = workgroupUniformLoad(&candId[bestSlot]); + + // Fan the node's K neighbor slots across lanes: visited-check + distance. + if (t < K) { + stgDist[t] = INF; + let nb = graph[node * K + t]; + if (nb != EMPTY) { + if (!markVisited(nb)) { + stgDist[t] = distanceTo(nb, qBase); + stgId[t] = nb; + } + } + } + workgroupBarrier(); + foldStaged(K, t, efA); + + iter = iter + 1u; + } + + // Emit the raw beam; the CPU dedups and picks the top-k. + let outBase = wid.x * EF; + for (var s = t; s < EF; s = s + WG) { + outDist[outBase + s] = candDist[s]; + outId[outBase + s] = candId[s]; + } +} +`; +} diff --git a/src/engine/wgsl/mask.ts b/src/engine/wgsl/mask.ts new file mode 100644 index 0000000..1c0b981 --- /dev/null +++ b/src/engine/wgsl/mask.ts @@ -0,0 +1,36 @@ +// Score-mask kernel (FR-7 in-index filtering, flat-family paths). +// +// The distance kernels leave one f32 score per corpus row in a dense storage +// buffer; this pass overwrites the score of every row whose mask bit is clear +// with the same -FLT_MAX sentinel the top-k reduction already treats as "not a +// result". Running it between the distance dispatch and the top-k turns the +// whole flat pipeline into a pre-filtered search: masked rows can never win a +// top-k slot, so no over-fetch is needed and GPU top-k stays on its small-k +// fast path. The mask is one bit per global row (LSB-first within each u32), +// built CPU-side from the metadata predicate. + +// Must sort below any real score and match the top-k merge's filler comparison +// (see src/index/gpuTopk.ts NEG_MAX / src/engine/wgsl/topk.ts). +const NEG_MAX = '-3.4e38'; + +export interface MaskShaderKey { + workgroupSize: number; +} + +export function buildMaskShader(key: MaskShaderKey): string { + return /* wgsl */ ` +// AUTO-GENERATED score-mask pass, wg=${key.workgroupSize} +@group(0) @binding(0) var scores: array; +@group(0) @binding(1) var mask: array; // 1 bit/row, LSB-first +@group(0) @binding(2) var params: vec4; // x = n + +@compute @workgroup_size(${key.workgroupSize}) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if (i >= params.x) { return; } + if (((mask[i >> 5u] >> (i & 31u)) & 1u) == 0u) { + scores[i] = f32(${NEG_MAX}); + } +} +`; +} diff --git a/src/index.ts b/src/index.ts index c8b025b..e6d494e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,9 @@ import { CpuIndex } from './fallback/cpu.js'; import { QuantIndex } from './index/quant.js'; import { IVFIndex } from './index/ivf.js'; import { IVFQuantIndex } from './index/ivfquant.js'; +import { HNSWIndex } from './index/hnsw.js'; import { Store, normalizeInPlace } from './store/store.js'; +import { compileFilter } from './store/filter.js'; import { queryTrace } from './engine/profile.js'; import { selectBackend, type PersistenceBackend } from './persist/backend.js'; import { serialize, deserialize, type Snapshot } from './persist/format.js'; @@ -44,9 +46,14 @@ export type { ExportOptions, QuantConfig, ANNConfig, + IVFConfig, + HNSWConfig, Embedder, TextRecord, Metric, + MetadataFilter, + FilterOps, + FilterValue, QueryOptions, QueryResult, Stats, @@ -63,6 +70,15 @@ export type { TransformersEmbedderOptions } from './embed/transformers.js'; const DEFAULT_SEED = 0x9e3779b9; +// Filtered queries with at most this many matching rows skip the GPU entirely: +// an exact CPU scan over so few rows is microseconds, cheaper than any dispatch +// + readback round-trip (mirrors GPU_TOPK_MIN_ROWS in spirit). +const FILTER_CPU_SCAN_MAX = 4096; + +interface MaskCapableIndex { + queryMasked(q: Float32Array, k: number, maskWords: Uint32Array): Promise; +} + // Decrypt a persisted/imported blob when it's an encrypted envelope. An encrypted // blob without a passphrase, or a passphrase against a plaintext blob, is a clear // mismatch worth erroring on rather than silently mis-loading. @@ -97,6 +113,14 @@ function buildIndex( ): VectorIndex { const seed = quant?.seed ?? DEFAULT_SEED; const rounds = quant?.rounds ?? 2; + // HNSW is CPU-resident (graph traversal doesn't batch onto the GPU) and metric- + // complete; it's the one index shared verbatim with the no-GPU fallback path. + if (ann?.type === 'hnsw') { + if (quantBits !== 0) { + throw new Error('HNSW currently supports fp32 only (quantBits: 0) — HNSW × quant is later work'); + } + return new HNSWIndex(dim, metric, ann, ctx); + } if (ann) { if (metric === 'l2') { throw new Error('IVF supports metric cosine/dot only (l2 is later work)'); @@ -117,14 +141,18 @@ function buildIndex( throw new Error(`quantBits ${quantBits} not supported (ships 0, 1, 4, 8)`); } -// Exact CPU flat index for the no-WebGPU fallback (§NFR-7). Quantization and IVF -// are GPU-throughput optimizations that add nothing to an exact CPU scan, so they -// aren't ported to the fallback yet — asking for them without a GPU is an error -// rather than a silent accuracy change. +// CPU indexes for the no-WebGPU fallback (§NFR-7): exact flat scan, or HNSW — +// which is CPU-native anyway, making it the one ANN option that works without a +// GPU. Quantization and IVF are GPU-throughput optimizations that aren't ported +// to the fallback — asking for them without a GPU is an error rather than a +// silent accuracy change. function buildCpuIndex(dim: number, metric: Metric, quantBits: 0 | 1 | 4 | 8, ann: BrowserVecConfig['ann']): VectorIndex { + if (ann?.type === 'hnsw' && quantBits === 0) { + return new HNSWIndex(dim, metric, ann); + } if (quantBits !== 0 || ann) { throw new Error( - 'CPU fallback supports fp32 flat only — quantBits/ann require WebGPU. ' + + 'CPU fallback supports fp32 flat and HNSW only — quantBits/IVF require WebGPU. ' + 'Drop them, or run where WebGPU is available.', ); } @@ -324,13 +352,61 @@ export class BrowserVec { if (opts.nprobe !== undefined && 'setNprobe' in this.index) { (this.index as { setNprobe(n: number): void }).setNprobe(opts.nprobe); } + // HNSW: same pattern for the per-query beam width. + if (opts.efSearch !== undefined && 'setEf' in this.index) { + (this.index as { setEf(n: number): void }).setEf(opts.efSearch); + } + + // Metadata filtering (FR-7). Pre-scan the metadata to find matching rows, + // then pick a strategy: + // - tiny match set → skip the index and exactly score just the matching + // rows on the CPU (the store keeps the fp32 vectors). Exact on every + // index type, including approximate ones. + // - mask-capable index (flat fp32/quant) → in-index pre-filter: a mask + // pass stamps non-matching rows' scores to -FLT_MAX between the distance + // kernel and top-k, so the GPU returns exactly the top-k matching rows + // at full speed, any selectivity, no over-fetch. + // - other indexes (IVF/HNSW/CPU fallback) → selective filters take the + // exact CPU scan; near-total matches stay on the index path and + // over-fetch by the excluded count, post-filtering (tombstone trick). + const predicate = opts.filter ? compileFilter(opts.filter) : null; + let skipRows = 0; // live rows the filter excludes (over-fetch path only) + let maskWords: Uint32Array | null = null; + if (predicate) { + const rowCount = this.store.rowCount; + const words = new Uint32Array(Math.ceil(rowCount / 32) || 1); + const matchRows: number[] = []; + for (let row = 0; row < rowCount; row++) { + if (this.store.isDeleted(row)) continue; + const entry = this.store.entryByRow(row)!; + if (predicate(entry.metadata)) { + matchRows.push(row); + words[row >> 5]! |= 1 << (row & 31); + } + } + if (matchRows.length === 0) return []; + if (matchRows.length <= FILTER_CPU_SCAN_MAX) return this.queryRowsExact(q, matchRows, k); + if ('queryMasked' in this.index) { + maskWords = words; // tombstones never match, so the mask excludes them too + } else { + skipRows = this.store.count - matchRows.length; + if (skipRows > Math.max(2 * k, 256)) return this.queryRowsExact(q, matchRows, k); + } + } // Deleted rows are tombstones still scored by the GPU, so over-fetch by the - // deleted count and filter them out — guarantees ≥k live results survive. - // When nothing is deleted, kEff == k and this is a no-op (unchanged path). + // deleted count (plus any filter-excluded live rows) and filter them out — + // guarantees ≥k qualifying results survive. When nothing is deleted and no + // filter is set, kEff == k and this is a no-op (unchanged path). The masked + // path needs no over-fetch at all: excluded rows can't win a top-k slot. const deleted = this.store.deletedCount; const total = this.index.size; - const kEff = Math.min(total, k + deleted); + const kEff = maskWords ? Math.min(total, k) : Math.min(total, k + deleted + skipRows); + + const search = (kk: number): Promise => + maskWords + ? (this.index as unknown as MaskCapableIndex).queryMasked(q, kk, maskWords) + : this.index.query(q, kk); queryTrace.reset(); const start = performance.now(); @@ -338,10 +414,10 @@ export class BrowserVec { if (this.quantBits > 0) { const doRerank = opts.rerank ?? this.rerankFactor > 1; const fetch = doRerank ? Math.min(total, Math.max(kEff, kEff * this.rerankFactor)) : kEff; - const approx = await this.index.query(q, fetch); + const approx = await search(fetch); hits = doRerank ? this.rerankExact(q, approx, kEff) : approx.slice(0, kEff); } else { - hits = await this.index.query(q, kEff); + hits = await search(kEff); } this.lastQueryMs = performance.now() - start; this.lastQueryGpuMs = queryTrace.gpuWaitMs; @@ -350,6 +426,7 @@ export class BrowserVec { for (const h of hits) { if (deleted && this.store.isDeleted(h.row)) continue; const entry = this.store.entryByRow(h.row)!; + if (predicate && !predicate(entry.metadata)) continue; const out: QueryResult = { id: entry.id, score: h.score }; if (entry.metadata !== undefined) out.metadata = entry.metadata; results.push(out); @@ -358,6 +435,87 @@ export class BrowserVec { return results; } + /** + * Exact filtered search: score only the pre-filtered `rows` straight from the + * store's CPU-side fp32 buffer and keep the top-k. Cost is O(matches · dim), + * independent of corpus size — the win of a selective filter. Exact for every + * index configuration (quantized/IVF/HNSW included) since it bypasses the + * index entirely. + */ + private queryRowsExact(q: Float32Array, rows: number[], k: number): QueryResult[] { + queryTrace.reset(); + const start = performance.now(); + const scores = new Float32Array(rows.length); + if (this.store.metric === 'l2') { + for (let i = 0; i < rows.length; i++) scores[i] = this.store.l2Row(rows[i]!, q); + } else { + for (let i = 0; i < rows.length; i++) scores[i] = this.store.dotRow(rows[i]!, q); + } + const hits = topK(scores, rows.length, k); + this.lastQueryMs = performance.now() - start; + this.lastQueryGpuMs = 0; + + return hits.map((h) => { + const entry = this.store.entryByRow(rows[h.row]!)!; + const out: QueryResult = { id: entry.id, score: h.score }; + if (entry.metadata !== undefined) out.metadata = entry.metadata; + return out; + }); + } + + /** + * Query many vectors at once. On an HNSW store with `search: 'gpu'` this is a + * SINGLE compute dispatch — one workgroup per query, all concurrent — which is + * the regime where the GPU graph kernel beats the CPU walk. Everywhere else it + * degrades gracefully to a sequential loop over query(). Results are per-query, + * same order as the input. + */ + async queryBatch(queries: Vector[], opts: QueryOptions = {}): Promise { + if (queries.length === 0) return []; + // Filtered batches go through query() per vector: the filter machinery + // (selectivity choice, exact scan, over-fetch) lives there. + if (!('queryBatch' in this.index) || opts.filter) { + const out: QueryResult[][] = []; + for (const q of queries) out.push(await this.query(q, opts)); + return out; + } + const k = opts.k ?? 10; + const dim = this.store.dimension; + const packed = new Float32Array(queries.length * dim); + for (let i = 0; i < queries.length; i++) { + const q = queries[i]!; + if (q.length !== dim) throw new Error(`query dim ${q.length} != store dim ${dim}`); + packed.set(q, i * dim); + if (this.store.normalize) normalizeInPlace(packed.subarray(i * dim, (i + 1) * dim)); + } + if (opts.efSearch !== undefined && 'setEf' in this.index) { + (this.index as { setEf(n: number): void }).setEf(opts.efSearch); + } + + const deleted = this.store.deletedCount; + const kEff = Math.min(this.index.size, k + deleted); + queryTrace.reset(); + const start = performance.now(); + const batch = await ( + this.index as unknown as { queryBatch(q: Float32Array, n: number, k: number): Promise } + ).queryBatch(packed, queries.length, kEff); + this.lastQueryMs = performance.now() - start; + this.lastQueryGpuMs = queryTrace.gpuWaitMs; + + return batch.map((hits) => { + const results: QueryResult[] = []; + for (const h of hits) { + if (deleted && this.store.isDeleted(h.row)) continue; + const entry = this.store.entryByRow(h.row)!; + const out: QueryResult = { id: entry.id, score: h.score }; + if (entry.metadata !== undefined) out.metadata = entry.metadata; + results.push(out); + if (results.length === k) break; + } + return results; + }); + } + /** * Delete a vector by id. Returns false if the id isn't present. The row is * tombstoned (filtered from results immediately) and its GPU memory is reclaimed @@ -442,14 +600,14 @@ export class BrowserVec { if (!this.backend || !this.persistName) { throw new Error('save() requires a persist config; pass { persist: { name } } to create()'); } - let bytes = this.snapshotBytes(); + let bytes = await this.snapshotBytes(); if (this.persistPassphrase) bytes = await encryptSnapshot(bytes, this.persistPassphrase); await this.backend.write(this.persistName, bytes); } /** Serialize the whole store to a single Blob. Optionally encrypted. */ async export(opts?: ExportOptions): Promise { - let bytes = this.snapshotBytes(); + let bytes = await this.snapshotBytes(); const passphrase = opts?.encryption?.passphrase; if (passphrase) bytes = await encryptSnapshot(bytes, passphrase); return new Blob([bytes], { type: 'application/octet-stream' }); @@ -485,7 +643,7 @@ export class BrowserVec { return db; } - private snapshotBytes(): ArrayBuffer { + private async snapshotBytes(): Promise { // Compact out tombstoned rows so persisted/exported snapshots hold only live // vectors (and a reload reclaims their memory). liveEntries/liveVectors are // row-aligned. @@ -493,14 +651,23 @@ export class BrowserVec { const entries = live.map((e) => e.metadata !== undefined ? { id: e.id, metadata: e.metadata } : { id: e.id }, ); - return serialize({ + // Persist the HNSW graph so loads skip the rebuild (M7c) — but only when no + // rows are tombstoned: compaction renumbers rows, which would invalidate the + // graph's adjacency. A post-delete snapshot just falls back to rebuild-on-load. + let graph; + if (this.store.deletedCount === 0 && 'exportGraphState' in this.index) { + graph = (await (this.index as unknown as HNSWIndex).exportGraphState()) ?? undefined; + } + const input: Parameters[0] = { dimension: this.store.dimension, metric: this.store.metric, normalize: this.store.normalize, count: live.length, entries, vectors: this.store.liveVectors(), - }); + }; + if (graph) input.graph = graph; + return serialize(input); } private async loadSnapshot(snap: Snapshot): Promise { @@ -518,7 +685,18 @@ export class BrowserVec { // Vectors were stored already prepared (normalized if applicable) — insert as-is. this.store.insert(e.id, vec, e.metadata); } - await this.index.append(snap.vectors.subarray(0, snap.count * dim), snap.count); + const vectors = snap.vectors.subarray(0, snap.count * dim); + // A persisted HNSW graph restores directly when the index config matches (M7c), + // skipping the O(N·efConstruction) rebuild. Any mismatch — different index + // type, different M — just rebuilds via the normal append path. + if (snap.graph && snap.count > 0 && 'loadWithGraph' in this.index) { + const hnsw = this.index as unknown as HNSWIndex; + if (hnsw.M === snap.graph.M) { + await hnsw.loadWithGraph(vectors, snap.count, snap.graph); + return; + } + } + await this.index.append(vectors, snap.count); } stats(): Stats { @@ -541,6 +719,13 @@ export class BrowserVec { const nlist = (this.index as { nlist: number }).nlist; if (nlist > 0) out.nlist = nlist; } + if ('maxLevel' in this.index) { + const maxLevel = (this.index as { maxLevel: number }).maxLevel; + if (maxLevel >= 0) out.maxLevel = maxLevel; + } + if ('searchMode' in this.index) { + out.graphSearch = (this.index as { searchMode: 'gpu' | 'cpu' }).searchMode; + } if ('ingestMode' in this.index) { const mode = (this.index as { ingestMode: 'worker' | 'main-thread' | 'pending' }).ingestMode; if (mode !== 'pending') out.ingest = mode; diff --git a/src/index/flat.ts b/src/index/flat.ts index 1644f03..113d1a9 100644 --- a/src/index/flat.ts +++ b/src/index/flat.ts @@ -17,6 +17,7 @@ import { import { buildDistanceShader } from '../engine/wgsl/distance.js'; import { tracedGpuWait } from '../engine/profile.js'; import { GpuTopK } from './gpuTopk.js'; +import { ScoreMask, MASKED_SCORE } from './scoreMask.js'; const WORKGROUP_SIZE = 64; @@ -47,6 +48,7 @@ export class FlatIndex { private scoreCapacity = 0; private rows = 0; private gpuTopk: GpuTopK | null = null; + private masker: ScoreMask | null = null; private readonly paramsScratch = new Uint32Array(4); constructor( @@ -113,7 +115,25 @@ export class FlatIndex { } /** Run the kernel over all rows and return the top-k by score (higher = closer). */ - async query(queryVec: Float32Array, k: number): Promise { + query(queryVec: Float32Array, k: number): Promise { + return this.search(queryVec, k, null); + } + + /** + * Filtered query (FR-7): same full distance dispatch, then a mask pass stamps + * the -FLT_MAX sentinel over rows whose bit is clear before top-k — masked + * rows can't win a slot, so results are exactly the top-k *matching* rows + * with no over-fetch. `maskWords` is 1 bit per global row, LSB-first. + */ + queryMasked(queryVec: Float32Array, k: number, maskWords: Uint32Array): Promise { + return this.search(queryVec, k, maskWords); + } + + private async search( + queryVec: Float32Array, + k: number, + maskWords: Uint32Array | null, + ): Promise { if (this.ctx.lost) throw new Error('GPU device lost; re-create the store'); if (this.rows === 0) return []; @@ -148,9 +168,17 @@ export class FlatIndex { device.queue.submit([enc.finish()]); }); + // Filtered query: stamp the sentinel over masked-out rows before top-k. + // Queue-ordered after the distance dispatches above. + if (maskWords) { + this.masker ??= new ScoreMask(this.ctx); + this.masker.apply(sp.scores, this.rows, maskWords); + } + // Reduce on the GPU once the O(N) score readback dominates; below the // threshold (or when a very large k would make the partials list exceed the // full readback) the copy-back + CPU sort is cheaper than a second dispatch. + // The GPU merge already drops sentinel (masked/filler) slots. if (GpuTopK.beneficial(this.rows, k)) { this.gpuTopk ??= new GpuTopK(this.ctx); return this.gpuTopk.query(sp.scores, this.rows, k); @@ -166,7 +194,9 @@ export class FlatIndex { const scores = new Float32Array(sp.readback.getMappedRange(0, this.rows * 4)); const hits = topK(scores, this.rows, k); sp.readback.unmap(); - return hits; + // When k exceeds the number of matching rows, sentinel slots reach the CPU + // select — drop them (the GPU reduction path does this in its merge). + return maskWords ? hits.filter((h) => h.score > MASKED_SCORE) : hits; } destroy(): void { @@ -176,6 +206,7 @@ export class FlatIndex { this.scores?.scores.destroy(); this.scores?.readback.destroy(); this.gpuTopk?.destroy(); + this.masker?.destroy(); } } @@ -185,6 +216,7 @@ export class FlatIndex { */ export function topK(scores: Float32Array, n: number, k: number): FlatHit[] { const limit = Math.min(k, n); + if (limit <= 0) return []; const heap: FlatHit[] = []; for (let row = 0; row < n; row++) { const score = scores[row]!; diff --git a/src/index/hnsw.ts b/src/index/hnsw.ts new file mode 100644 index 0000000..c22e341 --- /dev/null +++ b/src/index/hnsw.ts @@ -0,0 +1,277 @@ +// HNSW graph index (M7) — the VectorIndex seam over the CPU graph core. +// +// Where flat/IVF/quant indexes dispatch WGSL kernels, HNSW is inherently +// sequential pointer-chasing (each hop needs the previous hop's distances), so it +// runs on the CPU — which also makes it the first ANN index that works on the +// no-WebGPU fallback path. Following the kmeansTrainer pattern, the graph lives +// in a Web Worker when one is available so builds *and* searches stay off the +// main thread; otherwise the same HNSWGraph runs in-thread with identical, +// seeded-deterministic results. Callers never know which path ran (see +// `trainMode`, surfaced through stats().train like the IVF build). +// +// Unlike IVF there is no lazy rebuild: inserts extend the graph incrementally at +// append time, so the first query after a large ingest pays nothing extra. + +import type { HNSWConfig, Metric } from '../types.js'; +import type { DeviceContext } from '../engine/device.js'; +import type { FlatHit, VectorIndex } from './flat.js'; +import type { TrainMode } from './kmeansTrainer.js'; +import { HNSWGraph, type HNSWGraphState } from './hnswGraph.js'; +import { HNSWGpuSearcher } from './hnswGpu.js'; +// Vite base64-inlines the worker into the main bundle (must be a *static* import — +// see the note in quant/encoder.ts). `tsc` resolves it via env.d.ts, and the built +// dist never constructs the Worker unless `typeof Worker !== 'undefined'`. +import InlineHNSWWorker from './hnsw.worker?worker&inline'; + +const DEFAULT_EF_SEARCH = 64; + +interface Pending { + resolve: (msg: WorkerReply) => void; + reject: (e: unknown) => void; +} + +type WorkerReply = + | { type: 'appended'; id: number; maxLevel: number } + | { type: 'hits'; id: number; rows: Int32Array; scores: Float32Array } + | { type: 'graph'; id: number; links: Uint32Array; entry: number } + | { type: 'state'; id: number; state: HNSWGraphState } + | { type: 'loaded'; id: number; maxLevel: number }; + +export class HNSWIndex implements VectorIndex { + private rows = 0; + private readonly efSearch: number; + private efOverride: number | undefined; // per-query override (see setEf) + + // Exactly one of these is live: the worker proxy or the in-thread graph. + private worker: Worker | null = null; + private graph: HNSWGraph | null = null; + private topLevel = -1; // mirrored from the worker on each append ack + private nextId = 1; + private readonly pending = new Map(); + + // GPU search path (M7b): the CPU/worker still builds the graph; the searcher + // mirrors corpus + layer-0 adjacency on the GPU and runs the beam kernel. + // Null when search:'cpu' (default), no device, or after a capacity fallback. + private gpu: HNSWGpuSearcher | null = null; + private entryNode = -1; // mirrored with each graph export + + /** Configured out-degree — persisted graphs must match to be restorable. */ + readonly M: number; + + constructor( + private readonly dim: number, + metric: Metric, + params: HNSWConfig, + ctx: DeviceContext | null = null, + ) { + this.M = Math.max(2, params.M ?? 16); + this.efSearch = Math.max(1, params.efSearch ?? DEFAULT_EF_SEARCH); + const graphParams = { M: params.M, efConstruction: params.efConstruction, seed: params.seed }; + if (params.search === 'gpu' && ctx) { + // Throws early on degree > kernel limit (M ≤ 32) — a config error, not a + // runtime condition, so it should not silently fall back. + this.gpu = new HNSWGpuSearcher(ctx, dim, metric, 2 * Math.max(2, params.M ?? 16)); + } + if (typeof Worker !== 'undefined') { + try { + const w = new InlineHNSWWorker(); + w.onmessage = (e: MessageEvent) => this.onMessage(e); + w.onerror = () => this.failAll(new Error('hnsw worker crashed')); + w.postMessage({ type: 'init', dim, metric, params: graphParams }); + this.worker = w; + } catch { + // No worker (CSP block, non-Vite runtime) — fall through to in-thread. + } + } + if (!this.worker) this.graph = new HNSWGraph(dim, metric, params); + } + + get size(): number { + return this.rows; + } + + /** Where the graph build (and search) runs — surfaced as stats().train. */ + get trainMode(): TrainMode { + return this.worker ? 'worker' : 'main-thread'; + } + + /** Top graph layer (-1 while empty) — surfaced as stats().maxLevel. */ + get maxLevel(): number { + return this.graph ? this.graph.maxLevel : this.topLevel; + } + + /** Set the ef used by the next query() (consumed once), like IVF's setNprobe. */ + setEf(n: number | undefined): void { + this.efOverride = n; + } + + /** Which engine answers queries — surfaced as stats().graphSearch. */ + get searchMode(): 'gpu' | 'cpu' { + return this.gpu ? 'gpu' : 'cpu'; + } + + append(data: Float32Array, count: number): void | Promise { + if (data.length !== count * this.dim) { + throw new Error(`expected ${count * this.dim} floats, got ${data.length}`); + } + if (this.gpu) { + try { + this.gpu.append(data, count); + } catch { + // Corpus outgrew one storage buffer — the graph hops to arbitrary rows, + // so it can't chunk like the linear scans. Fall back to CPU search for + // good (the worker/in-thread graph is complete either way). + this.gpu.destroy(); + this.gpu = null; + } + } + this.rows += count; + if (this.graph) { + this.graph.append(data, count); + return; + } + // The caller may reuse/retain `data`; transfer an owned copy zero-copy. + const owned = data.slice(); + return this.rpc({ type: 'append', vectors: owned, count }, [owned.buffer]).then((msg) => { + if (msg.type === 'appended') this.topLevel = msg.maxLevel; + }); + } + + async query(queryVec: Float32Array, k: number): Promise { + if (this.rows === 0) return []; + const ef = Math.max(this.efOverride ?? this.efSearch, k); + this.efOverride = undefined; + if (this.gpu) { + await this.syncGpuGraph(); + const [hits] = await this.gpu.search(queryVec, 1, k, ef, this.entryNode); + return hits!; + } + if (this.graph) return this.graph.search(queryVec, k, ef); + // Worker messages are FIFO, so this search runs after every acked append. + const msg = await this.rpc({ type: 'search', query: queryVec, k, ef }); + if (msg.type !== 'hits') throw new Error('hnsw worker protocol error'); + const hits: FlatHit[] = []; + for (let i = 0; i < msg.rows.length; i++) { + hits.push({ row: msg.rows[i]!, score: msg.scores[i]! }); + } + return hits; + } + + /** + * Search `nQ` queries packed row-major in one call. On the GPU path this is a + * SINGLE dispatch (one workgroup per query) — the regime where the kernel + * beats the CPU walk, which pays per-query dispatch+readback otherwise. + * CPU paths just loop. Duck-typed by BrowserVec.queryBatch. + */ + async queryBatch(queries: Float32Array, nQ: number, k: number): Promise { + if (queries.length !== nQ * this.dim) { + throw new Error(`expected ${nQ * this.dim} floats, got ${queries.length}`); + } + const ef = Math.max(this.efOverride ?? this.efSearch, k); + this.efOverride = undefined; + if (this.rows === 0) return Array.from({ length: nQ }, () => []); + if (this.gpu) { + await this.syncGpuGraph(); + return this.gpu.search(queries, nQ, k, ef, this.entryNode); + } + const out: FlatHit[][] = []; + for (let i = 0; i < nQ; i++) { + const q = queries.subarray(i * this.dim, (i + 1) * this.dim); + if (this.graph) { + out.push(this.graph.search(q, k, ef)); + } else { + const msg = await this.rpc({ type: 'search', query: q.slice(), k, ef }); + if (msg.type !== 'hits') throw new Error('hnsw worker protocol error'); + const hits: FlatHit[] = []; + for (let j = 0; j < msg.rows.length; j++) hits.push({ row: msg.rows[j]!, score: msg.scores[j]! }); + out.push(hits); + } + } + return out; + } + + /** Snapshot the graph structure for persistence (M7c). Null while empty. */ + async exportGraphState(): Promise { + if (this.rows === 0) return null; + if (this.graph) return this.graph.serializeGraph(); + const msg = await this.rpc({ type: 'serialize' }); + if (msg.type !== 'state') throw new Error('hnsw worker protocol error'); + return msg.state; + } + + /** + * Restore a persisted graph + its vectors in one shot — the load-time + * counterpart of append() that skips the O(N·efConstruction) rebuild. Only + * valid on an empty index; the caller has already verified M matches. + */ + async loadWithGraph(vectors: Float32Array, count: number, state: HNSWGraphState): Promise { + if (this.rows !== 0) throw new Error('loadWithGraph requires an empty index'); + if (this.gpu) { + try { + this.gpu.append(vectors, count); + } catch { + this.gpu.destroy(); + this.gpu = null; + } + } + this.rows = count; + if (this.graph) { + this.graph.loadGraph(vectors, count, state); + return; + } + const owned = vectors.slice(); + const msg = await this.rpc({ type: 'load', vectors: owned, count, state }, [ + owned.buffer, + state.levels.buffer, + state.links0.buffer, + state.upper.buffer, + ]); + if (msg.type !== 'loaded') throw new Error('hnsw worker protocol error'); + this.topLevel = msg.maxLevel; + } + + /** Re-upload the layer-0 adjacency when appends have outdated the GPU mirror. */ + private async syncGpuGraph(): Promise { + if (this.gpu!.uploadedRows === this.rows) return; + if (this.graph) { + this.entryNode = this.graph.entryNode; + this.gpu!.uploadGraph(this.graph.exportLinks(), this.rows); + return; + } + const msg = await this.rpc({ type: 'export' }); + if (msg.type !== 'graph') throw new Error('hnsw worker protocol error'); + this.entryNode = msg.entry; + this.gpu!.uploadGraph(msg.links, this.rows); + } + + private rpc(payload: Record, transfer?: Transferable[]): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.worker!.postMessage({ ...payload, id }, transfer ?? []); + }); + } + + private onMessage(e: MessageEvent): void { + const msg = e.data; + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + p.resolve(msg); + } + + private failAll(err: unknown): void { + for (const p of this.pending.values()) p.reject(err); + this.pending.clear(); + } + + destroy(): void { + this.failAll(new Error('index destroyed')); + this.worker?.terminate(); + this.worker = null; + this.graph = null; + this.gpu?.destroy(); + this.gpu = null; + this.rows = 0; + } +} diff --git a/src/index/hnsw.worker.ts b/src/index/hnsw.worker.ts new file mode 100644 index 0000000..8c44612 --- /dev/null +++ b/src/index/hnsw.worker.ts @@ -0,0 +1,74 @@ +// HNSW Web Worker (§NFR-8). Graph construction is the expensive half of HNSW — +// O(log N) beam searches per insert, all CPU — and would jank the main thread +// during ingest exactly like the k-means mean-update did for IVF. This worker +// *owns* the graph and the packed corpus copy: appends stream vectors in +// (transferred, zero-copy) and searches send back only the tiny top-k arrays, so +// both build and query CPU time stay off the main thread. It owns no GPU state. +// Bundled and inlined by Vite via the `?worker&inline` import in hnsw.ts, so it +// ships inside the single-file dist with no separate asset. + +import type { Metric } from '../types.js'; +import { HNSWGraph, type HNSWGraphParams, type HNSWGraphState } from './hnswGraph.js'; + +// In a worker `self` is the DedicatedWorkerGlobalScope (postMessage takes a +// transfer list), but the DOM lib types it as Window. Alias to the minimal +// surface we use rather than pull in the conflicting WebWorker lib. +interface WorkerScope { + onmessage: ((e: MessageEvent) => void) | null; + postMessage(message: unknown, transfer?: Transferable[]): void; +} +const ctx = self as unknown as WorkerScope; + +type InitMessage = { type: 'init'; dim: number; metric: Metric; params: HNSWGraphParams }; +type AppendMessage = { type: 'append'; id: number; vectors: Float32Array; count: number }; +type SearchMessage = { type: 'search'; id: number; query: Float32Array; k: number; ef: number }; +type ExportMessage = { type: 'export'; id: number }; +type SerializeMessage = { type: 'serialize'; id: number }; +type LoadMessage = { type: 'load'; id: number; vectors: Float32Array; count: number; state: HNSWGraphState }; +type InMessage = InitMessage | AppendMessage | SearchMessage | ExportMessage | SerializeMessage | LoadMessage; + +let graph: HNSWGraph | null = null; + +ctx.onmessage = (e: MessageEvent) => { + const msg = e.data; + if (msg.type === 'init') { + graph = new HNSWGraph(msg.dim, msg.metric, msg.params); + return; + } + if (!graph) throw new Error('hnsw worker got work before init'); + if (msg.type === 'append') { + graph.append(msg.vectors, msg.count); + ctx.postMessage({ type: 'appended', id: msg.id, maxLevel: graph.maxLevel }); + return; + } + if (msg.type === 'export') { + // Layer-0 snapshot for the GPU search path (M7b) — transferred, not cloned. + const links = graph.exportLinks(); + ctx.postMessage({ type: 'graph', id: msg.id, links, entry: graph.entryNode }, [links.buffer]); + return; + } + if (msg.type === 'serialize') { + // Full-structure snapshot for persistence (M7c) — transferred, not cloned. + const state = graph.serializeGraph(); + ctx.postMessage({ type: 'state', id: msg.id, state }, [ + state.levels.buffer, + state.links0.buffer, + state.upper.buffer, + ]); + return; + } + if (msg.type === 'load') { + graph.loadGraph(msg.vectors, msg.count, msg.state); + ctx.postMessage({ type: 'loaded', id: msg.id, maxLevel: graph.maxLevel }); + return; + } + // search + const hits = graph.search(msg.query, msg.k, msg.ef); + const rows = new Int32Array(hits.length); + const scores = new Float32Array(hits.length); + for (let i = 0; i < hits.length; i++) { + rows[i] = hits[i]!.row; + scores[i] = hits[i]!.score; + } + ctx.postMessage({ type: 'hits', id: msg.id, rows, scores }, [rows.buffer, scores.buffer]); +}; diff --git a/src/index/hnswGpu.ts b/src/index/hnswGpu.ts new file mode 100644 index 0000000..88d79c8 --- /dev/null +++ b/src/index/hnswGpu.ts @@ -0,0 +1,244 @@ +// GPU graph-search executor (M7b) — owns the WebGPU side of HNSW. +// +// The CPU (worker) still owns graph construction; this class holds the GPU +// mirrors — corpus rows uploaded at append time, the flat layer-0 adjacency +// uploaded per build generation — and runs the single-dispatch beam-search +// kernel (engine/wgsl/graphSearch.ts). One workgroup per query makes batches +// concurrent for free: search() takes nQ packed queries in one dispatch. +// +// Honest performance model: a single query pays fixed dispatch + mapAsync +// readback latency (~ms in browsers), which the CPU walk doesn't — the GPU +// path earns its keep on BATCHED queries and very large corpora, and the +// examples measure exactly that trade instead of hiding it. +// +// The corpus mirror is a single storage buffer for now: the beam hops to +// arbitrary rows, so it can't be split across bind groups the way the linear +// scans are (§NFR-10). Past the device's per-buffer limit the index falls back +// to CPU search (hnsw.ts decides); chunked graph search is later work. + +import type { Metric } from '../types.js'; +import type { DeviceContext } from '../engine/device.js'; +import type { FlatHit } from './flat.js'; +import { buildGraphSearchShader, GRAPH_EF_CAP } from '../engine/wgsl/graphSearch.js'; +import { tracedGpuWait } from '../engine/profile.js'; + +const WORKGROUP_SIZE = 64; +const MAX_ENTRIES = 8; +const EMPTY = 0xffffffff; + +export class HNSWGpuSearcher { + private readonly pipeline: GPUComputePipeline; + private readonly paramsBuf: GPUBuffer; + private readonly entriesBuf: GPUBuffer; + + private corpusBuf: GPUBuffer | null = null; + private corpusCapRows = 0; + private corpusRows = 0; + + private graphBuf: GPUBuffer | null = null; + private graphCapRows = 0; + private graphRows = 0; // rows covered by the uploaded adjacency + + private queryBuf: GPUBuffer | null = null; + private queryCapQ = 0; + private outBuf: GPUBuffer | null = null; // dist then id planes, nQ*EF each + private readbackBuf: GPUBuffer | null = null; + private outCapQ = 0; + + constructor( + private readonly ctx: DeviceContext, + private readonly dim: number, + metric: Metric, + /** Fixed out-degree (graph rows are `degree` slots wide) = 2·M. */ + private readonly degree: number, + ) { + if (degree > WORKGROUP_SIZE) { + throw new Error(`GPU graph search supports degree ≤ ${WORKGROUP_SIZE} (M ≤ ${WORKGROUP_SIZE / 2}), got ${degree}`); + } + const { device } = ctx; + this.paramsBuf = device.createBuffer({ + label: 'browservec:graph-params', + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + this.entriesBuf = device.createBuffer({ + label: 'browservec:graph-entries', + size: MAX_ENTRIES * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + const code = buildGraphSearchShader({ dim, k: degree, metric, workgroupSize: WORKGROUP_SIZE }); + const module = device.createShaderModule({ label: 'browservec:graph-search', code }); + this.pipeline = device.createComputePipeline({ + label: 'browservec:graph-search', + layout: 'auto', + compute: { module, entryPoint: 'main' }, + }); + } + + /** Rows the corpus mirror can hold before hitting the per-buffer limit. */ + get maxRows(): number { + return Math.floor(this.ctx.limits.maxStorageBufferBindingSize / (this.dim * 4)); + } + + /** Mirror appended rows into the GPU corpus buffer. Throws past the buffer limit. */ + append(data: Float32Array, count: number): void { + const { device } = this.ctx; + const needed = this.corpusRows + count; + if (needed > this.corpusCapRows) { + const nextRows = Math.max(needed, Math.ceil(this.corpusCapRows * 1.5), 1024); + const capped = Math.min(nextRows, this.maxRows); + if (needed > capped) { + throw new Error(`GPU graph corpus exceeds one storage buffer (${this.maxRows} rows at dim=${this.dim})`); + } + const next = device.createBuffer({ + label: 'browservec:graph-corpus', + size: capped * this.dim * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + if (this.corpusBuf) { + const enc = device.createCommandEncoder(); + enc.copyBufferToBuffer(this.corpusBuf, 0, next, 0, this.corpusRows * this.dim * 4); + device.queue.submit([enc.finish()]); + this.corpusBuf.destroy(); + } + this.corpusBuf = next; + this.corpusCapRows = capped; + } + device.queue.writeBuffer(this.corpusBuf!, this.corpusRows * this.dim * 4, data, 0, count * this.dim); + this.corpusRows = needed; + } + + /** Upload a fresh layer-0 adjacency snapshot (rows × degree, EMPTY-padded). */ + uploadGraph(links: Uint32Array, rows: number): void { + const { device } = this.ctx; + if (rows > this.graphCapRows) { + this.graphBuf?.destroy(); + const capRows = Math.max(rows, Math.ceil(this.graphCapRows * 1.5), 1024); + this.graphBuf = device.createBuffer({ + label: 'browservec:graph-links', + size: capRows * this.degree * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.graphCapRows = capRows; + } + device.queue.writeBuffer(this.graphBuf!, 0, links, 0, rows * this.degree); + this.graphRows = rows; + } + + /** Adjacency generation currently on the GPU (rows covered). */ + get uploadedRows(): number { + return this.graphRows; + } + + /** + * Beam-search `nQ` packed queries in ONE dispatch (one workgroup each). + * Returns per-query hits, deduped and sorted, at most k each. + */ + async search(queries: Float32Array, nQ: number, k: number, ef: number, entry: number): Promise { + const { device } = this.ctx; + const efA = Math.min(Math.max(ef, k), GRAPH_EF_CAP); + this.ensureQueryCapacity(nQ); + + // Seed rows: the HNSW entry plus rows spread evenly across the corpus — + // the flat-graph stand-in for descending the (CPU-side) hierarchy. + const entries = new Uint32Array(MAX_ENTRIES).fill(EMPTY); + let e = 0; + entries[e++] = entry >>> 0; + const extra = Math.min(MAX_ENTRIES - 1, this.graphRows); + for (let i = 0; i < extra; i++) { + entries[e++] = Math.floor(((i + 1) * this.graphRows) / (extra + 1)) % this.graphRows; + } + device.queue.writeBuffer(this.entriesBuf, 0, entries, 0, e); + device.queue.writeBuffer(this.queryBuf!, 0, queries, 0, nQ * this.dim); + // iterCap: ef expansions matches the CPU beam's work bound; entries count e. + device.queue.writeBuffer(this.paramsBuf, 0, new Uint32Array([efA, efA, e, 0])); + + const bind = device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: this.corpusBuf! } }, + { binding: 1, resource: { buffer: this.graphBuf! } }, + { binding: 2, resource: { buffer: this.queryBuf! } }, + { binding: 3, resource: { buffer: this.entriesBuf } }, + { binding: 4, resource: { buffer: this.outBuf!, offset: 0, size: this.outCapQ * GRAPH_EF_CAP * 4 } }, + { binding: 5, resource: { buffer: this.outBuf!, offset: this.outCapQ * GRAPH_EF_CAP * 4 } }, + { binding: 6, resource: { buffer: this.paramsBuf } }, + ], + }); + + const enc = device.createCommandEncoder(); + const pass = enc.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, bind); + pass.dispatchWorkgroups(nQ); + pass.end(); + // Copy both planes (dist, id) for the live queries into the readback. + const distBytes = nQ * GRAPH_EF_CAP * 4; + enc.copyBufferToBuffer(this.outBuf!, 0, this.readbackBuf!, 0, distBytes); + enc.copyBufferToBuffer(this.outBuf!, this.outCapQ * GRAPH_EF_CAP * 4, this.readbackBuf!, distBytes, distBytes); + device.queue.submit([enc.finish()]); + + await tracedGpuWait(this.readbackBuf!.mapAsync(GPUMapMode.READ, 0, distBytes * 2)); + const mapped = this.readbackBuf!.getMappedRange(0, distBytes * 2); + const dists = new Float32Array(mapped, 0, nQ * GRAPH_EF_CAP); + const ids = new Uint32Array(mapped, distBytes, nQ * GRAPH_EF_CAP); + + const results: FlatHit[][] = []; + for (let q = 0; q < nQ; q++) { + const base = q * GRAPH_EF_CAP; + // Collect live beam entries, dedup (hash-shed can duplicate), sort, take k. + const seen = new Set(); + const hits: FlatHit[] = []; + for (let s = 0; s < efA; s++) { + const id = ids[base + s]!; + if (id === EMPTY || seen.has(id)) continue; + seen.add(id); + hits.push({ row: id, score: -dists[base + s]! }); + } + hits.sort((a, b) => b.score - a.score); + results.push(hits.slice(0, k)); + } + this.readbackBuf!.unmap(); + return results; + } + + private ensureQueryCapacity(nQ: number): void { + const { device } = this.ctx; + if (nQ > this.queryCapQ) { + this.queryBuf?.destroy(); + const cap = Math.max(nQ, 16); + this.queryBuf = device.createBuffer({ + label: 'browservec:graph-queries', + size: cap * this.dim * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.queryCapQ = cap; + } + if (nQ > this.outCapQ) { + this.outBuf?.destroy(); + this.readbackBuf?.destroy(); + const cap = Math.max(nQ, 16); + this.outBuf = device.createBuffer({ + label: 'browservec:graph-out', + size: cap * GRAPH_EF_CAP * 8, // dist plane + id plane + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + this.readbackBuf = device.createBuffer({ + label: 'browservec:graph-out-readback', + size: cap * GRAPH_EF_CAP * 8, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + this.outCapQ = cap; + } + } + + destroy(): void { + this.paramsBuf.destroy(); + this.entriesBuf.destroy(); + this.corpusBuf?.destroy(); + this.graphBuf?.destroy(); + this.queryBuf?.destroy(); + this.outBuf?.destroy(); + this.readbackBuf?.destroy(); + } +} diff --git a/src/index/hnswGraph.ts b/src/index/hnswGraph.ts new file mode 100644 index 0000000..e44754f --- /dev/null +++ b/src/index/hnswGraph.ts @@ -0,0 +1,604 @@ +// HNSW graph core — the CPU engine behind the graph-based ANN index (M7). +// +// Hierarchical Navigable Small World (Malkov & Yashunin, 2016): every vector is a +// node in a layered proximity graph. Layer 0 holds all nodes with out-degree ≤ 2M; +// each higher layer keeps an exponentially thinner subset (out-degree ≤ M) that +// acts as an express lane. A query greedily descends from the sparse top layer to +// layer 0, then runs a best-first beam search of width `ef` — visiting O(log N) +// nodes instead of scanning all N. Unlike IVF there is no build phase to redo on +// append: inserts are incremental by construction. +// +// This is a deliberately pointer-chasing, sequential algorithm — the opposite of +// the batch-parallel GPU kernels — so it runs entirely on the CPU. The index seam +// (hnsw.ts) hosts this class in a Web Worker when one is available, keeping both +// build and search off the main thread; the fallback runs it in-thread. Both paths +// execute this exact code on the same insert order with a seeded RNG, so a build +// is reproducible regardless of where it ran (same story as the k-means trainer). +// +// Everything lives in flat typed arrays (no per-node objects): adjacency is +// count-prefixed Int32Array blocks, the visited set is a generation-stamped +// Int32Array (no clearing between searches), and the two beam heaps are reused +// scratch. Distances use the house unrolled-×4 loops. + +import type { Metric } from '../types.js'; +import type { FlatHit } from './flat.js'; +import { mulberry32 } from '../quant/prng.js'; + +export interface HNSWGraphParams { + /** Out-degree per upper layer; layer 0 keeps 2·M. Default 16. */ + M?: number; + /** Beam width while inserting. Default 200. */ + efConstruction?: number; + /** Seed for the level RNG (reproducible builds). */ + seed?: number; +} + +/** + * The graph's persistable structure (M7c) — everything except the vectors, + * which the snapshot already stores. `upper` is the per-node upper-layer blocks + * concatenated in row order; node n owns levels[n]·(M+1) of them, so the split + * points reconstruct from `levels` alone. + */ +export interface HNSWGraphState { + M: number; + entry: number; + top: number; + levels: Int32Array; // rows + links0: Int32Array; // rows * (2M+1) + upper: Int32Array; // Σ levels[n]*(M+1) +} + +// Levels are geometric with ratio 1/M — P(level ≥ 30) is astronomically small even +// at M=2; the cap just bounds the per-node upper-links allocation. +const MAX_LEVEL = 30; + +/** + * Binary min-heap over parallel (dist, id) arrays — no per-entry allocation. The + * beam search needs a max-heap too (evict the worst kept result); callers get one + * by pushing negated distances into a second MinHeap. + */ +class MinHeap { + private dists: Float32Array; + private ids: Int32Array; + size = 0; + + constructor(cap = 256) { + this.dists = new Float32Array(cap); + this.ids = new Int32Array(cap); + } + + clear(): void { + this.size = 0; + } + + push(d: number, id: number): void { + if (this.size === this.dists.length) this.grow(); + const { dists, ids } = this; + let i = this.size++; + while (i > 0) { + const p = (i - 1) >> 1; + if (dists[p]! <= d) break; + dists[i] = dists[p]!; + ids[i] = ids[p]!; + i = p; + } + dists[i] = d; + ids[i] = id; + } + + peekDist(): number { + return this.dists[0]!; + } + + peekId(): number { + return this.ids[0]!; + } + + pop(): void { + const { dists, ids } = this; + const n = --this.size; + const d = dists[n]!; + const id = ids[n]!; + let i = 0; + for (;;) { + let c = 2 * i + 1; + if (c >= n) break; + if (c + 1 < n && dists[c + 1]! < dists[c]!) c++; + if (dists[c]! >= d) break; + dists[i] = dists[c]!; + ids[i] = ids[c]!; + i = c; + } + dists[i] = d; + ids[i] = id; + } + + private grow(): void { + const nd = new Float32Array(this.dists.length * 2); + const ni = new Int32Array(this.ids.length * 2); + nd.set(this.dists); + ni.set(this.ids); + this.dists = nd; + this.ids = ni; + } +} + +export class HNSWGraph { + private readonly M: number; + private readonly M0: number; // layer-0 out-degree cap = 2·M + private readonly efConstruction: number; + private readonly mL: number; // level multiplier 1/ln(M) + private readonly rng: () => number; + + // Corpus, packed row-major; grows geometrically like CpuIndex. + private data = new Float32Array(0); + private rows = 0; + private cap = 0; + + // Graph. Layer-0 adjacency is one flat block per node: [count, id0..id_{M0-1}]. + // Upper layers exist for only ~1/M of nodes, so they get a per-node Int32Array + // of `level` blocks shaped [count, id0..id_{M-1}] (block i = layer i+1). + private levels = new Int32Array(0); + private links0 = new Int32Array(0); + private upper: (Int32Array | undefined)[] = []; + private entry = -1; + private top = -1; + + // Generation-stamped visited set: bump `visitGen` instead of clearing. + private visited = new Int32Array(0); + private visitGen = 0; + + // Reused scratch: beam heaps, sorted beam output, heuristic selection. + private readonly cand = new MinHeap(); + private readonly res = new MinHeap(); // holds -dist → behaves as a max-heap + private outDists = new Float32Array(0); + private outIds = new Int32Array(0); + private readonly selIds: Int32Array; + private readonly selDists: Float32Array; + + constructor( + private readonly dim: number, + private readonly metric: Metric, + params: HNSWGraphParams = {}, + ) { + this.M = Math.max(2, params.M ?? 16); + this.M0 = this.M * 2; + this.efConstruction = Math.max(this.M, params.efConstruction ?? 200); + this.mL = 1 / Math.log(this.M); + this.rng = mulberry32((params.seed ?? 0x6d2b79f5) >>> 0); + this.selIds = new Int32Array(this.M0); + this.selDists = new Float32Array(this.M0); + } + + get size(): number { + return this.rows; + } + + /** Top graph layer (-1 while empty). */ + get maxLevel(): number { + return this.top; + } + + /** Entry node of the graph (-1 while empty). */ + get entryNode(): number { + return this.entry; + } + + /** Layer-0 out-degree cap (2·M) — the fixed slot count of exportLinks(). */ + get degree(): number { + return this.M0; + } + + /** Snapshot the graph structure for persistence (M7c). Arrays are tight copies. */ + serializeGraph(): HNSWGraphState { + let upperLen = 0; + for (let r = 0; r < this.rows; r++) upperLen += this.levels[r]! * (this.M + 1); + const upper = new Int32Array(upperLen); + let w = 0; + for (let r = 0; r < this.rows; r++) { + const blocks = this.levels[r]! * (this.M + 1); + if (blocks > 0) { + upper.set(this.upper[r]!.subarray(0, blocks), w); + w += blocks; + } + } + return { + M: this.M, + entry: this.entry, + top: this.top, + levels: this.levels.slice(0, this.rows), + links0: this.links0.slice(0, this.rows * (this.M0 + 1)), + upper, + }; + } + + /** + * Restore a persisted graph over its vectors — the load-time counterpart of + * append(), skipping the O(N·efC) rebuild entirely. Only valid on an empty + * graph with a matching M (the caller checks and falls back to append()). + * The level RNG is fast-forwarded one draw per restored row, so inserts after + * a load draw the same levels they would have on the never-saved store. + */ + loadGraph(vectors: Float32Array, count: number, state: HNSWGraphState): void { + if (this.rows !== 0) throw new Error('loadGraph requires an empty graph'); + if (state.M !== this.M) throw new Error(`graph M ${state.M} != configured M ${this.M}`); + if (vectors.length !== count * this.dim) { + throw new Error(`expected ${count * this.dim} floats, got ${vectors.length}`); + } + if (state.levels.length !== count || state.links0.length !== count * (this.M0 + 1)) { + throw new Error('graph state does not match vector count'); + } + this.ensureCapacity(count); + this.data.set(vectors, 0); + this.levels.set(state.levels, 0); + this.links0.set(state.links0, 0); + let off = 0; + for (let r = 0; r < count; r++) { + const blocks = state.levels[r]! * (this.M + 1); + if (blocks > 0) { + this.upper[r] = state.upper.slice(off, off + blocks); + off += blocks; + } + } + if (off !== state.upper.length) throw new Error('graph upper-links region does not match levels'); + this.entry = state.entry; + this.top = state.top; + this.rows = count; + for (let i = 0; i < count; i++) this.rng(); + } + + /** + * Export the layer-0 adjacency as a dense rows×2M table for the GPU kernel + * (M7b): fixed slots per row, 0xFFFFFFFF-padded. The hierarchy stays CPU-side + * — the flat bottom layer plus spread entry points is what the kernel walks. + */ + exportLinks(): Uint32Array { + const K = this.M0; + const stride = K + 1; + const out = new Uint32Array(this.rows * K).fill(0xffffffff); + for (let r = 0; r < this.rows; r++) { + const off = r * stride; + const cnt = this.links0[off]!; + for (let j = 0; j < cnt; j++) out[r * K + j] = this.links0[off + 1 + j]!; + } + return out; + } + + /** Append `count` rows and insert each into the graph (incremental — no rebuild). */ + append(vectors: Float32Array, count: number): void { + if (vectors.length !== count * this.dim) { + throw new Error(`expected ${count * this.dim} floats, got ${vectors.length}`); + } + this.ensureCapacity(this.rows + count); + this.data.set(vectors, this.rows * this.dim); + const first = this.rows; + this.rows += count; + for (let r = first; r < this.rows; r++) this.insert(r); + } + + /** + * k-NN search: greedy-descend the upper layers, beam-search layer 0 with width + * max(ef, k), return the k best as FlatHits (score follows the library's + * higher-is-closer convention for every metric). + */ + search(query: Float32Array, k: number, ef: number): FlatHit[] { + if (this.rows === 0) return []; + let curr = this.entry; + let currD = this.distToQuery(curr, query); + for (let lc = this.top; lc >= 1; lc--) { + let improved = true; + while (improved) { + improved = false; + const { arr, off, cnt } = this.linksOf(curr, lc); + for (let j = 1; j <= cnt; j++) { + const e = arr[off + j]!; + const d = this.distToQuery(e, query); + if (d < currD) { + curr = e; + currD = d; + improved = true; + } + } + } + } + const n = this.searchLayer(query, curr, currD, Math.max(ef, k), 0); + const hits: FlatHit[] = []; + const take = Math.min(k, n); + for (let i = 0; i < take; i++) { + hits.push({ row: this.outIds[i]!, score: -this.outDists[i]! }); + } + return hits; + } + + // ---- Distances -------------------------------------------------------------- + // Internal convention: smaller = closer. cosine/dot use -dot (vectors arrive + // normalized for cosine), l2 uses squared L2 — so score = -dist in every case, + // matching FlatIndex/CpuIndex semantics exactly. + + private distToQuery(row: number, q: Float32Array): number { + const { dim, data } = this; + const base = row * dim; + const tail = dim & ~3; + let a0 = 0, a1 = 0, a2 = 0, a3 = 0; + let i = 0; + if (this.metric === 'l2') { + for (; i < tail; i += 4) { + const d0 = data[base + i]! - q[i]!; + const d1 = data[base + i + 1]! - q[i + 1]!; + const d2 = data[base + i + 2]! - q[i + 2]!; + const d3 = data[base + i + 3]! - q[i + 3]!; + a0 += d0 * d0; + a1 += d1 * d1; + a2 += d2 * d2; + a3 += d3 * d3; + } + let acc = a0 + a1 + a2 + a3; + for (; i < dim; i++) { + const d = data[base + i]! - q[i]!; + acc += d * d; + } + return acc; + } + for (; i < tail; i += 4) { + a0 += data[base + i]! * q[i]!; + a1 += data[base + i + 1]! * q[i + 1]!; + a2 += data[base + i + 2]! * q[i + 2]!; + a3 += data[base + i + 3]! * q[i + 3]!; + } + let acc = a0 + a1 + a2 + a3; + for (; i < dim; i++) acc += data[base + i]! * q[i]!; + return -acc; + } + + /** Row-to-row distance for the neighbor-selection heuristic — no subarray copies. */ + private distRows(a: number, b: number): number { + const { dim, data } = this; + const ba = a * dim; + const bb = b * dim; + const tail = dim & ~3; + let a0 = 0, a1 = 0, a2 = 0, a3 = 0; + let i = 0; + if (this.metric === 'l2') { + for (; i < tail; i += 4) { + const d0 = data[ba + i]! - data[bb + i]!; + const d1 = data[ba + i + 1]! - data[bb + i + 1]!; + const d2 = data[ba + i + 2]! - data[bb + i + 2]!; + const d3 = data[ba + i + 3]! - data[bb + i + 3]!; + a0 += d0 * d0; + a1 += d1 * d1; + a2 += d2 * d2; + a3 += d3 * d3; + } + let acc = a0 + a1 + a2 + a3; + for (; i < dim; i++) { + const d = data[ba + i]! - data[bb + i]!; + acc += d * d; + } + return acc; + } + for (; i < tail; i += 4) { + a0 += data[ba + i]! * data[bb + i]!; + a1 += data[ba + i + 1]! * data[bb + i + 1]!; + a2 += data[ba + i + 2]! * data[bb + i + 2]!; + a3 += data[ba + i + 3]! * data[bb + i + 3]!; + } + let acc = a0 + a1 + a2 + a3; + for (; i < dim; i++) acc += data[ba + i]! * data[bb + i]!; + return -acc; + } + + // ---- Insert ----------------------------------------------------------------- + + /** Geometric level draw: P(level ≥ l) = M^-l, so layer l holds ~N/M^l nodes. */ + private randomLevel(): number { + const l = Math.floor(-Math.log(1 - this.rng()) * this.mL); + return Math.min(l, MAX_LEVEL); + } + + private insert(row: number): void { + const q = this.data.subarray(row * this.dim, (row + 1) * this.dim); + const level = this.randomLevel(); + this.levels[row] = level; + if (level > 0) this.upper[row] = new Int32Array(level * (this.M + 1)); + + if (this.entry === -1) { + this.entry = row; + this.top = level; + return; + } + + // Greedy-descend the layers above the new node's level to find a close entry. + let curr = this.entry; + let currD = this.distToQuery(curr, q); + for (let lc = this.top; lc > level; lc--) { + let improved = true; + while (improved) { + improved = false; + const { arr, off, cnt } = this.linksOf(curr, lc); + for (let j = 1; j <= cnt; j++) { + const e = arr[off + j]!; + const d = this.distToQuery(e, q); + if (d < currD) { + curr = e; + currD = d; + improved = true; + } + } + } + } + + // Beam-search each layer the node joins, pick diverse neighbors, link both ways. + for (let lc = Math.min(level, this.top); lc >= 0; lc--) { + const n = this.searchLayer(q, curr, currD, this.efConstruction, lc); + // Closest beam result seeds the next layer down — captured now because + // linkBack below reuses the out*/sel* scratch when a neighbor overflows. + const nextCurr = this.outIds[0]!; + const nextCurrD = this.outDists[0]!; + const maxDeg = lc === 0 ? this.M0 : this.M; + const selCount = this.selectHeuristic(n, this.M); + const chosenIds: number[] = []; + const chosenDists: number[] = []; + for (let s = 0; s < selCount; s++) { + chosenIds.push(this.selIds[s]!); + chosenDists.push(this.selDists[s]!); + } + const { arr, off } = this.linksOf(row, lc); + arr[off] = selCount; + for (let s = 0; s < selCount; s++) arr[off + 1 + s] = chosenIds[s]!; + for (let s = 0; s < selCount; s++) { + this.linkBack(chosenIds[s]!, row, chosenDists[s]!, lc, maxDeg); + } + curr = nextCurr; + currD = nextCurrD; + } + + if (level > this.top) { + this.entry = row; + this.top = level; + } + } + + /** + * Best-first beam search of width `ef` within one layer. Results land sorted + * ascending-by-distance in outDists/outIds; returns how many. Classic two-heap + * scheme: `cand` pops the closest unexpanded node, `res` (negated) evicts the + * worst kept result; stop once the closest candidate is worse than the worst + * kept result and the beam is full. + */ + private searchLayer(q: Float32Array, ep: number, epD: number, ef: number, level: number): number { + const { cand, res, visited } = this; + cand.clear(); + res.clear(); + const gen = ++this.visitGen; + visited[ep] = gen; + cand.push(epD, ep); + res.push(-epD, ep); + + while (cand.size > 0) { + const cD = cand.peekDist(); + if (cD > -res.peekDist() && res.size >= ef) break; + const c = cand.peekId(); + cand.pop(); + const { arr, off, cnt } = this.linksOf(c, level); + for (let j = 1; j <= cnt; j++) { + const e = arr[off + j]!; + if (visited[e] === gen) continue; + visited[e] = gen; + const d = this.distToQuery(e, q); + if (res.size < ef) { + cand.push(d, e); + res.push(-d, e); + } else if (d < -res.peekDist()) { + cand.push(d, e); + res.push(-d, e); + res.pop(); + } + } + } + + // Drain the max-heap back-to-front → ascending order, no sort. + const n = res.size; + if (this.outDists.length < n) { + this.outDists = new Float32Array(Math.max(n, this.outDists.length * 2, 256)); + this.outIds = new Int32Array(this.outDists.length); + } + for (let i = n - 1; i >= 0; i--) { + this.outDists[i] = -res.peekDist(); + this.outIds[i] = res.peekId(); + res.pop(); + } + return n; + } + + /** + * The paper's diversity heuristic over the beam output (ascending in + * outDists/outIds): keep a candidate only if it is closer to the query than to + * every already-kept neighbor. Prevents all M edges from pointing into one + * tight cluster, which is what keeps the graph navigable between clusters. + * Fills selIds/selDists, returns the kept count. + */ + private selectHeuristic(n: number, maxSel: number): number { + let kept = 0; + for (let i = 0; i < n && kept < maxSel; i++) { + const c = this.outIds[i]!; + const cD = this.outDists[i]!; + let ok = true; + for (let s = 0; s < kept; s++) { + if (this.distRows(c, this.selIds[s]!) < cD) { + ok = false; + break; + } + } + if (ok) { + this.selIds[kept] = c; + this.selDists[kept] = cD; + kept++; + } + } + return kept; + } + + /** + * Add `row` to `node`'s adjacency at `level`; when the list overflows `maxDeg`, + * re-select the best `maxDeg` of (existing ∪ new) with the same diversity + * heuristic, measured from `node`. + */ + private linkBack(node: number, row: number, dNodeRow: number, level: number, maxDeg: number): void { + const { arr, off } = this.linksOf(node, level); + const cnt = arr[off]!; + if (cnt < maxDeg) { + arr[off + 1 + cnt] = row; + arr[off] = cnt + 1; + return; + } + // Overflow: rank candidates by distance to `node`, insertion-sorted ascending + // into the shared out* scratch (cnt+1 ≤ M0+1 entries — tiny), then re-select. + const total = cnt + 1; + if (this.outDists.length < total) { + this.outDists = new Float32Array(Math.max(total, 256)); + this.outIds = new Int32Array(this.outDists.length); + } + for (let j = 0; j <= cnt; j++) { + const id = j < cnt ? arr[off + 1 + j]! : row; + const d = j < cnt ? this.distRows(node, id) : dNodeRow; + let p = j; + while (p > 0 && this.outDists[p - 1]! > d) { + this.outDists[p] = this.outDists[p - 1]!; + this.outIds[p] = this.outIds[p - 1]!; + p--; + } + this.outDists[p] = d; + this.outIds[p] = id; + } + const kept = this.selectHeuristic(total, maxDeg); + arr[off] = kept; + for (let s = 0; s < kept; s++) arr[off + 1 + s] = this.selIds[s]!; + } + + /** Locate `node`'s count-prefixed adjacency block at `level`. */ + private linksOf(node: number, level: number): { arr: Int32Array; off: number; cnt: number } { + if (level === 0) { + const off = node * (this.M0 + 1); + return { arr: this.links0, off, cnt: this.links0[off]! }; + } + const arr = this.upper[node]!; + const off = (level - 1) * (this.M + 1); + return { arr, off, cnt: arr[off]! }; + } + + private ensureCapacity(rows: number): void { + if (rows <= this.cap) return; + const next = Math.max(rows, Math.ceil(this.cap * 1.5), 1024); + const grow = (old: T, len: number, make: (n: number) => T): T => { + const a = make(len); + a.set(old as never); + return a; + }; + this.data = grow(this.data, next * this.dim, (n) => new Float32Array(n)); + this.levels = grow(this.levels, next, (n) => new Int32Array(n)); + this.links0 = grow(this.links0, next * (this.M0 + 1), (n) => new Int32Array(n)); + // Stamps stay valid across growth: new slots are 0, which never equals a live gen. + this.visited = grow(this.visited, next, (n) => new Int32Array(n)); + this.cap = next; + } +} diff --git a/src/index/quant.ts b/src/index/quant.ts index b2edf1f..6c0c729 100644 --- a/src/index/quant.ts +++ b/src/index/quant.ts @@ -14,6 +14,7 @@ import { createQuantEncoder, type QuantEncoder, type IngestMode } from '../quant import { tracedGpuWait } from '../engine/profile.js'; import { topK, type FlatHit } from './flat.js'; import { GpuTopK } from './gpuTopk.js'; +import { ScoreMask, MASKED_SCORE } from './scoreMask.js'; const WORKGROUP_SIZE = 64; @@ -36,6 +37,7 @@ export class QuantIndex { private readback: GPUBuffer | null = null; private scoreCap = 0; private gpuTopk: GpuTopK | null = null; + private masker: ScoreMask | null = null; private readonly paramsScratch = new Uint32Array(4); constructor( @@ -160,7 +162,24 @@ export class QuantIndex { } /** Approximate top-k by quantized score (rotates the query internally). */ - async query(queryVec: Float32Array, k: number): Promise { + query(queryVec: Float32Array, k: number): Promise { + return this.search(queryVec, k, null); + } + + /** + * Filtered query (FR-7): mask pass between the quantized distance dispatch + * and top-k stamps the sentinel over non-matching rows, so only matching rows + * reach the caller's exact re-rank. `maskWords` is 1 bit per global row. + */ + queryMasked(queryVec: Float32Array, k: number, maskWords: Uint32Array): Promise { + return this.search(queryVec, k, maskWords); + } + + private async search( + queryVec: Float32Array, + k: number, + maskWords: Uint32Array | null, + ): Promise { if (this.ctx.lost) throw new Error('GPU device lost; re-create the store'); if (this.rows === 0) return []; const { device } = this.ctx; @@ -195,6 +214,12 @@ export class QuantIndex { device.queue.submit([enc.finish()]); }); + // Filtered query: stamp the sentinel over masked-out rows before top-k. + if (maskWords) { + this.masker ??= new ScoreMask(this.ctx); + this.masker.apply(sp.score, this.rows, maskWords); + } + // On-GPU top-k once the corpus is large enough that the N-score readback // dominates (§14.2 lever 3). Scores are already dense-global (chunk offsets // applied in-shader), so the reduction is chunk-oblivious. Skipped when the @@ -214,7 +239,9 @@ export class QuantIndex { const scores = new Float32Array(sp.readback.getMappedRange(0, this.rows * 4)); const hits = topK(scores, this.rows, k); sp.readback.unmap(); - return hits; + // Drop sentinel slots when k exceeds the matching-row count (the GPU + // reduction path drops them in its merge). + return maskWords ? hits.filter((h) => h.score > MASKED_SCORE) : hits; } destroy(): void { @@ -226,5 +253,6 @@ export class QuantIndex { this.scoreBuf?.destroy(); this.readback?.destroy(); this.gpuTopk?.destroy(); + this.masker?.destroy(); } } diff --git a/src/index/scoreMask.ts b/src/index/scoreMask.ts new file mode 100644 index 0000000..e0ec088 --- /dev/null +++ b/src/index/scoreMask.ts @@ -0,0 +1,98 @@ +// Score-mask driver (FR-7 in-index filtering). +// +// Owns the compiled mask pipeline and a pooled GPU-side bitset buffer. Between +// a distance dispatch and the top-k, `encode()` uploads the per-row match +// bitset and stamps the -FLT_MAX sentinel over every masked-out row's score, +// so the unchanged top-k machinery (GPU reduction or CPU select) only ever +// surfaces rows that satisfy the metadata filter. Upload cost is n/8 bytes per +// filtered query (~125 KB at 1M rows). + +import type { DeviceContext } from '../engine/device.js'; +import { buildMaskShader } from '../engine/wgsl/mask.js'; + +const WORKGROUP_SIZE = 256; + +/** + * Any readback score at or below this is a masked-out row, not a real result + * (real scores — dot products or negative squared distances of finite vectors — + * are nowhere near -1e38). Kept slightly above the shader's -3.4e38 sentinel so + * f32 rounding can't leak a masked row through the comparison. + */ +export const MASKED_SCORE = -3.39e38; + +export class ScoreMask { + private readonly pipeline: GPUComputePipeline; + private readonly paramsBuf: GPUBuffer; + private maskBuf: GPUBuffer | null = null; + private maskCapWords = 0; + private readonly paramsScratch = new Uint32Array(4); + + constructor(private readonly ctx: DeviceContext) { + const { device } = ctx; + const module = device.createShaderModule({ + label: 'browservec:mask', + code: buildMaskShader({ workgroupSize: WORKGROUP_SIZE }), + }); + this.pipeline = device.createComputePipeline({ + label: 'browservec:mask', + layout: 'auto', + compute: { module, entryPoint: 'main' }, + }); + this.paramsBuf = device.createBuffer({ + label: 'browservec:mask-params', + size: 16, // vec4 + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + } + + private ensureCapacity(words: number): void { + if (this.maskBuf && this.maskCapWords >= words) return; + this.maskBuf?.destroy(); + const cap = Math.max(words, this.maskCapWords * 2, 256); + this.maskBuf = this.ctx.device.createBuffer({ + label: 'browservec:mask-bits', + size: cap * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.maskCapWords = cap; + } + + /** + * Upload `maskWords` (ceil(n/32) u32s, bit set = row passes the filter) and + * stamp the sentinel over all masked-out slots of the dense `scores` buffer. + * Enqueued on the device queue — ordered after the distance dispatches and + * before the top-k submission, no extra sync needed. + */ + apply(scores: GPUBuffer, n: number, maskWords: Uint32Array): void { + const { device } = this.ctx; + const words = Math.ceil(n / 32); + if (maskWords.length < words) { + throw new Error(`mask has ${maskWords.length} words, need ${words} for ${n} rows`); + } + this.ensureCapacity(words); + device.queue.writeBuffer(this.maskBuf!, 0, maskWords, 0, words); + this.paramsScratch[0] = n; + device.queue.writeBuffer(this.paramsBuf, 0, this.paramsScratch); + + const bind = device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: scores } }, + { binding: 1, resource: { buffer: this.maskBuf! } }, + { binding: 2, resource: { buffer: this.paramsBuf } }, + ], + }); + const enc = device.createCommandEncoder(); + const pass = enc.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, bind); + pass.dispatchWorkgroups(Math.ceil(n / WORKGROUP_SIZE)); + pass.end(); + device.queue.submit([enc.finish()]); + } + + destroy(): void { + this.paramsBuf.destroy(); + this.maskBuf?.destroy(); + } +} diff --git a/src/persist/format.ts b/src/persist/format.ts index a587887..3e2b4eb 100644 --- a/src/persist/format.ts +++ b/src/persist/format.ts @@ -2,23 +2,37 @@ // // Layout (little-endian): // [0..4) magic "BVEC" -// [4..8) u32 format version (= 1) +// [4..8) u32 format version (1, or 2 when a graph section is present) // [8..12) u32 dimension // [12..16) u32 metric code (0 cosine, 1 dot, 2 l2) // [16..20) u32 flags (bit0 = normalize) // [20..24) u32 count (rows) // [24..28) u32 metadata JSON byte length -// [28..32) u32 reserved (0) +// [28..32) u32 v1: reserved (0). v2: byte offset of the HNSW graph section (0 = none) // [32 .. 32+metaLen) metadata JSON (UTF-8): Array<{id, metadata?}> in row order // [pad to 4-byte alignment] // [.. + count*dim*4) Float32 vectors, row-major (post-normalization) +// +// v2 graph section (M7c — persisted HNSW graph, so loads skip the O(N·efC) +// rebuild). Starts 4-aligned right after the vectors; all words little-endian: +// 8×u32 header: magic "HNSW", graph version (=1), M, entry, top(maxLevel), +// upperLen, reserved, reserved +// i32 × count levels (per-node top layer) +// i32 × count*(2M+1) layer-0 adjacency, count-prefixed blocks +// i32 × upperLen upper-layer blocks, concatenated in row order +// Snapshots WITHOUT a graph keep writing v1, so older builds can still read +// everything they could before — only the new feature pays the version bump. import type { Metric } from '../types.js'; import type { RowEntry } from '../store/store.js'; +import type { HNSWGraphState } from '../index/hnswGraph.js'; const MAGIC = 0x43455642; // "BVEC" little-endian -export const FORMAT_VERSION = 1; +const GRAPH_MAGIC = 0x57534e48; // "HNSW" little-endian +export const FORMAT_VERSION = 2; const HEADER_BYTES = 32; +const GRAPH_HEADER_WORDS = 8; +const GRAPH_VERSION = 1; const METRIC_TO_CODE: Record = { cosine: 0, dot: 1, l2: 2 }; const CODE_TO_METRIC: Metric[] = ['cosine', 'dot', 'l2']; @@ -30,6 +44,8 @@ export interface Snapshot { count: number; entries: Array<{ id: string; metadata?: Metadata }>; vectors: Float32Array; // count * dim + /** Persisted HNSW graph (v2, M7c) — absent on v1 snapshots and non-HNSW stores. */ + graph?: HNSWGraphState; } type Metadata = RowEntry['metadata']; @@ -43,10 +59,12 @@ export interface SerializeInput { entries: Array<{ id: string; metadata?: Metadata }>; /** Packed vectors, count * dim floats. */ vectors: Float32Array; + /** HNSW graph to persist alongside (M7c). Bumps the snapshot to v2. */ + graph?: HNSWGraphState; } export function serialize(input: SerializeInput): ArrayBuffer { - const { dimension, metric, normalize, count, entries, vectors } = input; + const { dimension, metric, normalize, count, entries, vectors, graph } = input; if (vectors.length !== count * dimension) { throw new Error(`vectors length ${vectors.length} != count*dim ${count * dimension}`); } @@ -55,24 +73,48 @@ export function serialize(input: SerializeInput): ArrayBuffer { const metaBytes = new TextEncoder().encode(metaJson); const metaPadded = (metaBytes.length + 3) & ~3; // align vectors to 4 bytes - const total = HEADER_BYTES + metaPadded + count * dimension * 4; + const graphOffset = graph ? HEADER_BYTES + metaPadded + count * dimension * 4 : 0; + const graphWords = graph + ? GRAPH_HEADER_WORDS + graph.levels.length + graph.links0.length + graph.upper.length + : 0; + const total = HEADER_BYTES + metaPadded + count * dimension * 4 + graphWords * 4; const buf = new ArrayBuffer(total); const dv = new DataView(buf); dv.setUint32(0, MAGIC, true); - dv.setUint32(4, FORMAT_VERSION, true); + // Graph-less snapshots stay v1 so older builds keep reading them. + dv.setUint32(4, graph ? FORMAT_VERSION : 1, true); dv.setUint32(8, dimension, true); dv.setUint32(12, METRIC_TO_CODE[metric], true); dv.setUint32(16, normalize ? 1 : 0, true); dv.setUint32(20, count, true); dv.setUint32(24, metaBytes.length, true); - dv.setUint32(28, 0, true); + dv.setUint32(28, graphOffset, true); new Uint8Array(buf, HEADER_BYTES, metaBytes.length).set(metaBytes); // Vector region is 4-byte aligned, so a Float32Array view is safe. new Float32Array(buf, HEADER_BYTES + metaPadded, count * dimension).set(vectors); + if (graph) { + if (graph.levels.length !== count || graph.links0.length !== count * (2 * graph.M + 1)) { + throw new Error('graph state does not match snapshot count'); + } + const head = new Uint32Array(buf, graphOffset, GRAPH_HEADER_WORDS); + head[0] = GRAPH_MAGIC; + head[1] = GRAPH_VERSION; + head[2] = graph.M; + head[3] = graph.entry >>> 0; + head[4] = graph.top >>> 0; + head[5] = graph.upper.length; + let off = graphOffset + GRAPH_HEADER_WORDS * 4; + new Int32Array(buf, off, graph.levels.length).set(graph.levels); + off += graph.levels.length * 4; + new Int32Array(buf, off, graph.links0.length).set(graph.links0); + off += graph.links0.length * 4; + new Int32Array(buf, off, graph.upper.length).set(graph.upper); + } + return buf; } @@ -82,8 +124,8 @@ export function deserialize(buf: ArrayBuffer): Snapshot { if (dv.getUint32(0, true) !== MAGIC) throw new Error('bad magic: not a BrowserVec snapshot'); const version = dv.getUint32(4, true); - if (version !== FORMAT_VERSION) { - throw new Error(`unsupported snapshot version ${version} (this build reads ${FORMAT_VERSION})`); + if (version < 1 || version > FORMAT_VERSION) { + throw new Error(`unsupported snapshot version ${version} (this build reads 1..${FORMAT_VERSION})`); } const dimension = dv.getUint32(8, true); @@ -104,5 +146,31 @@ export function deserialize(buf: ArrayBuffer): Snapshot { // Copy out so the returned array doesn't pin the whole snapshot buffer. const vectors = new Float32Array(buf, vecOffset, count * dimension).slice(); - return { dimension, metric, normalize, count, entries, vectors }; + const snap: Snapshot = { dimension, metric, normalize, count, entries, vectors }; + + // v2: optional trailing HNSW graph section (offset stored in the v1-reserved word). + const graphOffset = version >= 2 ? dv.getUint32(28, true) : 0; + if (graphOffset !== 0) { + if (buf.byteLength < graphOffset + GRAPH_HEADER_WORDS * 4) throw new Error('snapshot truncated (graph header)'); + const head = new Uint32Array(buf, graphOffset, GRAPH_HEADER_WORDS); + if (head[0] !== GRAPH_MAGIC) throw new Error('bad graph section magic'); + if (head[1] !== GRAPH_VERSION) throw new Error(`unsupported graph section version ${head[1]}`); + const M = head[2]!; + const entry = head[3]! | 0; + const top = head[4]! | 0; + const upperLen = head[5]!; + const links0Len = count * (2 * M + 1); + let off = graphOffset + GRAPH_HEADER_WORDS * 4; + if (buf.byteLength < off + (count + links0Len + upperLen) * 4) { + throw new Error('snapshot truncated (graph region)'); + } + const levels = new Int32Array(buf, off, count).slice(); + off += count * 4; + const links0 = new Int32Array(buf, off, links0Len).slice(); + off += links0Len * 4; + const upper = new Int32Array(buf, off, upperLen).slice(); + snap.graph = { M, entry, top, levels, links0, upper }; + } + + return snap; } diff --git a/src/store/filter.ts b/src/store/filter.ts new file mode 100644 index 0000000..18288d0 --- /dev/null +++ b/src/store/filter.ts @@ -0,0 +1,68 @@ +// Metadata predicate evaluation (FR-7). +// +// Compiles a Mongo-ish MetadataFilter into a plain predicate over a record's +// metadata. Fields AND together; a bare value is shorthand for { $eq: value }. +// Validation (unknown operators, malformed arguments) happens at compile time so +// a typo throws once per query instead of silently matching nothing. + +import type { FilterOps, FilterValue, Metadata, MetadataFilter } from '../types.js'; + +type Predicate = (metadata?: Metadata) => boolean; + +const OPS = new Set(['$eq', '$ne', '$in', '$gt', '$gte', '$lt', '$lte']); + +function isOpsObject(cond: FilterValue | FilterValue[] | FilterOps): cond is FilterOps { + return cond !== null && typeof cond === 'object' && !Array.isArray(cond); +} + +function compileOp(field: string, op: string, arg: unknown): Predicate { + switch (op) { + case '$eq': + return (m) => m?.[field] === arg; + case '$ne': + // Mongo-ish: $ne also matches records where the field is missing. + return (m) => m?.[field] !== arg; + case '$in': { + if (!Array.isArray(arg)) { + throw new Error(`filter: $in on "${field}" expects an array, got ${typeof arg}`); + } + const set = new Set(arg as FilterValue[]); + return (m) => set.has(m?.[field] as FilterValue); + } + case '$gt': + case '$gte': + case '$lt': + case '$lte': { + if (typeof arg !== 'number') { + throw new Error(`filter: ${op} on "${field}" expects a number, got ${typeof arg}`); + } + // Range operators only match stored numbers — a missing field or a + // string/bool/null value never satisfies a numeric comparison. + if (op === '$gt') return (m) => typeof m?.[field] === 'number' && (m[field] as number) > arg; + if (op === '$gte') return (m) => typeof m?.[field] === 'number' && (m[field] as number) >= arg; + if (op === '$lt') return (m) => typeof m?.[field] === 'number' && (m[field] as number) < arg; + return (m) => typeof m?.[field] === 'number' && (m[field] as number) <= arg; + } + default: + throw new Error(`filter: unknown operator "${op}" on field "${field}"`); + } +} + +/** Compile a MetadataFilter into a single predicate (AND across fields/operators). */ +export function compileFilter(filter: MetadataFilter): Predicate { + const tests: Predicate[] = []; + for (const [field, cond] of Object.entries(filter)) { + if (isOpsObject(cond)) { + for (const [op, arg] of Object.entries(cond)) { + if (!OPS.has(op)) throw new Error(`filter: unknown operator "${op}" on field "${field}"`); + tests.push(compileOp(field, op, arg)); + } + } else { + tests.push(compileOp(field, '$eq', cond)); + } + } + return (metadata) => { + for (const t of tests) if (!t(metadata)) return false; + return true; + }; +} diff --git a/src/store/store.ts b/src/store/store.ts index f3153c8..384815d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -129,6 +129,36 @@ export class Store { return acc; } + /** + * Negative squared L2 distance of stored row `row` against `q` — same + * "higher = closer" convention as the GPU/CPU l2 kernels. Used by the exact + * filtered-scan path. Unrolled ×4 like {@link dotRow}. + */ + l2Row(row: number, q: Float32Array): number { + const dim = this.dimension; + const raw = this.raw; + const base = row * dim; + const vec = dim & ~3; + let a0 = 0, a1 = 0, a2 = 0, a3 = 0; + let i = 0; + for (; i < vec; i += 4) { + const d0 = raw[base + i]! - q[i]!; + const d1 = raw[base + i + 1]! - q[i + 1]!; + const d2 = raw[base + i + 2]! - q[i + 2]!; + const d3 = raw[base + i + 3]! - q[i + 3]!; + a0 += d0 * d0; + a1 += d1 * d1; + a2 += d2 * d2; + a3 += d3 * d3; + } + let acc = a0 + a1 + a2 + a3; + for (; i < dim; i++) { + const d = raw[base + i]! - q[i]!; + acc += d * d; + } + return -acc; + } + /** Validate length and (optionally) L2-normalize, returning a fresh Float32Array. */ prepare(vector: Vector): Float32Array { const v = vector instanceof Float32Array ? new Float32Array(vector) : Float32Array.from(vector); diff --git a/src/types.ts b/src/types.ts index 331f480..847371d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,10 +41,12 @@ export interface BrowserVecConfig { /** TurboQuant tuning (only used when quantBits > 0). */ quant?: QuantConfig; /** - * Enable the IVF approximate index (M4). When set, queries scan only the - * `nprobe` nearest of `nlist` clusters instead of the whole corpus — trading a - * little recall for a large latency drop at scale. Omit for exact flat search. - * Currently fp32 + cosine/dot only. + * Enable an approximate (ANN) index. Omit for exact flat search. + * - `{ type: 'ivf' }` (or omitted `type`, M4): k-means clusters, queries scan + * only the `nprobe` nearest of `nlist` — GPU-only, fp32 + cosine/dot. + * - `{ type: 'hnsw' }` (M7): CPU graph index searched by greedy descent — + * sub-linear queries with incremental inserts, works with or without WebGPU + * and on any metric. Currently fp32 only. */ ann?: ANNConfig; /** @@ -58,7 +60,11 @@ export interface BrowserVecConfig { chunkRows?: number; } -export interface ANNConfig { +export type ANNConfig = IVFConfig | HNSWConfig; + +export interface IVFConfig { + /** Index family. IVF is the default when `type` is omitted. */ + type?: 'ivf'; /** Number of clusters. Default ≈ sqrt(count), clamped to [16, 4096]. */ nlist?: number; /** Clusters scanned per query. Default ≈ 5% of nlist. Higher = better recall, slower. */ @@ -71,6 +77,29 @@ export interface ANNConfig { seed?: number; } +export interface HNSWConfig { + /** Index family: HNSW graph (M7). */ + type: 'hnsw'; + /** Graph out-degree per layer (layer 0 keeps 2·M). Higher = better recall, more memory + slower build. Default 16. */ + M?: number; + /** Candidate-list width while building. Higher = better graph quality, slower ingest. Default 200. */ + efConstruction?: number; + /** Default candidate-list width at query time (clamped to ≥ k). Higher = better recall, slower. Default 64. */ + efSearch?: number; + /** Seed for the level RNG, for reproducible builds. */ + seed?: number; + /** + * Where queries run (M7b). 'cpu' (default): graph walk in the Worker/in-thread. + * 'gpu': single-dispatch beam-search kernel — the whole search runs inside one + * compute dispatch, one workgroup per query, so `queryBatch` searches every + * query concurrently. Requires WebGPU, M ≤ 32, efSearch ≤ 256, and the corpus + * within one storage buffer (falls back to 'cpu' past that; see + * stats().graphSearch). Single queries pay fixed dispatch+readback latency — + * batches and large corpora are where 'gpu' wins. Ignored on the CPU fallback. + */ + search?: 'cpu' | 'gpu'; +} + export interface QuantConfig { /** Rotation seed. Fixed default so reloads re-quantize identically. */ seed?: number; @@ -146,9 +175,45 @@ export interface QueryResult { metadata?: Metadata; } +/** A metadata value usable in filters (matches the Metadata value type). */ +export type FilterValue = string | number | boolean | null; + +/** Per-field operators for {@link MetadataFilter}. Multiple operators AND together. */ +export interface FilterOps { + /** Field equals the value (strict ===). */ + $eq?: FilterValue; + /** Field differs from the value; also matches records missing the field. */ + $ne?: FilterValue; + /** Field equals any of the listed values. */ + $in?: FilterValue[]; + /** Numeric comparisons — only match when the stored value is a number. */ + $gt?: number; + $gte?: number; + $lt?: number; + $lte?: number; +} + +/** + * Mongo-ish metadata predicate (FR-7): fields AND together, a bare value is + * shorthand for `{ $eq: value }`. Example: + * `{ lang: 'en', year: { $gte: 2020 }, tag: { $in: ['a', 'b'] } }`. + */ +export type MetadataFilter = Record; + export interface QueryOptions { /** Number of neighbors to return. Default 10. */ k?: number; + /** + * Only return records whose metadata matches this predicate. Tiny match sets + * (≤ ~4k rows) run as an exact CPU scan over just the matching rows — exact + * on every index type. On flat stores (fp32 or quantized) larger filters run + * in-index on the GPU: a mask pass excludes non-matching rows between the + * distance kernel and top-k, so filtered queries keep full GPU speed at any + * selectivity (exact for fp32; quantized keeps its usual re-rank recall). + * IVF/HNSW/CPU-fallback stores fall back to the exact CPU scan for selective + * filters and index-with-over-fetch for near-total matches. + */ + filter?: MetadataFilter; /** * Override exact re-rank for this query (quantized stores only). Defaults to * the store's configured behavior. Has no effect on fp32 stores. @@ -159,6 +224,11 @@ export interface QueryOptions { * default — higher = better recall, slower. No effect on flat stores. */ nprobe?: number; + /** + * Candidate-list width for this query (HNSW stores only). Overrides the + * configured `efSearch` — higher = better recall, slower. Clamped to ≥ k. + */ + efSearch?: number; } export interface SupportInfo { @@ -189,8 +259,12 @@ export interface Stats { persist?: 'opfs' | 'indexeddb'; /** Quantization bit-width in use (0 = fp32). */ quantBits: 0 | 1 | 4 | 8; - /** IVF cluster count, if an ANN index is in use and built. */ + /** IVF cluster count, if an IVF index is in use and built. */ nlist?: number; + /** HNSW top graph layer, if an HNSW index is in use and non-empty. */ + maxLevel?: number; + /** HNSW query engine: 'gpu' (beam-search kernel) or 'cpu' (graph walk). */ + graphSearch?: 'gpu' | 'cpu'; /** Number of GPU buffers the corpus spans. >1 once it overflows one buffer. */ chunks?: number; /** @@ -200,9 +274,10 @@ export interface Stats { */ ingest?: 'worker' | 'main-thread'; /** - * Where the IVF k-means centroid mean-update ran (§NFR-8): 'worker' - * (offloaded off the main thread) or 'main-thread' (no Worker — ran in-thread). - * Absent for non-IVF stores and before the index's first build. + * Where the ANN index build ran (§NFR-8): 'worker' (offloaded off the main + * thread) or 'main-thread' (no Worker — ran in-thread). For IVF this is the + * k-means centroid mean-update; for HNSW it is the graph construction. + * Absent for flat stores and before the index's first build. */ train?: 'worker' | 'main-thread'; } diff --git a/tests/browser/fallback.test.ts b/tests/browser/fallback.test.ts new file mode 100644 index 0000000..3d7e321 --- /dev/null +++ b/tests/browser/fallback.test.ts @@ -0,0 +1,149 @@ +// CPU fallback (§NFR-7) end-to-end: force the no-GPU path via the library's own +// test seam and check the store behaves identically to the exact reference. +// These tests run on any browser, GPU or not — they anchor CI even when the +// runner has no WebGPU adapter. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { BrowserVec, bruteForce, randomVectors, records, recall } from './helpers'; + +beforeAll(() => { + (globalThis as Record).__BROWSERVEC_FORCE_CPU__ = true; +}); + +afterAll(() => { + delete (globalThis as Record).__BROWSERVEC_FORCE_CPU__; +}); + +describe('CPU fallback (fallback: "wasm")', () => { + it('reports the wasm device and supports exact flat search (cosine)', async () => { + const dim = 32; + const vectors = randomVectors(300, dim, 1); + const db = await BrowserVec.create({ dimension: dim, fallback: 'wasm' }); + try { + expect(db.stats().device).toBe('wasm'); + await db.addBatch(records(vectors)); + expect(db.stats().count).toBe(300); + + const [q] = randomVectors(1, dim, 2); + const got = await db.query(q!, { k: 10 }); + const want = bruteForce(vectors, q!, 10, 'cosine', true); + expect(got.map((h) => h.id)).toEqual(want.map((h) => h.id)); + for (let i = 0; i < got.length; i++) { + expect(got[i]!.score).toBeCloseTo(want[i]!.score, 3); + } + } finally { + db.destroy(); + } + }); + + it('matches the exact reference on l2 too', async () => { + const dim = 16; + const vectors = randomVectors(200, dim, 3); + const db = await BrowserVec.create({ dimension: dim, metric: 'l2', fallback: 'wasm' }); + try { + await db.addBatch(records(vectors)); + const [q] = randomVectors(1, dim, 4); + const got = await db.query(q!, { k: 5 }); + const want = bruteForce(vectors, q!, 5, 'l2', false); + expect(got.map((h) => h.id)).toEqual(want.map((h) => h.id)); + } finally { + db.destroy(); + } + }); + + it('supports metadata filters, delete, update, and get', async () => { + const dim = 8; + const vectors = randomVectors(100, dim, 5); + const db = await BrowserVec.create({ dimension: dim, fallback: 'wasm' }); + try { + await db.addBatch(records(vectors, (i) => ({ group: i % 2 === 0 ? 'even' : 'odd', n: i }))); + + const [q] = randomVectors(1, dim, 6); + const filtered = await db.query(q!, { k: 10, filter: { group: 'even' } }); + expect(filtered.length).toBe(10); + for (const h of filtered) expect(h.metadata!.group).toBe('even'); + + const ranged = await db.query(q!, { k: 100, filter: { n: { $gte: 90 } } }); + expect(ranged.length).toBe(10); + + expect(db.delete('v0')).toBe(true); + expect(db.get('v0')).toBeNull(); + expect(db.stats().count).toBe(99); + const afterDelete = await db.query(q!, { k: 100 }); + expect(afterDelete.some((h) => h.id === 'v0')).toBe(false); + + const g = db.get('v1')!; + expect(g.metadata).toEqual({ group: 'odd', n: 1 }); + + await db.update({ id: 'v2', vector: vectors[3]!, metadata: { group: 'updated', n: -1 } }); + expect(db.get('v2')!.metadata!.group).toBe('updated'); + } finally { + db.destroy(); + } + }); + + it('HNSW works on the CPU fallback with good recall', async () => { + const dim = 16; + const vectors = randomVectors(500, dim, 7); + const db = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + ann: { type: 'hnsw', seed: 1, efSearch: 128 }, + }); + try { + await db.addBatch(records(vectors)); + const stats = db.stats(); + expect(stats.device).toBe('wasm'); + expect(stats.maxLevel).toBeGreaterThanOrEqual(0); + + let total = 0; + const queries = randomVectors(10, dim, 8); + for (const q of queries) { + const got = await db.query(q, { k: 10 }); + total += recall(got, bruteForce(vectors, q, 10, 'cosine', true)); + } + expect(total / 10).toBeGreaterThanOrEqual(0.9); + } finally { + db.destroy(); + } + }); + + it('rejects quantization and IVF under the fallback', async () => { + await expect( + BrowserVec.create({ dimension: 16, fallback: 'wasm', quantBits: 8 }), + ).rejects.toThrow(); + await expect( + BrowserVec.create({ dimension: 16, fallback: 'wasm', ann: { type: 'ivf' } }), + ).rejects.toThrow(); + }); + + it('validates dimension and duplicate ids', async () => { + await expect(BrowserVec.create({ dimension: 0, fallback: 'wasm' })).rejects.toThrow(/positive integer/); + const db = await BrowserVec.create({ dimension: 4, fallback: 'wasm' }); + try { + await db.add({ id: 'a', vector: [1, 2, 3, 4] }); + await expect(db.add({ id: 'a', vector: [1, 2, 3, 4] })).rejects.toThrow(/duplicate/); + await expect(db.add({ id: 'b', vector: [1, 2] })).rejects.toThrow(/dim/); + } finally { + db.destroy(); + } + }); + + it('supports text ingestion via the hashing embedder', async () => { + const { hashingEmbedder } = await import('../../src/index'); + const embedder = hashingEmbedder({ dimension: 64 }); + const db = await BrowserVec.create({ dimension: 64, fallback: 'wasm', embedder }); + try { + await db.addTexts([ + { id: 'gpu', text: 'webgpu compute shaders for vector search' }, + { id: 'cake', text: 'chocolate cake baking recipe' }, + { id: 'db', text: 'vector database search in the browser' }, + ]); + const hits = await db.queryText('vector search', { k: 2 }); + expect(hits.map((h) => h.id)).toContain('db'); + expect(hits.map((h) => h.id)).not.toContain('cake'); + } finally { + db.destroy(); + } + }); +}); diff --git a/tests/browser/gpu.test.ts b/tests/browser/gpu.test.ts new file mode 100644 index 0000000..d4920bc --- /dev/null +++ b/tests/browser/gpu.test.ts @@ -0,0 +1,289 @@ +// WebGPU end-to-end suite: exact flat parity against the CPU reference, filtered +// queries, the quantization ladder, IVF, HNSW (cpu + gpu search), and chunking. +// Skipped as a block when the environment exposes no WebGPU adapter (the +// fallback + persistence suites still anchor CI there). + +import { describe, expect, it } from 'vitest'; +import { BrowserVec, bruteForce, randomVectors, records, recall, webgpuAvailable } from './helpers'; + +const hasGpu = await webgpuAvailable(); +// eslint-disable-next-line no-console +console.log(`[browservec tests] WebGPU adapter available: ${hasGpu}`); + +describe.skipIf(!hasGpu)('WebGPU flat index', () => { + it('matches the exact CPU reference (cosine)', async () => { + const dim = 64; + const vectors = randomVectors(1000, dim, 1); + const db = await BrowserVec.create({ dimension: dim }); + try { + expect(db.stats().device).toBe('webgpu'); + await db.addBatch(records(vectors)); + + for (const q of randomVectors(5, dim, 2)) { + const got = await db.query(q, { k: 10 }); + const want = bruteForce(vectors, q, 10, 'cosine', true); + expect(got.map((h) => h.id)).toEqual(want.map((h) => h.id)); + for (let i = 0; i < got.length; i++) { + expect(got[i]!.score).toBeCloseTo(want[i]!.score, 2); + } + } + } finally { + db.destroy(); + } + }); + + it('matches the exact CPU reference (l2 and dot)', async () => { + const dim = 32; + for (const metric of ['l2', 'dot'] as const) { + const vectors = randomVectors(400, dim, 3); + const db = await BrowserVec.create({ dimension: dim, metric }); + try { + await db.addBatch(records(vectors)); + const [q] = randomVectors(1, dim, 4); + const got = await db.query(q!, { k: 10 }); + const want = bruteForce(vectors, q!, 10, metric, false); + expect(got.map((h) => h.id)).toEqual(want.map((h) => h.id)); + } finally { + db.destroy(); + } + } + }); + + it('agrees with the forced-CPU fallback on the same data (NFR-7 parity)', async () => { + const dim = 24; + const vectors = randomVectors(500, dim, 5); + const gpu = await BrowserVec.create({ dimension: dim }); + (globalThis as Record).__BROWSERVEC_FORCE_CPU__ = true; + const cpu = await BrowserVec.create({ dimension: dim, fallback: 'wasm' }); + delete (globalThis as Record).__BROWSERVEC_FORCE_CPU__; + try { + await gpu.addBatch(records(vectors)); + await cpu.addBatch(records(vectors)); + for (const q of randomVectors(3, dim, 6)) { + const a = await gpu.query(q, { k: 10 }); + const b = await cpu.query(q, { k: 10 }); + expect(a.map((h) => h.id)).toEqual(b.map((h) => h.id)); + } + } finally { + gpu.destroy(); + cpu.destroy(); + } + }); + + it('filtered queries are exact at both selectivities (CPU-scan and GPU-mask paths)', async () => { + const dim = 16; + const n = 6000; // > FILTER_CPU_SCAN_MAX so broad filters take the GPU mask path + const vectors = randomVectors(n, dim, 7); + const db = await BrowserVec.create({ dimension: dim }); + try { + await db.addBatch(records(vectors, (i) => ({ n: i, rare: i % 1000 === 0 }))); + const [q] = randomVectors(1, dim, 8); + + // Selective filter (6 matches): exact CPU scan path. + const rare = await db.query(q!, { k: 10, filter: { rare: true } }); + expect(rare.length).toBe(6); + for (const h of rare) expect(h.metadata!.rare).toBe(true); + + // Broad filter (5000 matches): in-index GPU mask path, still exact. + const broad = await db.query(q!, { k: 10, filter: { n: { $lt: 5000 } } }); + const want = bruteForce(vectors.slice(0, 5000), q!, 10, 'cosine', true); + expect(broad.map((h) => h.id)).toEqual(want.map((h) => h.id)); + } finally { + db.destroy(); + } + }); + + it('queryBatch agrees with individual queries', async () => { + const dim = 32; + const vectors = randomVectors(300, dim, 9); + const db = await BrowserVec.create({ dimension: dim }); + try { + await db.addBatch(records(vectors)); + const queries = randomVectors(4, dim, 10); + const batch = await db.queryBatch(queries, { k: 5 }); + expect(batch.length).toBe(4); + for (let i = 0; i < queries.length; i++) { + const single = await db.query(queries[i]!, { k: 5 }); + expect(batch[i]!.map((h) => h.id)).toEqual(single.map((h) => h.id)); + } + } finally { + db.destroy(); + } + }); + + it('chunked corpora (chunkRows) return the same results as unchunked', async () => { + const dim = 16; + const vectors = randomVectors(350, dim, 11); + const plain = await BrowserVec.create({ dimension: dim }); + const chunked = await BrowserVec.create({ dimension: dim, chunkRows: 100 }); + try { + await plain.addBatch(records(vectors)); + await chunked.addBatch(records(vectors)); + expect(chunked.stats().chunks).toBeGreaterThanOrEqual(4); + + for (const q of randomVectors(3, dim, 12)) { + const a = await plain.query(q, { k: 10 }); + const b = await chunked.query(q, { k: 10 }); + expect(b.map((h) => h.id)).toEqual(a.map((h) => h.id)); + } + } finally { + plain.destroy(); + chunked.destroy(); + } + }); + + it('delete + compact rebuilds the index correctly', async () => { + const dim = 16; + const vectors = randomVectors(100, dim, 13); + const db = await BrowserVec.create({ dimension: dim }); + try { + await db.addBatch(records(vectors)); + db.delete('v10'); + db.delete('v20'); + const removed = await db.compact(); + expect(removed).toBe(2); + expect(db.stats().count).toBe(98); + expect(db.stats().deleted).toBeUndefined(); + + const got = await db.query(vectors[30]!, { k: 1 }); + expect(got[0]!.id).toBe('v30'); + const gone = await db.query(vectors[10]!, { k: 100 }); + expect(gone.some((h) => h.id === 'v10')).toBe(false); + } finally { + db.destroy(); + } + }); +}); + +describe.skipIf(!hasGpu)('quantization ladder (TurboQuant)', () => { + // Re-rank makes the top-k nearly exact; recall floors are set per bit width. + const cases = [ + { bits: 8 as const, floor: 0.95 }, + { bits: 4 as const, floor: 0.85 }, + { bits: 1 as const, floor: 0.7 }, + ]; + + it.each(cases)('int$bits achieves recall ≥ $floor with exact re-rank', async ({ bits, floor }) => { + const dim = 64; + const vectors = randomVectors(1000, dim, 20 + bits); + const db = await BrowserVec.create({ dimension: dim, quantBits: bits }); + try { + expect(db.stats().quantBits).toBe(bits); + await db.addBatch(records(vectors)); + + let total = 0; + const queries = randomVectors(10, dim, 30 + bits); + for (const q of queries) { + const got = await db.query(q, { k: 10 }); + total += recall(got, bruteForce(vectors, q, 10, 'cosine', true)); + } + expect(total / queries.length).toBeGreaterThanOrEqual(floor); + } finally { + db.destroy(); + } + }); + + it('rejects quantization on l2', async () => { + await expect(BrowserVec.create({ dimension: 16, metric: 'l2', quantBits: 8 })).rejects.toThrow(); + }); +}); + +describe.skipIf(!hasGpu)('IVF index', () => { + it('builds clusters and reaches good recall at a generous nprobe', async () => { + const dim = 32; + const vectors = randomVectors(2000, dim, 40); + const db = await BrowserVec.create({ + dimension: dim, + ann: { type: 'ivf', nlist: 32, nprobe: 16, seed: 1 }, + }); + try { + await db.addBatch(records(vectors)); + + let total = 0; + const queries = randomVectors(10, dim, 41); + for (const q of queries) { + const got = await db.query(q, { k: 10 }); + total += recall(got, bruteForce(vectors, q, 10, 'cosine', true)); + } + expect(total / queries.length).toBeGreaterThanOrEqual(0.8); + // The index builds on demand — nlist is reported once the first query has run. + expect(db.stats().nlist).toBe(32); + } finally { + db.destroy(); + } + }); + + it('a per-query nprobe override trades recall for speed', async () => { + const dim = 32; + const vectors = randomVectors(2000, dim, 42); + const db = await BrowserVec.create({ + dimension: dim, + ann: { type: 'ivf', nlist: 32, nprobe: 1, seed: 2 }, + }); + try { + await db.addBatch(records(vectors)); + const queries = randomVectors(10, dim, 43); + let low = 0; + let high = 0; + for (const q of queries) { + const truth = bruteForce(vectors, q, 10, 'cosine', true); + low += recall(await db.query(q, { k: 10 }), truth); + high += recall(await db.query(q, { k: 10, nprobe: 32 }), truth); + } + // Scanning every cluster must be exact; nprobe=1 strictly can't beat it. + expect(high / queries.length).toBeGreaterThanOrEqual(0.99); + expect(low / queries.length).toBeLessThanOrEqual(high / queries.length); + } finally { + db.destroy(); + } + }); +}); + +describe.skipIf(!hasGpu)('HNSW index (GPU host)', () => { + it('reaches good recall with the CPU graph walk', async () => { + const dim = 32; + const vectors = randomVectors(800, dim, 50); + const db = await BrowserVec.create({ + dimension: dim, + ann: { type: 'hnsw', seed: 1, efSearch: 128 }, + }); + try { + await db.addBatch(records(vectors)); + expect(db.stats().maxLevel).toBeGreaterThanOrEqual(0); + + let total = 0; + const queries = randomVectors(10, dim, 51); + for (const q of queries) { + total += recall(await db.query(q, { k: 10 }), bruteForce(vectors, q, 10, 'cosine', true)); + } + expect(total / queries.length).toBeGreaterThanOrEqual(0.9); + } finally { + db.destroy(); + } + }); + + it("search: 'gpu' answers batches with good recall", async () => { + const dim = 32; + const vectors = randomVectors(800, dim, 52); + const db = await BrowserVec.create({ + dimension: dim, + ann: { type: 'hnsw', seed: 2, search: 'gpu', M: 16, efSearch: 128 }, + }); + try { + await db.addBatch(records(vectors)); + // May report 'cpu' if the kernel's constraints aren't met; either way the + // results must be good. + expect(['gpu', 'cpu']).toContain(db.stats().graphSearch); + + const queries = randomVectors(8, dim, 53); + const results = await db.queryBatch(queries, { k: 10 }); + let total = 0; + for (let i = 0; i < queries.length; i++) { + total += recall(results[i]!, bruteForce(vectors, queries[i]!, 10, 'cosine', true)); + } + expect(total / queries.length).toBeGreaterThanOrEqual(0.85); + } finally { + db.destroy(); + } + }); +}); diff --git a/tests/browser/helpers.ts b/tests/browser/helpers.ts new file mode 100644 index 0000000..101370b --- /dev/null +++ b/tests/browser/helpers.ts @@ -0,0 +1,70 @@ +import { BrowserVec, type Metric, type VectorRecord } from '../../src/index'; +import { mulberry32 } from '../../src/quant/prng'; +import { normalizeInPlace } from '../../src/store/store'; + +/** True when a WebGPU adapter is actually reachable (flag-gated in headless CI). */ +export async function webgpuAvailable(): Promise { + if (typeof navigator === 'undefined' || !navigator.gpu) return false; + try { + return (await navigator.gpu.requestAdapter()) !== null; + } catch { + return false; + } +} + +export function randomVectors(rows: number, dim: number, seed: number, normalize = false): Float32Array[] { + const rng = mulberry32(seed); + const out: Float32Array[] = []; + for (let r = 0; r < rows; r++) { + const v = new Float32Array(dim); + for (let i = 0; i < dim; i++) v[i] = rng() * 2 - 1; + if (normalize) normalizeInPlace(v); + out.push(v); + } + return out; +} + +export function records(vectors: Float32Array[], meta?: (i: number) => Record): VectorRecord[] { + return vectors.map((vector, i) => ({ + id: `v${i}`, + vector, + ...(meta ? { metadata: meta(i) } : {}), + })); +} + +/** Exact brute-force reference over the ORIGINAL vectors, library score conventions. */ +export function bruteForce( + vectors: Float32Array[], + q: Float32Array, + k: number, + metric: Metric, + normalize: boolean, +): Array<{ id: string; score: number }> { + const qq = q.slice(); + if (normalize) normalizeInPlace(qq); + const scored = vectors.map((v, i) => { + const vv = v.slice(); + if (normalize) normalizeInPlace(vv); + let s = 0; + if (metric === 'l2') { + for (let j = 0; j < vv.length; j++) { + const d = vv[j]! - qq[j]!; + s -= d * d; + } + } else { + for (let j = 0; j < vv.length; j++) s += vv[j]! * qq[j]!; + } + return { id: `v${i}`, score: s }; + }); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); +} + +export function recall(got: Array<{ id: string }>, want: Array<{ id: string }>): number { + const truth = new Set(want.map((h) => h.id)); + let hit = 0; + for (const h of got) if (truth.has(h.id)) hit++; + return want.length === 0 ? 1 : hit / want.length; +} + +export { BrowserVec }; diff --git a/tests/browser/persist.test.ts b/tests/browser/persist.test.ts new file mode 100644 index 0000000..41126bf --- /dev/null +++ b/tests/browser/persist.test.ts @@ -0,0 +1,263 @@ +// Persistence e2e (M2/M7c): save/auto-load, export/import, encryption at rest, +// and tombstone compaction. Runs on the forced-CPU path so it works with or +// without a WebGPU adapter — the persistence layer is identical either way. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { BrowserVec, randomVectors, records } from './helpers'; + +beforeAll(() => { + (globalThis as Record).__BROWSERVEC_FORCE_CPU__ = true; +}); + +afterAll(() => { + delete (globalThis as Record).__BROWSERVEC_FORCE_CPU__; +}); + +let nameCounter = 0; +function uniqueName(): string { + return `bvec-test-${Date.now()}-${nameCounter++}`; +} + +describe('persistence (IndexedDB)', () => { + it('save() then create() auto-loads the snapshot', async () => { + const dim = 8; + const name = uniqueName(); + const vectors = randomVectors(50, dim, 1); + + const a = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb' }, + }); + await a.addBatch(records(vectors, (i) => ({ n: i }))); + const beforeSave = await a.query(vectors[7]!, { k: 3 }); + await a.save(); + a.destroy(); + + const b = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb' }, + }); + try { + expect(b.stats().count).toBe(50); + expect(b.stats().persist).toBe('indexeddb'); + expect(b.get('v7')!.metadata).toEqual({ n: 7 }); + const afterLoad = await b.query(vectors[7]!, { k: 3 }); + expect(afterLoad.map((h) => h.id)).toEqual(beforeSave.map((h) => h.id)); + expect(afterLoad[0]!.id).toBe('v7'); + } finally { + b.destroy(); + } + }); + + it('a dimension mismatch on auto-load throws instead of mis-loading', async () => { + const name = uniqueName(); + const a = await BrowserVec.create({ + dimension: 8, + fallback: 'wasm', + persist: { name, backend: 'indexeddb' }, + }); + await a.addBatch(records(randomVectors(5, 8, 2))); + await a.save(); + a.destroy(); + + await expect( + BrowserVec.create({ + dimension: 16, + fallback: 'wasm', + persist: { name, backend: 'indexeddb' }, + }), + ).rejects.toThrow(/dim/); + }); + + it('save() compacts tombstones — deleted rows are gone after reload', async () => { + const dim = 8; + const name = uniqueName(); + const a = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb' }, + }); + await a.addBatch(records(randomVectors(20, dim, 3))); + a.delete('v0'); + a.delete('v11'); + expect(a.stats().deleted).toBe(2); + await a.save(); + a.destroy(); + + const b = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb' }, + }); + try { + expect(b.stats().count).toBe(18); + expect(b.stats().deleted).toBeUndefined(); + expect(b.get('v0')).toBeNull(); + expect(b.get('v1')).not.toBeNull(); + } finally { + b.destroy(); + } + }); + + it('save() without a persist config throws', async () => { + const db = await BrowserVec.create({ dimension: 4, fallback: 'wasm' }); + try { + await expect(db.save()).rejects.toThrow(/persist/); + } finally { + db.destroy(); + } + }); +}); + +describe('export / import', () => { + it('round-trips a store through a Blob', async () => { + const dim = 8; + const vectors = randomVectors(30, dim, 4); + const a = await BrowserVec.create({ dimension: dim, fallback: 'wasm' }); + await a.addBatch(records(vectors, (i) => ({ tag: `t${i}` }))); + const blob = await a.export(); + const expected = await a.query(vectors[3]!, { k: 5 }); + a.destroy(); + + const b = await BrowserVec.import(blob, { fallback: 'wasm' }); + try { + expect(b.stats().count).toBe(30); + expect(b.get('v3')!.metadata).toEqual({ tag: 't3' }); + const got = await b.query(vectors[3]!, { k: 5 }); + expect(got.map((h) => h.id)).toEqual(expected.map((h) => h.id)); + } finally { + b.destroy(); + } + }); + + it('encrypted export requires the right passphrase', async () => { + const dim = 8; + const a = await BrowserVec.create({ dimension: dim, fallback: 'wasm' }); + await a.addBatch(records(randomVectors(10, dim, 5))); + const blob = await a.export({ encryption: { passphrase: 'sekret' } }); + a.destroy(); + + // Right passphrase works. + const ok = await BrowserVec.import(blob, { + fallback: 'wasm', + encryption: { passphrase: 'sekret' }, + }); + expect(ok.stats().count).toBe(10); + ok.destroy(); + + // Wrong passphrase and missing passphrase both fail loudly. + await expect( + BrowserVec.import(blob, { fallback: 'wasm', encryption: { passphrase: 'nope' } }), + ).rejects.toThrow(/wrong passphrase or corrupted/); + await expect(BrowserVec.import(blob, { fallback: 'wasm' })).rejects.toThrow(/encrypted/); + }); + + it('a passphrase against a plaintext blob is rejected', async () => { + const a = await BrowserVec.create({ dimension: 4, fallback: 'wasm' }); + await a.add({ id: 'x', vector: [1, 0, 0, 0] }); + const blob = await a.export(); + a.destroy(); + await expect( + BrowserVec.import(blob, { fallback: 'wasm', encryption: { passphrase: 'p' } }), + ).rejects.toThrow(/not encrypted/); + }); +}); + +describe('encrypted persistence at rest', () => { + it('auto-loads through the encryption envelope', async () => { + const dim = 8; + const name = uniqueName(); + const a = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb', encryption: { passphrase: 'hunter2' } }, + }); + await a.addBatch(records(randomVectors(12, dim, 6))); + await a.save(); + a.destroy(); + + const b = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb', encryption: { passphrase: 'hunter2' } }, + }); + expect(b.stats().count).toBe(12); + b.destroy(); + + await expect( + BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'indexeddb', encryption: { passphrase: 'wrong' } }, + }), + ).rejects.toThrow(/wrong passphrase or corrupted/); + }); +}); + +describe('HNSW graph persistence (M7c)', () => { + it('reloads an HNSW store with identical results and no drift after appends', async () => { + const dim = 16; + const name = uniqueName(); + const vectors = randomVectors(300, dim, 7); + const a = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + ann: { type: 'hnsw', seed: 42 }, + persist: { name, backend: 'indexeddb' }, + }); + await a.addBatch(records(vectors)); + const [q] = randomVectors(1, dim, 8); + const beforeSave = await a.query(q!, { k: 10 }); + await a.save(); + a.destroy(); + + const b = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + ann: { type: 'hnsw', seed: 42 }, + persist: { name, backend: 'indexeddb' }, + }); + try { + expect(b.stats().count).toBe(300); + expect(b.stats().maxLevel).toBeGreaterThanOrEqual(0); + const afterLoad = await b.query(q!, { k: 10 }); + expect(afterLoad.map((h) => h.id)).toEqual(beforeSave.map((h) => h.id)); + + // Appends after the reload keep working. + await b.addBatch(records(randomVectors(50, dim, 9)).map((r, i) => ({ ...r, id: `w${i}` }))); + expect(b.stats().count).toBe(350); + } finally { + b.destroy(); + } + }); +}); + +describe('OPFS backend', () => { + it('saves and loads via OPFS when available', async (ctx) => { + if (!navigator.storage?.getDirectory) return ctx.skip(); + const dim = 8; + const name = uniqueName(); + const a = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'opfs' }, + }); + await a.addBatch(records(randomVectors(10, dim, 10))); + await a.save(); + a.destroy(); + + const b = await BrowserVec.create({ + dimension: dim, + fallback: 'wasm', + persist: { name, backend: 'opfs' }, + }); + try { + expect(b.stats().count).toBe(10); + expect(b.stats().persist).toBe('opfs'); + } finally { + b.destroy(); + } + }); +}); diff --git a/tests/browser/support.test.ts b/tests/browser/support.test.ts new file mode 100644 index 0000000..1efaef0 --- /dev/null +++ b/tests/browser/support.test.ts @@ -0,0 +1,21 @@ +// Environment probe: records what this browser/CI runner actually supports so a +// skipped GPU suite is visible in the logs, and pins the invariants the other +// suites rely on (WASM everywhere, isSupported() telling the truth). + +import { describe, expect, it } from 'vitest'; +import { BrowserVec, webgpuAvailable } from './helpers'; + +describe('BrowserVec.isSupported', () => { + it('reports capabilities consistent with the environment', async () => { + const info = BrowserVec.isSupported(); + // eslint-disable-next-line no-console + console.log('[browservec tests] support:', JSON.stringify(info)); + + // WASM is table stakes in any browser we test. + expect(info.wasm).toBe(true); + // isSupported().webgpu is a cheap presence probe; if it says no, an adapter + // request must also fail. + if (!info.webgpu) expect(await webgpuAvailable()).toBe(false); + expect(typeof info.opfs).toBe('boolean'); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..7e96b76 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "types": ["@webgpu/types"] + }, + "include": ["../src", "."] +} diff --git a/tests/unit/codec.test.ts b/tests/unit/codec.test.ts new file mode 100644 index 0000000..9ed0611 --- /dev/null +++ b/tests/unit/codec.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { + quantizeRow, + dequantRow, + quantizeRow4, + dequantRow4, + quantizeRow1, + dequantRow1, +} from '../../src/quant/codec'; +import { mulberry32 } from '../../src/quant/prng'; + +function randomVec(n: number, seed: number): Float32Array { + const rng = mulberry32(seed); + const v = new Float32Array(n); + for (let i = 0; i < n; i++) v[i] = (rng() * 2 - 1) * 3; + return v; +} + +describe('int8 codec', () => { + it('round-trips within the quantization step (scale/127 per coord)', () => { + const v = randomVec(64, 1); + const q = quantizeRow(v); + const back = dequantRow(q.words, q.scale); + const step = q.scale / 127; + for (let i = 0; i < v.length; i++) { + expect(Math.abs(back[i]! - v[i]!)).toBeLessThanOrEqual(step / 2 + 1e-6); + } + }); + + it('uses max |coord| as the scale so nothing clips', () => { + const v = new Float32Array([0.5, -2.5, 1, 0]); + const q = quantizeRow(v); + expect(q.scale).toBe(2.5); + const back = dequantRow(q.words, q.scale); + expect(back[1]!).toBeCloseTo(-2.5, 4); + }); + + it('packs 4 coords per word', () => { + const q = quantizeRow(randomVec(32, 2)); + expect(q.words.length).toBe(8); + }); + + it('handles the all-zero row (scale defaults to 1)', () => { + const q = quantizeRow(new Float32Array(16)); + expect(q.scale).toBe(1); + expect(dequantRow(q.words, q.scale)).toEqual(new Float32Array(16)); + }); + + it('rejects lengths not divisible by 4', () => { + expect(() => quantizeRow(new Float32Array(7))).toThrow(/multiple of 4/); + }); +}); + +describe('int4 codec', () => { + it('round-trips within the coarser step (scale/7 per coord)', () => { + const v = randomVec(64, 3); + const q = quantizeRow4(v); + const back = dequantRow4(q.words, q.scale); + const step = q.scale / 7; + for (let i = 0; i < v.length; i++) { + expect(Math.abs(back[i]! - v[i]!)).toBeLessThanOrEqual(step / 2 + 1e-6); + } + }); + + it('packs 8 coords per word', () => { + const q = quantizeRow4(randomVec(64, 4)); + expect(q.words.length).toBe(8); + }); + + it('rejects lengths not divisible by 8', () => { + expect(() => quantizeRow4(new Float32Array(12))).toThrow(/multiple of 8/); + }); +}); + +describe('1-bit codec', () => { + it('keeps signs exactly and scales by mean |coord|', () => { + const v = randomVec(64, 5); + const q = quantizeRow1(v); + let absSum = 0; + for (const x of v) absSum += Math.abs(x); + expect(q.scale).toBeCloseTo(absSum / v.length, 5); + + const back = dequantRow1(q.words, q.scale); + for (let i = 0; i < v.length; i++) { + const sign = v[i]! >= 0 ? 1 : -1; + expect(back[i]!).toBeCloseTo(sign * q.scale, 5); + } + }); + + it('packs 32 coords per word', () => { + const q = quantizeRow1(randomVec(64, 6)); + expect(q.words.length).toBe(2); + }); + + it('rejects lengths not divisible by 32', () => { + expect(() => quantizeRow1(new Float32Array(48))).toThrow(/multiple of 32/); + }); + + it('treats zero as non-negative (bit set)', () => { + const v = new Float32Array(32); // all zeros + const q = quantizeRow1(v); + expect(q.words[0]).toBe(0xffffffff); + }); +}); diff --git a/tests/unit/cpu.test.ts b/tests/unit/cpu.test.ts new file mode 100644 index 0000000..85cdd16 --- /dev/null +++ b/tests/unit/cpu.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { CpuIndex } from '../../src/fallback/cpu'; +import { simdAvailable, createSimd } from '../../src/fallback/simd'; +import { topK } from '../../src/index/flat'; +import { mulberry32 } from '../../src/quant/prng'; + +function randomCorpus(rows: number, dim: number, seed: number): Float32Array { + const rng = mulberry32(seed); + const data = new Float32Array(rows * dim); + for (let i = 0; i < data.length; i++) data[i] = rng() * 2 - 1; + return data; +} + +/** Scalar reference scores matching the library convention (higher = closer). */ +function referenceScores(data: Float32Array, rows: number, dim: number, q: Float32Array, metric: 'dot' | 'l2') { + const scores = new Float32Array(rows); + for (let r = 0; r < rows; r++) { + let s = 0; + for (let i = 0; i < dim; i++) { + if (metric === 'l2') { + const d = data[r * dim + i]! - q[i]!; + s -= d * d; + } else { + s += data[r * dim + i]! * q[i]!; + } + } + scores[r] = s; + } + return scores; +} + +describe('WASM-SIMD kernel', () => { + it('is available in this Node runtime', () => { + expect(simdAvailable()).toBe(true); + expect(createSimd()).not.toBeNull(); + }); + + it('each instance gets its own linear memory', () => { + const a = createSimd()!; + const b = createSimd()!; + expect(a.mem).not.toBe(b.mem); + }); +}); + +// Run the same behavioral suite on both engines: the real SIMD path and the +// scalar JS fallback (forced by nulling the instance's simd handle). +describe.each([ + ['simd', false], + ['scalar', true], +])('CpuIndex (%s path)', (_name, forceScalar) => { + function makeIndex(dim: number, metric: 'dot' | 'l2' | 'cosine'): CpuIndex { + const idx = new CpuIndex(dim, metric); + if (forceScalar) (idx as unknown as { simd: null }).simd = null; + return idx; + } + + it.each(['dot', 'l2'] as const)('matches the exact brute-force reference (%s)', async (metric) => { + const dim = 33; // odd dim exercises the scalar tail after the ×4 unroll + const rows = 500; + const data = randomCorpus(rows, dim, 1); + const idx = makeIndex(dim, metric); + idx.append(data, rows); + expect(idx.size).toBe(rows); + + const rng = mulberry32(2); + for (let t = 0; t < 5; t++) { + const q = new Float32Array(dim); + for (let i = 0; i < dim; i++) q[i] = rng() * 2 - 1; + const hits = await idx.query(q, 10); + const want = topK(referenceScores(data, rows, dim, q, metric), rows, 10); + expect(hits.map((h) => h.row)).toEqual(want.map((h) => h.row)); + for (let i = 0; i < hits.length; i++) { + expect(hits[i]!.score).toBeCloseTo(want[i]!.score, 3); + } + } + idx.destroy(); + }); + + it('returns [] before any append and validates append length', async () => { + const idx = makeIndex(8, 'dot'); + expect(await idx.query(new Float32Array(8), 5)).toEqual([]); + expect(() => idx.append(new Float32Array(9), 1)).toThrow(/expected 8 floats/); + idx.destroy(); + }); + + it('supports incremental appends across memory growth', async () => { + const dim = 64; + const rows = 3000; // forces the SIMD linear memory (1 page) to grow + const data = randomCorpus(rows, dim, 3); + const idx = makeIndex(dim, 'dot'); + for (let r = 0; r < rows; r += 500) { + idx.append(data.subarray(r * dim, (r + 500) * dim), 500); + } + expect(idx.size).toBe(rows); + + // Query for an exact stored row: it must come back first with score ≈ |v|². + const target = data.slice(2222 * dim, 2223 * dim); + const hits = await idx.query(target, 1); + expect(hits[0]!.row).toBe(2222); + idx.destroy(); + }); + + it('writeRow overwrites a row in place', async () => { + const dim = 16; + const idx = makeIndex(dim, 'l2'); + idx.append(randomCorpus(4, dim, 4), 4); + const v = new Float32Array(dim).fill(0.5); + idx.writeRow(2, v); + const hits = await idx.query(v, 1); + expect(hits[0]!.row).toBe(2); + expect(hits[0]!.score).toBeCloseTo(0, 4); + idx.destroy(); + }); +}); + +describe('CpuIndex engine parity', () => { + it('simd and scalar paths agree on scores', async () => { + const dim = 20; + const rows = 200; + const data = randomCorpus(rows, dim, 5); + const simd = new CpuIndex(dim, 'l2'); + const scalar = new CpuIndex(dim, 'l2'); + (scalar as unknown as { simd: null }).simd = null; + simd.append(data, rows); + scalar.append(data, rows); + + const q = randomCorpus(1, dim, 6); + const a = await simd.query(q, 10); + const b = await scalar.query(q, 10); + expect(a.map((h) => h.row)).toEqual(b.map((h) => h.row)); + for (let i = 0; i < a.length; i++) expect(a[i]!.score).toBeCloseTo(b[i]!.score, 3); + simd.destroy(); + scalar.destroy(); + }); +}); diff --git a/tests/unit/crypto.test.ts b/tests/unit/crypto.test.ts new file mode 100644 index 0000000..c4decd0 --- /dev/null +++ b/tests/unit/crypto.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { encryptSnapshot, decryptSnapshot, isEncrypted } from '../../src/persist/crypto'; + +function payload(): ArrayBuffer { + const bytes = new Uint8Array(256); + for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) & 0xff; + return bytes.buffer; +} + +// PBKDF2 at 210k iterations runs a few times here; each call is ~100ms in Node. +describe('snapshot encryption', () => { + it('round-trips with the right passphrase', async () => { + const plain = payload(); + const env = await encryptSnapshot(plain, 'correct horse'); + expect(isEncrypted(env)).toBe(true); + expect(isEncrypted(plain)).toBe(false); + const back = await decryptSnapshot(env, 'correct horse'); + expect(new Uint8Array(back)).toEqual(new Uint8Array(plain)); + }); + + it('produces a different envelope every time (random salt/iv)', async () => { + const plain = payload(); + const a = new Uint8Array(await encryptSnapshot(plain, 'p')); + const b = new Uint8Array(await encryptSnapshot(plain, 'p')); + const identical = a.length === b.length && a.every((x, i) => x === b[i]); + expect(identical).toBe(false); + }); + + it('rejects a wrong passphrase loudly', async () => { + const env = await encryptSnapshot(payload(), 'right'); + await expect(decryptSnapshot(env, 'wrong')).rejects.toThrow(/wrong passphrase or corrupted/); + }); + + it('rejects tampered ciphertext (GCM auth)', async () => { + const env = await encryptSnapshot(payload(), 'p'); + const tampered = env.slice(0); + const bytes = new Uint8Array(tampered); + bytes[bytes.length - 1] ^= 0xff; + await expect(decryptSnapshot(tampered, 'p')).rejects.toThrow(/wrong passphrase or corrupted/); + }); + + it('rejects an empty passphrase and non-envelope input', async () => { + await expect(encryptSnapshot(payload(), '')).rejects.toThrow(/non-empty/); + await expect(decryptSnapshot(payload(), 'p')).rejects.toThrow(/bad magic/); + }); + + it('rejects unsupported envelope versions', async () => { + const env = await encryptSnapshot(payload(), 'p'); + const bumped = env.slice(0); + new DataView(bumped).setUint32(4, 2, true); + await expect(decryptSnapshot(bumped, 'p')).rejects.toThrow(/unsupported encryption version 2/); + }); +}); diff --git a/tests/unit/encode.test.ts b/tests/unit/encode.test.ts new file mode 100644 index 0000000..f003616 --- /dev/null +++ b/tests/unit/encode.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { BatchEncoder } from '../../src/quant/encode'; +import { Rotator } from '../../src/quant/rotator'; +import { quantizeRow, quantizeRow4, quantizeRow1 } from '../../src/quant/codec'; +import { mulberry32 } from '../../src/quant/prng'; + +function randomBatch(rows: number, dim: number, seed: number): Float32Array { + const rng = mulberry32(seed); + const data = new Float32Array(rows * dim); + for (let i = 0; i < data.length; i++) data[i] = rng() * 2 - 1; + return data; +} + +describe('BatchEncoder', () => { + it('matches manual rotate + quantize per row (int8)', () => { + const dim = 48; + const rows = 5; + const cfg = { dim, seed: 11, rounds: 2, bits: 8 as const }; + const enc = new BatchEncoder(cfg); + const data = randomBatch(rows, dim, 1); + const batch = enc.encode(data, rows, false); + + const rot = new Rotator(dim, cfg.seed, cfg.rounds); + for (let r = 0; r < rows; r++) { + const rotated = rot.rotate(data.subarray(r * dim, (r + 1) * dim)); + const q = quantizeRow(rotated); + expect(batch.words.subarray(r * enc.wordsPerRow, (r + 1) * enc.wordsPerRow)).toEqual(q.words); + expect(batch.scales[r]!).toBeCloseTo(q.scale, 6); + } + expect(batch.rotated).toBeUndefined(); + }); + + it('matches the 4-bit and 1-bit codecs too', () => { + const dim = 40; + const data = randomBatch(3, dim, 2); + for (const [bits, quantize] of [ + [4, quantizeRow4], + [1, quantizeRow1], + ] as const) { + const enc = new BatchEncoder({ dim, seed: 5, rounds: 2, bits }); + const batch = enc.encode(data, 3, false); + const rot = new Rotator(dim, 5, 2); + const q0 = quantize(rot.rotate(data.subarray(0, dim))); + expect(batch.words.subarray(0, enc.wordsPerRow)).toEqual(q0.words); + } + }); + + it('computes wordsPerRow from the padded dim and bit width', () => { + // dim 384 pads to 512. + expect(new BatchEncoder({ dim: 384, seed: 1, rounds: 2, bits: 8 }).wordsPerRow).toBe(128); + expect(new BatchEncoder({ dim: 384, seed: 1, rounds: 2, bits: 4 }).wordsPerRow).toBe(64); + expect(new BatchEncoder({ dim: 384, seed: 1, rounds: 2, bits: 1 }).wordsPerRow).toBe(16); + }); + + it('returns rotated rows when requested, matching the rotator', () => { + const dim = 20; + const enc = new BatchEncoder({ dim, seed: 3, rounds: 2, bits: 8 }); + const data = randomBatch(2, dim, 3); + const batch = enc.encode(data, 2, true); + expect(batch.rotated).toBeDefined(); + expect(batch.rotated!.length).toBe(2 * enc.paddedDim); + + const rot = new Rotator(dim, 3, 2); + const r1 = rot.rotate(data.subarray(dim, 2 * dim)); + expect(batch.rotated!.subarray(enc.paddedDim, 2 * enc.paddedDim)).toEqual(r1); + }); + + it('validates the data length', () => { + const enc = new BatchEncoder({ dim: 8, seed: 1, rounds: 2, bits: 8 }); + expect(() => enc.encode(new Float32Array(15), 2, false)).toThrow(/expected 16 floats/); + }); +}); diff --git a/tests/unit/filter.test.ts b/tests/unit/filter.test.ts new file mode 100644 index 0000000..f055437 --- /dev/null +++ b/tests/unit/filter.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { compileFilter } from '../../src/store/filter'; + +describe('compileFilter', () => { + it('treats a bare value as $eq', () => { + const p = compileFilter({ lang: 'en' }); + expect(p({ lang: 'en' })).toBe(true); + expect(p({ lang: 'de' })).toBe(false); + expect(p({})).toBe(false); + expect(p(undefined)).toBe(false); + }); + + it('$eq is strict — no type coercion', () => { + const p = compileFilter({ n: 1 }); + expect(p({ n: 1 })).toBe(true); + expect(p({ n: '1' })).toBe(false); + expect(p({ n: true })).toBe(false); + }); + + it('supports null equality', () => { + const p = compileFilter({ x: null }); + expect(p({ x: null })).toBe(true); + expect(p({ x: 0 })).toBe(false); + expect(p({})).toBe(false); // missing field is undefined, not null + }); + + it('$ne also matches records missing the field', () => { + const p = compileFilter({ lang: { $ne: 'en' } }); + expect(p({ lang: 'de' })).toBe(true); + expect(p({})).toBe(true); + expect(p(undefined)).toBe(true); + expect(p({ lang: 'en' })).toBe(false); + }); + + it('$in matches any listed value', () => { + const p = compileFilter({ tag: { $in: ['a', 'b'] } }); + expect(p({ tag: 'a' })).toBe(true); + expect(p({ tag: 'b' })).toBe(true); + expect(p({ tag: 'c' })).toBe(false); + expect(p({})).toBe(false); + }); + + it('numeric range operators only match stored numbers', () => { + const p = compileFilter({ year: { $gte: 2020 } }); + expect(p({ year: 2020 })).toBe(true); + expect(p({ year: 2019 })).toBe(false); + expect(p({ year: '2021' })).toBe(false); + expect(p({ year: null })).toBe(false); + expect(p({})).toBe(false); + }); + + it('covers all four range operators', () => { + expect(compileFilter({ n: { $gt: 5 } })({ n: 6 })).toBe(true); + expect(compileFilter({ n: { $gt: 5 } })({ n: 5 })).toBe(false); + expect(compileFilter({ n: { $lt: 5 } })({ n: 4 })).toBe(true); + expect(compileFilter({ n: { $lt: 5 } })({ n: 5 })).toBe(false); + expect(compileFilter({ n: { $lte: 5 } })({ n: 5 })).toBe(true); + expect(compileFilter({ n: { $gte: 5 } })({ n: 5 })).toBe(true); + }); + + it('multiple operators on one field AND together', () => { + const p = compileFilter({ year: { $gte: 2000, $lt: 2010 } }); + expect(p({ year: 2005 })).toBe(true); + expect(p({ year: 2010 })).toBe(false); + expect(p({ year: 1999 })).toBe(false); + }); + + it('multiple fields AND together', () => { + const p = compileFilter({ lang: 'en', year: { $gte: 2020 } }); + expect(p({ lang: 'en', year: 2021 })).toBe(true); + expect(p({ lang: 'en', year: 2019 })).toBe(false); + expect(p({ lang: 'de', year: 2021 })).toBe(false); + }); + + it('an empty filter matches everything', () => { + const p = compileFilter({}); + expect(p({ any: 'thing' })).toBe(true); + expect(p(undefined)).toBe(true); + }); + + it('throws at compile time on unknown operators', () => { + expect(() => compileFilter({ x: { $regex: 'a' } as never })).toThrow(/unknown operator "\$regex"/); + }); + + it('throws at compile time on malformed arguments', () => { + expect(() => compileFilter({ x: { $in: 'not-array' as never } })).toThrow(/expects an array/); + expect(() => compileFilter({ x: { $gt: 'nope' as never } })).toThrow(/expects a number/); + }); +}); diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts new file mode 100644 index 0000000..6a3a091 --- /dev/null +++ b/tests/unit/format.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { serialize, deserialize, FORMAT_VERSION, type SerializeInput } from '../../src/persist/format'; +import { HNSWGraph } from '../../src/index/hnswGraph'; +import { mulberry32 } from '../../src/quant/prng'; + +function makeInput(count = 3, dimension = 4): SerializeInput { + const rng = mulberry32(1); + const vectors = new Float32Array(count * dimension); + for (let i = 0; i < vectors.length; i++) vectors[i] = rng() * 2 - 1; + return { + dimension, + metric: 'cosine' as const, + normalize: true, + count, + entries: Array.from({ length: count }, (_, i) => ({ + id: `id-${i}`, + metadata: { n: i, tag: `t${i}`, flag: i % 2 === 0, nil: null }, + })), + vectors, + }; +} + +describe('snapshot format', () => { + it('round-trips vectors, ids, and metadata', () => { + const input = makeInput(); + const snap = deserialize(serialize(input)); + expect(snap.dimension).toBe(4); + expect(snap.metric).toBe('cosine'); + expect(snap.normalize).toBe(true); + expect(snap.count).toBe(3); + expect(snap.entries).toEqual(input.entries); + expect(snap.vectors).toEqual(input.vectors); + expect(snap.graph).toBeUndefined(); + }); + + it('round-trips every metric code and the normalize flag', () => { + for (const metric of ['cosine', 'dot', 'l2'] as const) { + for (const normalize of [true, false]) { + const snap = deserialize(serialize({ ...makeInput(), metric, normalize })); + expect(snap.metric).toBe(metric); + expect(snap.normalize).toBe(normalize); + } + } + }); + + it('round-trips unicode metadata and awkward JSON lengths', () => { + const input = makeInput(1); + input.entries = [{ id: '日本語-🚀', metadata: { s: 'héllo "quoted" \\ 中文' } }]; + const snap = deserialize(serialize(input)); + expect(snap.entries[0]!.id).toBe('日本語-🚀'); + expect(snap.entries[0]!.metadata).toEqual({ s: 'héllo "quoted" \\ 中文' }); + // Vectors must still be intact behind the padded metadata region. + expect(snap.vectors).toEqual(input.vectors); + }); + + it('handles entries without metadata and an empty store', () => { + const one = makeInput(1); + one.entries = [{ id: 'bare' }]; + expect(deserialize(serialize(one)).entries[0]).toEqual({ id: 'bare' }); + + const empty = { ...makeInput(0), entries: [], vectors: new Float32Array(0) }; + const snap = deserialize(serialize(empty)); + expect(snap.count).toBe(0); + expect(snap.entries).toEqual([]); + }); + + it('writes v1 when no graph is present', () => { + const buf = serialize(makeInput()); + expect(new DataView(buf).getUint32(4, true)).toBe(1); + }); + + it('writes v2 with a graph and round-trips it losslessly', () => { + const dim = 8; + const rows = 100; + const rng = mulberry32(2); + const vectors = new Float32Array(rows * dim); + for (let i = 0; i < vectors.length; i++) vectors[i] = rng() * 2 - 1; + const g = new HNSWGraph(dim, 'l2', { seed: 3, M: 8 }); + g.append(vectors, rows); + const graph = g.serializeGraph(); + + const buf = serialize({ + dimension: dim, + metric: 'l2', + normalize: false, + count: rows, + entries: Array.from({ length: rows }, (_, i) => ({ id: `v${i}` })), + vectors, + graph, + }); + expect(new DataView(buf).getUint32(4, true)).toBe(FORMAT_VERSION); + + const snap = deserialize(buf); + expect(snap.graph).toBeDefined(); + expect(snap.graph!.M).toBe(graph.M); + expect(snap.graph!.entry).toBe(graph.entry); + expect(snap.graph!.top).toBe(graph.top); + expect(snap.graph!.levels).toEqual(graph.levels); + expect(snap.graph!.links0).toEqual(graph.links0); + expect(snap.graph!.upper).toEqual(graph.upper); + }); + + it('rejects a vectors/count mismatch at serialize time', () => { + const input = makeInput(); + input.vectors = new Float32Array(5); + expect(() => serialize(input)).toThrow(/vectors length/); + }); + + it('rejects a graph that does not match the snapshot count', () => { + const input = makeInput(3, 4); + const g = new HNSWGraph(4, 'l2', { M: 8 }); + g.append(new Float32Array(8), 2); // 2 rows != count 3 + expect(() => serialize({ ...input, graph: g.serializeGraph() })).toThrow(/does not match/); + }); + + it('rejects bad magic, truncation, and unknown versions', () => { + const good = serialize(makeInput()); + + const badMagic = good.slice(0); + new DataView(badMagic).setUint32(0, 0x12345678, true); + expect(() => deserialize(badMagic)).toThrow(/bad magic/); + + expect(() => deserialize(good.slice(0, 16))).toThrow(/too small/); + expect(() => deserialize(good.slice(0, good.byteLength - 8))).toThrow(/truncated/); + + const badVersion = good.slice(0); + new DataView(badVersion).setUint32(4, 99, true); + expect(() => deserialize(badVersion)).toThrow(/unsupported snapshot version 99/); + + const badMetric = good.slice(0); + new DataView(badMetric).setUint32(12, 7, true); + expect(() => deserialize(badMetric)).toThrow(/unknown metric/); + }); + + it('returned vectors do not pin the snapshot buffer', () => { + const input = makeInput(); + const buf = serialize(input); + const snap = deserialize(buf); + new Uint8Array(buf).fill(0); // trash the source + expect(snap.vectors).toEqual(input.vectors); + }); +}); diff --git a/tests/unit/hashing.test.ts b/tests/unit/hashing.test.ts new file mode 100644 index 0000000..19043c1 --- /dev/null +++ b/tests/unit/hashing.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { hashingEmbedder } from '../../src/embed/hashing'; + +function norm(v: Float32Array): number { + let s = 0; + for (const x of v) s += x * x; + return Math.sqrt(s); +} + +function dot(a: Float32Array, b: Float32Array): number { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!; + return s; +} + +describe('hashingEmbedder', () => { + it('defaults to dimension 384 and honors an override', async () => { + expect(hashingEmbedder().dimension).toBe(384); + const e = hashingEmbedder({ dimension: 64 }); + expect(e.dimension).toBe(64); + const [v] = await e.embed(['hello']); + expect(v!.length).toBe(64); + }); + + it('produces unit-norm vectors', async () => { + const e = hashingEmbedder(); + const vs = await e.embed(['the quick brown fox', 'lazy dog']); + for (const v of vs) expect(norm(v)).toBeCloseTo(1, 4); + }); + + it('is deterministic and order-preserving in batches', async () => { + const e = hashingEmbedder(); + const [a1, b1] = await e.embed(['alpha', 'beta']); + const [b2, a2] = await e.embed(['beta', 'alpha']); + expect(a1).toEqual(a2); + expect(b1).toEqual(b2); + }); + + it('is case- and punctuation-insensitive via tokenization', async () => { + const e = hashingEmbedder(); + const [a, b] = await e.embed(['Hello, World!', 'hello world']); + expect(a).toEqual(b); + }); + + it('scores overlapping text higher than unrelated text', async () => { + const e = hashingEmbedder(); + const [q, close, far] = await e.embed([ + 'webgpu vector search in the browser', + 'fast vector search with webgpu', + 'banana bread recipe with walnuts', + ]); + expect(dot(q!, close!)).toBeGreaterThan(dot(q!, far!)); + }); + + it('returns a zero vector for empty text', async () => { + const e = hashingEmbedder(); + const [v] = await e.embed([' ']); + expect(norm(v!)).toBe(0); + }); + + it('bigrams distinguish word order', async () => { + const on = hashingEmbedder(); + const [a, b] = await on.embed(['new york city', 'city york new']); + expect(a).not.toEqual(b); + + const off = hashingEmbedder({ bigrams: false }); + const [c, d] = await off.embed(['new york city', 'city york new']); + expect(c).toEqual(d); + }); +}); diff --git a/tests/unit/hnswGraph.test.ts b/tests/unit/hnswGraph.test.ts new file mode 100644 index 0000000..3131b81 --- /dev/null +++ b/tests/unit/hnswGraph.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { HNSWGraph } from '../../src/index/hnswGraph'; +import { mulberry32 } from '../../src/quant/prng'; +import { normalizeInPlace } from '../../src/store/store'; + +function randomCorpus(rows: number, dim: number, seed: number, normalize = false): Float32Array { + const rng = mulberry32(seed); + const data = new Float32Array(rows * dim); + for (let i = 0; i < data.length; i++) data[i] = rng() * 2 - 1; + if (normalize) { + for (let r = 0; r < rows; r++) { + normalizeInPlace(data.subarray(r * dim, (r + 1) * dim)); + } + } + return data; +} + +/** Brute-force k-NN reference using the library's higher-is-closer scores. */ +function bruteForce( + data: Float32Array, + rows: number, + dim: number, + q: Float32Array, + k: number, + metric: 'dot' | 'l2', +): number[] { + const scored: Array<{ row: number; score: number }> = []; + for (let r = 0; r < rows; r++) { + let s = 0; + if (metric === 'l2') { + for (let i = 0; i < dim; i++) { + const d = data[r * dim + i]! - q[i]!; + s -= d * d; + } + } else { + for (let i = 0; i < dim; i++) s += data[r * dim + i]! * q[i]!; + } + scored.push({ row: r, score: s }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k).map((h) => h.row); +} + +function recallAt(graph: HNSWGraph, data: Float32Array, rows: number, dim: number, metric: 'dot' | 'l2', queries: number, k: number, ef: number): number { + const rng = mulberry32(999); + let hit = 0; + for (let qi = 0; qi < queries; qi++) { + const q = new Float32Array(dim); + for (let i = 0; i < dim; i++) q[i] = rng() * 2 - 1; + if (metric === 'dot') normalizeInPlace(q); + const truth = new Set(bruteForce(data, rows, dim, q, k, metric)); + for (const h of graph.search(q, k, ef)) if (truth.has(h.row)) hit++; + } + return hit / (queries * k); +} + +describe('HNSWGraph', () => { + it('returns [] on an empty graph', () => { + const g = new HNSWGraph(8, 'l2'); + expect(g.search(new Float32Array(8), 5, 64)).toEqual([]); + expect(g.size).toBe(0); + expect(g.maxLevel).toBe(-1); + }); + + it('finds an exact match and scores follow higher-is-closer', () => { + const dim = 16; + const rows = 200; + const data = randomCorpus(rows, dim, 1); + const g = new HNSWGraph(dim, 'l2', { seed: 1 }); + g.append(data, rows); + expect(g.size).toBe(rows); + + const target = data.slice(37 * dim, 38 * dim); + const hits = g.search(target, 3, 64); + expect(hits[0]!.row).toBe(37); + expect(hits[0]!.score).toBeCloseTo(0, 4); // -squared-L2 of itself + expect(hits[0]!.score).toBeGreaterThanOrEqual(hits[1]!.score); + expect(hits[1]!.score).toBeGreaterThanOrEqual(hits[2]!.score); + }); + + it('achieves high recall vs brute force (l2)', () => { + const dim = 16; + const rows = 600; + const data = randomCorpus(rows, dim, 2); + const g = new HNSWGraph(dim, 'l2', { seed: 2, M: 16, efConstruction: 200 }); + g.append(data, rows); + expect(recallAt(g, data, rows, dim, 'l2', 20, 10, 128)).toBeGreaterThanOrEqual(0.9); + }); + + it('achieves high recall vs brute force (dot / normalized cosine)', () => { + const dim = 16; + const rows = 600; + const data = randomCorpus(rows, dim, 3, true); + const g = new HNSWGraph(dim, 'dot', { seed: 3 }); + g.append(data, rows); + expect(recallAt(g, data, rows, dim, 'dot', 20, 10, 128)).toBeGreaterThanOrEqual(0.9); + }); + + it('supports incremental appends without a rebuild', () => { + const dim = 8; + const data = randomCorpus(300, dim, 4); + const g = new HNSWGraph(dim, 'l2', { seed: 4 }); + for (let r = 0; r < 300; r += 50) g.append(data.subarray(r * dim, (r + 50) * dim), 50); + expect(g.size).toBe(300); + expect(recallAt(g, data, 300, dim, 'l2', 10, 5, 128)).toBeGreaterThanOrEqual(0.9); + }); + + it('builds reproducibly from the same seed', () => { + const dim = 8; + const data = randomCorpus(200, dim, 5); + const a = new HNSWGraph(dim, 'l2', { seed: 42 }); + const b = new HNSWGraph(dim, 'l2', { seed: 42 }); + a.append(data, 200); + b.append(data, 200); + const sa = a.serializeGraph(); + const sb = b.serializeGraph(); + expect(sa.levels).toEqual(sb.levels); + expect(sa.links0).toEqual(sb.links0); + expect(sa.upper).toEqual(sb.upper); + expect(sa.entry).toBe(sb.entry); + }); + + it('serialize + loadGraph round-trips to identical search results', () => { + const dim = 12; + const rows = 400; + const data = randomCorpus(rows, dim, 6); + const g = new HNSWGraph(dim, 'l2', { seed: 6, M: 8 }); + g.append(data, rows); + const state = g.serializeGraph(); + + const g2 = new HNSWGraph(dim, 'l2', { seed: 6, M: 8 }); + g2.loadGraph(data, rows, state); + expect(g2.size).toBe(rows); + expect(g2.maxLevel).toBe(g.maxLevel); + expect(g2.entryNode).toBe(g.entryNode); + + const rng = mulberry32(7); + for (let qi = 0; qi < 10; qi++) { + const q = new Float32Array(dim); + for (let i = 0; i < dim; i++) q[i] = rng() * 2 - 1; + expect(g2.search(q, 10, 64)).toEqual(g.search(q, 10, 64)); + } + }); + + it('after loadGraph, further appends match the never-saved store (RNG fast-forward)', () => { + const dim = 8; + const data = randomCorpus(300, dim, 8); + const first = data.subarray(0, 200 * dim); + const rest = data.subarray(200 * dim); + + const never = new HNSWGraph(dim, 'l2', { seed: 9 }); + never.append(first, 200); + never.append(rest, 100); + + const saved = new HNSWGraph(dim, 'l2', { seed: 9 }); + saved.append(first, 200); + const reloaded = new HNSWGraph(dim, 'l2', { seed: 9 }); + reloaded.loadGraph(data.slice(0, 200 * dim), 200, saved.serializeGraph()); + reloaded.append(rest, 100); + + const sa = never.serializeGraph(); + const sb = reloaded.serializeGraph(); + expect(sb.levels).toEqual(sa.levels); + expect(sb.links0).toEqual(sa.links0); + }); + + it('loadGraph validates its inputs', () => { + const dim = 4; + const data = randomCorpus(10, dim, 10); + const g = new HNSWGraph(dim, 'l2', { seed: 1, M: 8 }); + g.append(data, 10); + const state = g.serializeGraph(); + + const nonEmpty = new HNSWGraph(dim, 'l2', { M: 8 }); + nonEmpty.append(data, 10); + expect(() => nonEmpty.loadGraph(data, 10, state)).toThrow(/empty graph/); + + const wrongM = new HNSWGraph(dim, 'l2', { M: 4 }); + expect(() => wrongM.loadGraph(data, 10, state)).toThrow(/M 8 != configured M 4/); + + const fresh = new HNSWGraph(dim, 'l2', { M: 8 }); + expect(() => fresh.loadGraph(data.subarray(0, 9 * dim), 9, state)).toThrow(/does not match/); + }); + + it('exportLinks produces a dense 2M-wide table padded with 0xFFFFFFFF', () => { + const dim = 4; + const M = 4; + const rows = 20; + const g = new HNSWGraph(dim, 'l2', { seed: 1, M }); + g.append(randomCorpus(rows, dim, 11), rows); + const links = g.exportLinks(); + expect(g.degree).toBe(2 * M); + expect(links.length).toBe(rows * 2 * M); + const state = g.serializeGraph(); + for (let r = 0; r < rows; r++) { + const cnt = state.links0[r * (2 * M + 1)]!; + for (let j = 0; j < 2 * M; j++) { + const v = links[r * 2 * M + j]!; + if (j < cnt) { + expect(v).toBeLessThan(rows); + expect(v).toBe(state.links0[r * (2 * M + 1) + 1 + j]! >>> 0); + } else { + expect(v).toBe(0xffffffff); + } + } + } + }); + + it('validates append length', () => { + const g = new HNSWGraph(8, 'l2'); + expect(() => g.append(new Float32Array(15), 2)).toThrow(/expected 16 floats/); + }); +}); diff --git a/tests/unit/kmeans.test.ts b/tests/unit/kmeans.test.ts new file mode 100644 index 0000000..ac2f513 --- /dev/null +++ b/tests/unit/kmeans.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { + kmeans, + normalizeRows, + randomInitCentroids, + updateCentroids, +} from '../../src/index/kmeans'; +import { mulberry32 } from '../../src/quant/prng'; + +/** Sample with `k` well-separated Gaussian-ish blobs on axis-aligned centers. */ +function blobs(k: number, perCluster: number, dim: number, seed: number) { + const rng = mulberry32(seed); + const n = k * perCluster; + const data = new Float32Array(n * dim); + const labels = new Int32Array(n); + for (let c = 0; c < k; c++) { + for (let j = 0; j < perCluster; j++) { + const r = c * perCluster + j; + labels[r] = c; + for (let i = 0; i < dim; i++) { + // Center: 10 on coordinate c (mod dim), noise ±0.5 elsewhere. + const center = i === c % dim ? 10 : 0; + data[r * dim + i] = center + (rng() - 0.5); + } + } + } + return { data, labels, n }; +} + +function nearest(data: Float32Array, row: number, centroids: Float32Array, nlist: number, dim: number) { + let best = 0; + let bestD = Infinity; + for (let c = 0; c < nlist; c++) { + let d = 0; + for (let i = 0; i < dim; i++) { + const t = data[row * dim + i]! - centroids[c * dim + i]!; + d += t * t; + } + if (d < bestD) { + bestD = d; + best = c; + } + } + return best; +} + +describe('kmeans', () => { + it('separates well-separated clusters (non-spherical / l2)', () => { + const dim = 8; + const k = 4; + const { data, labels, n } = blobs(k, 50, dim, 1); + const { centroids, nlist } = kmeans(data, n, { nlist: k, dim, seed: 7, spherical: false }); + expect(nlist).toBe(k); + + // Every point in a blob should map to the same centroid, and distinct blobs + // to distinct centroids. + const blobToCentroid = new Map(); + for (let r = 0; r < n; r++) { + const c = nearest(data, r, centroids, nlist, dim); + const prev = blobToCentroid.get(labels[r]!); + if (prev === undefined) blobToCentroid.set(labels[r]!, c); + else expect(c).toBe(prev); + } + expect(new Set(blobToCentroid.values()).size).toBe(k); + }); + + it('is deterministic for a fixed seed', () => { + const dim = 6; + const { data, n } = blobs(3, 30, dim, 2); + const a = kmeans(data, n, { nlist: 3, dim, seed: 42 }); + const b = kmeans(data, n, { nlist: 3, dim, seed: 42 }); + expect(a.centroids).toEqual(b.centroids); + }); + + it('normalizes centroids in spherical mode', () => { + const dim = 5; + const { data, n } = blobs(3, 30, dim, 3); + const { centroids, nlist } = kmeans(data, n, { nlist: 3, dim, seed: 1, spherical: true }); + for (let c = 0; c < nlist; c++) { + let s = 0; + for (let i = 0; i < dim; i++) s += centroids[c * dim + i]! ** 2; + expect(Math.sqrt(s)).toBeCloseTo(1, 4); + } + }); + + it('clamps nlist to the sample count', () => { + const dim = 4; + const data = new Float32Array(3 * dim).fill(1); + const { nlist } = kmeans(data, 3, { nlist: 16, dim }); + expect(nlist).toBe(3); + }); +}); + +describe('normalizeRows', () => { + it('gives every row unit norm and leaves zero rows finite', () => { + const m = new Float32Array([3, 4, 0, 0]); + normalizeRows(m, 2, 2); + expect(m[0]).toBeCloseTo(0.6, 5); + expect(m[1]).toBeCloseTo(0.8, 5); + expect(m[2]).toBe(0); + expect(m[3]).toBe(0); + }); +}); + +describe('GPU-assisted Lloyd helpers', () => { + it('randomInitCentroids picks sample rows deterministically', () => { + const dim = 4; + const { data, n } = blobs(2, 10, dim, 4); + const a = randomInitCentroids(data, n, 3, dim, 9, false); + const b = randomInitCentroids(data, n, 3, dim, 9, false); + expect(a).toEqual(b); + expect(a.length).toBe(3 * dim); + }); + + it('updateCentroids computes per-cluster means', () => { + const dim = 2; + // Two clusters: rows 0,1 -> cluster 0; row 2 -> cluster 1. + const sample = new Float32Array([0, 0, 2, 2, 10, 10]); + const assign = new Uint32Array([0, 0, 1]); + const centroids = new Float32Array(2 * dim); + updateCentroids(sample, 3, assign, centroids, 2, dim, 1, false); + expect(Array.from(centroids)).toEqual([1, 1, 10, 10]); + }); + + it('updateCentroids re-seeds empty clusters from the sample', () => { + const dim = 2; + const sample = new Float32Array([1, 2, 3, 4]); + const assign = new Uint32Array([0, 0]); // cluster 1 empty + const centroids = new Float32Array(2 * dim); + updateCentroids(sample, 2, assign, centroids, 2, dim, 5, false); + // Cluster 1 must equal one of the sample rows. + const c1 = [centroids[2], centroids[3]]; + expect([JSON.stringify([1, 2]), JSON.stringify([3, 4])]).toContain(JSON.stringify(c1)); + }); +}); diff --git a/tests/unit/prng.test.ts b/tests/unit/prng.test.ts new file mode 100644 index 0000000..10e58f1 --- /dev/null +++ b/tests/unit/prng.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { mulberry32, randomSigns } from '../../src/quant/prng'; + +describe('mulberry32', () => { + it('is deterministic for a given seed', () => { + const a = mulberry32(42); + const b = mulberry32(42); + for (let i = 0; i < 100; i++) expect(a()).toBe(b()); + }); + + it('produces different streams for different seeds', () => { + const a = mulberry32(1); + const b = mulberry32(2); + const same = Array.from({ length: 20 }, () => a() === b()); + expect(same.every(Boolean)).toBe(false); + }); + + it('stays in [0, 1)', () => { + const rng = mulberry32(0xdeadbeef); + for (let i = 0; i < 10_000; i++) { + const v = rng(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('has a roughly uniform mean', () => { + const rng = mulberry32(7); + let sum = 0; + const n = 50_000; + for (let i = 0; i < n; i++) sum += rng(); + expect(sum / n).toBeGreaterThan(0.48); + expect(sum / n).toBeLessThan(0.52); + }); +}); + +describe('randomSigns', () => { + it('contains only +1 and -1', () => { + const s = randomSigns(1024, 123); + for (const v of s) expect(Math.abs(v)).toBe(1); + }); + + it('is deterministic per seed and mixes both signs', () => { + const a = randomSigns(256, 9); + const b = randomSigns(256, 9); + expect(a).toEqual(b); + expect(a.includes(1)).toBe(true); + expect(a.includes(-1)).toBe(true); + }); +}); diff --git a/tests/unit/rotator.test.ts b/tests/unit/rotator.test.ts new file mode 100644 index 0000000..4025cb7 --- /dev/null +++ b/tests/unit/rotator.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { Rotator, fwht, padToPow2 } from '../../src/quant/rotator'; +import { mulberry32 } from '../../src/quant/prng'; + +function randomVec(n: number, seed: number): Float32Array { + const rng = mulberry32(seed); + const v = new Float32Array(n); + for (let i = 0; i < n; i++) v[i] = rng() * 2 - 1; + return v; +} + +function dot(a: Float32Array, b: Float32Array): number { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!; + return s; +} + +describe('padToPow2', () => { + it('rounds up to the next power of two with a floor of 4', () => { + expect(padToPow2(1)).toBe(4); + expect(padToPow2(4)).toBe(4); + expect(padToPow2(5)).toBe(8); + expect(padToPow2(384)).toBe(512); + expect(padToPow2(768)).toBe(1024); + expect(padToPow2(1024)).toBe(1024); + expect(padToPow2(1536)).toBe(2048); + }); +}); + +describe('fwht', () => { + it('is an involution up to the factor n', () => { + const n = 64; + const v = randomVec(n, 1); + const copy = v.slice(); + fwht(copy); + fwht(copy); + for (let i = 0; i < n; i++) expect(copy[i]!).toBeCloseTo(v[i]! * n, 3); + }); + + it('computes the known transform of a unit impulse', () => { + const v = new Float32Array(8); + v[0] = 1; + fwht(v); + // H·e0 is the all-ones row. + for (const x of v) expect(x).toBe(1); + }); +}); + +describe('Rotator', () => { + it('is orthonormal: preserves dot products and norms', () => { + const dim = 100; // non-power-of-two to exercise padding + const rot = new Rotator(dim, 12345); + const a = randomVec(dim, 2); + const b = randomVec(dim, 3); + const ra = rot.rotate(a); + const rb = rot.rotate(b); + expect(ra.length).toBe(rot.paddedDim); + expect(dot(ra, rb)).toBeCloseTo(dot(a, b), 3); + expect(dot(ra, ra)).toBeCloseTo(dot(a, a), 3); + }); + + it('reproduces the exact rotation from the same seed', () => { + const a = new Rotator(384, 99, 2); + const b = new Rotator(384, 99, 2); + const v = randomVec(384, 4); + expect(a.rotate(v)).toEqual(b.rotate(v)); + }); + + it('differs across seeds and across round counts', () => { + const v = randomVec(64, 5); + const r1 = new Rotator(64, 1).rotate(v); + const r2 = new Rotator(64, 2).rotate(v); + const r3 = new Rotator(64, 1, 3).rotate(v); + expect(r1).not.toEqual(r2); + expect(r1).not.toEqual(r3); + }); + + it('rotateInto validates the destination length', () => { + const rot = new Rotator(10, 1); + expect(() => rot.rotateInto(randomVec(10, 6), new Float32Array(8))).toThrow(/paddedDim/); + }); + + it('rotateInto overwrites any stale destination content', () => { + const rot = new Rotator(6, 1); + const dst = new Float32Array(rot.paddedDim).fill(123); + const v = randomVec(6, 7); + rot.rotateInto(v, dst); + expect(dst).toEqual(rot.rotate(v)); + }); +}); diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts new file mode 100644 index 0000000..54561de --- /dev/null +++ b/tests/unit/store.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { Store, normalizeInPlace } from '../../src/store/store'; + +describe('normalizeInPlace', () => { + it('scales to unit norm and leaves the zero vector untouched', () => { + const v = new Float32Array([3, 4]); + normalizeInPlace(v); + expect(v[0]).toBeCloseTo(0.6, 5); + expect(v[1]).toBeCloseTo(0.8, 5); + + const z = new Float32Array(4); + normalizeInPlace(z); + expect(z).toEqual(new Float32Array(4)); + }); +}); + +describe('Store', () => { + it('prepare validates dimension and normalizes when configured', () => { + const s = new Store(3, 'cosine', true); + expect(() => s.prepare([1, 2])).toThrow(/dim 2 != store dim 3/); + const v = s.prepare([3, 0, 4]); + expect(v[0]).toBeCloseTo(0.6, 5); + expect(v[2]).toBeCloseTo(0.8, 5); + + const raw = new Store(3, 'dot', false).prepare([3, 0, 4]); + expect(Array.from(raw)).toEqual([3, 0, 4]); + }); + + it('prepare copies its input (no aliasing)', () => { + const s = new Store(2, 'dot', false); + const src = new Float32Array([1, 2]); + const out = s.prepare(src); + out[0] = 99; + expect(src[0]).toBe(1); + }); + + it('insert assigns sequential rows and rejects duplicate ids', () => { + const s = new Store(2, 'dot', false); + expect(s.insert('a', s.prepare([1, 0]))).toBe(0); + expect(s.insert('b', s.prepare([0, 1]), { tag: 'x' })).toBe(1); + expect(() => s.insert('a', s.prepare([1, 1]))).toThrow(/duplicate id: a/); + expect(s.count).toBe(2); + expect(s.rowCount).toBe(2); + expect(s.entryById('b')!.metadata).toEqual({ tag: 'x' }); + expect(s.entryByRow(0)!.id).toBe('a'); + }); + + it('delete tombstones without dropping the row', () => { + const s = new Store(2, 'dot', false); + s.insert('a', s.prepare([1, 0])); + s.insert('b', s.prepare([0, 1])); + expect(s.delete('a')).toBe(true); + expect(s.delete('a')).toBe(false); // already gone + expect(s.delete('nope')).toBe(false); + + expect(s.count).toBe(1); + expect(s.rowCount).toBe(2); // tombstone still occupies the row + expect(s.deletedCount).toBe(1); + expect(s.isDeleted(0)).toBe(true); + expect(s.has('a')).toBe(false); + expect(s.has('b')).toBe(true); + }); + + it('liveEntries/liveVectors stay aligned and skip tombstones', () => { + const s = new Store(2, 'dot', false); + s.insert('a', s.prepare([1, 1])); + s.insert('b', s.prepare([2, 2])); + s.insert('c', s.prepare([3, 3])); + s.delete('b'); + + const entries = s.liveEntries(); + const vectors = s.liveVectors(); + expect(entries.map((e) => e.id)).toEqual(['a', 'c']); + expect(Array.from(vectors)).toEqual([1, 1, 3, 3]); + }); + + it('vectorAt returns a copy and bounds-checks', () => { + const s = new Store(2, 'dot', false); + s.insert('a', s.prepare([5, 6])); + const v = s.vectorAt(0)!; + expect(Array.from(v)).toEqual([5, 6]); + v[0] = 0; + expect(s.vectorAt(0)![0]).toBe(5); + expect(s.vectorAt(1)).toBeUndefined(); + expect(s.vectorAt(-1)).toBeUndefined(); + }); + + it('dotRow and l2Row follow the higher-is-closer convention', () => { + const dim = 5; // odd: exercises the unroll tail + const s = new Store(dim, 'dot', false); + const v = new Float32Array([1, -2, 3, -4, 5]); + s.insert('a', s.prepare(v)); + const q = new Float32Array([2, 1, 0, -1, 3]); + + let dot = 0; + let l2 = 0; + for (let i = 0; i < dim; i++) { + dot += v[i]! * q[i]!; + const d = v[i]! - q[i]!; + l2 += d * d; + } + expect(s.dotRow(0, q)).toBeCloseTo(dot, 4); + expect(s.l2Row(0, q)).toBeCloseTo(-l2, 4); + }); + + it('survives raw-buffer growth past the initial capacity', () => { + const dim = 8; + const s = new Store(dim, 'dot', false); + const rows = 3000; + for (let r = 0; r < rows; r++) { + const v = new Float32Array(dim).fill(r); + s.insert(`id-${r}`, s.prepare(v)); + } + expect(s.count).toBe(rows); + expect(s.rawView().length).toBe(rows * dim); + expect(s.vectorAt(2999)![0]).toBe(2999); + }); + + it('clear drops everything', () => { + const s = new Store(2, 'dot', false); + s.insert('a', s.prepare([1, 0])); + s.delete('a'); + s.clear(); + expect(s.count).toBe(0); + expect(s.rowCount).toBe(0); + expect(s.deletedCount).toBe(0); + expect(s.rawView().length).toBe(0); + }); +}); diff --git a/tests/unit/topk.test.ts b/tests/unit/topk.test.ts new file mode 100644 index 0000000..db55d45 --- /dev/null +++ b/tests/unit/topk.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { topK } from '../../src/index/flat'; +import { mulberry32 } from '../../src/quant/prng'; + +/** Exact reference: full sort descending by score. */ +function reference(scores: Float32Array, n: number, k: number) { + return Array.from(scores.subarray(0, n)) + .map((score, row) => ({ row, score })) + .sort((a, b) => b.score - a.score) + .slice(0, k); +} + +describe('topK', () => { + it('matches a full sort on random scores', () => { + const rng = mulberry32(1); + for (const [n, k] of [ + [100, 10], + [1000, 1], + [1000, 50], + [17, 17], + ] as const) { + const scores = new Float32Array(n); + for (let i = 0; i < n; i++) scores[i] = rng() * 200 - 100; + const got = topK(scores, n, k); + const want = reference(scores, n, k); + expect(got.map((h) => h.score)).toEqual(want.map((h) => h.score)); + expect(got.map((h) => h.row)).toEqual(want.map((h) => h.row)); + } + }); + + it('returns all rows in descending order when k > n', () => { + const scores = new Float32Array([3, 1, 2]); + const got = topK(scores, 3, 10); + expect(got.map((h) => h.row)).toEqual([0, 2, 1]); + }); + + it('handles k = 0 and n = 0', () => { + expect(topK(new Float32Array([1, 2]), 2, 0)).toEqual([]); + expect(topK(new Float32Array(0), 0, 5)).toEqual([]); + }); + + it('handles negative scores (l2 convention: higher = closer)', () => { + const scores = new Float32Array([-5, -1, -3]); + const got = topK(scores, 3, 2); + expect(got[0]).toEqual({ row: 1, score: -1 }); + expect(got[1]).toEqual({ row: 2, score: -3 }); + }); + + it('keeps every tied score', () => { + const scores = new Float32Array([1, 1, 1, 0]); + const got = topK(scores, 4, 3); + expect(got.map((h) => h.score)).toEqual([1, 1, 1]); + expect(new Set(got.map((h) => h.row))).toEqual(new Set([0, 1, 2])); + }); +}); diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..fd8c7ab --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,42 @@ +import { defineWorkspace } from 'vitest/config'; + +// Chromium flags that enable WebGPU in headless CI (Linux runners have no real +// GPU — Vulkan routes to a software adapter). On macOS the default Metal backend +// just works, so only the unsafe-webgpu opt-in is needed. +const webgpuFlags = + process.platform === 'linux' + ? [ + '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--use-angle=vulkan', + '--disable-vulkan-surface', + ] + : ['--enable-unsafe-webgpu']; + +export default defineWorkspace([ + { + test: { + name: 'unit', + environment: 'node', + include: ['tests/unit/**/*.test.ts'], + }, + }, + { + test: { + name: 'browser', + include: ['tests/browser/**/*.test.ts'], + testTimeout: 120_000, + hookTimeout: 120_000, + browser: { + enabled: true, + provider: 'playwright', + name: 'chromium', + headless: true, + screenshotFailures: false, + providerOptions: { + launch: { args: webgpuFlags }, + }, + }, + }, + }, +]);