Files
ruvnet--RuView/v2/crates/wifi-densepose-ruvector/src/hnsw_quantized.rs
T
rUv f756a8af49 feat(ADR-261): ruvector HNSW graph-ANN (25x measured vs linear) + honest SymphonyQG-direction refutation (#1063)
* feat(ruvector): real float HNSW + SymphonyQG-style quantized-traversal index (ADR-261)

Adds the graph-ANN index the ruvector retrieval path was missing (ADR-156
§5 #1 noted there was no HNSW baseline to measure SymphonyQG against).

- hnsw.rs: correct float HNSW (Malkov & Yashunin) — multi-layer NSW graph,
  ef_construction/ef_search, Algorithm-4 neighbour selection, seeded-
  deterministic level assignment (SplitMix64, reused from rotation.rs),
  L2 + cosine, brute-force ground truth, full degenerate-case guards.
  recall@10 correctness gate >=0.95 vs brute force (L2 + cosine).
- hnsw_quantized.rs: SymphonyQG-style variant — same graph, traversal scored
  by cheap 1-bit Hamming over the RaBitQ Pass-2 rotated sign code, final
  exact-float rerank.
- ann_measure.rs: shared deterministic planted-cluster fixture + recall/QPS
  measurement (ann_bench_report is the ADR source of truth).

Fixes an index-out-of-bounds bug the recall gate caught: insert wired
bidirectional edges before pushing the node's own link row. +20 tests,
ruvector lib 131->151, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* bench(ruvector): criterion ann_bench for HNSW vs quantized vs linear (ADR-261)

Times the same shared ann_measure fixture/indices through criterion so the
bench and the report test can never measure different graphs.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-261): graph-ANN index ADR with MEASURED HNSW vs quantized verdict

ADR-261 (Accepted): float HNSW ~25x QPS over linear scan at recall >=0.99
(the baseline ADR-156 said was missing). Honest negative: the 1-bit
quantized traversal is too coarse to beat float HNSW at equal recall at
N=10k (best recall 0.738, no >=0.90 equal-recall point) — the SymphonyQG
3.5-17x is NOT reproduced by our 1-bit construction; expected crossover at
large N + a multi-bit code. Caveat: our HNSW + our quant, not SymphonyQG's
system — direction tested, not a 1:1 reproduction.

ADR-156 §5 #1 + §8 backlog: CLAIMED -> MEASURED-direction-tested.
CHANGELOG [Unreleased] entry.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 02:33:32 -04:00

467 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A **SymphonyQG-style quantized-traversal HNSW** — ADR-261.
//!
//! # The SymphonyQG bet (what we are testing)
//!
//! [SymphonyQG (SIGMOD 2025)](../../../../../docs/adr/ADR-261-ruvector-graph-ann-index.md)
//! unifies **quantization with graph traversal**: instead of computing the full
//! float distance at every node the beam search visits (the cost that dominates
//! float HNSW — one `O(d)` float dot/diff per visited node), it scores traversal
//! candidates with a **cheap quantized distance** and only computes the exact
//! float distance for the *final* candidate set, which it **reranks**. The bet:
//! the quantized score is cheap enough — and accurate enough to keep the beam on
//! the right path — that you visit roughly as many nodes but pay far less per
//! node, and recover the small recall loss with a final exact rerank. Source
//! reports **3.517× QPS over HNSW at equal recall**.
//!
//! # Our implementation (honest scope)
//!
//! We are **not** reproducing SymphonyQG's exact system (their RaBitQ-fused codes,
//! their SIMD layout, their refined graph). We build the **direction** of the
//! claim from the pieces this crate already has, so the comparison is
//! apples-to-apples on *our* hardware:
//!
//! - **Same graph** as the float [`crate::HnswIndex`] — identical structure,
//! identical seed, identical level assignment. The *only* variable between the
//! float and quantized search is **how a candidate is scored during traversal**,
//! so any QPS/recall difference is attributable to the quantization, not to a
//! different graph.
//! - **Quantized score = 1-bit Hamming over the RaBitQ Pass-2 rotated sign code**
//! ([`crate::rotation`] + the sign-quantization in [`crate::sketch`]). Each
//! node stores its `ceil(D/8)`-byte sign code (`D = next_pow2(dim)`). During
//! traversal we compare query-code vs node-code by **POPCNT Hamming** — a few
//! machine words, no per-dimension float work.
//! - **Exact float rerank** of the final beam: the top `rerank` candidates by
//! Hamming are re-scored with the true float metric and the best `k` returned.
//!
//! This trades a small recall hit (the 1-bit code is a coarse angle proxy — the
//! same ~46%-strict limitation ADR-156 §10 measured) for far cheaper per-node
//! scoring, recovered by the float rerank. **Whether that nets a QPS win at our
//! test scale is the measured question ADR-261 answers** — and at small N the
//! float distance is cheap enough that the Hamming saving may not pay off. We
//! report the real number, win or lose, and do not tune to manufacture a speedup.
//!
//! # Determinism & robustness
//!
//! The graph seed drives everything (level assignment), so the quantized index
//! is as reproducible as the float one. Empty/degenerate inputs are guarded
//! exactly as in [`crate::hnsw`] — no panic on empty index, `k > n`, `k == 0`,
//! single node, ragged query, or zero dim.
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashSet};
use crate::hnsw::{HnswIndex, HnswParams, Metric};
use crate::rotation::Rotation;
/// A 1-bit Pass-2 sign code for one vector, over the padded rotation length `D`.
/// Stored as packed bytes; compared by POPCNT Hamming.
#[derive(Debug, Clone)]
struct Code {
bits: Vec<u8>,
}
impl Code {
/// Hamming distance to another code of the same length (popcount of XOR).
#[inline]
fn hamming(&self, other: &Code) -> u32 {
let n = self.bits.len().min(other.bits.len());
let mut acc = 0u32;
for i in 0..n {
acc += (self.bits[i] ^ other.bits[i]).count_ones();
}
acc
}
}
/// Build the packed 1-bit sign code of a rotated embedding over the padded
/// length `D = rotation.padded_dim()`. Bit set ⇒ rotated coord ≥ 0.
fn encode(embedding: &[f32], rotation: &Rotation) -> Code {
let rotated = rotation.apply_padded(embedding);
let d = rotated.len();
let mut bits = vec![0u8; d.div_ceil(8)];
for (i, &c) in rotated.iter().enumerate() {
if c >= 0.0 {
bits[i / 8] |= 1 << (7 - (i % 8));
}
}
Code { bits }
}
/// Min-heap node for the quantized beam (closest Hamming at the top).
#[derive(Debug, Clone, Copy)]
struct HScored {
/// Hamming distance (quantized score) — the traversal key.
ham: u32,
id: u32,
}
impl PartialEq for HScored {
fn eq(&self, other: &Self) -> bool {
self.ham == other.ham && self.id == other.id
}
}
impl Eq for HScored {}
impl Ord for HScored {
fn cmp(&self, other: &Self) -> Ordering {
self.ham.cmp(&other.ham).then(self.id.cmp(&other.id))
}
}
impl PartialOrd for HScored {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Reversed wrapper for a min-heap (smallest Hamming at the top).
#[derive(Debug, Clone, Copy)]
struct MinH(HScored);
impl PartialEq for MinH {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl Eq for MinH {}
impl Ord for MinH {
fn cmp(&self, other: &Self) -> Ordering {
other.0.cmp(&self.0)
}
}
impl PartialOrd for MinH {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// A SymphonyQG-style HNSW: the same graph as [`HnswIndex`], traversed by a
/// **cheap 1-bit Hamming score**, with a final **exact-float rerank**.
///
/// Built by inserting the same vectors in the same order with the same seed as
/// a float [`HnswIndex`], so the two indices share identical graph structure and
/// only differ in how the beam is scored. The shared [`Rotation`] (seed + dim)
/// is the index/query frame for the 1-bit codes.
#[derive(Debug, Clone)]
pub struct QuantizedHnswIndex {
/// The underlying graph (built with the float metric for exact rerank).
graph: HnswIndex,
/// Per-node 1-bit Pass-2 codes, indexed by id (parallel to graph vectors).
codes: Vec<Code>,
/// The rotation frame shared by index and query codes.
rotation: Rotation,
/// Number of final candidates to exact-float rerank (≥ k at query time).
default_rerank: usize,
}
impl QuantizedHnswIndex {
/// Build a quantized index over `vectors`, mirroring a float [`HnswIndex`]
/// built with the same `(dim, metric, params)` and insertion order. The
/// `rotation_seed` fixes the 1-bit code frame (index and query share it).
///
/// `default_rerank` is how many top-Hamming candidates get an exact float
/// re-score before returning the best `k`; it is clamped to `≥ k` at query
/// time. A larger rerank recovers more recall at more float cost — the knob
/// that, alongside `ef`, sets the equal-recall operating point.
pub fn build(
vectors: &[Vec<f32>],
dim: usize,
metric: Metric,
params: HnswParams,
rotation_seed: u64,
default_rerank: usize,
) -> Self {
let rotation = Rotation::new(rotation_seed, dim);
let mut graph = HnswIndex::new(dim, metric, params);
let mut codes = Vec::with_capacity(vectors.len());
for v in vectors {
graph.insert(v);
codes.push(encode(v, &rotation));
}
Self {
graph,
codes,
rotation,
default_rerank: default_rerank.max(1),
}
}
/// Number of indexed points.
#[inline]
pub fn len(&self) -> usize {
self.graph.len()
}
/// True iff empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.graph.is_empty()
}
/// Borrow the underlying float graph (for shared-graph benchmark parity:
/// the float-HNSW baseline runs on *this* graph so the only variable is
/// scoring).
#[inline]
pub fn graph(&self) -> &HnswIndex {
&self.graph
}
/// The rerank width this index defaults to.
#[inline]
pub fn default_rerank(&self) -> usize {
self.default_rerank
}
/// SymphonyQG-style search: traverse the graph scoring candidates by **1-bit
/// Hamming**, collect a beam of `ef`, then **exact-float rerank** the top
/// `rerank` (clamped ≥ k) and return the best `k` as `(id, float_dist)`.
///
/// Degenerate cases mirror [`HnswIndex::search`]: empty ⇒ empty; `k == 0` ⇒
/// empty; `k > n` ⇒ all; never panics.
pub fn search_quantized(
&self,
query: &[f32],
k: usize,
ef: usize,
rerank: usize,
) -> Vec<(u32, f32)> {
if k == 0 || self.is_empty() {
return Vec::new();
}
let ef = ef.max(k).max(1);
let rerank = rerank.max(k);
let q_code = encode(query, &self.rotation);
// Entry point: the graph's entry (highest-level node).
let entry = match self.graph.entry_point() {
Some(e) => e,
None => return Vec::new(),
};
// Greedy-descend upper layers by Hamming, then beam-search layer 0.
let mut ep = entry;
let mut layer = self.graph.top_level();
while layer > 0 {
ep = self.greedy_hamming(&q_code, ep, layer);
layer -= 1;
}
let beam = self.beam_hamming(&q_code, ep, ef);
// Exact-float rerank of the top `rerank` Hamming candidates.
let mut cand: Vec<HScored> = beam;
cand.sort_by_key(|c| c.ham);
cand.truncate(rerank);
let mut reranked: Vec<(u32, f32)> = cand
.iter()
.filter_map(|c| {
self.graph
.vector(c.id)
.map(|v| (c.id, self.graph.metric().distance(query, v)))
})
.collect();
reranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
reranked.truncate(k);
reranked
}
/// Search using the index's default `ef` (from graph params) and rerank.
#[inline]
pub fn search_default(&self, query: &[f32], k: usize) -> Vec<(u32, f32)> {
self.search_quantized(query, k, self.graph.params_ef_search(), self.default_rerank)
}
/// Greedy single-best descent on a layer scored by Hamming.
fn greedy_hamming(&self, q_code: &Code, start: u32, layer: usize) -> u32 {
let mut best = start;
let mut best_h = self.codes[best as usize].hamming(q_code);
loop {
let mut improved = false;
for &nbr in self.graph.neighbours(best, layer) {
let h = self.codes[nbr as usize].hamming(q_code);
if h < best_h {
best_h = h;
best = nbr;
improved = true;
}
}
if !improved {
return best;
}
}
}
/// Beam search on layer 0 scored by Hamming. Returns the `ef` best-Hamming
/// nodes (unsorted). Iterative — bounded by the visited set + the ef beam.
fn beam_hamming(&self, q_code: &Code, ep: u32, ef: usize) -> Vec<HScored> {
let mut visited: HashSet<u32> = HashSet::new();
let mut candidates: BinaryHeap<MinH> = BinaryHeap::new();
let mut results: BinaryHeap<HScored> = BinaryHeap::new(); // max-heap: worst at top
let h0 = self.codes[ep as usize].hamming(q_code);
let s0 = HScored { ham: h0, id: ep };
visited.insert(ep);
candidates.push(MinH(s0));
results.push(s0);
while let Some(MinH(cur)) = candidates.pop() {
let worst = results.peek().map(|s| s.ham).unwrap_or(u32::MAX);
if cur.ham > worst && results.len() >= ef {
break;
}
for &nbr in self.graph.neighbours(cur.id, 0) {
if !visited.insert(nbr) {
continue;
}
let h = self.codes[nbr as usize].hamming(q_code);
let worst = results.peek().map(|s| s.ham).unwrap_or(u32::MAX);
if results.len() < ef || h < worst {
let s = HScored { ham: h, id: nbr };
candidates.push(MinH(s));
results.push(s);
while results.len() > ef {
results.pop();
}
}
}
}
results.into_vec()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn split_mix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn unif01(state: &mut u64) -> f32 {
((split_mix64(state) >> 40) as f32) / ((1u64 << 24) as f32)
}
fn gauss(state: &mut u64) -> f32 {
let u1 = unif01(state).max(1e-7);
let u2 = unif01(state);
(-2.0 * u1.ln()).sqrt() * (std::f32::consts::TAU * u2).cos()
}
fn planted(dim: usize, n: usize, clusters: usize, seed: u64) -> Vec<Vec<f32>> {
let centres: Vec<Vec<f32>> = (0..clusters)
.map(|c| {
let mut s = seed ^ (0xC0FFEE_u64.wrapping_mul(c as u64 + 1));
(0..dim).map(|_| gauss(&mut s) * 3.0).collect()
})
.collect();
(0..n)
.map(|i| {
let c = i % clusters;
let mut s = seed ^ (i as u64).wrapping_mul(0x9E37);
(0..dim).map(|d| centres[c][d] + gauss(&mut s) * 0.35).collect()
})
.collect()
}
fn params(seed: u64) -> HnswParams {
HnswParams {
m: 16,
ef_construction: 200,
ef_search: 64,
seed,
}
}
#[test]
fn empty_quantized_search_is_empty_no_panic() {
let idx = QuantizedHnswIndex::build(&[], 8, Metric::Cosine, params(1), 0x42, 16);
assert!(idx.is_empty());
assert!(idx.search_quantized(&[0.0; 8], 5, 16, 16).is_empty());
}
#[test]
fn single_node_quantized_returns_itself() {
let v = vec![vec![1.0, 2.0, 3.0, 4.0]];
let idx = QuantizedHnswIndex::build(&v, 4, Metric::L2, params(2), 0x7, 8);
let r = idx.search_quantized(&v[0], 3, 16, 8);
assert_eq!(r.len(), 1);
assert_eq!(r[0].0, 0);
}
#[test]
fn k_zero_and_k_gt_n_no_panic() {
let vectors = planted(16, 40, 4, 0xABCD);
let idx = QuantizedHnswIndex::build(&vectors, 16, Metric::L2, params(3), 0x9, 32);
assert!(idx.search_quantized(&vectors[0], 0, 16, 16).is_empty());
let r = idx.search_quantized(&vectors[0], 1000, 64, 64);
assert_eq!(r.len(), 40);
}
#[test]
fn ragged_query_no_panic() {
let vectors = planted(16, 30, 3, 0x55);
let idx = QuantizedHnswIndex::build(&vectors, 16, Metric::Cosine, params(4), 0xB, 16);
assert!(!idx.search_quantized(&[1.0, 2.0, 3.0], 3, 16, 16).is_empty());
let long: Vec<f32> = (0..100).map(|i| i as f32).collect();
assert!(!idx.search_quantized(&long, 3, 16, 16).is_empty());
}
#[test]
fn quantized_is_deterministic() {
let vectors = planted(32, 300, 8, 0x2468);
let a = QuantizedHnswIndex::build(&vectors, 32, Metric::Cosine, params(0xFEED), 0xC0DE, 32);
let b = QuantizedHnswIndex::build(&vectors, 32, Metric::Cosine, params(0xFEED), 0xC0DE, 32);
let q = &vectors[100];
assert_eq!(
a.search_quantized(q, 10, 64, 32),
b.search_quantized(q, 10, 64, 32),
"quantized search must be deterministic"
);
}
/// Recall@10 of quantized-HNSW vs brute-force ground truth, averaged over
/// queries. With an exact-float rerank, recall should be high (the rerank
/// repairs most of the 1-bit traversal's coarseness). This is the quantized
/// variant's correctness gate.
#[test]
fn quantized_recall_at_10_is_high_with_rerank() {
let dim = 64;
let n = 2000;
let clusters = 32;
let seed = 0x9999;
let vectors = planted(dim, n, clusters, seed);
// Generous rerank so the exact float repairs the coarse Hamming beam.
let idx = QuantizedHnswIndex::build(&vectors, dim, Metric::L2, params(0xAAAA), 0x5EED, 64);
let mut total = 0.0f64;
let n_queries = 64;
for q in 0..n_queries {
let c = q % clusters;
let mut cs = seed ^ (0xC0FFEE_u64.wrapping_mul(c as u64 + 1));
let centre: Vec<f32> = (0..dim).map(|_| gauss(&mut cs) * 3.0).collect();
let mut s = seed ^ 0xDEAD_0000 ^ (q as u64).wrapping_mul(0x2545_F491);
let qv: Vec<f32> = (0..dim).map(|d| centre[d] + gauss(&mut s) * 0.35).collect();
let truth: HashSet<u32> = idx
.graph()
.brute_force(&qv, 10)
.into_iter()
.map(|(id, _)| id)
.collect();
let got = idx.search_quantized(&qv, 10, 128, 64);
let hit = got.iter().filter(|(id, _)| truth.contains(id)).count();
total += hit as f64 / 10.0;
}
let recall = total / n_queries as f64;
// The 1-bit code is coarse, so we do not demand the float 0.95 gate here;
// but with a 64-wide rerank over an ef=128 beam it must be clearly useful
// (well above random). ADR-261 reports the exact number; this gate just
// catches a broken traversal/rerank.
assert!(
recall >= 0.80,
"quantized recall@10 = {recall:.4} too low — traversal or rerank bug"
);
}
#[test]
fn zero_dim_no_panic() {
let vectors = vec![vec![], vec![]];
let idx = QuantizedHnswIndex::build(&vectors, 0, Metric::Cosine, params(5), 0x1, 4);
let r = idx.search_quantized(&[], 2, 16, 4);
assert_eq!(r.len(), 2);
}
}