diff --git a/Cargo.lock b/Cargo.lock index 7748654f2..b377bb0a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8381,6 +8381,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ruvector-acorn" +version = "2.2.0" +dependencies = [ + "criterion 0.5.1", + "rand 0.8.5", + "rand_distr 0.4.3", + "rayon", + "thiserror 2.0.18", +] + +[[package]] +name = "ruvector-acorn-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "getrandom 0.2.17", + "js-sys", + "ruvector-acorn", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "ruvector-attention" version = "2.2.0" @@ -9615,6 +9638,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ruvector-rabitq-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "getrandom 0.2.17", + "js-sys", + "ruvector-rabitq", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "ruvector-raft" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 5c66aaf74..d529f6c41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,10 @@ exclude = ["crates/micro-hnsw-wasm", "crates/ruvector-hyperbolic-hnsw", "crates/ # after running pgrx init. "crates/ruvector-postgres"] members = [ + "crates/ruvector-acorn", + "crates/ruvector-acorn-wasm", "crates/ruvector-rabitq", + "crates/ruvector-rabitq-wasm", "crates/ruvector-rulake", "crates/ruvector-core", "crates/ruvector-node", diff --git a/README.md b/README.md index 40a74c8e3..cf428b364 100644 --- a/README.md +++ b/README.md @@ -1466,6 +1466,8 @@ RuVector runs on Node.js, Rust, browsers, PostgreSQL, and Docker. Pick the packa | [@ruvector/core](https://www.npmjs.com/package/@ruvector/core) | Core vector database with HNSW | [![npm](https://img.shields.io/npm/v/@ruvector/core.svg)](https://www.npmjs.com/package/@ruvector/core) | [![downloads](https://img.shields.io/npm/dt/@ruvector/core.svg)](https://www.npmjs.com/package/@ruvector/core) | | [@ruvector/node](https://www.npmjs.com/package/@ruvector/node) | Unified Node.js bindings | [![npm](https://img.shields.io/npm/v/@ruvector/node.svg)](https://www.npmjs.com/package/@ruvector/node) | [![downloads](https://img.shields.io/npm/dt/@ruvector/node.svg)](https://www.npmjs.com/package/@ruvector/node) | | [ruvector-extensions](https://www.npmjs.com/package/ruvector-extensions) | Advanced features: embeddings, UI | [![npm](https://img.shields.io/npm/v/ruvector-extensions.svg)](https://www.npmjs.com/package/ruvector-extensions) | [![downloads](https://img.shields.io/npm/dt/ruvector-extensions.svg)](https://www.npmjs.com/package/ruvector-extensions) | +| [@ruvector/rabitq-wasm](https://www.npmjs.com/package/@ruvector/rabitq-wasm) | 1-bit quantized vector index in WASM | [![npm](https://img.shields.io/npm/v/@ruvector/rabitq-wasm.svg)](https://www.npmjs.com/package/@ruvector/rabitq-wasm) | [![downloads](https://img.shields.io/npm/dt/@ruvector/rabitq-wasm.svg)](https://www.npmjs.com/package/@ruvector/rabitq-wasm) | +| [@ruvector/acorn-wasm](https://www.npmjs.com/package/@ruvector/acorn-wasm) | Filtered HNSW (ACORN) in WASM | [![npm](https://img.shields.io/npm/v/@ruvector/acorn-wasm.svg)](https://www.npmjs.com/package/@ruvector/acorn-wasm) | [![downloads](https://img.shields.io/npm/dt/@ruvector/acorn-wasm.svg)](https://www.npmjs.com/package/@ruvector/acorn-wasm) | #### Graph & GNN diff --git a/crates/ruvector-acorn-wasm/Cargo.toml b/crates/ruvector-acorn-wasm/Cargo.toml new file mode 100644 index 000000000..895302dea --- /dev/null +++ b/crates/ruvector-acorn-wasm/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "ruvector-acorn-wasm" +version = "0.1.0" +edition = "2021" +description = "WASM bindings for ruvector-acorn — predicate-agnostic filtered HNSW for browsers and edge runtimes" +license = "MIT OR Apache-2.0" +repository = "https://github.com/ruvnet/ruvector" +keywords = ["acorn", "vector-search", "filtered-search", "hnsw", "wasm"] +categories = ["wasm", "science", "algorithms"] + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +ruvector-acorn = { path = "../ruvector-acorn" } +wasm-bindgen = "0.2" +js-sys = "0.3" +console_error_panic_hook = { version = "0.1", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "s" +lto = true + +# Research-tier crate, doc/style churn deferred. Correctness + suspicious lints +# stay denied. +[lints.rust] +unexpected_cfgs = { level = "allow", priority = -1 } + +[lints.clippy] +pedantic = { level = "allow", priority = -2 } +all = { level = "warn", priority = -1 } +correctness = "deny" +suspicious = "deny" diff --git a/crates/ruvector-acorn-wasm/build.sh b/crates/ruvector-acorn-wasm/build.sh new file mode 100755 index 000000000..d2bb80412 --- /dev/null +++ b/crates/ruvector-acorn-wasm/build.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +# Clear any host-only linker flags (the workspace dev shell may export +# `-fuse-ld=mold` for fast native builds; rust-lld for wasm32 rejects +# that flag). +unset RUSTFLAGS + +echo "Building RuVector ACORN WASM..." + +# Build for web (default — emits at root of npm/packages/acorn-wasm) +echo "Building for web target..." +wasm-pack build --target web --out-dir ../../npm/packages/acorn-wasm + +# Build for Node.js +echo "Building for Node.js target..." +wasm-pack build --target nodejs --out-dir ../../npm/packages/acorn-wasm/node + +# Build for bundlers (webpack, rollup, vite) +echo "Building for bundler target..." +wasm-pack build --target bundler --out-dir ../../npm/packages/acorn-wasm/bundler + +echo "Build complete!" +echo "Web: npm/packages/acorn-wasm/" +echo "Node.js: npm/packages/acorn-wasm/node/" +echo "Bundler: npm/packages/acorn-wasm/bundler/" + +# wasm-pack regenerates `package.json` from `Cargo.toml` metadata, but we +# need the scoped name `@ruvector/acorn-wasm` and a richer description / +# keyword set. Keep the canonical package.json under git as +# `package.scoped.json` and copy it over after the build. +if [ -f ../../npm/packages/acorn-wasm/package.scoped.json ]; then + cp ../../npm/packages/acorn-wasm/package.scoped.json \ + ../../npm/packages/acorn-wasm/package.json + echo "(restored scoped package.json from package.scoped.json)" +fi diff --git a/crates/ruvector-acorn-wasm/src/lib.rs b/crates/ruvector-acorn-wasm/src/lib.rs new file mode 100644 index 000000000..e19287221 --- /dev/null +++ b/crates/ruvector-acorn-wasm/src/lib.rs @@ -0,0 +1,260 @@ +//! WASM bindings for ruvector-acorn. +//! +//! Exposes [`AcornIndex`] — predicate-agnostic filtered HNSW (ACORN, +//! Patel et al., SIGMOD 2024) — as a JavaScript-friendly class for use +//! in browsers, Cloudflare Workers, Deno, and Bun. +//! +//! ```ignore +//! import init, { AcornIndex } from "@ruvector/acorn-wasm"; +//! await init(); +//! +//! const dim = 128; +//! const n = 5_000; +//! const vectors = new Float32Array(n * dim); // populate +//! // gamma=2 → ACORN-γ (best recall at low selectivity); gamma=1 → ACORN-1 +//! const idx = AcornIndex.build(vectors, dim, 2); +//! +//! const query = new Float32Array(dim); // populate +//! const evenIds = (id) => id % 2 === 0; +//! const results = idx.search(query, 10, evenIds); +//! // → [{id, distance}, ...] +//! ``` + +#![allow(clippy::new_without_default)] + +use ruvector_acorn::{AcornIndex1, AcornIndexGamma, FilteredIndex}; +use wasm_bindgen::prelude::*; + +/// Initialize panic hook for clearer error messages in the browser +/// console. Called once at module import. +#[wasm_bindgen(start)] +pub fn init() { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} + +/// Search result — single nearest-neighbor hit. +/// +/// Mirrors the structure used by `@ruvector/rabitq-wasm` so callers +/// porting code between backends get identical shapes. +#[wasm_bindgen] +#[derive(Clone, Copy, Debug)] +pub struct SearchResult { + /// Caller-supplied vector id (the position passed to `build`). + #[wasm_bindgen(readonly)] + pub id: u32, + /// Approximate L2² distance. + #[wasm_bindgen(readonly)] + pub distance: f32, +} + +/// Inner enum so we can ship one JS class with two backing index +/// variants. Hidden from the JS API surface. +enum Inner { + G1(AcornIndex1), + Gamma(AcornIndexGamma), +} + +/// ACORN filtered HNSW index. Build once, run many filtered searches. +/// +/// # Variants +/// - `gamma = 1` — standard HNSW edge budget (M=16). Smaller index, +/// good speed, recall drops at very low selectivity. +/// - `gamma = 2` — γ-augmented graph (M·γ = 32 edges per node). +/// ~2× memory, but holds 96% recall@10 at 1% predicate selectivity +/// where post-filter HNSW collapses to near-zero. +/// +/// Default if you don't know which to pick: `gamma = 2`. +#[wasm_bindgen] +pub struct AcornIndex { + inner: Inner, + dim: usize, +} + +#[wasm_bindgen] +impl AcornIndex { + /// Build an index from a flat `Float32Array` of length `n * dim`. + /// + /// # Errors + /// - `vectors.length` is not a multiple of `dim` + /// - `dim == 0` or `vectors.length == 0` + /// - `gamma == 0` + #[wasm_bindgen] + pub fn build(vectors: &[f32], dim: u32, gamma: u32) -> Result { + let dim = dim as usize; + if dim == 0 { + return Err(JsValue::from_str("dim must be > 0")); + } + if vectors.is_empty() { + return Err(JsValue::from_str("vectors must not be empty")); + } + if !vectors.len().is_multiple_of(dim) { + return Err(JsValue::from_str(&format!( + "vectors length {} is not a multiple of dim {}", + vectors.len(), + dim + ))); + } + if gamma == 0 { + return Err(JsValue::from_str("gamma must be >= 1")); + } + + let n = vectors.len() / dim; + let data: Vec> = (0..n) + .map(|i| vectors[i * dim..(i + 1) * dim].to_vec()) + .collect(); + + let inner = if gamma == 1 { + Inner::G1(AcornIndex1::build(data).map_err(acorn_err)?) + } else { + Inner::Gamma(AcornIndexGamma::new_with_gamma(data, gamma as usize).map_err(acorn_err)?) + }; + + Ok(Self { inner, dim }) + } + + /// Find the `k` nearest neighbors of `query` whose id passes + /// `predicate`. Returns hits in ascending distance. + /// + /// `predicate` is called with each candidate `id: number` and must + /// return a truthy value to admit the candidate. Calls cross the + /// JS↔WASM boundary once per node visited (≤ ef per query, ~150 + /// default), not once per vector — overhead is bounded. + /// + /// # Errors + /// - `query.length != dim` of the index + /// - `k == 0` + /// - `predicate` is not callable + #[wasm_bindgen] + pub fn search( + &self, + query: &[f32], + k: u32, + predicate: &js_sys::Function, + ) -> Result, JsValue> { + if k == 0 { + return Err(JsValue::from_str("k must be > 0")); + } + if query.len() != self.dim { + return Err(JsValue::from_str(&format!( + "query length {} != index dim {}", + query.len(), + self.dim + ))); + } + + // Cell-error to surface the first JS-side throw without + // unwinding through WASM. + let pred_err: std::cell::Cell> = std::cell::Cell::new(None); + let pred_fn = |id: u32| -> bool { + if pred_err.take().is_some() { + // Already errored on a previous call — treat as fail + // and the outer Err will be returned post-search. + return false; + } + let arg = JsValue::from(id); + match predicate.call1(&JsValue::NULL, &arg) { + Ok(v) => v.is_truthy(), + Err(e) => { + pred_err.set(Some(e)); + false + } + } + }; + + let hits = match &self.inner { + Inner::G1(idx) => idx.search(query, k as usize, &pred_fn), + Inner::Gamma(idx) => idx.search(query, k as usize, &pred_fn), + } + .map_err(acorn_err)?; + + if let Some(e) = pred_err.take() { + return Err(e); + } + + Ok(hits + .into_iter() + .map(|(id, distance)| SearchResult { id, distance }) + .collect()) + } + + /// Vector dimensionality of the index. + #[wasm_bindgen(getter)] + pub fn dim(&self) -> u32 { + self.dim as u32 + } + + /// Approximate heap size in bytes (graph edges + raw vectors). + #[wasm_bindgen(getter, js_name = memoryBytes)] + pub fn memory_bytes(&self) -> u32 { + let bytes = match &self.inner { + Inner::G1(idx) => idx.memory_bytes(), + Inner::Gamma(idx) => idx.memory_bytes(), + }; + bytes as u32 + } + + /// Variant label for diagnostics: `"ACORN-1 (γ=1, M=16)"` or + /// `"ACORN-γ (γ=2, M=32)"`. + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + match &self.inner { + Inner::G1(idx) => idx.name().to_string(), + Inner::Gamma(idx) => idx.name().to_string(), + } + } +} + +fn acorn_err(e: ruvector_acorn::AcornError) -> JsValue { + JsValue::from_str(&format!("AcornIndex: {e}")) +} + +/// Crate version string baked at build time. +#[wasm_bindgen(js_name = version)] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +// Tests for the WASM bindings live as `wasm_bindgen_test` and only run +// in a wasm32 environment via `wasm-pack test`. Native tests can't +// exercise the bindings because `wasm-bindgen 0.2.117` panics on +// `JsValue::from_str` outside a wasm runtime — same gate as +// `ruvector-rabitq-wasm`. +// +// The inner numerical correctness is covered by `ruvector-acorn`'s own +// test suite; here we only verify the JS-facing surface. +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use super::*; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn build_and_search() { + let dim = 16usize; + let n = 200usize; + let mut vectors = vec![0.0f32; n * dim]; + for i in 0..n { + for j in 0..dim { + vectors[i * dim + j] = (i * 31 + j) as f32 / 100.0; + } + } + let idx = AcornIndex::build(&vectors, dim as u32, 2).expect("build"); + assert_eq!(idx.dim(), dim as u32); + + // Predicate accepting all ids. + let always_true = js_sys::Function::new_no_args("return true"); + let query: Vec = vectors[..dim].to_vec(); + let hits = idx.search(&query, 5, &always_true).expect("search"); + assert_eq!(hits.len(), 5); + // Closest hit should be the seed point itself. + assert_eq!(hits[0].id, 0); + assert!(hits[0].distance < 1e-3); + } + + #[wasm_bindgen_test] + fn version_is_nonempty() { + assert!(!version().is_empty()); + } +} diff --git a/crates/ruvector-acorn/Cargo.toml b/crates/ruvector-acorn/Cargo.toml new file mode 100644 index 000000000..940a99a2b --- /dev/null +++ b/crates/ruvector-acorn/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ruvector-acorn" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "ACORN: Predicate-Agnostic Filtered HNSW — interleaved predicate evaluation inside the graph walk for 2-1000x QPS improvement over post-filter patterns at low selectivity" + +[[bin]] +name = "acorn-demo" +path = "src/main.rs" + +[[bench]] +name = "acorn_bench" +harness = false + +[dependencies] +rand = { workspace = true } +rand_distr = { workspace = true } +rayon = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } diff --git a/crates/ruvector-acorn/benches/acorn_bench.rs b/crates/ruvector-acorn/benches/acorn_bench.rs new file mode 100644 index 000000000..baa5a001d --- /dev/null +++ b/crates/ruvector-acorn/benches/acorn_bench.rs @@ -0,0 +1,52 @@ +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use rand::SeedableRng; +use rand_distr::{Distribution, Normal}; + +use ruvector_acorn::{AcornIndex1, AcornIndexGamma, FilteredIndex, FlatFilteredIndex}; + +fn make_data(n: usize, dim: usize, seed: u64) -> Vec> { + // `StdRng` is always available; `SmallRng` is feature-gated and not + // enabled in the workspace, which broke this bench when the gate flipped. + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let normal = Normal::new(0.0_f32, 1.0).unwrap(); + (0..n) + .map(|_| (0..dim).map(|_| normal.sample(&mut rng)).collect()) + .collect() +} + +fn bench_search(c: &mut Criterion) { + const N: usize = 2_000; + const DIM: usize = 64; + const K: usize = 10; + + let data = make_data(N, DIM, 42); + let queries = make_data(100, DIM, 99); + + let flat = FlatFilteredIndex::build(data.clone()).unwrap(); + let acorn1 = AcornIndex1::build(data.clone()).unwrap(); + let acorng = AcornIndexGamma::build(data.clone()).unwrap(); + + let mut g = c.benchmark_group("filtered_search_sel10pct"); + + for (name, idx) in [ + ("flat-baseline", &flat as &dyn FilteredIndex), + ("acorn1", &acorn1), + ("acorn-gamma2", &acorng), + ] { + g.bench_with_input(BenchmarkId::new(name, N), &(), |b, _| { + b.iter(|| { + for q in &queries { + black_box( + idx.search(q, K, &|id: u32| id % 10 == 0) + .unwrap_or_default(), + ); + } + }); + }); + } + + g.finish(); +} + +criterion_group!(benches, bench_search); +criterion_main!(benches); diff --git a/crates/ruvector-acorn/src/dist.rs b/crates/ruvector-acorn/src/dist.rs new file mode 100644 index 000000000..1882bf5a2 --- /dev/null +++ b/crates/ruvector-acorn/src/dist.rs @@ -0,0 +1,60 @@ +/// Squared Euclidean (L2²) distance — avoids sqrt for comparison-only paths. +/// +/// Hand-unrolled by 4 to give LLVM enough independent accumulators to +/// vectorize on x86_64 (AVX2/SSE) and aarch64 (NEON). On contemporary +/// Apple Silicon and modern x86, this runs roughly 3-5× faster than the +/// naïve iterator for D ≥ 64 — which is the regime that dominates index +/// build and search time. +#[inline] +pub fn l2_sq(a: &[f32], b: &[f32]) -> f32 { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + let mut s0 = 0.0f32; + let mut s1 = 0.0f32; + let mut s2 = 0.0f32; + let mut s3 = 0.0f32; + let chunks = n / 4; + let tail = n % 4; + for k in 0..chunks { + let i = k * 4; + let d0 = a[i] - b[i]; + let d1 = a[i + 1] - b[i + 1]; + let d2 = a[i + 2] - b[i + 2]; + let d3 = a[i + 3] - b[i + 3]; + s0 += d0 * d0; + s1 += d1 * d1; + s2 += d2 * d2; + s3 += d3 * d3; + } + let mut sum = s0 + s1 + s2 + s3; + let base = chunks * 4; + for i in 0..tail { + let d = a[base + i] - b[base + i]; + sum += d * d; + } + sum +} + +/// Euclidean distance (for reporting, not inner-loop comparison). +#[inline] +pub fn l2(a: &[f32], b: &[f32]) -> f32 { + l2_sq(a, b).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_self_distance() { + let v = vec![1.0_f32, 2.0, 3.0]; + assert_eq!(l2_sq(&v, &v), 0.0); + } + + #[test] + fn known_l2() { + let a = vec![0.0_f32, 0.0]; + let b = vec![3.0_f32, 4.0]; + assert!((l2(&a, &b) - 5.0).abs() < 1e-5); + } +} diff --git a/crates/ruvector-acorn/src/error.rs b/crates/ruvector-acorn/src/error.rs new file mode 100644 index 000000000..6b4578433 --- /dev/null +++ b/crates/ruvector-acorn/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Error, Debug, Clone, PartialEq)] +pub enum AcornError { + #[error("dimension mismatch: expected {expected}, got {actual}")] + DimMismatch { expected: usize, actual: usize }, + #[error("empty dataset: cannot build index over zero vectors")] + EmptyDataset, + #[error("k={k} exceeds dataset size={n}")] + KTooLarge { k: usize, n: usize }, + #[error("gamma must be >= 1, got {gamma}")] + InvalidGamma { gamma: usize }, +} diff --git a/crates/ruvector-acorn/src/graph.rs b/crates/ruvector-acorn/src/graph.rs new file mode 100644 index 000000000..3fca39716 --- /dev/null +++ b/crates/ruvector-acorn/src/graph.rs @@ -0,0 +1,218 @@ +use std::collections::BinaryHeap; +use std::sync::Mutex; + +use rayon::prelude::*; + +use crate::dist::l2_sq; +use crate::error::AcornError; + +/// Ordered f32 wrapper: total ordering via `total_cmp`. +#[derive(Clone, Copy, PartialEq)] +pub struct OrdF32(pub f32); +impl Eq for OrdF32 {} +impl PartialOrd for OrdF32 { + fn partial_cmp(&self, o: &Self) -> Option { + Some(self.cmp(o)) + } +} +impl Ord for OrdF32 { + fn cmp(&self, o: &Self) -> std::cmp::Ordering { + self.0.total_cmp(&o.0) + } +} + +/// Greedy k-NN graph used by all ACORN variants. +/// +/// Build strategy: for each node `i`, scan all previous nodes `j < i` and +/// keep the `max_neighbors` nearest. Bidirectional edges are added (each +/// node also gets at most `max_neighbors` back-edges). This gives an +/// O(n² × D) build — appropriate for the PoC scale (≤ 20 K vectors). +/// +/// The forward pass (computing each node's nearest neighbors) is parallel +/// over `i` via rayon; the back-edge merge is serial because it mutates +/// shared state. For a 5K×128 dataset this is ~6× faster on an 8-core box. +/// +/// Vectors are stored in **flat row-major** layout (`Vec` of length +/// n·dim) instead of `Vec>`. This eliminates per-vector heap +/// indirection, gives the L2² inner loop a contiguous slice it can vectorize +/// over, and makes the index ~2× more cache-friendly during search. +pub struct AcornGraph { + /// `neighbors[i]` = sorted-by-distance list of neighbor node IDs. + pub neighbors: Vec>, + /// Raw vectors in row-major layout, length = n × dim. + pub data: Vec, + pub dim: usize, + /// Edge budget per node (M for ACORN-1, γ·M for ACORN-γ). + pub max_neighbors: usize, +} + +impl AcornGraph { + pub fn build(data: Vec>, max_neighbors: usize) -> Result { + if data.is_empty() { + return Err(AcornError::EmptyDataset); + } + let dim = data[0].len(); + let n = data.len(); + + // Flatten input into a single contiguous buffer for cache-friendly + // distance scans during build and search. + let mut flat: Vec = Vec::with_capacity(n * dim); + for row in &data { + if row.len() != dim { + return Err(AcornError::DimMismatch { + expected: dim, + actual: row.len(), + }); + } + flat.extend_from_slice(row); + } + let row = |i: usize| -> &[f32] { &flat[i * dim..(i + 1) * dim] }; + + // Parallel forward pass: each node i picks its top `max_neighbors` + // nearest predecessors j < i. No shared mutation, embarrassingly + // parallel. + let forward: Vec> = (0..n) + .into_par_iter() + .map(|i| { + if i == 0 { + return Vec::new(); + } + let edge_limit = max_neighbors.min(i); + let mut heap: BinaryHeap<(OrdF32, u32)> = BinaryHeap::with_capacity(edge_limit + 1); + let row_i = row(i); + for j in 0..i { + let d = l2_sq(row_i, row(j)); + if heap.len() < edge_limit { + heap.push((OrdF32(d), j as u32)); + } else if let Some(&(OrdF32(worst), _)) = heap.peek() { + if d < worst { + heap.pop(); + heap.push((OrdF32(d), j as u32)); + } + } + } + heap.into_iter().map(|(_, j)| j).collect() + }) + .collect(); + + // Serial back-edge merge: each j gets at most `max_neighbors` total + // edges including the back-edges it picks up here. + let neighbors_lock: Vec>> = forward.into_iter().map(Mutex::new).collect(); + // Walk i in increasing order so back-edges are merged deterministically. + for i in 0..n { + let forward_i: Vec = neighbors_lock[i].lock().unwrap().clone(); + for &j in &forward_i { + let j = j as usize; + let mut nj = neighbors_lock[j].lock().unwrap(); + if nj.len() < max_neighbors { + nj.push(i as u32); + } + } + } + let neighbors: Vec> = neighbors_lock + .into_iter() + .map(|m| m.into_inner().unwrap()) + .collect(); + + Ok(Self { + neighbors, + data: flat, + dim, + max_neighbors, + }) + } + + pub fn len(&self) -> usize { + self.data.len() / self.dim.max(1) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Borrow vector `i` as a contiguous slice — the hot path for L2². + #[inline(always)] + pub fn row(&self, i: usize) -> &[f32] { + &self.data[i * self.dim..(i + 1) * self.dim] + } + + /// Estimated heap memory in bytes: edge lists + raw f32 vectors. + pub fn memory_bytes(&self) -> usize { + let edges: usize = self.neighbors.iter().map(|v| v.len()).sum(); + edges * 4 + self.data.len() * 4 + } +} + +/// Find the `k` nearest neighbors of `query` among `data` by brute force. +/// Returns indices sorted nearest-first. Used by the post-filter baseline. +pub fn flat_k_nearest(data: &[Vec], query: &[f32], k: usize) -> Vec { + let mut heap: BinaryHeap<(OrdF32, u32)> = BinaryHeap::new(); + for (i, v) in data.iter().enumerate() { + let d = l2_sq(v, query); + if heap.len() < k { + heap.push((OrdF32(d), i as u32)); + } else if let Some(&(OrdF32(w), _)) = heap.peek() { + if d < w { + heap.pop(); + heap.push((OrdF32(d), i as u32)); + } + } + } + let mut out: Vec<(OrdF32, u32)> = heap.into_sorted_vec(); + out.sort_by_key(|a| a.0); + out.into_iter().map(|(_, id)| id).collect() +} + +/// Compute exact top-k result set for recall measurement. +pub fn exact_filtered_knn( + data: &[Vec], + query: &[f32], + k: usize, + predicate: impl Fn(u32) -> bool + Sync, +) -> Vec { + // Parallel scoring + filter; collect, then truncate to top-k. For recall + // measurement only, so the extra heap-vs-sort tradeoff doesn't matter. + let mut scored: Vec<(OrdF32, u32)> = (0..data.len()) + .into_par_iter() + .filter(|&i| predicate(i as u32)) + .map(|i| (OrdF32(l2_sq(&data[i], query)), i as u32)) + .collect(); + scored.sort_by_key(|a| a.0); + scored.truncate(k); + scored.into_iter().map(|(_, id)| id).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_data(n: usize, d: usize) -> Vec> { + (0..n) + .map(|i| (0..d).map(|j| (i * d + j) as f32 * 0.01).collect()) + .collect() + } + + #[test] + fn build_small_graph() { + let data = make_data(20, 8); + let g = AcornGraph::build(data, 4).unwrap(); + assert_eq!(g.len(), 20); + // Every node except node 0 has at least 1 neighbor. + for i in 1..20usize { + assert!(!g.neighbors[i].is_empty(), "node {i} has no neighbors"); + } + } + + #[test] + fn flat_knn_returns_self() { + let data: Vec> = vec![ + vec![0.0, 0.0], + vec![1.0, 0.0], + vec![0.0, 1.0], + vec![10.0, 10.0], + ]; + let query = vec![0.01_f32, 0.01]; + let nn = flat_k_nearest(&data, &query, 1); + assert_eq!(nn[0], 0); // node 0 is [0,0] — closest + } +} diff --git a/crates/ruvector-acorn/src/index.rs b/crates/ruvector-acorn/src/index.rs new file mode 100644 index 000000000..13383b190 --- /dev/null +++ b/crates/ruvector-acorn/src/index.rs @@ -0,0 +1,296 @@ +use crate::error::AcornError; +use crate::graph::{exact_filtered_knn, AcornGraph}; +use crate::search::{acorn_search, flat_filtered_search}; + +/// Common interface for all filtered-search index variants. +pub trait FilteredIndex { + /// Build index from a dataset. + fn build(data: Vec>) -> Result + where + Self: Sized; + + /// Search for `k` nearest neighbors passing `predicate`. + fn search( + &self, + query: &[f32], + k: usize, + predicate: &dyn Fn(u32) -> bool, + ) -> Result, AcornError>; + + /// Approximate heap memory used by the index. + fn memory_bytes(&self) -> usize; + + /// Index variant name for display. + fn name(&self) -> &'static str; +} + +// --------------------------------------------------------------------------- +// Variant 1: FlatFilteredIndex — post-filter brute-force scan +// --------------------------------------------------------------------------- + +/// Baseline: scan all vectors, apply predicate after distance computation. +/// O(n × D) per query. Best at high selectivity; degrades badly at low. +pub struct FlatFilteredIndex { + data: Vec>, +} + +impl FilteredIndex for FlatFilteredIndex { + fn build(data: Vec>) -> Result { + if data.is_empty() { + return Err(AcornError::EmptyDataset); + } + Ok(Self { data }) + } + + fn search( + &self, + query: &[f32], + k: usize, + predicate: &dyn Fn(u32) -> bool, + ) -> Result, AcornError> { + if k > self.data.len() { + return Err(AcornError::KTooLarge { + k, + n: self.data.len(), + }); + } + let dim = self.data[0].len(); + if query.len() != dim { + return Err(AcornError::DimMismatch { + expected: dim, + actual: query.len(), + }); + } + Ok(flat_filtered_search(&self.data, query, k, predicate)) + } + + fn memory_bytes(&self) -> usize { + self.data.len() * self.data.first().map(|v| v.len()).unwrap_or(0) * 4 + } + + fn name(&self) -> &'static str { + "FlatFiltered (baseline)" + } +} + +// --------------------------------------------------------------------------- +// Variant 2: AcornIndex1 — γ=1 (standard M edges, ACORN search) +// --------------------------------------------------------------------------- + +/// ACORN-1: same edge budget as standard HNSW (M=16), but search always +/// expands ALL neighbors regardless of predicate. The graph is built with +/// greedy NN insertion. At low selectivity this outperforms the post-filter +/// baseline because it never abandons the beam when nodes fail the predicate. +pub struct AcornIndex1 { + graph: AcornGraph, + ef: usize, +} + +impl AcornIndex1 { + const M: usize = 16; + + pub fn with_ef(mut self, ef: usize) -> Self { + self.ef = ef; + self + } +} + +impl FilteredIndex for AcornIndex1 { + fn build(data: Vec>) -> Result { + if data.is_empty() { + return Err(AcornError::EmptyDataset); + } + let graph = AcornGraph::build(data, Self::M)?; + Ok(Self { graph, ef: 100 }) + } + + fn search( + &self, + query: &[f32], + k: usize, + predicate: &dyn Fn(u32) -> bool, + ) -> Result, AcornError> { + if k > self.graph.len() { + return Err(AcornError::KTooLarge { + k, + n: self.graph.len(), + }); + } + let dim = self.graph.dim; + if query.len() != dim { + return Err(AcornError::DimMismatch { + expected: dim, + actual: query.len(), + }); + } + Ok(acorn_search(&self.graph, query, k, self.ef, predicate)) + } + + fn memory_bytes(&self) -> usize { + self.graph.memory_bytes() + } + + fn name(&self) -> &'static str { + "ACORN-1 (γ=1, M=16)" + } +} + +// --------------------------------------------------------------------------- +// Variant 3: AcornIndexGamma — γ=2 (2×M edges, ACORN search) +// --------------------------------------------------------------------------- + +/// ACORN-γ (γ=2): double the edge budget per node (32 neighbors). Denser +/// graph guarantees navigability even under 1% selectivity predicates. +/// Trades ~2× memory and ~2× build time for significantly better recall at +/// very low selectivities where ACORN-1 may still miss valid nodes. +pub struct AcornIndexGamma { + graph: AcornGraph, + #[allow(dead_code)] // carried for diagnostics / Display + gamma: usize, + ef: usize, +} + +impl AcornIndexGamma { + const M: usize = 16; + + pub fn new_with_gamma(data: Vec>, gamma: usize) -> Result { + if gamma < 1 { + return Err(AcornError::InvalidGamma { gamma }); + } + let graph = AcornGraph::build(data, Self::M * gamma)?; + Ok(Self { + graph, + gamma, + ef: 150, + }) + } + + pub fn with_ef(mut self, ef: usize) -> Self { + self.ef = ef; + self + } +} + +impl FilteredIndex for AcornIndexGamma { + fn build(data: Vec>) -> Result { + Self::new_with_gamma(data, 2) + } + + fn search( + &self, + query: &[f32], + k: usize, + predicate: &dyn Fn(u32) -> bool, + ) -> Result, AcornError> { + if k > self.graph.len() { + return Err(AcornError::KTooLarge { + k, + n: self.graph.len(), + }); + } + let dim = self.graph.dim; + if query.len() != dim { + return Err(AcornError::DimMismatch { + expected: dim, + actual: query.len(), + }); + } + Ok(acorn_search(&self.graph, query, k, self.ef, predicate)) + } + + fn memory_bytes(&self) -> usize { + self.graph.memory_bytes() + } + + fn name(&self) -> &'static str { + "ACORN-γ (γ=2, M=32)" + } +} + +/// Measure recall@k: fraction of true top-k in returned top-k. +pub fn recall_at_k( + data: &[Vec], + queries: &[Vec], + k: usize, + predicate: impl Fn(u32) -> bool + Copy + Sync, + index: &dyn FilteredIndex, +) -> f64 { + let mut hit = 0usize; + let mut total = 0usize; + + for q in queries { + let truth = exact_filtered_knn(data, q, k, predicate); + if truth.is_empty() { + continue; + } + let got = index.search(q, k, &predicate).unwrap_or_default(); + let got_set: std::collections::HashSet = got.iter().map(|(id, _)| *id).collect(); + hit += truth.iter().filter(|id| got_set.contains(id)).count(); + total += truth.len(); + } + + if total == 0 { + 1.0 + } else { + hit as f64 / total as f64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gaussian_data(n: usize, dim: usize, seed: u64) -> Vec> { + use rand::SeedableRng; + use rand_distr::{Distribution, Normal}; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let normal = Normal::new(0.0_f32, 1.0).unwrap(); + (0..n) + .map(|_| (0..dim).map(|_| normal.sample(&mut rng)).collect()) + .collect() + } + + #[test] + fn flat_index_full_recall() { + let data = gaussian_data(200, 32, 42); + let flat = FlatFilteredIndex::build(data.clone()).unwrap(); + let queries = gaussian_data(10, 32, 99); + let r = recall_at_k(&data, &queries, 5, |_| true, &flat); + assert!(r > 0.99, "flat full-pass recall should be ~1.0, got {r:.3}"); + } + + #[test] + fn acorn1_reasonable_recall_half_filter() { + // ACORN-1 with a greedy single-level graph achieves moderate recall. + // The key property tested: ACORN search returns SOME correct neighbors + // under a selective predicate (50%). Recall > 30% confirms the search + // is correctly navigating the predicate subgraph (vs. 0% if broken). + let data = gaussian_data(500, 32, 42); + let idx = AcornIndex1::build(data.clone()).unwrap(); + let queries = gaussian_data(20, 32, 99); + let r = recall_at_k(&data, &queries, 5, |id| id % 2 == 0, &idx); + assert!( + r > 0.30, + "ACORN-1 half-filter recall should be >0.30, got {r:.3}" + ); + } + + #[test] + fn dim_mismatch_returns_error() { + let data = gaussian_data(50, 16, 1); + let idx = FlatFilteredIndex::build(data).unwrap(); + let bad_query = vec![0.0_f32; 8]; + assert!(idx.search(&bad_query, 3, &|_| true).is_err()); + } + + #[test] + fn acorn_gamma_build_and_search() { + let data = gaussian_data(200, 16, 7); + let idx = AcornIndexGamma::new_with_gamma(data.clone(), 2).unwrap(); + let q = gaussian_data(5, 16, 77); + for query in &q { + let res = idx.search(query, 5, &|_| true).unwrap(); + assert_eq!(res.len(), 5); + } + } +} diff --git a/crates/ruvector-acorn/src/lib.rs b/crates/ruvector-acorn/src/lib.rs new file mode 100644 index 000000000..bbf66a625 --- /dev/null +++ b/crates/ruvector-acorn/src/lib.rs @@ -0,0 +1,39 @@ +//! ACORN: Predicate-Agnostic Filtered HNSW for ruvector +//! +//! Implements the ACORN algorithm from: +//! Patel et al., "ACORN: Performant and Predicate-Agnostic Search Over +//! Vector Embeddings and Structured Data", SIGMOD 2024, arXiv:2403.04871. +//! +//! ## The problem +//! +//! Standard filtered vector search runs the ANN graph traversal first, then +//! discards results that fail the predicate. At low selectivity (e.g., only +//! 1% of the dataset passes) the beam exhausts before finding k valid +//! candidates — recall collapses to near zero. +//! +//! ## The ACORN solution +//! +//! Two changes to standard HNSW: +//! 1. **Denser graph**: build with γ·M neighbors per node instead of M. +//! More edges keep the graph navigable even in sparse predicate subgraphs. +//! 2. **Predicate-agnostic traversal**: during search, expand ALL neighbors +//! regardless of whether the current node passes the predicate. Failing +//! nodes are skipped in results but their neighborhood is still explored. +//! +//! ## Variants in this crate +//! +//! | Struct | γ | M | Edge budget | Use when | +//! |--------|---|---|-------------|----------| +//! | `FlatFilteredIndex` | N/A | N/A | 0 | Baseline, high selectivity | +//! | `AcornIndex1` | 1 | 16 | 16/node | Moderate selectivity (≥10%) | +//! | `AcornIndexGamma` | 2 | 16 | 32/node | Low selectivity (<10%) | + +pub mod dist; +pub mod error; +pub mod graph; +pub mod index; +pub mod search; + +pub use error::AcornError; +pub use graph::AcornGraph; +pub use index::{recall_at_k, AcornIndex1, AcornIndexGamma, FilteredIndex, FlatFilteredIndex}; diff --git a/crates/ruvector-acorn/src/main.rs b/crates/ruvector-acorn/src/main.rs new file mode 100644 index 000000000..ddeadfb8e --- /dev/null +++ b/crates/ruvector-acorn/src/main.rs @@ -0,0 +1,190 @@ +//! ACORN filtered-HNSW demo and benchmark harness. +//! +//! Runs three index variants at three predicate selectivities and prints +//! a table of recall@10, QPS, memory (MB), and build time (ms). +//! +//! Usage: cargo run --release -p ruvector-acorn + +use std::time::Instant; + +use rand::SeedableRng; +use rand_distr::{Distribution, Normal}; + +use ruvector_acorn::{recall_at_k, AcornIndex1, AcornIndexGamma, FilteredIndex, FlatFilteredIndex}; + +const N: usize = 5_000; +const DIM: usize = 128; +const N_QUERIES: usize = 500; +const K: usize = 10; +fn gaussian_vectors(n: usize, dim: usize, seed: u64) -> Vec> { + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let normal = Normal::new(0.0_f32, 1.0).unwrap(); + (0..n) + .map(|_| (0..dim).map(|_| normal.sample(&mut rng)).collect()) + .collect() +} + +/// Measure QPS by running `n_queries` searches and timing the total. +fn bench_qps( + index: &dyn FilteredIndex, + queries: &[Vec], + k: usize, + predicate: &dyn Fn(u32) -> bool, +) -> f64 { + let start = Instant::now(); + for q in queries { + let _ = index.search(q, k, predicate).unwrap_or_default(); + } + let elapsed = start.elapsed().as_secs_f64(); + queries.len() as f64 / elapsed +} + +/// Selectivity: fraction of n nodes that pass the predicate. +fn selectivity_predicate(n: usize, fraction: f64) -> impl Fn(u32) -> bool + Copy { + let threshold = (n as f64 * fraction) as u32; + move |id: u32| id < threshold +} + +fn print_header() { + println!( + "\n{:<26} {:>6} {:>8} {:>10} {:>12} {:>10}", + "Variant", "Sel%", "Rec@10", "QPS", "Mem(MB)", "Build(ms)" + ); + println!("{}", "-".repeat(78)); +} + +fn run_variant( + label: &str, + index: &dyn FilteredIndex, + data: &[Vec], + queries: &[Vec], + build_ms: f64, + sel_pct: f64, + predicate: &(dyn Fn(u32) -> bool + Sync), +) { + let recall = recall_at_k(data, queries, K, predicate, index); + let qps = bench_qps(index, queries, K, predicate); + let mem_mb = index.memory_bytes() as f64 / 1_048_576.0; + println!( + "{:<26} {:>5.0}% {:>7.1}% {:>10.0} {:>11.2} {:>10.1}", + label, + sel_pct * 100.0, + recall * 100.0, + qps, + mem_mb, + build_ms, + ); +} + +fn main() { + println!("ACORN Filtered-HNSW Benchmark"); + println!("Dataset: n={N}, D={DIM}, queries={N_QUERIES}, k={K}"); + println!("Hardware: {}", std::env::consts::ARCH); + + let data = gaussian_vectors(N, DIM, 42); + let queries = gaussian_vectors(N_QUERIES, DIM, 99); + + // --- Build all three indices and record build times --- + let t0 = Instant::now(); + let flat = FlatFilteredIndex::build(data.clone()).unwrap(); + let flat_build_ms = t0.elapsed().as_secs_f64() * 1000.0; + + let t1 = Instant::now(); + let acorn1 = AcornIndex1::build(data.clone()).unwrap(); + let acorn1_build_ms = t1.elapsed().as_secs_f64() * 1000.0; + + let t2 = Instant::now(); + let acorng = AcornIndexGamma::build(data.clone()).unwrap(); + let acorng_build_ms = t2.elapsed().as_secs_f64() * 1000.0; + + println!("\nBuild times:"); + println!(" FlatFiltered: {flat_build_ms:.1} ms"); + println!(" ACORN-1: {acorn1_build_ms:.1} ms"); + println!(" ACORN-γ (γ=2): {acorng_build_ms:.1} ms"); + + // --- Benchmark at three selectivity levels --- + let selectivities: &[(f64, &str)] = &[(0.50, "50%"), (0.10, "10%"), (0.01, "1%")]; + + print_header(); + + for &(sel, sel_label) in selectivities { + let pred = selectivity_predicate(N, sel); + + // Count valid nodes. + let n_valid = (0..N as u32).filter(|&id| pred(id)).count(); + if n_valid == 0 { + println!(" [skip {sel_label}: no valid nodes]"); + continue; + } + + run_variant( + flat.name(), + &flat, + &data, + &queries, + flat_build_ms, + sel, + &pred, + ); + run_variant( + acorn1.name(), + &acorn1, + &data, + &queries, + acorn1_build_ms, + sel, + &pred, + ); + run_variant( + acorng.name(), + &acorng, + &data, + &queries, + acorng_build_ms, + sel, + &pred, + ); + println!(); + } + + // --- Recall vs selectivity sweep for ACORN-γ --- + println!("\nRecall@10 sweep across selectivities (ACORN-γ vs FlatFiltered):"); + println!( + "{:>8} {:>16} {:>16}", + "Sel%", "FlatFiltered R@10", "ACORN-γ R@10" + ); + println!("{}", "-".repeat(44)); + for sel_frac in [0.50, 0.20, 0.10, 0.05, 0.02, 0.01] { + let pred = selectivity_predicate(N, sel_frac); + let r_flat = recall_at_k(&data, &queries, K, pred, &flat); + let r_acorn = recall_at_k(&data, &queries, K, pred, &acorng); + println!( + "{:>7.0}% {:>16.1}% {:>16.1}%", + sel_frac * 100.0, + r_flat * 100.0, + r_acorn * 100.0 + ); + } + + // --- Edge count statistics --- + println!("\nGraph edge statistics:"); + let acorn1_edges: usize = { + // Access via memory estimate: edges × 4 bytes of the edge list portion. + // We re-derive from memory_bytes which includes both vectors and edges. + // Approximation: edges ≈ (memory_bytes - raw_vecs) / 4 + let raw_vecs = N * DIM * 4; + (acorn1.memory_bytes().saturating_sub(raw_vecs)) / 4 + }; + let acorng_edges: usize = { + let raw_vecs = N * DIM * 4; + (acorng.memory_bytes().saturating_sub(raw_vecs)) / 4 + }; + println!(" ACORN-1 total edges: ~{acorn1_edges}"); + println!(" ACORN-γ total edges: ~{acorng_edges}"); + println!( + " Edge ratio γ/1: {:.2}×", + acorng_edges as f64 / acorn1_edges.max(1) as f64 + ); + + println!("\nDone."); +} diff --git a/crates/ruvector-acorn/src/search.rs b/crates/ruvector-acorn/src/search.rs new file mode 100644 index 000000000..98e2ee61a --- /dev/null +++ b/crates/ruvector-acorn/src/search.rs @@ -0,0 +1,212 @@ +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +use crate::dist::l2_sq; +use crate::graph::{AcornGraph, OrdF32}; + +/// ACORN beam search — the core innovation over standard HNSW + post-filter. +/// +/// Standard post-filter HNSW skips predicate-failing nodes during traversal, +/// starving the beam of candidates when predicate selectivity is low (e.g. 1%). +/// +/// ACORN's fix: expand ALL neighbors regardless of predicate outcome. +/// A node that fails the predicate is NOT added to `results`, but its neighbors +/// ARE added to `candidates`. The denser graph (built with γ·M edges) ensures +/// enough valid nodes are reachable even through chains of failing nodes. +/// +/// # Parameters +/// - `ef` — beam width. Bounds the size of `candidates` (search frontier) and +/// `results` (top-k passing predicate). Higher = better recall, lower = faster. +/// Typical: 64–200. +/// +/// # Implementation notes +/// - `visited` uses `Vec` (size n) instead of `HashSet`: O(1) lookup +/// without hashing or allocator pressure on the hot path. +/// - `candidates` and `results` are jointly bounded by `ef`: when +/// `len(candidates) >= ef` we only admit neighbors that improve on the +/// farthest in-flight candidate, evicting it. This is the bounded-beam +/// invariant the previous implementation accidentally violated by always +/// pushing without eviction. +pub fn acorn_search( + graph: &AcornGraph, + query: &[f32], + k: usize, + ef: usize, + predicate: impl Fn(u32) -> bool, +) -> Vec<(u32, f32)> { + if graph.is_empty() { + return vec![]; + } + let n = graph.len(); + let ef = ef.max(k); + + // Multi-probe entry: sample evenly-spaced nodes to find a good starting + // point. O(probes × D) overhead vs O(n × D) for flat — negligible. + let n_probes = (n as f64).sqrt().ceil() as usize; + let n_probes = n_probes.clamp(4, 64); + let entry = (0..n_probes) + .map(|i| (i * n / n_probes) as u32) + .min_by(|&a, &b| { + l2_sq(query, graph.row(a as usize)).total_cmp(&l2_sq(query, graph.row(b as usize))) + }) + .unwrap_or(0); + + let mut visited: Vec = vec![false; n]; + // Min-heap by distance — pop closest unexplored candidate first. + let mut candidates: BinaryHeap> = BinaryHeap::with_capacity(ef + 1); + // Max-heap by distance — peek = farthest accepted result so far. + let mut results: BinaryHeap<(OrdF32, u32)> = BinaryHeap::with_capacity(k + 1); + // Max-heap mirror of `candidates` distances — peek = farthest pending + // candidate, used to gate eviction when the frontier exceeds ef. + let mut farthest_in_beam: BinaryHeap = BinaryHeap::with_capacity(ef + 1); + + let d0 = l2_sq(query, graph.row(entry as usize)); + candidates.push(Reverse((OrdF32(d0), entry))); + farthest_in_beam.push(OrdF32(d0)); + visited[entry as usize] = true; + + while let Some(Reverse((OrdF32(curr_d), curr))) = candidates.pop() { + // Pop curr's mirror entry from the farthest-tracker. Since the two + // heaps may diverge in eviction order, we lazily filter stale entries + // when peeking below. + // Prune: if current distance already worse than our k-th result → stop. + if results.len() >= k { + if let Some(&(OrdF32(worst), _)) = results.peek() { + if curr_d > worst { + break; + } + } + } + + // ACORN key: always process neighbors regardless of predicate. + if predicate(curr) { + results.push((OrdF32(curr_d), curr)); + if results.len() > k { + results.pop(); // evict worst + } + } + + for &neighbor in &graph.neighbors[curr as usize] { + let ni = neighbor as usize; + if visited[ni] { + continue; + } + visited[ni] = true; + let nd = l2_sq(query, graph.row(ni)); + + // Bounded beam: only admit if there's room or the new candidate + // is closer than the worst pending one. + if candidates.len() < ef { + candidates.push(Reverse((OrdF32(nd), neighbor))); + farthest_in_beam.push(OrdF32(nd)); + } else if let Some(&OrdF32(worst_pending)) = farthest_in_beam.peek() { + if nd < worst_pending { + farthest_in_beam.pop(); + farthest_in_beam.push(OrdF32(nd)); + candidates.push(Reverse((OrdF32(nd), neighbor))); + // The old worst-pending is now logically evicted; the + // stale entry in `candidates` is small enough to ignore + // (bounded by ef) and the prune-on-distance check above + // will reject it before we waste neighbor expansions. + } + } + } + } + + let mut out: Vec<(u32, f32)> = results.into_iter().map(|(OrdF32(d), id)| (id, d)).collect(); + out.sort_by(|a, b| a.1.total_cmp(&b.1)); + out +} + +/// Post-filter brute-force scan — the baseline that ACORN improves on. +/// +/// Scans ALL vectors in order, applies the predicate, and collects the k +/// nearest that pass. O(n × D) per query with no graph overhead. At high +/// selectivity this is competitive; at low selectivity it wastes time scoring +/// vectors that will be filtered out after sorting. +pub fn flat_filtered_search( + data: &[Vec], + query: &[f32], + k: usize, + predicate: impl Fn(u32) -> bool, +) -> Vec<(u32, f32)> { + let mut heap: BinaryHeap<(OrdF32, u32)> = BinaryHeap::with_capacity(k + 1); + + for (i, v) in data.iter().enumerate() { + if !predicate(i as u32) { + continue; + } + let d = l2_sq(v, query); + if heap.len() < k { + heap.push((OrdF32(d), i as u32)); + } else if let Some(&(OrdF32(worst), _)) = heap.peek() { + if d < worst { + heap.pop(); + heap.push((OrdF32(d), i as u32)); + } + } + } + + let mut out: Vec<(u32, f32)> = heap.into_iter().map(|(OrdF32(d), id)| (id, d)).collect(); + out.sort_by(|a, b| a.1.total_cmp(&b.1)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::AcornGraph; + + fn unit_data(n: usize) -> Vec> { + (0..n).map(|i| vec![i as f32, 0.0]).collect() + } + + #[test] + fn flat_search_correctness() { + let data = unit_data(10); + let query = vec![4.5_f32, 0.0]; + // All nodes pass predicate. + let res = flat_filtered_search(&data, &query, 3, |_| true); + assert_eq!(res.len(), 3); + // Nearest to 4.5 on the line: node 4 (d=0.25), node 5 (d=0.25), then 3 or 6. + let ids: Vec = res.iter().map(|r| r.0).collect(); + assert!(ids.contains(&4) || ids.contains(&5)); + } + + #[test] + fn flat_search_with_predicate() { + let data = unit_data(10); + let query = vec![0.0_f32, 0.0]; + // Only even nodes pass. + let res = flat_filtered_search(&data, &query, 3, |id| id % 2 == 0); + let ids: Vec = res.iter().map(|r| r.0).collect(); + for id in &ids { + assert_eq!(id % 2, 0, "odd node {id} should not appear"); + } + assert_eq!(ids[0], 0); // node 0 is at distance 0 + } + + #[test] + fn acorn_search_all_pass() { + let data = unit_data(20); + let graph = AcornGraph::build(data, 8).unwrap(); + let query = vec![10.0_f32, 0.0]; + let res = acorn_search(&graph, &query, 5, 50, |_| true); + assert_eq!(res.len(), 5); + // Results should be sorted nearest-first. + for w in res.windows(2) { + assert!(w[0].1 <= w[1].1 + 1e-5); + } + } + + #[test] + fn acorn_search_half_predicate() { + let data = unit_data(30); + let graph = AcornGraph::build(data, 8).unwrap(); + let query = vec![15.0_f32, 0.0]; + let res = acorn_search(&graph, &query, 5, 80, |id| id % 2 == 0); + for (id, _) in &res { + assert_eq!(id % 2, 0, "odd node should not appear"); + } + } +} diff --git a/crates/ruvector-rabitq-wasm/Cargo.toml b/crates/ruvector-rabitq-wasm/Cargo.toml new file mode 100644 index 000000000..c6ef8dcc8 --- /dev/null +++ b/crates/ruvector-rabitq-wasm/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "ruvector-rabitq-wasm" +version = "0.1.0" +edition = "2021" +description = "WASM bindings for ruvector-rabitq — 1-bit quantized vector index for browsers and edge runtimes" +license = "MIT OR Apache-2.0" +repository = "https://github.com/ruvnet/ruvector" +keywords = ["rabitq", "vector-search", "wasm", "quantization", "embeddings"] +categories = ["wasm", "science", "algorithms"] + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +ruvector-rabitq = { path = "../ruvector-rabitq" } +wasm-bindgen = "0.2" +js-sys = "0.3" +console_error_panic_hook = { version = "0.1", optional = true } +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "s" +lto = true + +# Workspace cleanup pass: research-tier crate, doc/style churn deferred. +# Correctness + suspicious lints stay denied. +[lints.rust] +unexpected_cfgs = { level = "allow", priority = -1 } + +[lints.clippy] +pedantic = { level = "allow", priority = -2 } +all = { level = "warn", priority = -1 } +correctness = "deny" +suspicious = "deny" diff --git a/crates/ruvector-rabitq-wasm/build.sh b/crates/ruvector-rabitq-wasm/build.sh new file mode 100755 index 000000000..1f407f5df --- /dev/null +++ b/crates/ruvector-rabitq-wasm/build.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +# Clear any host-only linker flags (the workspace dev shell may export +# `-fuse-ld=mold` for fast native builds; rust-lld for wasm32 rejects +# that flag). +unset RUSTFLAGS + +echo "Building RuVector RaBitQ WASM..." + +# Build for web (default — emits at root of npm/packages/rabitq-wasm) +echo "Building for web target..." +wasm-pack build --target web --out-dir ../../npm/packages/rabitq-wasm + +# Build for Node.js +echo "Building for Node.js target..." +wasm-pack build --target nodejs --out-dir ../../npm/packages/rabitq-wasm/node + +# Build for bundlers (webpack, rollup, vite) +echo "Building for bundler target..." +wasm-pack build --target bundler --out-dir ../../npm/packages/rabitq-wasm/bundler + +echo "Build complete!" +echo "Web: npm/packages/rabitq-wasm/" +echo "Node.js: npm/packages/rabitq-wasm/node/" +echo "Bundler: npm/packages/rabitq-wasm/bundler/" + +# wasm-pack regenerates `package.json` from `Cargo.toml` metadata, but we +# need the scoped name `@ruvector/rabitq-wasm` and a richer description / +# keyword set. The canonical package.json + README live alongside the +# generated artifacts and are kept under git; restore them after the build +# so subsequent `wasm-pack build` runs don't clobber them. +if [ -f ../../npm/packages/rabitq-wasm/package.scoped.json ]; then + cp ../../npm/packages/rabitq-wasm/package.scoped.json \ + ../../npm/packages/rabitq-wasm/package.json + echo "(restored scoped package.json from package.scoped.json)" +fi diff --git a/crates/ruvector-rabitq-wasm/src/lib.rs b/crates/ruvector-rabitq-wasm/src/lib.rs new file mode 100644 index 000000000..2bd8853d2 --- /dev/null +++ b/crates/ruvector-rabitq-wasm/src/lib.rs @@ -0,0 +1,188 @@ +//! WASM bindings for ruvector-rabitq. +//! +//! Exposes [`RabitqIndex`] as a JavaScript-friendly class for use in +//! browsers and edge runtimes (Cloudflare Workers, Deno, Bun). +//! Single-threaded — the underlying `from_vectors_parallel` falls back +//! to sequential iteration on wasm32 (output is bit-identical because +//! rotation is deterministic). +//! +//! ```ignore +//! import init, { RabitqIndex } from "ruvector-rabitq"; +//! await init(); +//! +//! const dim = 768; +//! const n = 10_000; +//! const vectors = new Float32Array(n * dim); // populate +//! const idx = RabitqIndex.build(vectors, dim, 42, 20); +//! const query = new Float32Array(dim); // populate +//! const results = idx.search(query, 10); // [{id, distance}, ...] +//! ``` + +#![allow(clippy::new_without_default)] + +use ruvector_rabitq::{AnnIndex, RabitqPlusIndex}; +use wasm_bindgen::prelude::*; + +/// Initialize panic hook for clearer error messages in the browser +/// console. Called once at module import. +#[wasm_bindgen(start)] +pub fn init() { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} + +/// Search result — single nearest-neighbor hit. +/// +/// Mirrors the structure used by the Python SDK's `RabitqIndex.search` +/// so callers porting code between languages get identical shapes. +#[wasm_bindgen] +#[derive(Clone, Copy, Debug)] +pub struct SearchResult { + /// Caller-supplied vector id (the position passed to `build`). + #[wasm_bindgen(readonly)] + pub id: u32, + /// Approximate L2² distance after RaBitQ rerank. + #[wasm_bindgen(readonly)] + pub distance: f32, +} + +/// 1-bit quantized vector index. Builds in O(n × dim) memory + O(n × dim) +/// time; searches in O(n) hamming distance + O(rerank_factor × k × dim) +/// exact-L2² rerank. +#[wasm_bindgen] +pub struct RabitqIndex { + inner: RabitqPlusIndex, +} + +#[wasm_bindgen] +impl RabitqIndex { + /// Build an index from a flat Float32Array of length `n * dim`. + /// + /// `seed` controls the random rotation matrix; the same `(seed, + /// dim, vectors)` triple produces bit-identical codes (ADR-154 + /// determinism guarantee). `rerank_factor` is the multiplier on + /// `k` for the exact-L2² rerank pool — typical 20. + /// + /// Errors: + /// - `vectors.length` is not a multiple of `dim` + /// - `dim == 0` or `vectors.length == 0` + #[wasm_bindgen] + pub fn build( + vectors: &[f32], + dim: u32, + seed: u64, + rerank_factor: u32, + ) -> Result { + let dim = dim as usize; + if dim == 0 { + return Err(JsValue::from_str("dim must be > 0")); + } + if vectors.is_empty() { + return Err(JsValue::from_str("vectors must not be empty")); + } + if !vectors.len().is_multiple_of(dim) { + return Err(JsValue::from_str(&format!( + "vectors length {} is not a multiple of dim {}", + vectors.len(), + dim + ))); + } + + let n = vectors.len() / dim; + let items: Vec<(usize, Vec)> = (0..n) + .map(|i| (i, vectors[i * dim..(i + 1) * dim].to_vec())) + .collect(); + + let inner = + RabitqPlusIndex::from_vectors_parallel(dim, seed, rerank_factor as usize, items) + .map_err(|e| JsValue::from_str(&format!("RabitqIndex.build: {e}")))?; + + Ok(Self { inner }) + } + + /// Find the `k` nearest neighbors of `query`. Returns hits in + /// ascending distance. + /// + /// Errors: + /// - `query.length != dim` of the index + /// - `k == 0` + #[wasm_bindgen] + pub fn search(&self, query: &[f32], k: u32) -> Result, JsValue> { + if k == 0 { + return Err(JsValue::from_str("k must be > 0")); + } + let hits = self + .inner + .search(query, k as usize) + .map_err(|e| JsValue::from_str(&format!("RabitqIndex.search: {e}")))?; + + Ok(hits + .into_iter() + .map(|h| SearchResult { + id: h.id as u32, + distance: h.score, + }) + .collect()) + } + + /// Number of vectors indexed. + #[wasm_bindgen(getter)] + pub fn len(&self) -> u32 { + self.inner.len() as u32 + } + + /// True iff the index has zero vectors. Mirrors Rust's `is_empty` + /// convention; exposed because `wasm-bindgen` getter for `len` + /// returns u32, so callers can't `idx.len === 0` reliably. + #[wasm_bindgen(getter, js_name = isEmpty)] + pub fn is_empty(&self) -> bool { + self.inner.len() == 0 + } +} + +/// Crate version string baked at build time. +#[wasm_bindgen(js_name = version)] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +// Tests for the WASM bindings live as `wasm_bindgen_test` and only run +// in a wasm32 environment via `wasm-pack test`. Native tests can't +// exercise the bindings because `wasm-bindgen 0.2.117` panics on +// `JsValue::from_str` outside a wasm runtime. +// +// The inner numerical correctness is covered by `ruvector-rabitq`'s +// own test suite; here we only verify the JS-facing surface. +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use super::*; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn build_and_search() { + let dim = 32usize; + let n = 100usize; + let mut vectors = vec![0.0f32; n * dim]; + for i in 0..n { + for j in 0..dim { + vectors[i * dim + j] = (i * 31 + j) as f32 / 100.0; + } + } + let idx = RabitqIndex::build(&vectors, dim as u32, 42, 20).expect("build"); + assert_eq!(idx.len(), n as u32); + assert!(!idx.is_empty()); + + let query: Vec = vectors[..dim].to_vec(); + let hits = idx.search(&query, 5).expect("search"); + assert_eq!(hits.len(), 5); + assert_eq!(hits[0].id, 0); + assert!(hits[0].distance < 1e-3); + } + + #[wasm_bindgen_test] + fn version_is_nonempty() { + assert!(!version().is_empty()); + } +} diff --git a/crates/ruvector-rabitq/Cargo.toml b/crates/ruvector-rabitq/Cargo.toml index d20793303..2463c26d6 100644 --- a/crates/ruvector-rabitq/Cargo.toml +++ b/crates/ruvector-rabitq/Cargo.toml @@ -19,10 +19,15 @@ harness = false [dependencies] rand = { workspace = true } rand_distr = { workspace = true } -rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +# rayon is native-only — wasm32 falls back to sequential iteration +# in `from_vectors_parallel_with_rotation`. Output is bit-identical +# because rotation is deterministic. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] criterion = { workspace = true } diff --git a/crates/ruvector-rabitq/src/index.rs b/crates/ruvector-rabitq/src/index.rs index 5427787ec..1a559f567 100644 --- a/crates/ruvector-rabitq/src/index.rs +++ b/crates/ruvector-rabitq/src/index.rs @@ -665,7 +665,6 @@ impl RabitqPlusIndex { kind: RandomRotationKind, items: Vec<(usize, Vec)>, ) -> Result { - use rayon::prelude::*; let mut out = Self::new_with_rotation(dim, seed, rerank_factor, kind); for (_, v) in &items { if v.len() != dim { @@ -675,11 +674,26 @@ impl RabitqPlusIndex { }); } } - // Phase 1: rotate + bit-pack every vector in parallel. The - // rotation matrix is read-only so this is a pure data race - // against nothing. + // Phase 1: rotate + bit-pack every vector. On native we use rayon + // parallel iteration (rotation matrix is read-only — no race). On + // wasm32 (single-threaded) we fall back to sequential — output is + // bit-identical because the rotation is deterministic, parallel + // ordering doesn't affect bytes. + #[cfg(not(target_arch = "wasm32"))] + let encoded: Vec<(usize, Vec, f32, Vec)> = { + use rayon::prelude::*; + items + .into_par_iter() + .map(|(id, v)| { + let (packed, _) = out.inner.encode_query_packed(&v); + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + (id, packed, norm, v) + }) + .collect() + }; + #[cfg(target_arch = "wasm32")] let encoded: Vec<(usize, Vec, f32, Vec)> = items - .into_par_iter() + .into_iter() .map(|(id, v)| { let (packed, _) = out.inner.encode_query_packed(&v); let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); diff --git a/docs/adr/ADR-161-rabitq-wasm-npm-package.md b/docs/adr/ADR-161-rabitq-wasm-npm-package.md new file mode 100644 index 000000000..7a2bcfd24 --- /dev/null +++ b/docs/adr/ADR-161-rabitq-wasm-npm-package.md @@ -0,0 +1,106 @@ +# ADR-161: Publish `ruvector-rabitq-wasm` as `@ruvector/rabitq-wasm` on npm + +**Status**: Proposed +**Date**: 2026-04-26 +**Driver**: User-flagged gap — the `ruvector-rabitq-wasm` Rust crate +shipped in commit `a674d6eba` but has no `package.json`, README, or +npm publication. The rotation-based 1-bit RaBitQ index (ADR-154) is +the most browser-relevant of the ruvector backends because it shrinks +embeddings 32× — exactly what edge / WebGPU / Cloudflare-Worker +deployments need. Letting the WASM bindings sit dark wastes the work. + +## Context + +ruvector already publishes one WASM package — `@ruvector/graph-wasm` +(v2.0.3, ~50 K monthly downloads) — built from +`crates/ruvector-graph-wasm/build.sh` via three `wasm-pack` targets +(`web`, `nodejs`, `bundler`) emitting into `npm/packages/graph-wasm/`. +The package is wired into npm via: + +- `package.json` with `name = "@ruvector/graph-wasm"`, + `publishConfig.access = "public"`, `files` listing the `.wasm` and + `.js`/`.d.ts` artifacts that wasm-pack emits, and a homepage / + repository pointer back into the Rust crate. +- `index.js` and `index.d.ts` shims that re-export the wasm-pack + output. +- `README.md` describing usage in browser / Node / bundler contexts. + +`ruvector-rabitq-wasm` already exposes the public surface (commit +`a674d6eba`): + +- `RabitqIndex.build(vectors: Float32Array, dim: u32, seed: u64, + rerank_factor: u32) -> RabitqIndex` +- `RabitqIndex.search(query: Float32Array, k: u32) -> SearchResult[]` +- `SearchResult { id: u32, distance: f32 }` +- `version()` for build-time crate version. +- `wasm-bindgen-test` suite under `#[cfg(target_arch = "wasm32")]`. + +The native build is bit-identical to the wasm32 build because RaBitQ +rotation is deterministic by construction (`(seed, dim, vectors)` → +fixed codes — ADR-154 invariant). + +## Decision + +Mirror the `graph-wasm` packaging pattern for `rabitq-wasm`: + +1. Add `crates/ruvector-rabitq-wasm/build.sh` — the standard 3-target + `wasm-pack build` script that emits into + `npm/packages/rabitq-wasm/{,node/,bundler/}`. +2. Add `npm/packages/rabitq-wasm/package.json`: + - `name`: `@ruvector/rabitq-wasm` + - `version`: `0.1.0` (matches Cargo) + - `description`: 1-bit quantized vector index (RaBitQ) for browsers and edge runtimes + - `keywords`: rabitq, vector-search, quantization, hnsw, ann, embeddings, wasm, webassembly, rust + - `files`: just the wasm-pack-generated artifacts + - `publishConfig.access = "public"` +3. Add `npm/packages/rabitq-wasm/README.md` — minimal install + usage + example matching the doctest at the top of `lib.rs`. +4. Add a `Cargo.toml` `[lib] crate-type = ["cdylib", "rlib"]` if not + already present (it is — verified before this ADR). +5. CI: leave the existing `check-wasm-dedup` job in place; do not add + a wasm-pack-build CI job initially because wasm-pack downloads + tooling at job start and we want to keep PR #391 / #393 unblocked. + A follow-up ADR can wire it into `.github/workflows/ci.yml`. +6. Publish manually for now: `wasm-pack publish` after a clean `npm + pack` review. Future ADR can switch to a release-please workflow. + +## Versioning + +The Cargo crate is at `0.1.0`. The npm package starts at `0.1.0` and +tracks Cargo. Because RaBitQ codes are stable across architectures +(rotation determinism), there is no separate semver story for the +WASM build versus the Rust build — same `0.1.0` ships everywhere. + +## Alternatives considered + +- **Don't publish; keep the crate internal.** Leaves a working WASM + artifact unused. RaBitQ's primary value proposition (32× memory + reduction for embedding indices) is most relevant at the edge — + exactly the deployment target that needs npm distribution. +- **Publish under `ruvector-rabitq` (no scope).** The graph-wasm + precedent uses `@ruvector/*`; mixing scoped and unscoped names is + noise. +- **Bundle into `@ruvector/core`.** The NAPI-RS `core` package is + Node-only (loads `.node` native binaries). WASM is a different + delivery mechanism and a different audience — keeping them in + separate npm packages lets browser and Worker users avoid the + Node-only bits. + +## Consequences + +- Edge / browser users can `npm install @ruvector/rabitq-wasm` and + get a 1-bit index without dragging in any of the workspace's + Node-only crates. +- One more npm publish surface to maintain. Mitigated by reusing the + exact directory layout / build.sh pattern from graph-wasm so + release tooling treats them uniformly. +- The crate's existing `wasm_bindgen_test` suite remains the primary + correctness gate for the JS surface; numerical correctness is + covered by the parent `ruvector-rabitq` test suite. + +## See also + +- ADR-154 — RaBitQ rotation-based 1-bit quantization +- ADR-162 — `ruvector-acorn-wasm` packaging (sibling ADR) +- `crates/ruvector-graph-wasm/build.sh` — the script we mirror +- `npm/packages/graph-wasm/` — the npm structure we mirror diff --git a/docs/adr/ADR-162-acorn-wasm-npm-package.md b/docs/adr/ADR-162-acorn-wasm-npm-package.md new file mode 100644 index 000000000..0277d703c --- /dev/null +++ b/docs/adr/ADR-162-acorn-wasm-npm-package.md @@ -0,0 +1,130 @@ +# ADR-162: Add `ruvector-acorn-wasm` crate and publish as `@ruvector/acorn-wasm` on npm + +**Status**: Proposed +**Date**: 2026-04-26 +**Driver**: ADR-160 ships a pure-Rust ACORN filtered HNSW with 96% +recall@10 at 1% selectivity. Filtered vector search is the dominant +production access pattern (RAG with metadata filters, ACL-gated +retrieval, e-commerce attribute filters), and the most useful place +for it is *closer to the user*: at the edge, in the browser, or in a +worker. Today the crate is workspace-internal and the only Rust-to-JS +delivery for the workspace is `@ruvector/graph-wasm`. Add a sibling +WASM crate + npm package so browser/edge users can consume ACORN +without a server. + +## Context + +ADR-160 introduces `crates/ruvector-acorn` with a `FilteredIndex` +trait and three variants: `FlatFilteredIndex`, `AcornIndex1` (γ=1, +M=16), `AcornIndexGamma` (γ=2, M=32). The optimization round (PR +#391, commit `eb88176`) added: + +- **Bounded-beam fix** in `acorn_search` (correctness) +- **Parallel build** with rayon (≈80× faster index construction) +- **Flat row-major data layout** (cache locality + SIMD) +- **`Vec` visited** (no hashing on the hot path) +- **Hand-unrolled L2²** (3-5× faster distance kernel for D ≥ 64) + +The crate has 12/12 unit tests passing and a `cargo run --release` +benchmark binary that produces a recall/QPS table. + +ADR-161 covers the sibling `ruvector-rabitq-wasm` packaging. This ADR +is the parallel decision for the *missing* acorn WASM crate — the +Rust crate exists but has no `wasm-bindgen` wrapper and no npm +package. + +## Decision + +1. **Add `crates/ruvector-acorn-wasm`** — new workspace member. Mirrors + the layout of `crates/ruvector-rabitq-wasm`: + - `Cargo.toml` with `crate-type = ["cdylib", "rlib"]`, `wasm-bindgen`, + `js-sys`, `serde-wasm-bindgen`, `console_error_panic_hook` + (default-feature), `getrandom` with `js` feature behind a + `cfg(target_arch = "wasm32")` block. Depends on + `ruvector-acorn` from the workspace. + - `src/lib.rs` exposing: + - `AcornIndex` (default = γ=2, M=32 — best recall) with + `build(vectors: &[f32], dim: u32, gamma: u32) -> AcornIndex`. + - `search(query: &[f32], k: u32, predicate: &js_sys::Function) -> SearchResult[]`. + The predicate is a JS callback `(id: number) => boolean` so + browser callers can plug in arbitrary filter logic without + crossing the FFI boundary on every vector. + - `SearchResult { id: u32, distance: f32 }` mirroring the RaBitQ + binding for shape-symmetric SDKs. + - `version()` for the build-time crate version. + - `wasm-bindgen-test` smoke test under `#[cfg(target_arch = + "wasm32")]` (the same gate the rabitq-wasm crate uses to dodge + wasm-bindgen 0.2.117's native-context panics). + +2. **Add `npm/packages/acorn-wasm/`** — three-target wasm-pack output + (`web`, `nodejs`, `bundler`) plus: + - `package.json` named `@ruvector/acorn-wasm`, version `0.1.0`, + `publishConfig.access = "public"`, identical structure to + `npm/packages/graph-wasm/package.json`. + - `README.md` with install + minimal usage example. + +3. **Add `crates/ruvector-acorn-wasm/build.sh`** — the standard 3-target + `wasm-pack build` script that emits into `npm/packages/acorn-wasm/`. + +4. **Don't add a CI wasm-pack job yet** — same reasoning as ADR-161. + `check-wasm-dedup` keeps the build honest; a follow-up ADR can + wire the publish step into release-please. + +5. **Default the JS class to ACORN-γ.** The trait + three variants in + the Rust crate are useful for benchmarking; for npm consumers, + ship the variant with the best recall/cost trade-off. ACORN-γ at + γ=2 doubles edges (≈3 MB for n=5K, D=128) but maintains 96% + recall@10 at 1% selectivity. We expose `gamma: u32` as an explicit + parameter so callers can pick γ=1 if they need a smaller graph. + +## Predicate boundary + +The Rust crate accepts `&dyn Fn(u32) -> bool`. In WASM we expose the +predicate as a `js_sys::Function` so the JavaScript runtime evaluates +each filter test. This crosses the FFI boundary once per node visited +during search (≤ ef nodes ≈ 150 default), not once per vector — the +overhead is bounded and predictable. The alternative (compiling +predicates as a closure in WASM via macros) is significantly more +complex and offers no real perf win at the scales where browser-side +ACORN makes sense. + +## Versioning + +The Rust crate starts at `0.1.0` to match its sibling. +`@ruvector/acorn-wasm@0.1.0` ships in lockstep. ACORN itself is +deterministic given a fixed graph build seed (the greedy NN-descent +isn't seeded today — listed as roadmap), so wasm32 and native +produce identical search output for an identical input set. + +## Alternatives considered + +- **Bundle ACORN into `@ruvector/graph-wasm`.** That package targets + Cypher-style graph DB use, not ANN search. Combining doubles the + WASM bundle size and confuses keyword discovery (graph DB users + searching for it now have to wade through filter-search content). +- **Don't ship; let users compile their own.** Only realistic for + Rust users. Browser/Worker consumers would have to set up + wasm-pack + a build pipeline themselves, which is a deal-breaker + for "I just want to add filtered search to my page" scenarios. +- **Predicate as a Rust closure encoded as an opcode tape.** Would + let us avoid the JS-call-per-node FFI hop, but adds a mini-DSL + surface. Not worth the complexity at filter-cost ≪ distance-cost. + +## Consequences + +- A second WASM npm package the project maintains. Mitigated by + using the same directory layout / build.sh pattern as graph-wasm + and rabitq-wasm so release tooling sees them all uniformly. +- The Rust trait surface stays the same; the WASM crate is a + thin façade. Future Rust-side optimizations (parallel queries, + simsimd kernel, NN-descent build) flow to the WASM build for free. +- Browser and edge-runtime users can `npm install + @ruvector/acorn-wasm` and get filtered ANN search with no server. + +## See also + +- ADR-160 — ACORN predicate-agnostic filtered HNSW +- ADR-161 — `ruvector-rabitq-wasm` npm packaging (sibling ADR) +- `crates/ruvector-rabitq-wasm/src/lib.rs` — the sibling crate we + mirror +- `npm/packages/graph-wasm/` — the npm structure pattern diff --git a/npm/packages/acorn-wasm/.gitignore b/npm/packages/acorn-wasm/.gitignore new file mode 100644 index 000000000..4f5899d2e --- /dev/null +++ b/npm/packages/acorn-wasm/.gitignore @@ -0,0 +1,14 @@ +# wasm-pack output is built on demand by `crates/ruvector-acorn-wasm/build.sh` +# and published from this directory. Don't commit generated artifacts. +ruvector_acorn_wasm_bg.wasm +ruvector_acorn_wasm_bg.wasm.d.ts +ruvector_acorn_wasm.js +ruvector_acorn_wasm.d.ts +node/ +bundler/ + +# `package.json` is regenerated by wasm-pack on every build, so we keep +# the canonical scoped version in `package.scoped.json` (committed) and +# ignore `package.json` here. `build.sh` copies scoped → package.json +# at the end of every build. +package.json diff --git a/npm/packages/acorn-wasm/README.md b/npm/packages/acorn-wasm/README.md new file mode 100644 index 000000000..c46da6d15 --- /dev/null +++ b/npm/packages/acorn-wasm/README.md @@ -0,0 +1,148 @@ +# @ruvector/acorn-wasm + +**ACORN predicate-agnostic filtered HNSW in WebAssembly.** High-recall vector search with arbitrary metadata filters, in the browser or at the edge. + +[![npm](https://img.shields.io/npm/v/@ruvector/acorn-wasm.svg)](https://www.npmjs.com/package/@ruvector/acorn-wasm) +[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](https://github.com/ruvnet/RuVector#license) + +## What is ACORN? + +ACORN ([Patel et al., SIGMOD 2024, arXiv:2403.04871](https://arxiv.org/abs/2403.04871)) solves filtered HNSW's **recall-collapse problem**. Standard post-filter HNSW retrieves k candidates and discards the ones that fail your predicate — but at low selectivity (e.g. 1 % of vectors match) you'd need to retrieve thousands of candidates to expect 10 valid hits, and recall drops to near-zero. ACORN fixes this structurally with two changes: + +1. **γ-augmented graph construction** — `γ × M` edges per node instead of `M`. The denser graph stays navigable even when the predicate prunes most nodes. +2. **Predicate-agnostic traversal** — expand all neighbors regardless of predicate. A failing node doesn't enter the result set, but its neighbors enter the candidate frontier. The beam never starves. + +Net effect: **96 % recall@10 at 1 % selectivity** where post-filter HNSW collapses to near-zero. + +## Install + +```bash +npm install @ruvector/acorn-wasm +``` + +## Usage (browser) + +```js +import init, { AcornIndex } from "@ruvector/acorn-wasm"; + +await init(); + +const dim = 128; +const n = 5_000; +const vectors = new Float32Array(n * dim); +// ... populate `vectors` with embeddings (n × dim, row-major) ... + +// gamma=2 → ACORN-γ (best recall at low selectivity) +// gamma=1 → ACORN-1 (smaller index, fine for moderate selectivity) +const idx = AcornIndex.build(vectors, dim, 2); + +const query = new Float32Array(dim); +// ... fill query ... + +// Predicate is any JS function (id: number) => boolean +const inStock = (id) => products[id].stockCount > 0; +const results = idx.search(query, 10, inStock); +// → [{ id, distance }, ...] +``` + +## Usage (Node.js / Bun) + +```js +import { AcornIndex } from "@ruvector/acorn-wasm/node/ruvector_acorn_wasm.js"; +// no `init()` for the node target + +const idx = AcornIndex.build(vectors, 128, 2); +const results = idx.search(query, 10, (id) => metadata[id].published); +``` + +## Usage (bundlers — Vite, Webpack, Rollup) + +```js +import { AcornIndex } from "@ruvector/acorn-wasm/bundler/ruvector_acorn_wasm.js"; +// the bundler handles the .wasm import transparently +``` + +## API + +### `class AcornIndex` + +#### `AcornIndex.build(vectors, dim, gamma)` + +Build an index from a flat `Float32Array` of length `n * dim`. + +| Parameter | Type | Description | +|---|---|---| +| `vectors` | `Float32Array` | Row-major matrix of `n` vectors, each of length `dim`. | +| `dim` | `number` | Vector dimensionality. | +| `gamma` | `number` | Edge multiplier. `1` → ACORN-1 (M=16). `2` → ACORN-γ (M·γ=32, recommended for low selectivity). | + +Throws if `dim == 0`, `vectors` is empty, `vectors.length` is not a multiple of `dim`, or `gamma == 0`. + +#### `idx.search(query, k, predicate)` + +Find the `k` nearest neighbors of `query` whose `id` satisfies `predicate`. Returns an array of `SearchResult` ordered ascending by distance. + +`predicate` is invoked as `predicate(id: number) => boolean` for each node visited during search (≤ ef nodes, ~150 default — bounded). Use it for any metadata filter: equality, range, geo, ACL, composite — there is no schema coupling. + +#### `idx.dim` (getter, number) + +Vector dimensionality of the index. + +#### `idx.memoryBytes` (getter, number) + +Approximate heap size — graph edges + raw vectors, in bytes. + +#### `idx.name` (getter, string) + +Variant label for diagnostics: `"ACORN-1 (γ=1, M=16)"` or `"ACORN-γ (γ=2, M=32)"`. + +### `interface SearchResult` + +```ts +{ + id: number; // caller-supplied vector id + distance: number; // approximate L2² distance +} +``` + +### `version()` + +Returns the crate version baked at build time. + +## Recall and performance + +Native Rust benchmark (x86_64, n=5K, D=128, k=10): + +| Selectivity | ACORN-γ recall@10 | ACORN-γ QPS | Flat scan recall | Flat scan QPS | +|---|---|---|---|---| +| 50 % | 34.5 % | 65 K | 100.0 % | 18 K | +| 10 % | 79.7 % | 47 K | 100.0 % | 60 K | +| **1 %** | **96.0 %** | 18 K | 100.0 % | 151 K | + +The structural win is at **low selectivity**: ACORN-γ holds high recall as the predicate gets more selective, while post-filter approaches collapse. WASM throughput is typically 30–60 % of native at the same dataset size. + +## Why use this in the browser + +- **Filtered RAG without a server.** Query an embedding store with arbitrary metadata filters entirely client-side. +- **Privacy.** User vectors never leave the device. +- **Edge runtimes.** Cloudflare Workers, Deno Deploy, Vercel Edge — same `.wasm`, no native binaries. +- **Predicate is just JS.** Any `(id: number) => boolean` function works — your filter logic stays in JS where you already have it. + +## Sister packages + +- [`@ruvector/rabitq-wasm`](https://www.npmjs.com/package/@ruvector/rabitq-wasm) — 1-bit quantized vector index (when you need 32× memory reduction more than predicate filtering). +- [`@ruvector/graph-wasm`](https://www.npmjs.com/package/@ruvector/graph-wasm) — Cypher-compatible hypergraph database in WASM. +- [`ruvector`](https://www.npmjs.com/package/ruvector), [`@ruvector/core`](https://www.npmjs.com/package/@ruvector/core) — Node.js NAPI bindings for the full ruvector engine. + +## Source + +- **Rust crate**: [`crates/ruvector-acorn-wasm/`](https://github.com/ruvnet/RuVector/tree/main/crates/ruvector-acorn-wasm) +- **Algorithm crate**: [`crates/ruvector-acorn/`](https://github.com/ruvnet/RuVector/tree/main/crates/ruvector-acorn) +- **ADR**: [ADR-160 — ACORN predicate-agnostic filtered HNSW](https://github.com/ruvnet/RuVector/blob/main/docs/adr/ADR-160-acorn-filtered-hnsw.md) +- **Packaging ADR**: [ADR-162 — `ruvector-acorn-wasm` npm package](https://github.com/ruvnet/RuVector/blob/main/docs/adr/ADR-162-acorn-wasm-npm-package.md) +- **Paper**: [arXiv:2403.04871](https://arxiv.org/abs/2403.04871) +- **Repository**: [github.com/ruvnet/RuVector](https://github.com/ruvnet/RuVector) + +## License + +MIT OR Apache-2.0 diff --git a/npm/packages/acorn-wasm/package.scoped.json b/npm/packages/acorn-wasm/package.scoped.json new file mode 100644 index 000000000..4d24fb76e --- /dev/null +++ b/npm/packages/acorn-wasm/package.scoped.json @@ -0,0 +1,55 @@ +{ + "name": "@ruvector/acorn-wasm", + "version": "0.1.0", + "type": "module", + "description": "ACORN predicate-agnostic filtered HNSW in WebAssembly — high-recall vector search with arbitrary metadata filters, for browsers, Cloudflare Workers, Deno, and Bun", + "main": "ruvector_acorn_wasm.js", + "types": "ruvector_acorn_wasm.d.ts", + "module": "ruvector_acorn_wasm.js", + "sideEffects": [ + "./snippets/*" + ], + "keywords": [ + "acorn", + "filtered-vector-search", + "predicate-filter", + "hnsw", + "ann", + "approximate-nearest-neighbor", + "vector-search", + "vector-database", + "embeddings", + "wasm", + "webassembly", + "ai", + "machine-learning", + "rag", + "retrieval-augmented-generation", + "semantic-search", + "rust", + "browser", + "edge", + "cloudflare-workers" + ], + "author": "RuVector Team", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/ruvnet/RuVector.git", + "directory": "crates/ruvector-acorn-wasm" + }, + "homepage": "https://github.com/ruvnet/RuVector#readme", + "bugs": { + "url": "https://github.com/ruvnet/RuVector/issues" + }, + "files": [ + "ruvector_acorn_wasm_bg.wasm", + "ruvector_acorn_wasm.js", + "ruvector_acorn_wasm.d.ts", + "ruvector_acorn_wasm_bg.wasm.d.ts", + "README.md" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/npm/packages/rabitq-wasm/.gitignore b/npm/packages/rabitq-wasm/.gitignore new file mode 100644 index 000000000..f5468a89b --- /dev/null +++ b/npm/packages/rabitq-wasm/.gitignore @@ -0,0 +1,14 @@ +# wasm-pack output is built on demand by `crates/ruvector-rabitq-wasm/build.sh` +# and published from this directory. Don't commit generated artifacts. +ruvector_rabitq_wasm_bg.wasm +ruvector_rabitq_wasm_bg.wasm.d.ts +ruvector_rabitq_wasm.js +ruvector_rabitq_wasm.d.ts +node/ +bundler/ + +# `package.json` is regenerated by wasm-pack on every build, so we keep +# the canonical scoped version in `package.scoped.json` (committed) and +# ignore `package.json` here. `build.sh` copies scoped → package.json +# at the end of every build. +package.json diff --git a/npm/packages/rabitq-wasm/README.md b/npm/packages/rabitq-wasm/README.md new file mode 100644 index 000000000..e60ccaeac --- /dev/null +++ b/npm/packages/rabitq-wasm/README.md @@ -0,0 +1,129 @@ +# @ruvector/rabitq-wasm + +**RaBitQ 1-bit quantized vector index in WebAssembly.** Compress embeddings 32× and run approximate nearest-neighbor search in the browser, Cloudflare Workers, Deno, or Bun. + +[![npm](https://img.shields.io/npm/v/@ruvector/rabitq-wasm.svg)](https://www.npmjs.com/package/@ruvector/rabitq-wasm) +[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](https://github.com/ruvnet/RuVector#license) + +## What is RaBitQ? + +RaBitQ is a rotation-based 1-bit vector quantization scheme that compresses each f32 embedding into a single bit per dimension while preserving rank order under L2 distance. A small "rerank pool" of exact-distance computations on the top candidates restores recall. + +For a 768-dimensional embedding (~3 KB raw), RaBitQ stores **96 bytes** of quantized code plus the rotation matrix — a 32× memory reduction. Search runs in two phases: + +1. **Hamming-distance scan** over the 1-bit codes — fast, branch-free, ~10× more vectors per cache line than f32. +2. **Exact L2² rerank** of the top `rerank_factor × k` candidates — restores recall. + +The rotation is **deterministic** from `(seed, dim, vectors)`, so the same input always produces bit-identical codes whether you build on x86_64, aarch64, or wasm32. + +## Install + +```bash +npm install @ruvector/rabitq-wasm +``` + +## Usage (browser) + +```js +import init, { RabitqIndex } from "@ruvector/rabitq-wasm"; + +await init(); + +const dim = 768; +const n = 10_000; +const vectors = new Float32Array(n * dim); +// ... populate `vectors` with your embeddings (n × dim, row-major) ... + +// seed = 42 for reproducibility; rerank_factor = 20 is the typical default +const idx = RabitqIndex.build(vectors, dim, 42n, 20); + +const query = new Float32Array(dim); +// ... fill query ... + +const results = idx.search(query, 10); +// → [{ id: 7421, distance: 0.0023 }, { id: 9011, distance: 0.0041 }, ...] +``` + +## Usage (Node.js / Bun) + +```js +import { RabitqIndex } from "@ruvector/rabitq-wasm/node/ruvector_rabitq_wasm.js"; +// no `init()` needed for the node target + +const idx = RabitqIndex.build(vectors, 768, 42n, 20); +const results = idx.search(query, 10); +``` + +## Usage (bundlers — Vite, Webpack, Rollup) + +```js +import { RabitqIndex } from "@ruvector/rabitq-wasm/bundler/ruvector_rabitq_wasm.js"; +// the bundler handles the .wasm import transparently +``` + +## API + +### `class RabitqIndex` + +#### `RabitqIndex.build(vectors, dim, seed, rerankFactor)` + +Build an index from a flat `Float32Array` of length `n * dim`. + +| Parameter | Type | Description | +|---|---|---| +| `vectors` | `Float32Array` | Row-major matrix of `n` vectors, each of length `dim`. | +| `dim` | `number` | Vector dimensionality. | +| `seed` | `bigint` | Random rotation seed. Same `(seed, dim, vectors)` triple → bit-identical codes. | +| `rerankFactor` | `number` | Multiplier on `k` for the exact-L2² rerank pool. Typical: 20. | + +Throws if `dim == 0`, `vectors` is empty, or `vectors.length` is not a multiple of `dim`. + +#### `idx.search(query, k)` + +Find the `k` nearest neighbors of `query`. Returns an array of `SearchResult` ordered ascending by distance. + +#### `idx.len` (getter, number) + +Number of vectors indexed. + +#### `idx.isEmpty` (getter, boolean) + +`true` iff no vectors have been indexed. + +### `interface SearchResult` + +```ts +{ + id: number; // caller-supplied vector id (its row index in `build`) + distance: number; // approximate L2² distance after rerank +} +``` + +### `version()` + +Returns the crate version baked at build time. + +## Why use this in the browser + +- **32× smaller indices.** A 100 K × 768 embedding store is ~9.6 MB instead of ~300 MB — fits comfortably in any browser tab. +- **Cache-line-friendly hamming scan.** The 1-bit codes pack 64 dimensions into one `u64`, so the hot path runs at memory bandwidth. +- **Deterministic across architectures.** Builds on your x86_64 build server, runs identically on the user's ARM phone or in a Cloudflare Worker. +- **No server.** Run RAG, semantic search, or recommendation lookup entirely client-side. + +## Sister packages + +- [`@ruvector/acorn-wasm`](https://www.npmjs.com/package/@ruvector/acorn-wasm) — predicate-agnostic filtered HNSW (when you also need to filter results by metadata). +- [`@ruvector/graph-wasm`](https://www.npmjs.com/package/@ruvector/graph-wasm) — Cypher-compatible hypergraph database in WASM. +- [`ruvector`](https://www.npmjs.com/package/ruvector), [`@ruvector/core`](https://www.npmjs.com/package/@ruvector/core) — Node.js NAPI bindings for the full ruvector engine. + +## Source + +- **Rust crate**: [`crates/ruvector-rabitq-wasm/`](https://github.com/ruvnet/RuVector/tree/main/crates/ruvector-rabitq-wasm) +- **Algorithm crate**: [`crates/ruvector-rabitq/`](https://github.com/ruvnet/RuVector/tree/main/crates/ruvector-rabitq) +- **ADR**: [ADR-154 RaBitQ rotation-based 1-bit quantization](https://github.com/ruvnet/RuVector/blob/main/docs/adr/ADR-154-rabitq-rotation-based-1bit-quantization.md) +- **Packaging ADR**: [ADR-161 — `ruvector-rabitq-wasm` npm package](https://github.com/ruvnet/RuVector/blob/main/docs/adr/ADR-161-rabitq-wasm-npm-package.md) +- **Repository**: [github.com/ruvnet/RuVector](https://github.com/ruvnet/RuVector) + +## License + +MIT OR Apache-2.0 diff --git a/npm/packages/rabitq-wasm/package.scoped.json b/npm/packages/rabitq-wasm/package.scoped.json new file mode 100644 index 000000000..91387f112 --- /dev/null +++ b/npm/packages/rabitq-wasm/package.scoped.json @@ -0,0 +1,53 @@ +{ + "name": "@ruvector/rabitq-wasm", + "version": "0.1.0", + "type": "module", + "description": "RaBitQ 1-bit quantized vector index in WebAssembly — 32× embedding compression with high-recall rerank, for browsers, Cloudflare Workers, Deno, and Bun", + "main": "ruvector_rabitq_wasm.js", + "types": "ruvector_rabitq_wasm.d.ts", + "module": "ruvector_rabitq_wasm.js", + "sideEffects": [ + "./snippets/*" + ], + "keywords": [ + "rabitq", + "vector-search", + "ann", + "approximate-nearest-neighbor", + "quantization", + "1-bit-quantization", + "embeddings", + "wasm", + "webassembly", + "ai", + "machine-learning", + "rag", + "retrieval-augmented-generation", + "semantic-search", + "rust", + "browser", + "edge", + "cloudflare-workers" + ], + "author": "RuVector Team", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/ruvnet/RuVector.git", + "directory": "crates/ruvector-rabitq-wasm" + }, + "homepage": "https://github.com/ruvnet/RuVector#readme", + "bugs": { + "url": "https://github.com/ruvnet/RuVector/issues" + }, + "files": [ + "ruvector_rabitq_wasm_bg.wasm", + "ruvector_rabitq_wasm.js", + "ruvector_rabitq_wasm.d.ts", + "ruvector_rabitq_wasm_bg.wasm.d.ts", + "README.md" + ], + "publishConfig": { + "access": "public" + } +}