diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 000000000..6053b8e3f --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,60 @@ +# cargo-audit configuration for the ruvector workspace. +# +# Ignored advisories MUST have a justification. Anything fixable should be +# fixed via a dependency bump rather than ignored here. Re-evaluate the +# `until` dates periodically. + +[advisories] +ignore = [ + # ------------------------------------------------------------------ + # Vulnerabilities (genuinely no upstream fix available) + # ------------------------------------------------------------------ + + # rsa 0.9.x — Marvin Attack (timing sidechannel on RSA decryption). + # No fixed upgrade is available from upstream `rsa`. We do not expose + # an RSA decryption oracle: TLS in this workspace runs on rustls with + # Ed25519/X25519 suites, and `rsa` is pulled only transitively (e.g. + # SQL drivers, JWT verification paths) where we never decrypt + # attacker-controlled ciphertexts under a long-lived RSA key. + # Re-evaluate when the `rsa` crate ships a constant-time implementation. + "RUSTSEC-2023-0071", + + # ------------------------------------------------------------------ + # "Unmaintained" warnings (informational, not vulnerabilities) + # ------------------------------------------------------------------ + # These are pulled transitively through deps we do not control. They + # are not exploitable on their own; they are notices that the upstream + # crate is no longer accepting patches. We mute them to keep CI clean + # and revisit when the parent dep migrates. + + "RUSTSEC-2021-0140", # rusttype — transitive via plotters; pure rendering, no untrusted input + "RUSTSEC-2022-0054", # wee_alloc — transitive via wasm-bindgen-cli internals + "RUSTSEC-2024-0370", # proc-macro-error — build-time only (proc-macro), no runtime exposure + "RUSTSEC-2024-0380", # pqcrypto-dilithium — replaced by pqcrypto-mldsa, awaiting parent migration + "RUSTSEC-2024-0381", # pqcrypto-kyber — replaced by pqcrypto-mlkem, awaiting parent migration + "RUSTSEC-2024-0384", # instant — transitive via parking_lot/older time deps + "RUSTSEC-2024-0388", # derivative — transitive proc-macro + "RUSTSEC-2024-0436", # paste — transitive proc-macro, build-time only + "RUSTSEC-2025-0119", # number_prefix — transitive via indicatif rendering + "RUSTSEC-2025-0124", # rand_os — transitive, replaced by getrandom in modern code paths + "RUSTSEC-2025-0134", # rustls-pemfile — transitive; rustls itself is current + "RUSTSEC-2025-0141", # bincode — unmaintained notice; we pin a known-good version + "RUSTSEC-2026-0105", # core2 — transitive, no_std fallback for std::io types + + # ------------------------------------------------------------------ + # Soundness/unsoundness notices in deps we do not directly control + # ------------------------------------------------------------------ + + # lru — IterMut Stacked Borrows violation. Used transitively; we do + # not call IterMut from the affected crate. Track parent dep upgrade. + "RUSTSEC-2024-0408", + + # pprof — unsound `slice::from_raw_parts` usage. Only loaded behind + # benchmark/profiling features, never in production binaries. + "RUSTSEC-2026-0002", + + # rand — unsoundness when using a custom global logger with rand::rng(). + # We never install a custom logger in the rand call path. Awaiting + # transitive upgrade across the workspace. + "RUSTSEC-2026-0097", +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c8addc4d..4490e1bdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,8 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # Skip building unused proc-macro features in test bin link steps + CARGO_INCREMENTAL: 0 jobs: fmt: @@ -67,10 +69,130 @@ jobs: - name: Clippy (workspace) run: cargo clippy --workspace --exclude ruvector-postgres --all-targets -- -W warnings + # The full workspace test suite exceeds the 30-minute timeout on a single + # runner. We split the work into parallel matrix jobs grouped by domain so + # each shard fits comfortably under the timeout, and use `cargo-nextest` for + # faster test discovery and execution. test: - name: Tests + name: Tests (${{ matrix.name }}) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - name: vector-index + packages: >- + -p ruvector-rabitq + -p ruvector-rulake + -p ruvector-diskann + -p ruvector-graph + -p ruvector-gnn + -p ruvector-cnn + - name: rvagent + packages: >- + -p rvagent-a2a + -p rvagent-acp + -p rvagent-backends + -p rvagent-cli + -p rvagent-core + -p rvagent-mcp + -p rvagent-middleware + -p rvagent-subagents + -p rvagent-tools + -p rvagent-wasm + - name: ruvix + packages: >- + -p ruvix-aarch64 + -p ruvix-bench + -p ruvix-boot + -p ruvix-cap + -p ruvix-demo + -p ruvix-drivers + -p ruvix-hal + -p ruvix-integration + -p ruvix-nucleus + -p ruvix-proof + -p ruvix-queue + -p ruvix-region + -p ruvix-sched + -p ruvix-shell + -p ruvix-types + -p ruvix-vecgraph + - name: ruqu-quantum + packages: >- + -p ruqu + -p ruqu-algorithms + -p ruqu-core + -p ruqu-exotic + -p ruqu-wasm + - name: ml-research + packages: >- + -p ruvector-attention + -p ruvector-mincut + -p ruvector-scipix + -p ruvector-fpga-transformer + -p ruvector-sparse-inference + -p ruvector-sparsifier + -p ruvector-solver + -p ruvector-graph-transformer + -p ruvector-domain-expansion + -p ruvector-robotics + - name: core-and-rest + # Everything else: core, delta, dag, server/cluster, math, etc. + # Uses --workspace + --exclude to subtract the groups above so we + # don't have to enumerate ~100 crates by hand. + packages: >- + --workspace + --exclude ruvector-postgres + --exclude ruvector-decompiler + --exclude ruvector-rabitq + --exclude ruvector-rulake + --exclude ruvector-diskann + --exclude ruvector-graph + --exclude ruvector-gnn + --exclude ruvector-cnn + --exclude rvagent-a2a + --exclude rvagent-acp + --exclude rvagent-backends + --exclude rvagent-cli + --exclude rvagent-core + --exclude rvagent-mcp + --exclude rvagent-middleware + --exclude rvagent-subagents + --exclude rvagent-tools + --exclude rvagent-wasm + --exclude ruvix-aarch64 + --exclude ruvix-bench + --exclude ruvix-boot + --exclude ruvix-cap + --exclude ruvix-demo + --exclude ruvix-drivers + --exclude ruvix-hal + --exclude ruvix-integration + --exclude ruvix-nucleus + --exclude ruvix-proof + --exclude ruvix-queue + --exclude ruvix-region + --exclude ruvix-sched + --exclude ruvix-shell + --exclude ruvix-types + --exclude ruvix-vecgraph + --exclude ruqu + --exclude ruqu-algorithms + --exclude ruqu-core + --exclude ruqu-exotic + --exclude ruqu-wasm + --exclude ruvector-attention + --exclude ruvector-mincut + --exclude ruvector-scipix + --exclude ruvector-fpga-transformer + --exclude ruvector-sparse-inference + --exclude ruvector-sparsifier + --exclude ruvector-solver + --exclude ruvector-graph-transformer + --exclude ruvector-domain-expansion + --exclude ruvector-robotics steps: - uses: actions/checkout@v4 @@ -82,20 +204,35 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 + with: + key: test-${{ matrix.name }} + + - name: Install cargo-nextest + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest - - name: Run tests (workspace) - run: cargo test --workspace --exclude ruvector-postgres --exclude ruvector-decompiler + - name: Run tests (${{ matrix.name }}) + run: cargo nextest run --no-fail-fast ${{ matrix.packages }} + + - name: Run doctests (${{ matrix.name }}) + # nextest does not run doctests; do them in a separate step. Cheap + # because compilation is already cached from the nextest run. + run: cargo test --doc ${{ matrix.packages }} audit: name: Security audit runs-on: ubuntu-latest timeout-minutes: 30 - continue-on-error: true steps: - uses: actions/checkout@v4 - name: Install cargo-audit - run: cargo install cargo-audit --locked + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit - name: Run cargo audit + # Configuration (including the justified ignore list) lives in + # .cargo/audit.toml at the workspace root. run: cargo audit diff --git a/Cargo.lock b/Cargo.lock index 938d3d3ff..63a7fec18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2616,9 +2616,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" @@ -3827,23 +3827,26 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "hf-hub" -version = "0.3.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b780635574b3d92f036890d8373433d6f9fc7abb320ee42a5c25897fc8ed732" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ - "dirs 5.0.1", + "dirs 6.0.0", "futures", + "http 1.4.0", "indicatif", + "libc", "log", "native-tls", "num_cpus", - "rand 0.8.5", - "reqwest 0.11.27", + "rand 0.9.2", + "reqwest 0.12.28", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "ureq 2.12.1", + "windows-sys 0.60.2", ] [[package]] @@ -4033,20 +4036,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -4056,10 +4045,10 @@ dependencies = [ "http 1.4.0", "hyper 1.9.0", "hyper-util", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", "webpki-roots 1.0.6", ] @@ -4250,16 +4239,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "1.1.0" @@ -7069,6 +7048,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -7327,7 +7328,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls 0.23.37", + "rustls", "socket2 0.6.3", "thiserror 2.0.18", "tokio", @@ -7347,7 +7348,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash 2.1.2", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -7956,7 +7957,6 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -7966,7 +7966,6 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -7975,13 +7974,11 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", - "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.25.4", "winreg 0.50.0", ] @@ -8002,7 +7999,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.9.0", - "hyper-rustls 0.27.7", + "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "js-sys", @@ -8013,7 +8010,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -8021,7 +8018,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower 0.5.3", "tower-http 0.6.8", @@ -8289,18 +8286,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.37" @@ -8311,7 +8296,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki", "subtle", "zeroize", ] @@ -8337,19 +8322,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -8529,7 +8504,7 @@ dependencies = [ "rand 0.8.5", "rand_distr 0.4.3", "rayon", - "reqwest 0.11.27", + "reqwest 0.12.28", "ruvector-core 2.2.0", "rvf-crypto", "rvf-types", @@ -8812,7 +8787,7 @@ dependencies = [ "rand_distr 0.4.3", "rayon", "redb", - "reqwest 0.11.27", + "reqwest 0.12.28", "rkyv", "serde", "serde_json", @@ -9640,6 +9615,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" @@ -10930,16 +10919,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -12291,23 +12270,13 @@ dependencies = [ "whoami 2.1.1", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls", "tokio", ] @@ -12341,10 +12310,10 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite", "webpki-roots 0.26.11", ] @@ -12699,7 +12668,7 @@ dependencies = [ "httparse", "log", "rand 0.8.5", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "sha1", "thiserror 1.0.69", @@ -12900,10 +12869,11 @@ dependencies = [ "log", "native-tls", "once_cell", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "serde", "serde_json", + "socks", "url", "webpki-roots 0.26.11", ] @@ -12945,7 +12915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 1.1.0", + "idna", "percent-encoding", "serde", "serde_derive", @@ -13006,11 +12976,11 @@ dependencies = [ [[package]] name = "validator" -version = "0.18.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" dependencies = [ - "idna 0.5.0", + "idna", "once_cell", "regex", "serde", @@ -13022,13 +12992,13 @@ dependencies = [ [[package]] name = "validator_derive" -version = "0.18.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ "darling 0.20.11", "once_cell", - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", @@ -13371,12 +13341,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 5c66aaf74..cd37ed2d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ exclude = ["crates/micro-hnsw-wasm", "crates/ruvector-hyperbolic-hnsw", "crates/ "crates/ruvector-postgres"] members = [ "crates/ruvector-rabitq", + "crates/ruvector-rabitq-wasm", "crates/ruvector-rulake", "crates/ruvector-core", "crates/ruvector-node", diff --git a/crates/ruvector-core/Cargo.toml b/crates/ruvector-core/Cargo.toml index efb6733f7..52bb4f33a 100644 --- a/crates/ruvector-core/Cargo.toml +++ b/crates/ruvector-core/Cargo.toml @@ -44,7 +44,7 @@ chrono = { workspace = true } uuid = { workspace = true, features = ["v4"] } # HTTP client for API embeddings (not available in WASM) -reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } # ONNX Runtime for local semantic embeddings (not available in WASM) ort = { version = "2.0.0-rc.9", optional = true } @@ -53,7 +53,7 @@ ort = { version = "2.0.0-rc.9", optional = true } tokenizers = { version = "0.20", default-features = false, features = ["onig"], optional = true } # HuggingFace Hub for model downloads -hf-hub = { version = "0.3", optional = true } +hf-hub = { version = "0.4", optional = true } [dev-dependencies] criterion = { workspace = true } 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/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/crates/ruvllm-cli/Cargo.toml b/crates/ruvllm-cli/Cargo.toml index aab0e9554..57588db42 100644 --- a/crates/ruvllm-cli/Cargo.toml +++ b/crates/ruvllm-cli/Cargo.toml @@ -26,7 +26,7 @@ tokio = { workspace = true, features = ["full", "signal"] } futures = { workspace = true } # HuggingFace Hub for model downloads -hf-hub = { version = "0.3", features = ["tokio"] } +hf-hub = { version = "0.4", features = ["tokio"] } # HTTP server for inference API axum = { version = "0.7", features = ["ws"] } diff --git a/crates/ruvllm/Cargo.toml b/crates/ruvllm/Cargo.toml index a37cf4a9a..193f33da9 100644 --- a/crates/ruvllm/Cargo.toml +++ b/crates/ruvllm/Cargo.toml @@ -70,7 +70,7 @@ candle-transformers = { version = "0.8", optional = true } tokenizers = { version = "0.20", optional = true, default-features = false, features = ["onig"] } # HuggingFace Hub for model downloads -hf-hub = { version = "0.3", optional = true, features = ["tokio"] } +hf-hub = { version = "0.4", optional = true, features = ["tokio"] } # mistral-rs backend for high-performance inference (optional) # NOTE: mistralrs crate is not yet on crates.io - use git dependency when available: diff --git a/examples/benchmarks/Cargo.toml b/examples/benchmarks/Cargo.toml index e4c32053b..5bf0f7faa 100644 --- a/examples/benchmarks/Cargo.toml +++ b/examples/benchmarks/Cargo.toml @@ -54,7 +54,7 @@ statistical = "1.0" hdrhistogram = "7.5" # HTTP for tool-augmented tests -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.12", features = ["json"] } # Visualization plotters = { version = "0.3", optional = true } diff --git a/examples/scipix/Cargo.toml b/examples/scipix/Cargo.toml index a3edcc5c3..b584e6d8a 100644 --- a/examples/scipix/Cargo.toml +++ b/examples/scipix/Cargo.toml @@ -55,7 +55,7 @@ tower-http = { version = "0.5", features = ["fs", "trace", "cors", "compression- hyper = { version = "1.0", features = ["full"] } # Validation -validator = { version = "0.18", features = ["derive"] } +validator = { version = "0.20", features = ["derive"] } # Rate limiting governor = "0.6"