From 0726282ed615aca52e869af62f819d98ab203f21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 07:26:44 +0000 Subject: [PATCH 1/3] feat(ruvector-coherence-quant): coherence-adaptive quantization PoC Nightly research crate testing whether mutual-kNN boundary detection (a lightweight, ground-truth-free proxy for local graph conductance) can drive per-vector 4-bit/8-bit scalar quantization allocation. Baseline (uniform 8-bit), candidate A (uniform 4-bit), and candidate B (coherence-adaptive) are implemented with real bit-packed storage, deterministic synthetic datasets, and a reproducible benchmark. Result documented separately: hypothesis is falsified on the tested workload. --- Cargo.lock | 4 + Cargo.toml | 2 + crates/ruvector-coherence-quant/Cargo.toml | 17 ++ .../src/bin/benchmark.rs | 257 ++++++++++++++++++ .../ruvector-coherence-quant/src/coherence.rs | 123 +++++++++ .../ruvector-coherence-quant/src/dataset.rs | 135 +++++++++ crates/ruvector-coherence-quant/src/lib.rs | 110 ++++++++ .../ruvector-coherence-quant/src/metrics.rs | 57 ++++ .../ruvector-coherence-quant/src/quantize.rs | 145 ++++++++++ crates/ruvector-coherence-quant/src/search.rs | 81 ++++++ 10 files changed, 931 insertions(+) create mode 100644 crates/ruvector-coherence-quant/Cargo.toml create mode 100644 crates/ruvector-coherence-quant/src/bin/benchmark.rs create mode 100644 crates/ruvector-coherence-quant/src/coherence.rs create mode 100644 crates/ruvector-coherence-quant/src/dataset.rs create mode 100644 crates/ruvector-coherence-quant/src/lib.rs create mode 100644 crates/ruvector-coherence-quant/src/metrics.rs create mode 100644 crates/ruvector-coherence-quant/src/quantize.rs create mode 100644 crates/ruvector-coherence-quant/src/search.rs diff --git a/Cargo.lock b/Cargo.lock index 895e642572..9aeac57bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9095,6 +9095,10 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ruvector-coherence-quant" +version = "0.1.0" + [[package]] name = "ruvector-collections" version = "2.3.0" diff --git a/Cargo.toml b/Cargo.toml index b4381e6431..2523bd84dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -302,6 +302,8 @@ members = [ "crates/ruvector-streaming-qng", # Entropy-adaptive ANN beam search: live Shannon entropy gates beam width (ADR-303) "crates/ruvector-entropy-ann", + # Coherence-adaptive quantization: mutual-kNN boundary detection drives bit-width allocation (ADR-305) + "crates/ruvector-coherence-quant", ] resolver = "2" diff --git a/crates/ruvector-coherence-quant/Cargo.toml b/crates/ruvector-coherence-quant/Cargo.toml new file mode 100644 index 0000000000..3b3f26b7d9 --- /dev/null +++ b/crates/ruvector-coherence-quant/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ruvector-coherence-quant" +version = "0.1.0" +edition = "2021" +description = "Coherence-adaptive quantization: mutual-kNN boundary detection drives per-vector bit-width allocation for scalar vector quantization" +license = "MIT OR Apache-2.0" +repository = "https://github.com/ruvnet/ruvector" +keywords = ["ann", "quantization", "coherence", "vector-search", "compression"] +categories = ["algorithms", "data-structures"] + +[[bin]] +name = "benchmark" +path = "src/bin/benchmark.rs" + +[profile.release] +opt-level = 3 +lto = "thin" diff --git a/crates/ruvector-coherence-quant/src/bin/benchmark.rs b/crates/ruvector-coherence-quant/src/bin/benchmark.rs new file mode 100644 index 0000000000..059919a353 --- /dev/null +++ b/crates/ruvector-coherence-quant/src/bin/benchmark.rs @@ -0,0 +1,257 @@ +//! Benchmark: Coherence-Adaptive Quantization +//! +//! Compares uniform 8-bit (baseline), uniform 4-bit (candidate A), and +//! mutual-kNN-coherence-adaptive 4/8-bit (candidate B) scalar quantization +//! on a clustered synthetic corpus. +//! +//! Run: +//! cargo run --release -p ruvector-coherence-quant --bin benchmark + +use ruvector_coherence_quant::{ + coherence::{build_knn_graph, mutual_knn_coherence}, + dataset::{clustered_vectors, ground_truth, jittered_queries}, + metrics::LatencyStats, + quantize::{quantize, Bits, QuantizedVector}, + recall_at_k, + search::QuantizedIndex, +}; +use std::time::Instant; + +// ─── Pre-registered experiment configuration ────────────────────────────── +// (fixed before benchmarking; see docs/research/nightly/2026-08-15-coherence-adaptive-quant) +const N: usize = 4_000; +const DIM: usize = 32; +const N_CLUSTERS: usize = 12; +const CLUSTER_NOISE: f32 = 0.18; +const CORPUS_SEED: u64 = 42; + +const K_COHERENCE: usize = 12; // k for the mutual-kNN coherence graph +const COHERENCE_THRESHOLD: f32 = 0.5; // >= threshold -> core (4-bit); < threshold -> boundary (8-bit) + +const K_RECALL: usize = 10; +const N_QUERIES: usize = 300; +const QUERY_JITTER: f32 = 0.12; +const QUERY_SEED: u64 = 777; + +// ─── Pre-registered acceptance thresholds ───────────────────────────────── +// ACCEPT requires all of: +// 1. candidate_A recall is meaningfully worse than baseline (>1.5pp) -- +// otherwise uniform 4-bit already suffices and there is nothing to fix. +// 2. candidate_B recall is within 1.5pp of baseline (recovers most of the +// precision loss uniform 4-bit incurs). +// 3. candidate_B total memory <= 65% of baseline memory (meaningfully +// compressed, not just "pretend to adapt but store everything at 8-bit"). +const RECALL_GAP_MIN_FOR_SIGNAL: f32 = 0.015; +const RECALL_RECOVERY_TOLERANCE: f32 = 0.015; +const MEMORY_BUDGET_FRACTION: f32 = 0.65; + +fn build_index(corpus: &[Vec], bits: &[Bits]) -> QuantizedIndex { + let vectors: Vec = corpus + .iter() + .zip(bits.iter()) + .map(|(v, &b)| quantize(v, b)) + .collect(); + QuantizedIndex { vectors } +} + +fn compute_recall( + index: &QuantizedIndex, + queries: &[Vec], + corpus: &[Vec], + k: usize, +) -> f32 { + let total: f32 = queries + .iter() + .map(|q| { + let gt = ground_truth(q, corpus, k); + let hits = index.search(q, k); + recall_at_k(>, &hits, k) + }) + .sum(); + total / queries.len() as f32 +} + +struct VariantResult { + name: &'static str, + recall: f32, + stats: LatencyStats, + mem_bytes: usize, + mean_bits: f32, +} + +fn print_row(r: &VariantResult) { + println!( + " {:<28} recall={:.4} mean_bits={:.2} mem={:>7}KB \ + mean={:6.1}us p50={:6.1}us p95={:7.1}us {:>8.0} qps", + r.name, + r.recall, + r.mean_bits, + r.mem_bytes / 1024, + r.stats.mean_us, + r.stats.p50_us, + r.stats.p95_us, + r.stats.throughput_qps, + ); +} + +fn main() { + println!("=== Coherence-Adaptive Quantization Benchmark ==="); + println!(); + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + let ncpu = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); + println!("OS: {os} / {arch}"); + println!("CPU threads: {ncpu}"); + println!("Rust: (see: rustc --version)"); + println!(); + + println!("Dataset:"); + println!(" N (corpus) : {N}"); + println!(" Dimensions : {DIM}"); + println!(" Clusters : {N_CLUSTERS} noise={CLUSTER_NOISE}"); + println!(" Queries : {N_QUERIES} (jitter={QUERY_JITTER}, held-out)"); + println!(" k (recall) : {K_RECALL}"); + println!(" k (coherence): {K_COHERENCE} threshold={COHERENCE_THRESHOLD}"); + println!(); + + println!("Building corpus..."); + let t0 = Instant::now(); + let corpus = clustered_vectors(N, DIM, N_CLUSTERS, CLUSTER_NOISE, CORPUS_SEED); + println!(" corpus built in {:.1}ms", t0.elapsed().as_millis()); + + let queries = jittered_queries(&corpus, N_QUERIES, QUERY_JITTER, QUERY_SEED); + + println!("Building mutual-kNN coherence graph (k={K_COHERENCE})..."); + let t1 = Instant::now(); + let knn = build_knn_graph(&corpus, K_COHERENCE); + let coherence = mutual_knn_coherence(&knn); + let coherence_build_ms = t1.elapsed().as_millis(); + println!(" coherence graph + scores built in {coherence_build_ms}ms"); + + let mut sorted_coherence = coherence.clone(); + sorted_coherence.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mean_coherence = coherence.iter().sum::() / coherence.len() as f32; + let p50_coherence = sorted_coherence[sorted_coherence.len() / 2]; + let n_core = coherence + .iter() + .filter(|&&c| c >= COHERENCE_THRESHOLD) + .count(); + let n_boundary = coherence.len() - n_core; + println!( + " coherence: mean={mean_coherence:.3} p50={p50_coherence:.3} \ + core(>= {COHERENCE_THRESHOLD})={n_core} ({:.1}%) boundary={n_boundary} ({:.1}%)", + 100.0 * n_core as f32 / coherence.len() as f32, + 100.0 * n_boundary as f32 / coherence.len() as f32, + ); + println!(); + + // ── Variant bit assignments ──────────────────────────────────────────── + let bits_baseline: Vec = vec![Bits::Eight; N]; + let bits_candidate_a: Vec = vec![Bits::Four; N]; + let bits_candidate_b: Vec = coherence + .iter() + .map(|&c| { + if c >= COHERENCE_THRESHOLD { + Bits::Four + } else { + Bits::Eight + } + }) + .collect(); + + let index_baseline = build_index(&corpus, &bits_baseline); + let index_a = build_index(&corpus, &bits_candidate_a); + let index_b = build_index(&corpus, &bits_candidate_b); + + println!("Benchmarking variants ({N_QUERIES} held-out queries)..."); + println!(); + + let mut results = Vec::new(); + for (name, index) in [ + ("baseline_uniform_8bit", &index_baseline), + ("candidate_A_uniform_4bit", &index_a), + ("candidate_B_coherence_adaptive", &index_b), + ] { + let recall = compute_recall(index, &queries, &corpus, K_RECALL); + let (_, stats) = LatencyStats::measure(N_QUERIES, |i| index.search(&queries[i], K_RECALL)); + let r = VariantResult { + name, + recall, + stats, + mem_bytes: index.total_bytes(), + mean_bits: index.mean_bits(), + }; + print_row(&r); + results.push(r); + } + + println!(); + println!("─── Acceptance Evaluation ───"); + println!(); + let baseline = &results[0]; + let cand_a = &results[1]; + let cand_b = &results[2]; + + let a_gap = baseline.recall - cand_a.recall; + let b_gap = baseline.recall - cand_b.recall; + let b_mem_fraction = cand_b.mem_bytes as f32 / baseline.mem_bytes as f32; + + let signal_present = a_gap > RECALL_GAP_MIN_FOR_SIGNAL; + let recall_recovered = b_gap <= RECALL_RECOVERY_TOLERANCE; + let memory_compressed = b_mem_fraction <= MEMORY_BUDGET_FRACTION; + + println!( + " 1. Uniform 4-bit recall gap vs baseline : {:.4} (need > {:.4} for a real signal) [{}]", + a_gap, + RECALL_GAP_MIN_FOR_SIGNAL, + if signal_present { "PASS" } else { "FAIL" } + ); + println!( + " 2. Candidate B recall gap vs baseline : {:.4} (need <= {:.4}) [{}]", + b_gap, + RECALL_RECOVERY_TOLERANCE, + if recall_recovered { "PASS" } else { "FAIL" } + ); + println!( + " 3. Candidate B memory / baseline memory : {:.3} (need <= {:.2}) [{}]", + b_mem_fraction, + MEMORY_BUDGET_FRACTION, + if memory_compressed { "PASS" } else { "FAIL" } + ); + println!(); + + let verdict = if !signal_present { + "INCONCLUSIVE — uniform 4-bit already matches baseline recall on this dataset; no precision-loss signal for coherence-adaptive allocation to fix" + } else if recall_recovered && memory_compressed { + "ACCEPT — coherence-adaptive bit allocation recovers baseline recall at compressed memory" + } else { + "REJECT — coherence-adaptive bit allocation does not clear both thresholds" + }; + println!("VERDICT: {verdict}"); + + println!(); + println!("─── Memory Summary ───"); + println!( + " baseline (8-bit uniform) : {} KB ({:.2} bits/dim avg)", + baseline.mem_bytes / 1024, + baseline.mean_bits + ); + println!( + " candidate A (4-bit uniform): {} KB ({:.2} bits/dim avg)", + cand_a.mem_bytes / 1024, + cand_a.mean_bits + ); + println!( + " candidate B (adaptive) : {} KB ({:.2} bits/dim avg)", + cand_b.mem_bytes / 1024, + cand_b.mean_bits + ); + println!(); + println!("Coherence graph build overhead: {coherence_build_ms}ms (one-time, amortized at index build)"); + + if verdict.starts_with("REJECT") { + std::process::exit(1); + } +} diff --git a/crates/ruvector-coherence-quant/src/coherence.rs b/crates/ruvector-coherence-quant/src/coherence.rs new file mode 100644 index 0000000000..6a115ebe3d --- /dev/null +++ b/crates/ruvector-coherence-quant/src/coherence.rs @@ -0,0 +1,123 @@ +//! Mutual k-NN boundary / coherence scoring. +//! +//! For each vector, computes the fraction of its k nearest neighbours for +//! which the relationship is *mutual*: `v` is among `u`'s k nearest +//! neighbours as well as `u` being among `v`'s. Vectors deep inside a dense +//! cluster tend to share most of their nearest neighbours mutually, because +//! everyone nearby agrees on who is close. Vectors that sit on a cluster +//! boundary or bridge between clusters have more asymmetric neighbour +//! relationships: their neighbours' own nearest points often lie inside a +//! different, denser region, so the relationship is one-directional. +//! +//! This is a lightweight, ground-truth-free structural proxy for local graph +//! conductance / cut-boundary detection -- the same family of signal the +//! `ruvector-mincut` crate computes with subpolynomial dynamic algorithms +//! over general graphs. This PoC intentionally does not depend on +//! `ruvector-mincut` (kept evaluator-independent and dependency-light for +//! benchmarking); production adoption should replace +//! [`mutual_knn_coherence`] with a `ruvector-mincut` conductance query over +//! the same k-NN graph. See the research README for that integration path. + +use crate::dataset::l2sq; + +pub struct KnnGraph { + pub k: usize, + /// `neighbours[i]` = the k nearest neighbour indices of vector `i`, + /// sorted by ascending distance. + pub neighbours: Vec>, +} + +/// Brute-force k-NN graph construction. O(n^2 * dim); fine for PoC-scale +/// corpora (a production index would reuse the HNSW/graph build already +/// paid for). +pub fn build_knn_graph(corpus: &[Vec], k: usize) -> KnnGraph { + let n = corpus.len(); + let neighbours: Vec> = (0..n) + .map(|i| { + let mut dists: Vec<(usize, f32)> = (0..n) + .filter(|&j| j != i) + .map(|j| (j, l2sq(&corpus[i], &corpus[j]))) + .collect(); + dists.sort_unstable_by(|a, b| { + a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal) + }); + dists.truncate(k); + dists.into_iter().map(|(j, _)| j).collect() + }) + .collect(); + KnnGraph { k, neighbours } +} + +/// coherence(v) in `[0, 1]`: fraction of v's k nearest neighbours that are +/// *mutual* (v is also among that neighbour's k nearest neighbours). +/// +/// 1.0 = fully mutual neighbourhood (deep cluster core). +/// 0.0 = no mutual neighbours (isolated / bridging point). +pub fn mutual_knn_coherence(graph: &KnnGraph) -> Vec { + let n = graph.neighbours.len(); + let sets: Vec> = graph + .neighbours + .iter() + .map(|nb| nb.iter().copied().collect()) + .collect(); + (0..n) + .map(|i| { + let nb = &graph.neighbours[i]; + if nb.is_empty() { + return 0.0; + } + let mutual = nb.iter().filter(|&&j| sets[j].contains(&i)).count(); + mutual as f32 / nb.len() as f32 + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dataset::{clustered_vectors, random_unit_vectors}; + + #[test] + fn clustered_corpus_has_higher_mean_coherence_than_random() { + // Tight clusters should produce systematically higher mutual-kNN + // coherence than uniformly random points, because random points + // have no consistent local density structure to agree on. + let clustered = clustered_vectors(600, 12, 6, 0.05, 7); + let random = random_unit_vectors(600, 12, 7); + + let g_clustered = build_knn_graph(&clustered, 10); + let g_random = build_knn_graph(&random, 10); + + let c_clustered = mutual_knn_coherence(&g_clustered); + let c_random = mutual_knn_coherence(&g_random); + + let mean = |v: &[f32]| v.iter().sum::() / v.len() as f32; + let mean_clustered = mean(&c_clustered); + let mean_random = mean(&c_random); + + assert!( + mean_clustered > mean_random, + "clustered mean coherence {mean_clustered:.3} should exceed random {mean_random:.3}" + ); + } + + #[test] + fn coherence_is_bounded_zero_one() { + let corpus = clustered_vectors(200, 8, 4, 0.1, 3); + let graph = build_knn_graph(&corpus, 8); + let scores = mutual_knn_coherence(&graph); + for &s in &scores { + assert!((0.0..=1.0).contains(&s), "coherence out of range: {s}"); + } + } + + #[test] + fn fully_mutual_pair_has_coherence_one() { + // Two isolated points, k=1: each other's only (hence mutual) neighbour. + let corpus = vec![vec![0.0_f32, 0.0], vec![0.1_f32, 0.0]]; + let graph = build_knn_graph(&corpus, 1); + let scores = mutual_knn_coherence(&graph); + assert!((scores[0] - 1.0).abs() < 1e-6); + assert!((scores[1] - 1.0).abs() < 1e-6); + } +} diff --git a/crates/ruvector-coherence-quant/src/dataset.rs b/crates/ruvector-coherence-quant/src/dataset.rs new file mode 100644 index 0000000000..b08b009d00 --- /dev/null +++ b/crates/ruvector-coherence-quant/src/dataset.rs @@ -0,0 +1,135 @@ +//! Deterministic synthetic dataset generation. + +/// A deterministic pseudo-random f32 in [0, 1). +fn lcg_rand(state: &mut u64) -> f32 { + *state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((*state >> 33) as f32) / (u32::MAX as f32) +} + +fn normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt().max(1e-9); + v.iter_mut().for_each(|x| *x /= norm); +} + +/// Generate `n` random unit-normalised vectors of dimension `dim`. +pub fn random_unit_vectors(n: usize, dim: usize, seed: u64) -> Vec> { + let mut state = seed; + (0..n) + .map(|_| { + let mut v: Vec = (0..dim).map(|_| lcg_rand(&mut state) * 2.0 - 1.0).collect(); + normalize(&mut v); + v + }) + .collect() +} + +/// Cluster-structured dataset: `num_clusters` Gaussian-like blobs. +/// +/// Simulates a typical agent-memory / RAG corpus where embeddings cluster by +/// topic. Vectors deep inside a cluster are quantization-friendly (dense, +/// redundant neighbourhoods); vectors near cluster boundaries are not. +pub fn clustered_vectors( + n: usize, + dim: usize, + num_clusters: usize, + noise: f32, + seed: u64, +) -> Vec> { + let mut state = seed; + + let centroids: Vec> = (0..num_clusters) + .map(|_| { + let mut c: Vec = (0..dim).map(|_| lcg_rand(&mut state) * 2.0 - 1.0).collect(); + normalize(&mut c); + c + }) + .collect(); + + (0..n) + .map(|i| { + let c = ¢roids[i % num_clusters]; + let mut v: Vec = c + .iter() + .map(|&x| { + let n_val = (lcg_rand(&mut state) * 2.0 - 1.0) * noise; + x + n_val + }) + .collect(); + normalize(&mut v); + v + }) + .collect() +} + +/// Deterministic held-out query set: samples `n` corpus points and perturbs +/// each with extra noise, then re-normalises. Distinct from the corpus +/// points themselves but drawn from the same cluster structure, which is +/// the realistic regime for agent-memory / RAG retrieval (queries paraphrase +/// something close to, but not identical to, a stored memory). +pub fn jittered_queries( + corpus: &[Vec], + n: usize, + extra_noise: f32, + seed: u64, +) -> Vec> { + let mut state = seed; + (0..n) + .map(|i| { + let base = &corpus[i % corpus.len()]; + let mut v: Vec = base + .iter() + .map(|&x| x + (lcg_rand(&mut state) * 2.0 - 1.0) * extra_noise) + .collect(); + normalize(&mut v); + v + }) + .collect() +} + +/// Brute-force exact top-k nearest neighbours by squared L2 distance. +pub fn ground_truth(query: &[f32], corpus: &[Vec], k: usize) -> Vec { + let mut dists: Vec<(usize, f32)> = corpus + .iter() + .enumerate() + .map(|(i, v)| (i, l2sq(query, v))) + .collect(); + dists.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + dists.truncate(k); + dists.into_iter().map(|(i, _)| i).collect() +} + +pub fn l2sq(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_generation() { + let a = random_unit_vectors(10, 8, 42); + let b = random_unit_vectors(10, 8, 42); + assert_eq!(a, b); + } + + #[test] + fn unit_norm() { + let vecs = clustered_vectors(20, 16, 4, 0.1, 99); + for v in &vecs { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-4, "norm={norm}"); + } + } + + #[test] + fn ground_truth_returns_k_and_self_nearest() { + let corpus = random_unit_vectors(100, 8, 1); + let query = corpus[0].clone(); + let gt = ground_truth(&query, &corpus, 10); + assert_eq!(gt.len(), 10); + assert_eq!(gt[0], 0); + } +} diff --git a/crates/ruvector-coherence-quant/src/lib.rs b/crates/ruvector-coherence-quant/src/lib.rs new file mode 100644 index 0000000000..2f4fc34798 --- /dev/null +++ b/crates/ruvector-coherence-quant/src/lib.rs @@ -0,0 +1,110 @@ +//! # ruvector-coherence-quant +//! +//! Coherence-adaptive quantization for approximate vector search. +//! +//! Uniform scalar/product quantization applies the same bit budget to every +//! vector in a corpus. But not every vector is equally sensitive to +//! precision loss: a vector deep in a dense semantic cluster is redundantly +//! described by its neighbours (losing a few bits of precision barely moves +//! its rank among nearby candidates), while a vector that sits on a cluster +//! boundary or bridges two topics has few redundant neighbours to fall back +//! on, so quantization error there is more likely to flip nearest-neighbour +//! rankings. +//! +//! This crate tests whether a **mutual k-NN coherence score** +//! ([`coherence::mutual_knn_coherence`]) — a lightweight, ground-truth-free +//! proxy for local graph conductance / cluster-boundary detection — can +//! drive a per-vector bit-width decision that recovers most of full-precision +//! recall at close to aggressive-quantization memory. +//! +//! **Hypothesised mechanism:** high mutual-kNN coherence (v's neighbours +//! agree that v is close to them too) marks a cluster core → quantize +//! aggressively (4-bit). Low coherence marks a boundary/bridge point → +//! keep more precision (8-bit). +//! +//! ## Variants +//! +//! | Variant | Bit allocation | Description | +//! |---------|----------------|-------------| +//! | Baseline | uniform 8-bit | Full scalar-quantization precision | +//! | Candidate A | uniform 4-bit | Aggressive uniform compression | +//! | Candidate B | adaptive 4/8-bit | Mutual-kNN coherence decides per vector | +//! +//! ## RuVector ecosystem fit +//! +//! Connects the coherence/graph-conductance theme (`ruvector-mincut`, +//! `ruvector-coherence`) with the quantization theme (`ruvector-rabitq`, +//! `ruvector-turboquant`, `ruvector-pq-search`) and agent-memory footprint +//! reduction (`ruvector-agent-memory`). This PoC keeps its own lightweight +//! mutual-kNN graph builder rather than depending on `ruvector-mincut` +//! directly, to stay evaluator-independent; see the research README for the +//! production integration path. + +pub mod coherence; +pub mod dataset; +pub mod metrics; +pub mod quantize; +pub mod search; + +pub use search::{Hit, QuantizedIndex}; + +/// Recall@k: fraction of true top-k found in approximate results. +/// +/// The denominator is `min(k, ground_truth.len())` only. A searcher that +/// returns fewer than `k` results is penalised, not rewarded: missing +/// results count as misses. +pub fn recall_at_k(ground_truth: &[usize], results: &[Hit], k: usize) -> f32 { + let k = k.min(ground_truth.len()); + if k == 0 { + return 0.0; + } + let gt: std::collections::HashSet = ground_truth[..k].iter().cloned().collect(); + let found = results + .iter() + .take(k) + .filter(|h| gt.contains(&h.id)) + .count(); + found as f32 / k as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recall_at_k_perfect() { + let gt = vec![0usize, 1, 2, 3, 4]; + let results: Vec = gt + .iter() + .enumerate() + .map(|(i, &id)| Hit { id, dist: i as f32 }) + .collect(); + let r = recall_at_k(>, &results, 5); + assert!((r - 1.0).abs() < 1e-6); + } + + #[test] + fn recall_at_k_zero() { + let gt = vec![0usize, 1, 2]; + let results: Vec = vec![ + Hit { id: 10, dist: 0.1 }, + Hit { id: 11, dist: 0.2 }, + Hit { id: 12, dist: 0.3 }, + ]; + let r = recall_at_k(>, &results, 3); + assert!(r.abs() < 1e-6); + } + + #[test] + fn recall_at_k_partial() { + let gt = vec![0usize, 1, 2, 3]; + let results: Vec = vec![ + Hit { id: 0, dist: 0.1 }, + Hit { id: 99, dist: 0.2 }, + Hit { id: 2, dist: 0.3 }, + Hit { id: 98, dist: 0.4 }, + ]; + let r = recall_at_k(>, &results, 4); + assert!((r - 0.5).abs() < 1e-6); + } +} diff --git a/crates/ruvector-coherence-quant/src/metrics.rs b/crates/ruvector-coherence-quant/src/metrics.rs new file mode 100644 index 0000000000..8cec78abee --- /dev/null +++ b/crates/ruvector-coherence-quant/src/metrics.rs @@ -0,0 +1,57 @@ +//! Latency measurement helpers. + +use std::time::Instant; + +pub struct LatencyStats { + pub mean_us: f64, + pub p50_us: f64, + pub p95_us: f64, + pub throughput_qps: f64, +} + +impl LatencyStats { + /// Runs `f(i)` for `i in 0..n`, timing each call individually. + pub fn measure(n: usize, mut f: impl FnMut(usize) -> T) -> (Vec, LatencyStats) { + let mut results = Vec::with_capacity(n); + let mut times_us = Vec::with_capacity(n); + let total_start = Instant::now(); + for i in 0..n { + let t0 = Instant::now(); + results.push(f(i)); + times_us.push(t0.elapsed().as_secs_f64() * 1e6); + } + let total_elapsed = total_start.elapsed().as_secs_f64().max(1e-9); + + let mut sorted = times_us.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n_f = n.max(1) as f64; + let mean = times_us.iter().sum::() / n_f; + let p50_idx = (n.saturating_sub(1)) / 2; + let p95_idx = (((n as f64) * 0.95) as usize).min(n.saturating_sub(1)); + let p50 = sorted.get(p50_idx).copied().unwrap_or(0.0); + let p95 = sorted.get(p95_idx).copied().unwrap_or(0.0); + + ( + results, + LatencyStats { + mean_us: mean, + p50_us: p50, + p95_us: p95, + throughput_qps: n_f / total_elapsed, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn measure_returns_n_results() { + let (results, stats) = LatencyStats::measure(10, |i| i * 2); + assert_eq!(results.len(), 10); + assert!(stats.mean_us >= 0.0); + assert!(stats.throughput_qps > 0.0); + } +} diff --git a/crates/ruvector-coherence-quant/src/quantize.rs b/crates/ruvector-coherence-quant/src/quantize.rs new file mode 100644 index 0000000000..43db0e9a72 --- /dev/null +++ b/crates/ruvector-coherence-quant/src/quantize.rs @@ -0,0 +1,145 @@ +//! Per-vector min-max scalar quantization with real bit-packed storage. +//! +//! Codes are packed to their actual bit width (4-bit codes share a byte, +//! two codes per byte) so the memory numbers reported by the benchmark are +//! the real serialized size, not an approximation. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Bits { + Four, + Eight, +} + +impl Bits { + fn levels(self) -> u32 { + match self { + Bits::Four => 15, // 2^4 - 1 + Bits::Eight => 255, // 2^8 - 1 + } + } + + pub fn bits(self) -> u8 { + match self { + Bits::Four => 4, + Bits::Eight => 8, + } + } +} + +pub struct QuantizedVector { + pub bits: Bits, + pub min: f32, + pub scale: f32, + pub dim: usize, + /// Packed codes. `Eight` -> 1 byte/code. `Four` -> 2 codes/byte (low + /// nibble = even index, high nibble = odd index). + pub packed: Vec, +} + +pub fn quantize(v: &[f32], bits: Bits) -> QuantizedVector { + let dim = v.len(); + let min = v.iter().cloned().fold(f32::INFINITY, f32::min); + let max = v.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + let range = (max - min).max(1e-9); + let levels = bits.levels() as f32; + let scale = range / levels; + + let codes: Vec = v + .iter() + .map(|&x| (((x - min) / scale).round().clamp(0.0, levels)) as u8) + .collect(); + + let packed = match bits { + Bits::Eight => codes, + Bits::Four => codes + .chunks(2) + .map(|c| { + let lo = c[0] & 0x0F; + let hi = if c.len() > 1 { c[1] & 0x0F } else { 0 }; + lo | (hi << 4) + }) + .collect(), + }; + + QuantizedVector { + bits, + min, + scale, + dim, + packed, + } +} + +pub fn dequantize(q: &QuantizedVector) -> Vec { + match q.bits { + Bits::Eight => q + .packed + .iter() + .map(|&c| q.min + c as f32 * q.scale) + .collect(), + Bits::Four => { + let mut out = Vec::with_capacity(q.dim); + for &byte in &q.packed { + let lo = byte & 0x0F; + let hi = (byte >> 4) & 0x0F; + out.push(q.min + lo as f32 * q.scale); + if out.len() < q.dim { + out.push(q.min + hi as f32 * q.scale); + } + } + out.truncate(q.dim); + out + } + } +} + +/// Real serialized size in bytes: `min` (4B) + `scale` (4B) + packed codes. +pub fn stored_bytes(q: &QuantizedVector) -> usize { + 8 + q.packed.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_eight_bit_is_low_error() { + let v = vec![0.1_f32, -0.5, 0.9, -0.9, 0.0, 0.33]; + let q = quantize(&v, Bits::Eight); + let d = dequantize(&q); + for (a, b) in v.iter().zip(d.iter()) { + assert!((a - b).abs() < 0.01, "a={a} b={b}"); + } + } + + #[test] + fn roundtrip_four_bit_is_bounded_error() { + let v = vec![0.1_f32, -0.5, 0.9, -0.9, 0.0, 0.33, 0.7, -0.2, 0.15]; + let q = quantize(&v, Bits::Four); + let d = dequantize(&q); + assert_eq!(d.len(), v.len()); + // 4-bit range/15 step; error should never exceed one full step. + let range = 0.9 - (-0.9); + let step = range / 15.0; + for (a, b) in v.iter().zip(d.iter()) { + assert!((a - b).abs() <= step + 1e-4, "a={a} b={b} step={step}"); + } + } + + #[test] + fn four_bit_packing_is_half_size_of_eight_bit() { + let v: Vec = (0..32).map(|i| i as f32 / 32.0).collect(); + let q8 = quantize(&v, Bits::Eight); + let q4 = quantize(&v, Bits::Four); + assert_eq!(stored_bytes(&q8), 8 + 32); + assert_eq!(stored_bytes(&q4), 8 + 16); + } + + #[test] + fn odd_dimension_roundtrips_correct_length() { + let v = vec![0.2_f32, -0.1, 0.4, 0.05, -0.3]; + let q4 = quantize(&v, Bits::Four); + let d = dequantize(&q4); + assert_eq!(d.len(), 5); + } +} diff --git a/crates/ruvector-coherence-quant/src/search.rs b/crates/ruvector-coherence-quant/src/search.rs new file mode 100644 index 0000000000..505b45cae5 --- /dev/null +++ b/crates/ruvector-coherence-quant/src/search.rs @@ -0,0 +1,81 @@ +//! Brute-force search over a quantized (bit-packed) index. +//! +//! Distance is asymmetric: the raw f32 query is compared against the +//! dequantized corpus vector, matching how a scalar-quantized ADC-style +//! index scans in production (the query itself is never quantized). + +use crate::dataset::l2sq; +use crate::quantize::{dequantize, stored_bytes, QuantizedVector}; + +#[derive(Debug, Clone, Copy)] +pub struct Hit { + pub id: usize, + pub dist: f32, +} + +pub struct QuantizedIndex { + pub vectors: Vec, +} + +impl QuantizedIndex { + pub fn search(&self, query: &[f32], k: usize) -> Vec { + let mut scored: Vec = self + .vectors + .iter() + .enumerate() + .map(|(i, q)| { + let dec = dequantize(q); + Hit { + id: i, + dist: l2sq(query, &dec), + } + }) + .collect(); + scored.sort_unstable_by(|a, b| { + a.dist + .partial_cmp(&b.dist) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scored.truncate(k); + scored + } + + pub fn total_bytes(&self) -> usize { + self.vectors.iter().map(stored_bytes).sum() + } + + pub fn mean_bits(&self) -> f32 { + let total: u32 = self.vectors.iter().map(|v| v.bits.bits() as u32).sum(); + total as f32 / self.vectors.len().max(1) as f32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dataset::random_unit_vectors; + use crate::quantize::{quantize, Bits}; + + #[test] + fn eight_bit_search_finds_self_as_nearest() { + let corpus = random_unit_vectors(50, 8, 5); + let vectors: Vec = + corpus.iter().map(|v| quantize(v, Bits::Eight)).collect(); + let index = QuantizedIndex { vectors }; + let hits = index.search(&corpus[3], 1); + assert_eq!(hits[0].id, 3); + } + + #[test] + fn mean_bits_reports_mix() { + let corpus = random_unit_vectors(4, 4, 1); + let vectors: Vec = vec![ + quantize(&corpus[0], Bits::Four), + quantize(&corpus[1], Bits::Four), + quantize(&corpus[2], Bits::Eight), + quantize(&corpus[3], Bits::Eight), + ]; + let index = QuantizedIndex { vectors }; + assert!((index.mean_bits() - 6.0).abs() < 1e-6); + } +} From 4049c9451c2309817126db550975d330a9d27d2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 07:26:49 +0000 Subject: [PATCH 2/3] docs: add nightly research report for coherence-adaptive quantization Documents the falsified hypothesis, benchmark methodology, raw results, failure analysis, and next-research direction for ruvector-coherence-quant. --- .../README.md | 264 ++++++++++++++++++ .../gist.md | 95 +++++++ 2 files changed, 359 insertions(+) create mode 100644 docs/research/nightly/2026-08-15-coherence-adaptive-quant/README.md create mode 100644 docs/research/nightly/2026-08-15-coherence-adaptive-quant/gist.md diff --git a/docs/research/nightly/2026-08-15-coherence-adaptive-quant/README.md b/docs/research/nightly/2026-08-15-coherence-adaptive-quant/README.md new file mode 100644 index 0000000000..3b33a868e9 --- /dev/null +++ b/docs/research/nightly/2026-08-15-coherence-adaptive-quant/README.md @@ -0,0 +1,264 @@ +# Coherence-Adaptive Quantization: Mutual-kNN Boundary Detection for Bit-Width Allocation + +**Status**: PoC complete — **negative result** (hypothesis falsified as tested) +**Crate**: [`ruvector-coherence-quant`](../../../../crates/ruvector-coherence-quant) +**ADR**: [ADR-305](../../adr/ADR-305-coherence-adaptive-quant.md) + +## Abstract + +Uniform scalar quantization applies the same bit budget to every vector in a corpus, regardless +of how sensitive that vector's nearest-neighbour ranking is to precision loss. This experiment +tested whether a **mutual-kNN coherence score** — a lightweight, ground-truth-free proxy for +local graph conductance / cluster-boundary detection, in the spirit of `ruvector-mincut`'s +dynamic min-cut conductance signal but computed directly and cheaply over a k-NN graph — could +drive a per-vector 4-bit/8-bit allocation that recovers most of full-precision recall at close to +aggressive-quantization memory. + +**Measured PoC outcome: negative.** On a 4,000-vector, 32-dimensional, 12-cluster synthetic +corpus, coherence-adaptive allocation (63.3% of vectors at 4-bit, 36.7% at 8-bit, mean 5.47 +bits/dim) recovered only 12.5pp of the 13.0pp recall gap between uniform 8-bit and uniform 4-bit, +while using 25% more memory than uniform 4-bit. Both the recall-recovery and memory-budget +acceptance gates fail. See "Why it failed" below for the most likely mechanism. + +## Hypothesis + +```text +Given a 4,000-vector corpus at dimension 32, arranged in 12 semantic clusters (noise=0.18), + +when per-vector scalar quantization bit-width is chosen by mutual-kNN coherence +(coherence >= 0.5 -> 4-bit "core"; coherence < 0.5 -> 8-bit "boundary"), instead of a +uniform 4-bit budget, + +then recall@10 on held-out (jittered) queries should recover to within 1.5 percentage points +of the uniform 8-bit baseline, + +subject to total index memory remaining at or below 65% of the uniform 8-bit baseline, and +subject to uniform 4-bit itself showing a real (>1.5pp) recall gap versus baseline (otherwise +there is no precision-loss problem to fix). +``` + +This hypothesis was fixed before benchmarking and was not changed after seeing results. + +## Why this matters in 2026 + +Agent-memory and RAG corpora keep growing — long-running agents accumulate memories faster than +operators are willing to pay for full-precision storage. Existing RuVector quantization research +(`ruvector-rabitq`, `ruvector-turboquant`, `ruvector-pq-search`, `ruvector-matryoshka`) all apply +a *uniform* compression policy across the corpus. A working coherence-adaptive policy would let an +index spend its bit budget where it is needed (ambiguous, boundary-region memories) and save it +where it is not (redundant, core-cluster memories) — the same "spend compute/precision where +uncertainty lives" principle behind entropy-adaptive beam search (`ruvector-entropy-ann`, +ADR-303), applied to storage instead of search. + +## Why it could matter in 2036 / 2046 + +Long-horizon agent operating systems and edge/RVM cognitive appliances will hold portable +cognitive state (RVF) under hard memory ceilings. A *correct* content-aware compression signal +— one where perceptual/retrieval importance, not memory-allocation heuristics, determines bit +budget — is a prerequisite for graceful degradation under memory pressure rather than uniform +degradation. This experiment is a small, falsifiable step toward finding whether *local graph +structure* is a valid importance signal for that purpose. As tested, it is not (see below); the +negative result narrows the search space for future attempts. + +## Why RuVector is the right substrate + +RuVector already owns the two structural primitives this hypothesis composes: a real dynamic +min-cut / conductance implementation (`ruvector-mincut`) and multiple production quantization +codecs. Testing whether a *cheap* structural proxy (mutual-kNN mutuality, no dynamic min-cut +machinery required) captures the same signal is exactly the kind of "new composition of known +techniques, RuVector-specific" experiment the nightly harness should prioritize before committing +engineering effort to wiring the full `ruvector-mincut` conductance API into the quantization +write path. + +## Architecture + +```mermaid +flowchart LR + A[Clustered corpus
4000 x 32d] --> B[k-NN graph
k=12, brute force] + B --> C[Mutual-kNN coherence
score per vector] + C --> D{coherence >= 0.5?} + D -->|yes, core| E[4-bit scalar quant] + D -->|no, boundary| F[8-bit scalar quant] + E --> G[QuantizedIndex] + F --> G + G --> H[Brute-force search
vs held-out queries] + H --> I[recall@10, memory, latency] +``` + +## Implementation + +New crate `ruvector-coherence-quant`, self-contained (no dependency on `ruvector-mincut` or +`ruvector-core`, to keep the evaluator independent of the candidate and the benchmark fast to +iterate on): + +- `dataset.rs` — deterministic LCG-seeded clustered corpus + held-out "jittered" query generator + (queries are corpus points + extra noise, renormalized — realistic paraphrase-style retrieval, + not identical to any stored vector) + brute-force ground truth. +- `coherence.rs` — `build_knn_graph` (brute-force k-NN) and `mutual_knn_coherence`: for each + vector, the fraction of its k nearest neighbours for which the relationship is mutual (v is + also among that neighbour's k nearest neighbours). This is the structural signal under test. +- `quantize.rs` — per-vector min-max scalar quantization to 4 or 8 bits, with **real bit-packed + storage** (two 4-bit codes share a byte) so reported memory is the actual serialized size, not + an approximation. +- `search.rs` — brute-force search over the quantized (dequantized-on-read) index; asymmetric + distance (raw f32 query vs dequantized corpus vector), matching how production scalar-quantized + ADC-style indexes scan. +- `bin/benchmark.rs` — orchestrates baseline / candidate A / candidate B, evaluates the + pre-registered acceptance gates, prints ACCEPT/REJECT/INCONCLUSIVE. + +Three variants, matching the harness's required baseline/candidate_A/candidate_B structure: + +| Variant | Bit allocation | +|---|---| +| `baseline_uniform_8bit` | every vector, 8-bit | +| `candidate_A_uniform_4bit` | every vector, 4-bit | +| `candidate_B_coherence_adaptive` | 4-bit if mutual-kNN coherence >= 0.5, else 8-bit | + +## Benchmark methodology + +- Release build (`opt-level = 3`, `lto = "thin"`), `cargo run --release -p ruvector-coherence-quant + --bin benchmark`. +- Deterministic seeds throughout: corpus seed 42, query seed 777. Two independent runs produced + bit-identical recall and memory numbers (latency varies by a few percent between runs, as + expected for wall-clock timing on a shared CPU). +- Corpus and query generation are excluded from the timed search loop; only `QuantizedIndex::search` + is timed via `LatencyStats::measure`, one call per query. +- Coherence-graph build time (k-NN + mutual scoring) is reported separately as a one-time index-build + cost, not folded into per-query latency. +- Hardware: `linux/x86_64`, 4 CPU threads available. Rust 1.94.1 / Cargo 1.94.1. + +## Results (raw, from the run captured for this document) + +``` +Dataset: N=4000, dim=32, clusters=12, noise=0.18, k(recall)=10, k(coherence)=12, threshold=0.5 +Coherence: mean=0.555 p50=0.583 core(>=0.5)=2532 (63.3%) boundary=1468 (36.7%) + + baseline_uniform_8bit recall=0.9887 mean_bits=8.00 mem=156KB mean=258.3us p95=305.4us 3870 qps + candidate_A_uniform_4bit recall=0.8583 mean_bits=4.00 mem= 93KB mean=399.0us p95=445.2us 2506 qps + candidate_B_coherence_adaptive recall=0.8770 mean_bits=5.47 mem=116KB mean=353.8us p95=403.0us 2826 qps + +Acceptance gates: + 1. uniform 4-bit recall gap vs baseline : 0.1303 (need > 0.0150) PASS + 2. candidate B recall gap vs baseline : 0.1117 (need <= 0.0150) FAIL + 3. candidate B memory / baseline memory : 0.747 (need <= 0.65) FAIL + +VERDICT: REJECT +Coherence graph build overhead: 808ms (one-time, amortized at index build) +``` + +Reproduce with: + +```bash +cargo run --release -p ruvector-coherence-quant --bin benchmark +``` + +## Why it failed + +The acceptance gates were designed to distinguish "coherence-adaptive allocation captures the +quantization-sensitivity signal" from "it doesn't." Gate 1 confirms there was a real signal to +capture: uniform 4-bit loses 13.0 percentage points of recall versus 8-bit on this corpus. But +candidate B — despite giving 36.7% of vectors a full doubling of precision — only recovered 1.9pp +of that 13.0pp gap (14% of the gap closed, for 25% more memory than uniform 4-bit). + +The most likely explanation: **mutual-kNN coherence measures local density agreement, not +per-vector quantization sensitivity.** A vector's susceptibility to precision loss under min-max +scalar quantization is driven by the *shape* of its value distribution across dimensions (how +much a `range/15` step distorts individual coordinates) and by how close its true neighbours are +in absolute distance (tight clusters are less forgiving of rank-order noise, not more). Neither +of those is what mutual-kNN mutuality measures. A vector can have a highly mutual neighbourhood +(dense, locally-agreed-upon core) and still have coordinate ranges that quantize poorly, or sit at +a distance from its true top-10 where quantization noise dominates ranking regardless of its local +mutuality. + +This is analogous to the ADR-303 entropy-adaptive-ANN negative result: a graph/distributional +signal that is intuitively appealing and *does* correlate with something real (mutual-kNN +coherence genuinely is higher in dense clusters than in random point clouds — see +`coherence::tests::clustered_corpus_has_higher_mean_coherence_than_random`) does not automatically +correlate with the specific downstream quantity (quantization-induced recall loss) the hypothesis +needed it to predict. + +## What would falsify (and did falsify) the hypothesis + +Falsification criterion, set in advance: candidate B fails to close the recall gap to within +1.5pp of baseline, or fails to do so at <=65% of baseline memory. Both conditions occurred. The +hypothesis is falsified as implemented. + +## What this does NOT claim + +- This does not claim mutual-kNN coherence is useless — the coherence-vs-random test in + `coherence.rs` confirms it is a real, reproducible structural signal. It claims that signal does + not transfer to *quantization bit-width allocation* on this workload. +- This does not claim graph-conductance-based bit allocation is impossible — only that the cheap + mutual-kNN proxy tested here does not deliver it. A direct `ruvector-mincut` conductance query, + or a signal derived from actual quantization error magnitude (e.g., per-dimension range / + entropy of the vector's own coordinates) rather than neighbourhood mutuality, remains untested. +- This does not claim uniform quantization is optimal — only that this particular adaptive + allocation strategy does not beat it on the tested tradeoff. + +## RVF / RVM / ruFlo / MCP integration analysis + +Given the negative result, no integration is recommended at this time. For completeness: + +- **RVF**: a working content-aware bit-allocation policy would be directly portable as index + metadata inside an RVF cognitive package (per-vector precision map travels with the index). Not + pursued further given the negative result. +- **RVM**: no coherence domain or proof-gated mutation implications — this is a pure index-layer + compression policy with no write-path authority changes. +- **ruFlo**: a *working* version of this could become a background "index re-tiering" workflow + (periodically re-score and re-quantize as cluster structure drifts). Not worth building on top + of a falsified allocation signal. +- **MCP**: no new surface warranted. + +## Edge / WASM analysis + +The bit-packing implementation (`quantize.rs`) is already allocation-light and would compile to +WASM without changes; the k-NN coherence graph build is the only O(n²) component and would need a +production ANN-based approximation (self-join via the existing HNSW graph) rather than brute +force before any edge deployment — moot given the negative result. + +## Competitor comparison + +Not performed. Comparing against Milvus/Qdrant/Weaviate/FAISS mixed-precision quantization +schemes is only meaningful once a working RuVector variant exists to compare; a negative internal +PoC result has nothing to benchmark externally. Documented per Step 35's guidance to avoid +implying a comparison that wasn't measured. + +## Practical and long-horizon applications + +Deferred: per the harness's own rule (never inflate a rejected candidate's relevance), this +document does not enumerate hypothetical applications for a falsified mechanism. The applications +listed under "Why this matters" above remain valid for the *general problem* (content-aware +quantization for agent memory); they are not claims about this specific mechanism. + +## Limitations + +- Corpus scale (N=4,000) and dimensionality (32) are PoC-scale; the coherence-vs-quantization + relationship was not tested at production scale (millions of vectors) or at higher + dimensionality (384–1536, typical embedding sizes) where quantization error characteristics + differ. +- The k-NN graph is brute-force (O(n²)); this is fine for a 4,000-vector PoC but would not scale + without reusing an existing approximate graph (e.g., the corpus's own HNSW graph). +- Only min-max scalar quantization was tested, not product quantization or RaBitQ-style binary + quantization, where the sensitivity-to-precision relationship may differ. +- Query set is synthetic (jittered corpus points); real agent-memory query distributions may + differ from this held-out regime. + +## Next research + +1. Test whether a **quantization-error-derived** per-vector signal (e.g., per-dimension range or + local intrinsic dimensionality) predicts recall sensitivity better than neighbourhood mutuality + — a more direct measurement of what actually breaks under quantization. +2. If a working signal is found, integrate it with the real `ruvector-mincut` conductance API + rather than the standalone mutual-kNN proxy, to validate whether the production-grade + conductance signal (which captures more than 1-hop mutuality) behaves differently. +3. Retest at higher dimensionality (384+) where per-dimension quantization noise averages out + differently than at dim=32. + +## References + +- `ruvector-mincut` crate (`crates/ruvector-mincut`) — subpolynomial dynamic min-cut / conductance. +- ADR-303, `docs/research/nightly/2026-08-13-entropy-adaptive-ann` — prior negative result with a + structurally similar lesson (a real, measurable graph/distributional signal that does not + transfer to the specific downstream metric it was hypothesised to predict). +- `ruvector-rabitq`, `ruvector-turboquant`, `ruvector-pq-search`, `ruvector-matryoshka` — + existing RuVector uniform quantization codecs this experiment intended to complement. diff --git a/docs/research/nightly/2026-08-15-coherence-adaptive-quant/gist.md b/docs/research/nightly/2026-08-15-coherence-adaptive-quant/gist.md new file mode 100644 index 0000000000..9cc3684573 --- /dev/null +++ b/docs/research/nightly/2026-08-15-coherence-adaptive-quant/gist.md @@ -0,0 +1,95 @@ +# Coherence-Adaptive Quantization: A Negative Result + +## Problem + +Vector indexes that quantize every stored vector at the same bit-width waste precision on +"redundant" vectors (deep inside a dense semantic cluster, cheaply reconstructable from their +neighbours) and under-serve "sensitive" ones (near a cluster boundary, where quantization noise +is more likely to flip a nearest-neighbour ranking). If a cheap, local structural signal could +tell the two apart, an index could spend its bit budget where it matters. + +## Hypothesis + +**Mutual k-NN coherence** — the fraction of a vector's k nearest neighbours for which the +relationship is mutual (they also list it among their own k nearest neighbours) — was tested as +that signal. High mutuality was hypothesised to mark a "safe to compress" cluster core; low +mutuality was hypothesised to mark a "needs precision" boundary/bridge point. + +## Technical design + +A new crate, `ruvector-coherence-quant`, implements: + +1. A brute-force k-NN graph and the mutual-kNN coherence score per vector. +2. Per-vector min-max scalar quantization to 4 or 8 bits, with real bit-packed storage (two 4-bit + codes share one byte — memory numbers reflect actual serialized size). +3. Three index variants: uniform 8-bit (baseline), uniform 4-bit (candidate A), and + coherence-adaptive 4-bit-core/8-bit-boundary (candidate B, threshold 0.5). +4. A benchmark measuring recall@10, memory, and search latency over 300 held-out (jittered) + queries against a 4,000-vector, 32-dimensional, 12-cluster synthetic corpus. + +## Actual benchmark evidence + +``` +baseline_uniform_8bit recall=0.9887 mem=156KB 8.00 bits/dim +candidate_A_uniform_4bit recall=0.8583 mem= 93KB 4.00 bits/dim +candidate_B_coherence_adaptive recall=0.8770 mem=116KB 5.47 bits/dim (63.3% core, 36.7% boundary) +``` + +Pre-registered acceptance gates (fixed before the benchmark ran): + +- Uniform 4-bit must show a real recall gap vs baseline (>1.5pp) — **passed** (gap = 13.0pp). +- Candidate B must close that gap to within 1.5pp of baseline — **failed** (gap = 11.2pp; only + 1.9pp of the 13.0pp gap closed, despite giving over a third of the corpus double the bits). +- Candidate B memory must be <=65% of baseline — **failed** (74.7%). + +**Verdict: REJECT.** Reproducible across independent runs (identical recall/memory across two +runs; latency varies a few percent as expected for wall-clock timing). + +## Why it likely failed + +Mutual-kNN coherence is a real, reproducible signal — it is measurably higher on clustered data +than on uniformly random point clouds (verified by a dedicated unit test). But it measures *local +density agreement*, not *quantization sensitivity*. What actually determines how much a vector's +top-10 ranking degrades under min-max scalar quantization is the shape of its own coordinate +distribution (how much a `range/15` step distorts it) and its absolute distance to its true +nearest neighbours — neither of which mutual-kNN mutuality captures. A vector can sit in a +densely-agreed-upon neighbourhood and still quantize badly, or sit at a boundary and quantize +fine, if its coordinate range happens to be small. + +This mirrors a prior RuVector nightly result (ADR-303, entropy-adaptive beam search): a +graph/distributional signal that is intuitively appealing and *does* correlate with something real +does not automatically correlate with the specific downstream quantity it was hypothesised to +predict. + +## Limitations + +PoC scale only (N=4,000, dim=32, brute-force O(n²) k-NN graph). Only min-max scalar quantization +was tested, not product or binary quantization. Held-out queries are synthetic jittered corpus +points, not real agent-memory query traffic. + +## Production relevance + +None recommended at this time — the mechanism is rejected as tested. The general problem (agent +memory needs content-aware compression under growing corpus size and edge memory ceilings) remains +open and worth revisiting with a quantization-error-derived signal instead of a neighbourhood- +mutuality signal, or with the production `ruvector-mincut` conductance API rather than the +lightweight proxy used here. + +## RuVector ecosystem implications + +The falsified mechanism does not touch RVF, RVM, or the write path; nothing changes for those +subsystems. `ruvector-mincut`'s conductance primitives remain untested for this use case (this PoC +deliberately used a cheaper, evaluator-independent proxy first) and are the recommended next probe +if the general direction is revisited. + +## Future direction + +Test a quantization-error-derived per-vector signal (per-dimension coordinate range or local +intrinsic dimensionality) as a direct measurement of what breaks under quantization, rather than a +neighbourhood-structure proxy for it. + +## References + +- Crate: `crates/ruvector-coherence-quant` +- ADR-305 +- Prior related negative result: ADR-303 / `docs/research/nightly/2026-08-13-entropy-adaptive-ann` From 05800930461bdd5567dd06e4eb6f9e95243b81b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 07:26:49 +0000 Subject: [PATCH 3/3] docs: add ADR-305 for coherence-adaptive quantization negative result Records the decision not to adopt mutual-kNN coherence as a quantization bit-allocation signal, with measured evidence, alternatives considered, and open questions for future revisits. --- docs/adr/ADR-305-coherence-adaptive-quant.md | 206 +++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/adr/ADR-305-coherence-adaptive-quant.md diff --git a/docs/adr/ADR-305-coherence-adaptive-quant.md b/docs/adr/ADR-305-coherence-adaptive-quant.md new file mode 100644 index 0000000000..a8b136bf2f --- /dev/null +++ b/docs/adr/ADR-305-coherence-adaptive-quant.md @@ -0,0 +1,206 @@ +# ADR-305: Coherence-Adaptive Quantization (Mutual-kNN Bit-Width Allocation) + +**Date**: 2026-08-15 +**Status**: Closed — negative result (documented; not recommended for production) +**Deciders**: Nightly research agent +**Tags**: ann, quantization, coherence, mincut, ruvector-coherence-quant, negative-result + +--- + +## Context + +RuVector owns multiple uniform quantization codecs (`ruvector-rabitq`, `ruvector-turboquant`, +`ruvector-pq-search`, `ruvector-matryoshka`) and a real dynamic min-cut / graph conductance engine +(`ruvector-mincut`). No prior work combined the two: every existing quantization scheme applies +the same bit budget to every stored vector, regardless of whether that vector is locally +redundant (deep in a dense cluster, cheaply reconstructable from neighbours) or locally sensitive +(near a boundary/bridge, where quantization noise is more likely to flip a nearest-neighbour +ranking). + +Agent-memory corpora grow faster than operators want to pay for full-precision storage. A working +content-aware bit-allocation policy would let an index spend precision where it is needed and save +it where it is not — the same principle behind the (also-rejected) entropy-adaptive beam search +work in ADR-303, applied to storage instead of search. + +--- + +## Hypothesis + +```text +Given a 4,000-vector corpus at dimension 32, 12 semantic clusters, noise=0.18, + +when per-vector scalar quantization bit-width is chosen by mutual-kNN coherence +(>= 0.5 -> 4-bit; < 0.5 -> 8-bit) instead of a uniform 4-bit budget, + +then recall@10 on held-out queries should recover to within 1.5 percentage points of the +uniform 8-bit baseline, + +subject to total memory remaining <= 65% of the 8-bit baseline, and subject to uniform 4-bit +itself showing a real (>1.5pp) recall gap versus baseline. +``` + +Fixed before benchmarking; not changed after seeing results. + +--- + +## Decision + +**Do not adopt mutual-kNN coherence as a quantization bit-allocation signal.** The PoC computes a +mutual-kNN coherence score (fraction of a vector's k nearest neighbours that are mutually +nearest-neighbours) as a lightweight, ground-truth-free structural proxy for local graph +conductance, and uses it to allocate 4-bit vs 8-bit scalar quantization per vector. It was +measured against uniform 8-bit and uniform 4-bit baselines and **fails both pre-registered +acceptance gates**. The crate is merged as a documented negative result and a reusable benchmark +harness (deterministic dataset generation, real bit-packed quantization, brute-force k-NN +coherence scoring) for future quantization-signal experiments. + +### Measured result + +``` +baseline_uniform_8bit recall=0.9887 mem=156KB 8.00 bits/dim +candidate_A_uniform_4bit recall=0.8583 mem= 93KB 4.00 bits/dim +candidate_B_coherence_adaptive recall=0.8770 mem=116KB 5.47 bits/dim (63.3% core @4bit, 36.7% boundary @8bit) + +Gate 1 (uniform 4-bit shows real signal, gap > 1.5pp): gap = 13.03pp -> PASS +Gate 2 (candidate B recall within 1.5pp of baseline): gap = 11.17pp -> FAIL +Gate 3 (candidate B memory <= 65% of baseline): 74.7% -> FAIL + +VERDICT: REJECT +``` + +Reproducible: two independent runs produced bit-identical recall and memory values (latency +varies a few percent between runs, as expected for wall-clock CPU timing). + +Reproduce with: + +```bash +cargo test --release -p ruvector-coherence-quant +cargo run --release -p ruvector-coherence-quant --bin benchmark +``` + +--- + +## Why the signal fails (measured) + +The hypothesis was that mutual-kNN coherence encodes quantization sensitivity: + +- High mutuality (dense, locally-agreed-upon neighbourhood) → redundant → safe to compress hard. +- Low mutuality (boundary/bridge point) → few redundant neighbours to fall back on → keep precision. + +The measurements refute this on the PoC data: + +1. **Mutuality is real but measures the wrong quantity.** A dedicated unit test + (`coherence::tests::clustered_corpus_has_higher_mean_coherence_than_random`) confirms mutual-kNN + coherence is genuinely higher on clustered data than on random point clouds — the signal is not + noise. But quantization-induced recall loss under min-max scalar quantization is driven by a + vector's own coordinate-range shape (how much a `range/15` quantization step distorts its + components) and its absolute distance to its true top-k, not by whether its neighbours agree it + is nearby. +2. **Weak transfer, not zero transfer.** Candidate B did recover some recall (87.70% vs 85.83% for + uniform 4-bit) — the direction is not reversed, unlike the ADR-303 entropy sign flip. But giving + 36.7% of the corpus a full doubling of precision closed only 14% of the recall gap while adding + 25% more memory than uniform 4-bit, which is not a favourable trade against either gate. +3. **Threshold choice (0.5) was not the failure mode.** The gap between candidate B and the 1.5pp + target (11.17pp vs 1.5pp) is large enough that no reasonable coherence-threshold retuning would + close it without abandoning the memory-budget gate — this is a signal-quality problem, not a + hyperparameter problem. + +--- + +## Alternatives Considered + +| Alternative | Notes | +|---|---| +| Uniform quantization (status quo) | Remains the recommendation | +| Quantization-error-derived signal (per-dimension coordinate range) | Untested; more directly measures what breaks under quantization | +| Direct `ruvector-mincut` conductance query | Untested; production-grade signal, more expensive than the mutual-kNN proxy tested here | +| Product quantization with mixed codebook sizes | Untested; different quantization error model than min-max scalar | + +--- + +## Consequences + +### What the merge provides + +- A self-contained, zero-dependency Rust harness (deterministic clustered dataset generation, + real bit-packed 4/8-bit scalar quantization, brute-force mutual-kNN coherence scoring, + recall/memory/latency benchmark) usable as a baseline for future quantization-signal experiments. +- A validated (but non-transferable) structural signal: mutual-kNN coherence reliably separates + clustered from random data, documented as a building block for other uses even though it fails + this specific application. +- Documented failure mode (weak signal transfer from neighbourhood structure to quantization + sensitivity) that future work can avoid by testing quantization-error-derived signals directly. + +### Costs / trade-offs measured + +- Coherence graph build (k=12, brute-force k-NN over N=4,000): ~800ms one-time cost, amortized at + index build time, excluded from per-query latency. +- Candidate B search latency (354µs mean) sits between baseline (258µs) and candidate A (399µs) — + expected, since 4-bit dequantization is marginally cheaper per vector than 8-bit, and candidate B + is a majority-4-bit mix. + +### If this is ever revisited + +1. Replace the neighbourhood-mutuality signal with a per-vector quantization-error estimate + (coordinate range, local intrinsic dimensionality) that more directly predicts rank-order + sensitivity. +2. If a working signal is found, validate it against the real `ruvector-mincut` conductance API + rather than the standalone mutual-kNN proxy used here. +3. Retest at production embedding dimensionality (384+), where per-dimension quantization noise + averages differently than at dim=32. +4. Always report the uniform-4-bit and uniform-8-bit bracket as permanent benchmark columns, as + done here, so any future "adaptive" claim is falsifiable against both ends. + +--- + +## Implementation Status + +**PoC**: `crates/ruvector-coherence-quant` v0.1.0 — merged as negative result +**Tests**: 16 assertions, all pass (`cargo test --release -p ruvector-coherence-quant`) +**Clippy**: clean (`cargo clippy --release -p ruvector-coherence-quant --all-targets`) +**Benchmark**: `cargo run --release -p ruvector-coherence-quant --bin benchmark` + +No production integration is planned. + +--- + +## Security + +No new attack surface: the crate is a standalone benchmark harness with no network I/O, no +external input parsing beyond in-process generated data, and is not wired into any production +index or MCP surface. + +## Governance + +No mutation authority, no write-path changes, no witness/provenance implications — this is a +read-only research benchmark. + +## Migration / Rollback + +N/A — not integrated into any production path. Rollback is deleting the crate and its workspace +member entry, which the harness has not recommended. + +## Rejection Criteria + +Already applied: this ADR documents the rejection itself, per the pre-registered acceptance gates +in the Hypothesis section. + +## Open Questions + +1. Does a quantization-error-derived signal (rather than a neighbourhood-structure signal) predict + recall sensitivity well enough to clear the same gates? +2. Does the production `ruvector-mincut` conductance API behave differently from the mutual-kNN + proxy tested here, given it captures more than 1-hop neighbourhood structure? +3. Does the relationship change at production embedding dimensionality (384+) where distance + concentration effects differ from dim=32? + +--- + +## References + +- Crate: `crates/ruvector-coherence-quant` +- `ruvector-mincut` — subpolynomial dynamic min-cut / conductance engine (untested integration path) +- ADR-303, `docs/research/nightly/2026-08-13-entropy-adaptive-ann` — prior negative result with a + structurally similar lesson (a real, measurable signal that does not transfer to the specific + downstream metric it was hypothesised to predict) +- Research README: `docs/research/nightly/2026-08-15-coherence-adaptive-quant/README.md`