Extends the scan dispatch ladder to scalar → AVX2 → AVX-512 VPOPCNTDQ.
The new kernel runs under #[target_feature(enable = "avx2,avx512f,
avx512bw,avx512vpopcntdq")] and processes 8 u64s per zmm load via
_mm512_popcnt_epi64.
select_impl() now prefers avx512f+avx512vpopcntdq, falls back to
avx2+popcnt, then scalar. All paths cached in the existing OnceLock.
Measured on host with all three levels available (n=100k, D=128,
rerank×20, single-thread, ruLake Fresh path):
before (AVX2 path): ~3,681 QPS
after (AVX-512): ~4,067 QPS (+10.5%)
Below the 2× target because at D=128 only 2 u64s per candidate feed
VPOPCNTDQ — the kernel is memory-bandwidth-bound on the sequential
packed stream, and the _mm512_storeu_si512 → scalar fold for
per-candidate pair reduction eats part of the win. A vpsadbw-based
in-register reduction would recover more but would balloon the
intrinsics surface beyond what fits cleanly in scan.rs.
Determinism preserved: scan_avx512 is byte-identical to scan_scalar
at D=64, D=100, D=128, D=192, D=200, plus tail sizes n=7 and 1023.
New test scan_avx512_matches_scalar exercises a 1000-vector D=128
run; the existing run_both harness adds AVX-512 parity to every
shape it tests.
Clippy clean (one allow(incompatible_msrv) scoped to scan_avx512
only — AVX-512 intrinsics stabilized in Rust 1.89, runtime detection
guarantees safe dispatch).
38 → 39 rabitq lib tests. Rulake unchanged (42).
Co-Authored-By: claude-flow <ruv@ruv.net>
=== Agent A: rabitq — non-dense ID preservation through persist ===
crates/ruvector-rabitq/src/{index,persist}.rs
Wave-5's warm_from_dir collapsed external u64 ids to (0..n) identity
because RabitqPlusIndex lacked an outer ids accessor. Surprise finding:
the persist LOAD path was already id-preserving — the pipeline reads
`id:u32` from disk and hands (id, v) into from_vectors_parallel, which
writes `id` into inner.ids. The only missing piece was the outer-layer
accessor so ruLake could read them back.
Added:
- RabitqPlusIndex::external_ids(&self) -> &[u32] (thin forward)
- RabitqPlusIndex::ids_u64(&self) -> Vec<u64> (widening clone)
Regression test `persist_preserves_non_dense_ids` builds an index with
non-dense external ids (13*i + 7 for i in 0..50), save/load, asserts
byte-identical ids after round-trip. 37 → 38 rabitq tests.
=== rulake: drop the (0..n) workaround ===
crates/ruvector-rulake/src/lake.rs
warm_from_dir now calls `idx.ids_u64()` instead of synthesizing
(0..n). Non-dense external ids round-trip faithfully. The
~15-line inline comment documenting the old limitation is gone;
replaced with a 4-line pointer to the wave-6 close.
=== Agent B: warm_restart runnable example ===
crates/ruvector-rulake/examples/warm_restart.rs (new)
Runnable demo of the full save → ship → warm-restart cycle:
- Phase 1: prime from backend, save to disk
- Phase 2: spin up a FRESH RuLake with NO backend, warm_from_dir,
query, assert warm_installs=1 / primes=0
- Phase 3: cold-prime from backend for comparison
- Final: report cold/warm speedup
Measured at n=5000 D=128 (agent's single-run numbers):
Phase 1 prime: 5.03 ms
save_cache_to_dir: 3.44 ms (2.46 MiB rbpx)
Phase 2 warm: 5.00 ms (warm_installs=1, primes=0)
Phase 3 cold: 3.60 ms
Speedup cold/warm: 0.70×
Honest finding: at n=5k D=128, cold-prime is actually faster than
warm-load because our parallel prime is <5ms and parsing 2.5 MB of
rbpx is slower. The warm-restart win shows up at larger n where
compression dominates; documented in the example's closing block.
Steady-state QPS matches within 1.2% (same compressed index in both).
38 rabitq + 21 rulake lib + 22 rulake federation = 81 tests. Clippy
-D warnings clean across both crates.
Co-Authored-By: claude-flow <ruv@ruv.net>
Wires the previously-shipped rabitq::persist module into ruLake's
lake.rs as first-class cache-save/restore APIs. The architectural
blocker I've deferred across 3 waves is now closed.
=== Agent A: rabitq::RabitqPlusIndex::export_items() ===
crates/ruvector-rabitq/src/index.rs +1 method, +1 test.
Exposes `export_items() -> Vec<(usize, Vec<f32>)>` — each row as
(pos, original_vec) extracted from originals_flat with one clone per
row. Feeds directly into persist::save_index or
from_vectors_parallel_with_rotation. No new deps, no public API
breakage.
Regression test (`export_items_roundtrip_via_from_vectors_parallel`)
builds via serial add(), exports, rebuilds via the parallel path,
asserts byte-identical search results on 5 queries. Tests: 36 → 37.
=== Agent B: RuLake save_cache_to_dir + warm_from_dir ===
crates/ruvector-rulake/src/{cache.rs, lake.rs, tests/federation_smoke.rs}.
New API:
pub fn save_cache_to_dir(&self, key, dir) -> Result<PathBuf>
— writes dir/index.rbpx (atomic temp+rename+fsync) alongside
the table.rulake.json bundle sidecar. Uses export_items +
persist::save_index.
pub fn warm_from_dir(&self, key, dir) -> Result<usize>
— reads bundle, witness-verifies, loads index.rbpx via
persist::load_index, cross-checks dim+rerank_factor, installs
into cache via the new install_prebuilt path. Returns n vectors.
Does NOT require the backend to be registered — warm restart
without backend RTT is the point.
New on CacheStats: warm_installs counter (separate from primes so
warm-restart cost isn't confused with cold-prime cost).
New on VectorCache: install_prebuilt + install_prebuilt_interned —
insert a pre-built Arc<RabitqPlusIndex> at a known witness without
any prime-timer bookkeeping. Respects the LRU cap. Shared-entry
path reuses an existing witness entry if another pointer already
holds it (witness-addressed cache sharing remains the headline).
New test: `warm_from_dir_skips_backend_and_returns_bit_exact_results`
Prime a 50-vec D=8 collection, save, spin up a FRESH RuLake with
NO backend registered + Consistency::Frozen, warm_from_dir, run the
same query, assert byte-identical ids + f32 score bits,
warm_installs=1, primes=0. Closes the "restart without re-prime"
gap end-to-end.
Documented limitation: pos_to_id reconstructed as (0..n) identity
because RabitqPlusIndex doesn't expose outer ids() accessor, and
the rabitq agent's scope prohibited adding it. Every current prime
path uses positional ids so this is byte-equivalent to the real
ids; external non-dense u64 ids would collapse (a known M2+ issue
filed inline).
Tests: 37 rabitq + 21 rulake lib + 22 rulake federation = 80 total.
Clippy -D warnings clean across both crates.
Co-Authored-By: claude-flow <ruv@ruv.net>
Adds direct comparison in rulake-demo. RandomRotationKind re-exported
at the crate root so callers don't need to reach into the rotation
module.
Measured (clustered Gaussian, D=128, rerank×20):
n= 5 000 Haar build: 22.4 ms Hadamard: 7.2 ms (3.09×)
n=50 000 Haar build: 211.6 ms Hadamard: 72.7 ms (2.91×)
n=100 000 Haar build: 421.1 ms Hadamard: 142.9 ms (2.95×)
Matches the O(D²) → O(D log D) theoretical speedup: at D=128,
~16 K flops for the dense matrix multiply vs ~900 flops for three
FWHT passes + three sign-vector multiplies. The 3× ceiling reflects
that other allocations + SoA writes take non-negligible fraction of
build time.
Per-query QPS is flat (±3% noise) because the query-side rotation
is only one of many per-query steps — the scan + rerank dominate,
especially at n ≥ 50k. Hadamard's win is entirely on the prime /
cold-start path, which was already the critical-path latency for
cache-miss queries.
Hadamard + existing parallel prime stack:
n=100k total prime (incl. compression + SoA writes) still ~40 ms
(parallel prime already dominates), but single-threaded rabitq-
demo shows the pure-rotation win at 3×.
Co-Authored-By: claude-flow <ruv@ruv.net>
Two parallel swarm agents delivered disjoint features for M2:
=== Agent A: seed-based index persistence ===
NEW: crates/ruvector-rabitq/src/persist.rs (+393 LoC)
save_index / load_index serialize a RabitqPlusIndex via its *build
inputs* (dim, seed, rerank_factor, ids, vectors) rather than the
opaque internal SoA state. Rationale: (dim, seed, data) →
bit-identical index by construction (RaBitQ is deterministic), and
the public API doesn't expose packed / rotation / cos_lut — so
seed-based reconstruction is the only path without touching index.rs.
On-disk format (32-byte header + payload):
magic "rbpx0001" | version:u32 | dim:u32 | seed:u64
| rerank_factor:u32 | n:u32 | (id:u32, v:f32[dim])*n
DoS caps: dim ≤ 8192, n ≤ 100M, rerank_factor ≤ 1024. Format is
portable — no matrix, no packed codes stored (rebuilt on load).
Tests: serialize_roundtrip_preserves_search_results (10 queries,
byte-exact ids + score bits), reject_bad_magic, reject_version_too_new,
reject_oversize_fields (4 sub-cases).
=== Agent B: randomized Hadamard (HD-HD-HD) rotation ===
MODIFIED: crates/ruvector-rabitq/src/rotation.rs (+219 LoC)
Adds RandomRotation::hadamard(dim, seed) as an opt-in O(D log D)
rotation. Storage is 3 × padded_dim × 4 bytes of ±1 signs instead
of D×D × 4 bytes of Haar matrix (1.5 KiB vs 64 KiB at D=128).
Based on TurboQuant 2025 (arXiv:2504.19874 §3.2): D₃·FWHT·D₂·FWHT·D₁
is close-to-Haar-uniform in the Johnson–Lindenstrauss sense, which
is all RaBitQ's error bound requires. For non-power-of-2 dim:
zero-pad to next_power_of_two, apply, truncate.
Backward-compatible: RandomRotation::random() still returns the
Haar matrix. New RandomRotationKind { HaarDense, HadamardSigned }
enum for introspection. RabitqIndex unchanged — integration into
the scan path is future work (ADR-158 pending).
Tests: hadamard_apply_preserves_norm_power_of_two (D=128, 256),
hadamard_apply_preserves_norm_non_power_of_two (D=1000 → pad 1024,
norm ∈ [0.95, 1.05] on 100 unit vectors), hadamard_is_deterministic,
hadamard_is_fast.
=== Totals ===
25 → 33 rabitq lib tests (+4 persist, +4 hadamard). All 21 rulake
federation + 21 rulake lib tests unchanged and passing. Clippy -D
warnings clean across both crates.
Both agents worked on strictly disjoint file scopes (persist.rs +
lib.rs one-liner vs rotation.rs only) — no merge conflicts.
Co-Authored-By: claude-flow <ruv@ruv.net>
Implements the profiler's top-priority optimization: a SIMD-friendly
scan kernel that decouples the XNOR+popcount agree-count pass from
the cos-LUT + score + TopK heap reduction.
Design (crates/ruvector-rabitq/src/scan.rs):
- scan_scalar: portable u64::count_ones, byte-identical to the
original inline loop.
- scan_avx2: #[target_feature(enable="avx2,popcnt")], 4-candidate
outer unroll via core::arch::x86_64::_popcnt64. Processes 4
rows per loop iteration, amortizing branch + stride overhead.
- scan: runtime dispatcher, cached in std::sync::OnceLock<fn(...)>
so the CPUID check runs once per process.
symmetric_scan_topk in index.rs now:
1. Calls scan::scan(...) once to fill a scratch Vec<u32> of
agree-counts (the whole-table popcount pass).
2. Walks the agree array with the cos-LUT + score + TopK heap —
a serial reduction that was never SIMD-amenable.
Determinism preserved: scan_avx2 and scan_scalar produce byte-
identical agree-count arrays. Two new tests verify this at D=128
(n=1000) and D=64/100/192/200 with tail cases n=1023/7.
Measured (single-thread, cargo run --release rulake-demo):
n= 5 000 direct RaBitQ+: 17,915 → 18,998 QPS (+6%)
n=50 000 direct RaBitQ+: 5,230 → 5,959 QPS (+14%)
n=100k direct RaBitQ+: 3,058 → 3,681 QPS (+20%)
Win grows with n as the per-query allocation overhead becomes a
smaller fraction of scan time. Smaller than the 2-4× upper-bound
profiler estimate because rerank=20 keeps ~30-40% of query time in
the exact-L2 rerank step (unchanged by this patch).
25 rabitq tests passing (23 prior + 2 new scan determinism tests).
Clippy -D warnings clean. No new deps. All unsafe confined to the
two SIMD functions in scan.rs.
Co-Authored-By: claude-flow <ruv@ruv.net>
Memory audit finding #4: encode_query_packed previously did
q.to_vec() // alloc #1 (unit buffer)
self.rotation.apply(&unit) // alloc #2 (rotated buffer)
vec![0u64; n_words] // alloc #3 (returned packed words)
per query. 3 heap allocations per search, firing at ~10k QPS, caused
measurable allocator contention under concurrent load.
Fix: thread_local scratch holds (unit_buf, rotated_buf) across queries
on the same thread. RandomRotation gains an apply_into(&[f32],
&mut [f32]) variant that writes into the scratch rather than allocating.
Only the returned Vec<u64> is freshly allocated (the caller needs
ownership). Net: 3 → 1 allocation per query on the hot path.
New RandomRotation::apply_into is the building block for future
in-place paths; apply() is now a thin wrapper around it.
Measured QPS lift at n=100k (stacked with earlier iter-2/3 security +
flatten):
single-thread QPS: 2,975 → 3,137 (+5%)
concurrent 1-shard: 23,681 → 24,255 (+2%)
The uplift is smaller than the profiler's 30–50% estimate because
at n=100k the scan dominates query encoding. On smaller collections
(n=5k) where encoding is a larger fraction the relative win is
similar. Allocator contention dominates only at much higher QPS.
23 rabitq tests passing. Clippy -D warnings clean.
Co-Authored-By: claude-flow <ruv@ruv.net>
Two memory/perf fixes from the 2026-04-23 audit round.
Flatten (finding #3 of memory audit, top-priority):
RabitqPlusIndex::originals was Vec<Vec<f32>> — one heap allocation
per row, 24 B Vec header × n, pointer-chasing on rerank. Replaced
with originals_flat: Vec<f32> of length n*dim. Row i is
originals_flat[i*dim..(i+1)*dim], accessed via a new
fn original(&self, pos) -> &[f32].
Memory win at n=1M, D=128:
before: 512 MB data + 24 MB Vec headers + 1M heap allocations
after: 512 MB data + 24 B Vec header + 1 allocation
That's 24 MB + allocator fragmentation eliminated.
Drop the double-clone (finding #5):
RabitqPlusIndex::add previously did self.inner.add(id, vector.clone())
+ self.originals.push(vector) — the clone was redundant since
RabitqIndex::add takes owned Vec<f32>. Reordered: extend the flat
buffer first (cheap slice copy), then hand the owned vector to the
inner index. One less alloc per add on the serial prime path.
Also tightened memory_bytes() accounting: 24 B header + n*dim*4 of
payload (instead of 24 B × n + n*dim*4).
Measured prime-time + QPS at n=100k (rayon parallel prime already
landed; this layers on top):
n=100k single-thread QPS: 2,975 → 3,132 (+5%)
n=100k concurrent 4-shard: 33,094 → 33,663 (+2%)
The memory win is the real prize — the perf uplift is small because
rerank is a tiny fraction of scan cost at rerank_factor=20.
23 rabitq tests + 42 rulake tests passing. Clippy clean.
Co-Authored-By: claude-flow <ruv@ruv.net>
RabitqPlusIndex::from_vectors_parallel rotates + bit-packs every
vector in parallel using rayon, then commits the SoA serially.
Produces a bit-identical index to the serial add loop — rotation
matrix is seeded once at construction and encode is deterministic,
so parallel ordering cannot affect output bytes.
VectorCache::prime picks between serial add() and the new parallel
constructor based on batch size (PARALLEL_PRIME_THRESHOLD = 1024).
Below 1k vectors the rayon task-queue overhead outweighs the D×D
rotation savings; above it the parallel path dominates.
Measured (clustered D=128, rerank×20):
n=5k prime 22.3 ms → 4.5 ms (4.9×)
n=50k prime 213 ms → 19.6 ms (10.9×)
n=100k prime 420 ms → 37.6 ms (11.2×)
This is the biggest cold-start-latency win available in M1. Real
backend deployments where prime cost is the critical-path latency
on a cache miss now see p99 drop by an order of magnitude.
rayon dep is no longer feature-gated in rabitq (it's already a
runtime dep via the workspace-pinned 1.10 that ruLake uses).
40 tests passing. Clippy -D warnings clean.
Co-Authored-By: claude-flow <ruv@ruv.net>
Four in-scope M1 items from the remaining backlog, landed together
because they cross-cut cleanly.
Iter 23 (rabitq): VectorKernel trait + CpuKernel default
- Trait: id(), caps() → KernelCaps, scan(ScanRequest) → ScanResponse.
Scan-phase determinism is the hard contract; rerank-phase nondet
is declared via caps().deterministic = false and the caller's
dispatch policy filters those out of Fresh/Frozen paths (ADR-157).
- CpuKernel wraps RabitqPlusIndex::search_with_rerank, always
available, unbounded dim, deterministic.
- Tests: CPU kernel matches direct search byte-exactly + respects
per-call rerank override + caps advertised correctly.
Iter 24 (rulake): memory_class on RuLakeBundle (ADR-156)
- Opaque caller-defined tag — agent systems write "episodic" /
"semantic" / etc; ruLake stores but never interprets.
- Not part of the witness: two bundles with identical data but
different memory_class share the cache.
- Serde default+skip_if_none keeps old bundles forward-compatible.
- Test: roundtrip + witness-unchanged + legacy bundles without the
field still parse.
Iter 25 (rulake): examples/sidecar_daemon.rs
- Runnable demo of publish_bundle / refresh_from_bundle_dir pair.
- Publisher mutates backend + re-publishes; daemon poll loop
detects witness change, invalidates; next query re-primes.
- Includes a bug fix in refresh_from_bundle_dir: when the cache
pointer is None (already invalidated), report UpToDate instead
of Invalidated so daemons don't re-fire on every poll between
"we invalidated" and "somebody queried."
Iter 26 (rulake): CacheStats::stats_by_collection
- Per-(backend, collection) counters, one level finer than
stats_by_backend. Operators can identify which specific
collection is hot and pin it in LRU or increase its shard count.
21 federation + 11 bundle + 3 fs_backend + 3 kernel = 38 tests
passing across both crates. Clippy -D warnings clean. Example runs
end-to-end.
Co-Authored-By: claude-flow <ruv@ruv.net>
Ships the cross-crate fix that iter 12's concurrent bench identified:
K-shard federation no longer pays K× the rerank cost.
Changes:
- rabitq: RabitqPlusIndex::search_with_rerank(query, k, rerank_factor)
— non-mutating per-call override, same body as search(). The stored
field stays the default used by plain search().
- rulake: VectorCache::search_cached_with_rerank(key, q, k, rf_opt)
forwards through. search_cached() remains the default path.
- rulake: RuLake::search_federated uses an adaptive default of
max(MIN_PER_SHARD_RERANK=5, global / K). search_federated_with_rerank
lets callers override explicitly (None = adaptive, Some(global) =
byte-exact parity with single-shard).
Bench (n=100k, 8 clients × 300 queries, same box):
shards before QPS after QPS per-shard rerank
1 2,963 2,854 20
2 2,500 2,959 (1.04×) 10
4 1,778 2,791 (0.98×) 5
4-shard federation went from 0.60× the single-shard baseline to
0.98×. At 2 shards, the mutex serialization overhead even nets us
slightly above 1-shard. Federation is genuinely free now.
Recall gate: adaptive_per_shard_rerank_preserves_recall asserts
recall@10 ≥ 0.85 at K=2 and K=4 on clustered D=128 n=5k.
This closes the M2 cross-crate task filed in ADR-155 (iter 13). The
strategic review's "immediate optimization, high impact" is shipped.
27 → 28 tests passing. Clippy -D warnings clean in both crates.
Co-Authored-By: claude-flow <ruv@ruv.net>
Added three scoped allows at lib + bin entry: `manual_div_ceil`,
`needless_range_loop`, `doc_overindented_list_items`. The two suppressed
lints fire in hot-path SoA walks where the index variable is intentional
(manual bounds-unchecked access via `.add(i * n_words)`); the doc one
is a cosmetic nit. All 13 previous clippy warnings now resolve.
cargo clippy -p ruvector-rabitq --release --all-targets -- -D warnings
✓ clean
cargo test -p ruvector-rabitq --release
✓ 20 passed
cargo doc -p ruvector-rabitq --no-deps
✓ clean
Co-Authored-By: claude-flow <ruv@ruv.net>
Replaces the per-entry `Vec<(usize, BinaryCode)>` storage (where each code
heap-allocated its own `Vec<u64>`) with flat struct-of-arrays:
ids: Vec<u32> — 4 B / vector
norms: Vec<f32> — 4 B / vector
packed: Vec<u64> — n × n_words contiguous slab
and adds a cos-lookup table keyed on the agreement count so the
`.cos()` call in the estimator drops to a single L1 indexed load.
Measured at n=100k, D=128 (same seeds, same dataset, same host):
| variant | before QPS | after QPS | Δ | r@10 |
|--------------------------|-----------:|----------:|------:|------:|
| Flat | 309 | 306 | — | 100.0%|
| RaBitQ sym no rerank | 1,176 | 3,639 | 3.09× | 8.1% |
| RaBitQ+ sym rerank×5 | 811 | 2,058 | 2.54× | 87.9% |
| RaBitQ+ sym rerank×20 | 544 | 957 | 1.76× |100.0% |
Flat's f32 baseline is unchanged (as expected — SoA only affects the
binary-code scan). Rerank×20 is now 3.13× over flat at 100% recall@10,
up from 1.76× in v1.
Memory also improved: `RabitqIndex` at n=100k drops from 5.8 MB to
2.4 MB = 21× compression vs flat (up from 8.7×), because the SoA layout
collapses the 40 B per-entry tuple+header overhead to 8 B per row.
Asymmetric path is unchanged — its O(D) scalar signed-dot-product
dominates; the SoA layout helps the outer walk but not the inner
arithmetic. SIMD gather is the next lever for that path.
Changes:
- `RabitqIndex` storage: SoA with u32 ids, f32 norms, flat u64 packed
slab. Adds `last_word_mask` (for D % 64 != 0) and `cos_lut` (D+1 f32s).
- New `RabitqIndex::symmetric_scan_topk()` — raw-pointer SoA walk with
aligned-D fast path (`D % 64 == 0` skips the last-word AND). Used by
both `RabitqIndex::search` and `RabitqPlusIndex::search`.
- `TopK::push_raw(id, score, pos)` + `into_sorted_with_pos()` so rerank
can look up `originals[pos]` in O(1) without repacking IDs.
- `RabitqAsymIndex::search` walks SoA directly (kernel still O(D)).
- `.codes()` accessor replaced with SoA accessors (`ids()`, `norms()`,
`packed()`, `cos_lut()`, `n_words()`); `codes_materialised()` returns
the boxed AoS view for back-compat at O(n) allocation cost.
- New `encode_query_packed()` — returns just the packed words so the
hot scan doesn't allocate a BinaryCode box per search.
All 20 tests still pass (including the D=100 non-aligned regression;
the fast path is gated on `last_word_mask == !0`, so unaligned D falls
to the masked code path).
BENCHMARK.md updated with before/after table and the "what changed"
narrative.
Co-Authored-By: claude-flow <ruv@ruv.net>
Fixes the four concrete bugs and three integrity issues surfaced in the
deep review of commit f2dbb6efb, adds the SIGMOD-2024-style asymmetric
IP estimator, and ships a single-source-of-truth benchmark harness whose
numbers are reproducible with one `cargo run`.
### Bug fixes
1. **Padding-safe popcount** (`quantize.rs::masked_xnor_popcount`). At
`D % 64 != 0` the zero padding of the last u64 was being counted as
matching bits, biasing the estimator. New method masks the unused MSBs
of the last word before popcount. Regression test at D=100 pins it
(raw XNOR returns 28 matches for opposite vectors; masked returns 0).
2. **Honest memory accounting**. `RabitqIndex` previously stored the
original f32 vectors unconditionally but omitted them from
`memory_bytes()`. Fixed by (a) dropping `originals` from `RabitqIndex`
entirely — rerank lives in `RabitqPlusIndex` only, and (b) including
all allocations in `memory_bytes()` for every variant. New test
`memory_accounting_is_honest` enforces `RabitqIndex < Flat` and
`RabitqPlusIndex > Flat` (since the latter truly stores both).
3. **NaN-safe sort**. Replaced every `partial_cmp().unwrap()` with
`f32::total_cmp`; a rogue NaN (possible near the `.cos()` domain
edge) now sorts to the back instead of panicking search. New test
`nan_query_does_not_panic`.
4. **Renamed misleading test**. `rabitq_recall_at_10_above_70pct` was
asserting `> 0.20`. Renamed to `rabitq_recall_above_random`.
### Algorithm upgrade
5. **Asymmetric estimator** (`quantize.rs::estimated_sq_distance_asymmetric`).
Query stays in f32 (rotated once), database stays 1-bit. IP is
reconstructed by summing the rotated query's components with per-dim
signs read from the stored code and rescaling by 1/√D. O(D) per
candidate vs O(D/64) popcount — slower but tighter. Closes the gap
between this crate and the SIGMOD 2024 RaBitQ estimator (the prior
code was Charikar-2002 hyperplane-LSH on a rotated basis). Exposed
as `RabitqAsymIndex` with optional rerank.
### Optimizations
6. **Top-k via bounded max-heap**: O(n log k) per search instead of
O(n log n) sort. Matters at n ≥ 10 000.
7. **Single query rotation per search** amortised across all candidates
for both symmetric and asymmetric paths.
8. **Stricter full-pairs orthogonality test** at D ∈ {64, 128, 256} —
previous test only checked (row 0, row 1) of a 64×64 matrix.
### Honest benchmarks
The new `rabitq-demo` binary produces recall@1/@10/@100, QPS, memory, and
build time for all four indexes on the SAME clustered dataset, across
n ∈ {1 k, 5 k, 50 k, 100 k}. Headline numbers (Ryzen-class, single thread):
| config (n=100k, D=128) | r@10 | QPS | mem/MB | vs flat |
|---|---:|---:|---:|---:|
| FlatF32 | 100.0% | 309 | 50.4 | — |
| Sym rerank×5 | 87.9% | 811 | 56.9 | 2.6× |
| Sym rerank×20 | 100.0% | 544 | 56.9 | 1.76× |
Scaling regression of rerank×5 from 100% at n=5k to 87.9% at n=100k is
now explicitly documented (it was hidden in the previous gist).
Mem: codes-only at n=100k is 5.8 MB vs Flat's 50.4 MB = 8.7× compression
on the real index, 32× per-vector codes-vs-f32.
### Test count
- Before: 10 tests
- After: 20 tests (including 2 non-aligned-D regression tests, NaN
safety, asymmetric-vs-symmetric ordering, full-pairs orthogonality
at D=64/128/256, memory accounting, heap top-k ordering).
### Writing
- `lib.rs` doc block now honestly describes the two estimators and
doesn't claim pure-std (deps: rand, rand_distr, serde, thiserror).
- New `BENCHMARK.md` captures every number with the seed and reproducer.
- Doc comments through the crate reference the SIGMOD 2024 paper
accurately — the symmetric path is Charikar-style, the asymmetric is
RaBitQ-2024-style; both are shipped.
### What's NOT shipped yet (named)
- SIFT1M / GIST1M / DEEP10M benchmarks (still on Gaussian clusters).
- HNSW integration (the production shape).
- SIMD popcount via `std::arch` (scalar POPCNT is used today).
- Parallel search via `rayon` (feature-gated, off by default).
20 tests pass. Benchmark reproducer: `cargo run --release -p
ruvector-rabitq --bin rabitq-demo`.
Co-Authored-By: claude-flow <ruv@ruv.net>