diff --git a/Cargo.lock b/Cargo.lock index 854cd017f6..28a979d0d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4921,14 +4921,14 @@ dependencies = [ [[package]] name = "lattice-embed" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "188e4627dabd63544da5a57daabfe3105bf4d4970840f2e5a5c7f01123835ecb" +checksum = "3a8471670d8eb3dc5b52b7c977f1be449a4d39404ebd47bd084a1e922fa6002e" dependencies = [ "async-trait", "blake3", "chrono", - "lattice-inference", + "lattice-inference 0.7.1", "lru 0.16.4", "parking_lot 0.12.5", "serde", @@ -4944,9 +4944,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b113852e4c522b6607cd8e01842b5dff7de307d4cc9223dfd798b0136e2aa62b" dependencies = [ - "axum 0.8.9", "clap", - "futures", "half", "image 0.25.10", "indexmap 2.12.1", @@ -4959,7 +4957,27 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "tokio", + "tracing", +] + +[[package]] +name = "lattice-inference" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6ebeb70001f572be393e646e9207b2b10fc8c7649eb0b197d027bc87018585" +dependencies = [ + "clap", + "half", + "image 0.25.10", + "indexmap 2.12.1", + "libc", + "memmap2", + "rayon", + "rustc-hash 2.1.2", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", "tracing", "ureq 2.12.1", ] @@ -11020,7 +11038,7 @@ dependencies = [ "futures-core", "half", "hf-hub", - "lattice-inference", + "lattice-inference 0.6.1", "md5", "memmap2", "metal 0.29.0", diff --git a/crates/ruvector-core/Cargo.toml b/crates/ruvector-core/Cargo.toml index 144c706fea..9b49e5fb4b 100644 --- a/crates/ruvector-core/Cargo.toml +++ b/crates/ruvector-core/Cargo.toml @@ -55,12 +55,20 @@ tokenizers = { version = "0.20", default-features = false, features = ["onig"], # HuggingFace Hub for model downloads hf-hub = { version = "0.4", optional = true } -# Native (pure-Rust) local embeddings via lattice-embed (not available in WASM). -# NOTE: lattice-embed 0.6 requires Rust >= 1.93 (edition 2024). Cargo cannot -# express a per-feature `rust-version`, so enabling the `lattice-embeddings` -# feature raises the effective MSRV above this crate's workspace-inherited -# 1.77 for anyone who turns it on. The default build is unaffected. -lattice-embed = { version = "0.6.1", optional = true } +# lattice-embed serves two independent features here, which is why it is pinned +# with `default-features = false`: +# * `lattice-simd` — SIMD distance kernels only. No model, no tokenizer, +# no download stack. Works on wasm32. +# * `lattice-embeddings` — local embedding models, which additionally need +# `native` (and `download` for first-use fetches). +# Turning default features off is what separates the two: `native` is what pulls +# `lattice-inference`, so the kernels-only path does not drag in the model tree. +# +# NOTE: lattice-embed requires Rust >= 1.93 (edition 2024). Cargo cannot express +# a per-feature `rust-version`, so enabling either feature raises the effective +# MSRV above this crate's workspace-inherited 1.77 for anyone who turns it on, +# the same way `simd-avx512` requires >= 1.89. The default build is unaffected. +lattice-embed = { version = "0.7.1", optional = true, default-features = false } tokio = { workspace = true, optional = true } [dev-dependencies] @@ -124,7 +132,22 @@ uuid-support = [] # Deprecated: uuid is now always included real-embeddings = [] # Feature flag for embedding provider API (use ApiEmbedding for production) api-embeddings = ["reqwest"] # API-based embeddings (not available in WASM) onnx-embeddings = ["ort", "tokenizers", "hf-hub"] # ONNX-based local embeddings (not available in WASM) -lattice-embeddings = ["dep:lattice-embed", "dep:tokio"] # Native pure-Rust local embeddings via lattice-embed (not available in WASM) +# Native pure-Rust local embeddings via lattice-embed (not available in WASM). +# Forwards `native`/`download` explicitly because the dependency is pinned with +# `default-features = false`; this reproduces the previous default feature set. +lattice-embeddings = [ + "dep:lattice-embed", + "lattice-embed/native", + "lattice-embed/download", + "dep:tokio", +] +# Route the f32 distance kernels through lattice-embed instead of SimSIMD. +# Unlike `simd`, this one covers wasm32 (via `simd128`): SimSIMD's call sites +# are gated off on wasm32 (the simsimd dependency itself still resolves +# there), so without this feature the generic distance path falls back to +# scalar on wasm32. Takes precedence over `simd` where both are enabled. Off +# by default. +lattice-simd = ["dep:lattice-embed"] [lib] crate-type = ["rlib"] diff --git a/crates/ruvector-core/src/distance.rs b/crates/ruvector-core/src/distance.rs index ef2a10bd74..a386e9fa7d 100644 --- a/crates/ruvector-core/src/distance.rs +++ b/crates/ruvector-core/src/distance.rs @@ -1,5 +1,28 @@ //! SIMD-optimized distance metrics -//! Uses SimSIMD when available (native), falls back to pure Rust for WASM +//! +//! For Euclidean, cosine, and dot, three mutually exclusive backends are selected at +//! compile time: +//! +//! - `lattice-simd`: `lattice-embed`'s kernels. Covers wasm32 (`simd128`) as well as +//! x86_64 and aarch64, so it is the only backend that vectorizes on wasm. +//! - `simd` on non-wasm: SimSIMD. Its call sites are excluded on wasm32 (the simsimd +//! dependency itself still resolves there), so scalar is used on wasm32 unless +//! `lattice-simd` is enabled. +//! - otherwise: the portable scalar path. +//! +//! `lattice-simd` takes precedence where both are enabled. The scalar path stays the +//! reference implementation that the backends are checked against. +//! +//! Manhattan is not part of this split: it uses [`crate::simd_intrinsics`]'s +//! x86_64/aarch64 dispatch by default, regardless of the `simd` feature. +//! +//! ## Call sites +//! +//! The generic [`distance`] function and its four metric-specific adapters below back +//! the generic distance path (used by, e.g., [`crate::index::flat`]'s `FlatIndex`). +//! `crate::index::hnsw`'s HNSW index does not go through this module: it dispatches its +//! own kernels directly via `crate::simd_intrinsics` for every metric, so enabling +//! `lattice-simd` does not change HNSW's distance evaluations. use crate::error::{Result, RuvectorError}; use crate::types::DistanceMetric; @@ -25,13 +48,25 @@ pub fn distance(a: &[f32], b: &[f32], metric: DistanceMetric) -> Result { /// Euclidean (L2) distance #[inline] pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 { - #[cfg(all(feature = "simd", not(target_arch = "wasm32")))] + #[cfg(feature = "lattice-simd")] + { + // Already sqrt-ed, matching this function's contract. + lattice_embed::simd::euclidean_distance(a, b) + } + #[cfg(all( + not(feature = "lattice-simd"), + feature = "simd", + not(target_arch = "wasm32") + ))] { (simsimd::SpatialSimilarity::sqeuclidean(a, b) .expect("SimSIMD euclidean failed") .sqrt()) as f32 } - #[cfg(any(not(feature = "simd"), target_arch = "wasm32"))] + #[cfg(all( + not(feature = "lattice-simd"), + any(not(feature = "simd"), target_arch = "wasm32") + ))] { // Unrolled scalar fallback for WASM — 4x unroll for ILP let len = a.len(); @@ -56,11 +91,25 @@ pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 { /// Cosine distance (1 - cosine_similarity) #[inline] pub fn cosine_distance(a: &[f32], b: &[f32]) -> f32 { - #[cfg(all(feature = "simd", not(target_arch = "wasm32")))] + #[cfg(feature = "lattice-simd")] + { + // lattice returns similarity; this function's contract is 1 - similarity. + // Its kernels return 0.0 when either norm is exactly zero, so a zero vector + // yields 1.0 here, matching the scalar path below. + 1.0 - lattice_embed::simd::cosine_similarity(a, b) + } + #[cfg(all( + not(feature = "lattice-simd"), + feature = "simd", + not(target_arch = "wasm32") + ))] { simsimd::SpatialSimilarity::cosine(a, b).expect("SimSIMD cosine failed") as f32 } - #[cfg(any(not(feature = "simd"), target_arch = "wasm32"))] + #[cfg(all( + not(feature = "lattice-simd"), + any(not(feature = "simd"), target_arch = "wasm32") + ))] { // Single-pass cosine fallback for WASM — avoids 3x iteration overhead let (mut dot, mut norm_a_sq, mut norm_b_sq) = (0.0f32, 0.0f32, 0.0f32); @@ -81,12 +130,24 @@ pub fn cosine_distance(a: &[f32], b: &[f32]) -> f32 { /// Dot product distance (negative for maximization) #[inline] pub fn dot_product_distance(a: &[f32], b: &[f32]) -> f32 { - #[cfg(all(feature = "simd", not(target_arch = "wasm32")))] + #[cfg(feature = "lattice-simd")] + { + // Negated, matching this function's maximization contract. + -lattice_embed::simd::dot_product(a, b) + } + #[cfg(all( + not(feature = "lattice-simd"), + feature = "simd", + not(target_arch = "wasm32") + ))] { let dot = simsimd::SpatialSimilarity::dot(a, b).expect("SimSIMD dot product failed"); (-dot) as f32 } - #[cfg(any(not(feature = "simd"), target_arch = "wasm32"))] + #[cfg(all( + not(feature = "lattice-simd"), + any(not(feature = "simd"), target_arch = "wasm32") + ))] { // Pure Rust fallback for WASM let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); @@ -97,7 +158,16 @@ pub fn dot_product_distance(a: &[f32], b: &[f32]) -> f32 { /// Manhattan (L1) distance — delegates to SIMD when available #[inline] pub fn manhattan_distance(a: &[f32], b: &[f32]) -> f32 { - crate::simd_intrinsics::manhattan_distance_simd(a, b) + #[cfg(feature = "lattice-simd")] + { + lattice_embed::simd::manhattan_distance(a, b) + } + #[cfg(not(feature = "lattice-simd"))] + { + // `simd_intrinsics` dispatches x86_64 and aarch64 and falls through to + // scalar everywhere else, wasm32 included. + crate::simd_intrinsics::manhattan_distance_simd(a, b) + } } /// Batch distance calculation optimized with Rayon (native) or sequential (WASM) @@ -172,6 +242,113 @@ mod tests { assert!((dist - 9.0).abs() < 0.01); // |1-4| + |2-5| + |3-6| = 9 } + /// Reference implementations, deliberately naive and backend-independent. + /// Whichever backend is compiled in must agree with these. + mod reference { + pub fn euclidean(a: &[f32], b: &[f32]) -> f32 { + a.iter() + .zip(b) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt() + } + + pub fn cosine(a: &[f32], b: &[f32]) -> f32 { + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na = a.iter().map(|x| x * x).sum::().sqrt(); + let nb = b.iter().map(|x| x * x).sum::().sqrt(); + if na == 0.0 || nb == 0.0 { + 1.0 + } else { + 1.0 - dot / (na * nb) + } + } + + pub fn dot(a: &[f32], b: &[f32]) -> f32 { + -a.iter().zip(b).map(|(x, y)| x * y).sum::() + } + + pub fn manhattan(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| (x - y).abs()).sum::() + } + } + + /// Deterministic pseudo-random vectors, no dev-dependency needed. + fn vecs(dim: usize, seed: u32) -> (Vec, Vec) { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + let mut next = || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s as f32 / u32::MAX as f32) * 2.0 - 1.0 + }; + ( + (0..dim).map(|_| next()).collect(), + (0..dim).map(|_| next()).collect(), + ) + } + + /// The active backend must agree with the scalar reference on every metric. + /// + /// This is what catches an adapter mistake: dropping the `1.0 -` on cosine or the + /// negation on dot product still compiles and still passes the loose + /// single-case assertions above, but fails here. + #[test] + fn test_backend_matches_scalar_reference() { + // Dimensions straddling the SIMD lane widths (4/8/16) and their remainders, + // so tail handling is exercised rather than assumed. + for dim in [1usize, 3, 4, 7, 8, 15, 16, 17, 31, 64, 127, 384, 768] { + for seed in 0..4u32 { + let (a, b) = vecs(dim, seed); + + let got = euclidean_distance(&a, &b); + let want = reference::euclidean(&a, &b); + assert!( + (got - want).abs() <= 1e-3 * want.abs().max(1.0), + "euclidean mismatch at dim={dim} seed={seed}: got {got}, want {want}" + ); + + let got = cosine_distance(&a, &b); + let want = reference::cosine(&a, &b); + assert!( + (got - want).abs() <= 1e-4, + "cosine mismatch at dim={dim} seed={seed}: got {got}, want {want}" + ); + + let got = dot_product_distance(&a, &b); + let want = reference::dot(&a, &b); + assert!( + (got - want).abs() <= 1e-3 * want.abs().max(1.0), + "dot mismatch at dim={dim} seed={seed}: got {got}, want {want}" + ); + + let got = manhattan_distance(&a, &b); + let want = reference::manhattan(&a, &b); + assert!( + (got - want).abs() <= 1e-3 * want.abs().max(1.0), + "manhattan mismatch at dim={dim} seed={seed}: got {got}, want {want}" + ); + } + } + } + + /// A zero vector must not produce NaN, and cosine distance must saturate at 1.0. + #[test] + fn test_zero_vector_is_not_nan() { + let zero = vec![0.0f32; 8]; + let other = vec![1.0f32; 8]; + + let d = cosine_distance(&zero, &other); + assert!(d.is_finite(), "cosine distance went non-finite: {d}"); + assert!( + (d - 1.0).abs() < 1e-6, + "zero vector should give cosine distance 1.0, got {d}" + ); + + assert!(euclidean_distance(&zero, &other).is_finite()); + assert!(dot_product_distance(&zero, &other).is_finite()); + } + #[test] fn test_dimension_mismatch() { let a = vec![1.0, 2.0]; @@ -179,4 +356,93 @@ mod tests { let result = distance(&a, &b, DistanceMetric::Euclidean); assert!(result.is_err()); } + + /// Recomputes what each adapter's contract says the `lattice-simd` backend must + /// produce, straight from `lattice_embed`'s kernels. This is the seam that pins + /// backend *selection* (not just arithmetic): the loose `test_backend_matches_scalar_reference` + /// tolerance above passes even if an adapter silently fell back to the scalar path, + /// but a bit-exact comparison against this function does not, since the scalar path's + /// summation order and rounding differ from the lattice kernels'. + #[cfg(feature = "lattice-simd")] + fn lattice_kernel_result(a: &[f32], b: &[f32], metric: DistanceMetric) -> f32 { + match metric { + DistanceMetric::Euclidean => lattice_embed::simd::euclidean_distance(a, b), + DistanceMetric::Cosine => 1.0 - lattice_embed::simd::cosine_similarity(a, b), + DistanceMetric::DotProduct => -lattice_embed::simd::dot_product(a, b), + DistanceMetric::Manhattan => lattice_embed::simd::manhattan_distance(a, b), + } + } + + /// Under `lattice-simd`, every public adapter must dispatch to the lattice kernels + /// bit-for-bit. If any one of the four adapters is edited to fall through to its + /// scalar or SimSIMD branch instead, this test fails even though the value stays + /// numerically close, because the two implementations round differently. + #[cfg(feature = "lattice-simd")] + #[test] + fn test_backend_selection_uses_lattice_simd() { + for dim in [1usize, 3, 4, 7, 8, 15, 16, 17, 31, 64, 127, 384, 768] { + for seed in 0..4u32 { + let (a, b) = vecs(dim, seed); + + for metric in [ + DistanceMetric::Euclidean, + DistanceMetric::Cosine, + DistanceMetric::DotProduct, + DistanceMetric::Manhattan, + ] { + let got = distance(&a, &b, metric).unwrap(); + let want = lattice_kernel_result(&a, &b, metric); + assert_eq!( + got.to_bits(), + want.to_bits(), + "{metric:?} did not dispatch to the lattice-simd kernel at dim={dim} seed={seed}: got {got}, want {want}" + ); + } + } + } + } + + /// Documents an intentional divergence: when the product of the two vector norms + /// (`norm_a_sq.sqrt() * norm_b_sq.sqrt()`) is strictly between 0 and 1e-8, the scalar + /// path's `denom > 1e-8` guard saturates cosine distance at 1.0, while the + /// `lattice-simd` kernel only short-circuits on an *exactly* zero norm and otherwise + /// computes the real cosine similarity. This is a deliberate contract difference + /// between the two backends at the sub-1e-8 boundary, not a bug. + /// + /// The scalar assertion below calls the compiled production `cosine_distance` itself, + /// under the same `cfg` as its scalar branch (`distance.rs:103-121`), rather than a + /// hand-copied mirror — so a regression in that branch (e.g. dropping the saturation + /// guard) fails this test instead of leaving a separately-maintained copy green. + #[test] + fn test_tiny_norm_cosine_divergence_is_intentional() { + #[cfg(all( + not(feature = "lattice-simd"), + any(not(feature = "simd"), target_arch = "wasm32") + ))] + { + // Each vector's norm is ~1e-9 (nonzero), so their product — the scalar + // path's denom — is ~1e-18, well under its 1e-8 guard threshold. + let a = vec![1e-9f32, 0.0, 0.0, 0.0]; + let b = vec![1e-9f32, 0.0, 0.0, 0.0]; + let scalar = cosine_distance(&a, &b); + assert!( + (scalar - 1.0).abs() < 1e-6, + "scalar path should saturate at 1.0 below its 1e-8 denom guard, got {scalar}" + ); + } + + #[cfg(feature = "lattice-simd")] + { + // These vectors are parallel, so the true cosine distance is ~0. The lattice + // kernel does not apply the scalar path's 1e-8 guard, so it should report + // that, diverging from the scalar path's saturation-at-1.0 behavior above. + let a = vec![1e-9f32, 0.0, 0.0, 0.0]; + let b = vec![1e-9f32, 0.0, 0.0, 0.0]; + let lattice = cosine_distance(&a, &b); + assert!( + lattice.is_finite() && lattice < 0.5, + "lattice path should not saturate at 1.0 for a tiny nonzero norm, got {lattice}" + ); + } + } } diff --git a/crates/ruvector-wasm/Cargo.toml b/crates/ruvector-wasm/Cargo.toml index 1901233037..4ab1c905e5 100644 --- a/crates/ruvector-wasm/Cargo.toml +++ b/crates/ruvector-wasm/Cargo.toml @@ -65,6 +65,18 @@ rand = { workspace = true } [features] default = [] simd = ["ruvector-core/simd"] +# Vectorized distance kernels that actually reach wasm32. `simd` above calls +# SimSIMD, but ruvector-core gates those call sites on +# `not(target_arch = "wasm32")` (the simsimd dependency itself still resolves +# on wasm32; only the call sites are excluded), so a browser build takes +# ruvector-core's own scalar fallback instead. This one forwards to +# lattice-embed, whose kernels compile to `simd128`. Build with +# `RUSTFLAGS="-C target-feature=+simd128"`; without that flag, SIMD128 +# acceleration is absent and distance calls run through lattice-embed's own +# scalar fallback — not a behavioral no-op, since its exact-zero cosine norm +# guard differs from ruvector-core's own scalar path, which saturates at +# `denom <= 1e-8` (see ruvector-core/src/distance.rs `cosine_distance`). +lattice-simd = ["ruvector-core/lattice-simd"] # Collections and filter features (not available in WASM due to file I/O requirements) # These features are provided for completeness but will not work in browser WASM collections = ["dep:ruvector-collections", "dep:ruvector-filter"] diff --git a/scripts/check_wasm_simd.sh b/scripts/check_wasm_simd.sh new file mode 100755 index 0000000000..6fe97c45d0 --- /dev/null +++ b/scripts/check_wasm_simd.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Verifies that ruvector-wasm's `lattice-simd` feature actually vectorizes on +# wasm32: builds the crate with and without `-C target-feature=+simd128` and +# counts SIMD128 opcodes in each emitted .wasm artifact. Fails closed on any +# missing prerequisite, missing/empty artifact, build failure, grep failure, +# or unmet opcode condition. +set -euo pipefail + +PACKAGE="ruvector-wasm" +RUST_TARGET="wasm32-unknown-unknown" +FEATURE="lattice-simd" +ARTIFACT="ruvector_wasm.wasm" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +BASE_TARGET_DIR="${CARGO_TARGET_DIR:-target}/wasm-simd-check" +SIMD_TARGET_DIR="$BASE_TARGET_DIR/simd128" +CONTROL_TARGET_DIR="$BASE_TARGET_DIR/control" + +echo "== Prerequisites ==" + +if ! rustup target list --installed 2>/dev/null | grep -qx "$RUST_TARGET"; then + echo "FAIL: rust target '$RUST_TARGET' is not installed." >&2 + echo " Install it with: rustup target add $RUST_TARGET" >&2 + exit 1 +fi +echo "OK: $RUST_TARGET target installed" + +if ! command -v wasm-objdump >/dev/null 2>&1; then + echo "FAIL: wasm-objdump not found on PATH (ships with WABT)." >&2 + echo " Install it with: brew install wabt (macOS) or apt-get install wabt (Linux)." >&2 + exit 1 +fi +echo "OK: wasm-objdump found at $(command -v wasm-objdump)" + +count_simd128_opcodes() { + local wasm_file="$1" + if [[ ! -s "$wasm_file" ]]; then + echo "FAIL: expected wasm artifact at '$wasm_file' but it is missing or empty." >&2 + exit 1 + fi + + local disasm + if ! disasm="$(wasm-objdump -d "$wasm_file")"; then + echo "FAIL: wasm-objdump could not disassemble '$wasm_file'." >&2 + exit 1 + fi + + # grep -c exits 1 on zero matches (a legitimate count: the control arm is + # expected to land here) and 0 on a match; capture the real status + # ourselves rather than let `|| true` fold every other status (2+: a real + # grep failure) into the same "zero" bucket a broken grep invocation would + # otherwise be indistinguishable from a genuine zero-opcode build. + local count grep_status + count="$(grep -Ec '\b(v128|i8x16|i16x8|i32x4|i64x2|f32x4|f64x2)\.[a-z_0-9]+' <<<"$disasm")" && grep_status=0 || grep_status=$? + if (( grep_status != 0 && grep_status != 1 )); then + echo "FAIL: grep exited $grep_status while counting SIMD128 opcodes for '$wasm_file'." >&2 + exit 1 + fi + if ! [[ "$count" =~ ^[0-9]+$ ]]; then + echo "FAIL: grep produced a non-numeric SIMD128 opcode count ('$count') for '$wasm_file'." >&2 + exit 1 + fi + + echo "$count" +} + +build_artifact() { + local target_dir="$1" + local rustflags="$2" + local artifact_path="$target_dir/$RUST_TARGET/release/$ARTIFACT" + + # Delete any artifact left over from a previous run of this script before + # building: target dirs persist across invocations, so a failed build must + # not be able to fall through to a stale non-empty artifact satisfying the + # downstream "exists and is non-empty" check. + if ! rm -f "$artifact_path"; then + echo "FAIL: could not remove stale artifact '$artifact_path'." >&2 + return 1 + fi + + # Check the build's exit status explicitly instead of leaning on `set -e`: + # this call sits inside a function invoked via command substitution + # (`X="$(build_artifact ...)"`), and a failing non-final command in that + # position does not reliably abort the script under `errexit` — only the + # function's own final exit status, captured here, does. + if ! CARGO_TARGET_DIR="$target_dir" RUSTFLAGS="$rustflags" \ + cargo build --release -p "$PACKAGE" --target "$RUST_TARGET" --features "$FEATURE" 1>&2; then + echo "FAIL: cargo build failed (CARGO_TARGET_DIR=$target_dir, RUSTFLAGS='$rustflags')." >&2 + return 1 + fi + + echo "$artifact_path" +} + +echo "" +echo "== Arm A: RUSTFLAGS='-C target-feature=+simd128', --features $FEATURE ==" +if ! SIMD_WASM="$(build_artifact "$SIMD_TARGET_DIR" "-C target-feature=+simd128")"; then + echo "FAIL: build_artifact failed for the +simd128 arm." >&2 + exit 1 +fi +SIMD_COUNT="$(count_simd128_opcodes "$SIMD_WASM")" +if ! [[ "$SIMD_COUNT" =~ ^[0-9]+$ ]]; then + echo "FAIL: non-numeric SIMD128 opcode count for the +simd128 arm: '$SIMD_COUNT'." >&2 + exit 1 +fi +echo "SIMD128 opcode count: $SIMD_COUNT" +if (( SIMD_COUNT <= 0 )); then + echo "FAIL: expected > 0 SIMD128 opcodes with +simd128 and --features $FEATURE, got $SIMD_COUNT." >&2 + exit 1 +fi + +echo "" +echo "== Arm B (control): no target-feature flag, --features $FEATURE ==" +if ! CONTROL_WASM="$(build_artifact "$CONTROL_TARGET_DIR" "")"; then + echo "FAIL: build_artifact failed for the control arm." >&2 + exit 1 +fi +CONTROL_COUNT="$(count_simd128_opcodes "$CONTROL_WASM")" +if ! [[ "$CONTROL_COUNT" =~ ^[0-9]+$ ]]; then + echo "FAIL: non-numeric SIMD128 opcode count for the control arm: '$CONTROL_COUNT'." >&2 + exit 1 +fi +echo "SIMD128 opcode count: $CONTROL_COUNT" + +# Without the target-feature flag, lattice-embed's wasm32 kernels take their +# scalar fallback (crates/ruvector-core/src/distance.rs), so this arm +# currently measures 0 SIMD128 opcodes. A future dependency could +# legitimately contribute some vector code even without the flag, so this +# asserts the delta direction (control strictly below the +simd128 build) +# rather than hard-coding zero. +if (( CONTROL_COUNT >= SIMD_COUNT )); then + echo "FAIL: expected the control build to carry fewer SIMD128 opcodes than the +simd128 build (control=$CONTROL_COUNT, simd128=$SIMD_COUNT)." >&2 + exit 1 +fi + +echo "" +echo "== PASS ==" +echo "+simd128 build: $SIMD_COUNT SIMD128 opcodes" +echo "control build: $CONTROL_COUNT SIMD128 opcodes"