Skip to content
Open
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ members = [
"crates/ruvllm_retrieval_diffusion",
# RAIRS IVF: Redundant Assignment + Amplified Inverse Residual (ADR-193)
"crates/ruvector-rairs",
# BET 2 ⊗ BET 4: region-pruned filtered ANN vs ACORN (SepRAG issue #534, off main)
"crates/ruvector-filtered-bench",
]
resolver = "2"

Expand Down
144 changes: 129 additions & 15 deletions crates/ruvector-acorn/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,59 @@ pub fn acorn_search(
k: usize,
ef: usize,
predicate: impl Fn(u32) -> bool,
) -> Vec<(u32, f32)> {
let mut evals = 0u64;
acorn_search_impl(graph, query, k, ef, predicate, &mut evals, None)
}

/// Like [`acorn_search`] but also returns the exact number of distance
/// evaluations (`l2_sq` calls) performed — the hardware-independent cost metric
/// used by the filtered-ANN benchmark (`ruvector-filtered-bench`). Results are
/// identical to [`acorn_search`]; only the eval counter is added.
pub fn acorn_search_counted(
graph: &AcornGraph,
query: &[f32],
k: usize,
ef: usize,
predicate: impl Fn(u32) -> bool,
) -> (Vec<(u32, f32)>, u64) {
let mut evals = 0u64;
let out = acorn_search_impl(graph, query, k, ef, predicate, &mut evals, None);
(out, evals)
}

/// ACORN search seeded from caller-supplied entry nodes instead of the default
/// multi-probe entry — the substrate for contender D (predicate-aware entry). The beam
/// starts from `seeds` (each costs one distance-eval, counted); everything else is the
/// identical predicate-agnostic traversal. Returns results + exact eval count.
pub fn acorn_search_seeded_counted(
graph: &AcornGraph,
query: &[f32],
k: usize,
ef: usize,
predicate: impl Fn(u32) -> bool,
seeds: &[u32],
) -> (Vec<(u32, f32)>, u64) {
let mut evals = 0u64;
let out = acorn_search_impl(graph, query, k, ef, predicate, &mut evals, Some(seeds));
(out, evals)
}

fn acorn_search_impl(
graph: &AcornGraph,
query: &[f32],
k: usize,
ef: usize,
predicate: impl Fn(u32) -> bool,
evals: &mut u64,
seeds: Option<&[u32]>,
) -> Vec<(u32, f32)> {
if graph.is_empty() {
return vec![];
}
let n = graph.len();
let ef = ef.max(k);

// Multi-probe entry: sample evenly-spaced nodes to find a good starting
// point. O(probes × D) overhead vs O(n × D) for flat — negligible.
let n_probes = (n as f64).sqrt().ceil() as usize;
let n_probes = n_probes.clamp(4, 64);
let entry = (0..n_probes)
.map(|i| (i * n / n_probes) as u32)
.min_by(|&a, &b| {
l2_sq(query, graph.row(a as usize)).total_cmp(&l2_sq(query, graph.row(b as usize)))
})
.unwrap_or(0);

let mut visited: Vec<bool> = vec![false; n];
// Min-heap by distance — pop closest unexplored candidate first.
let mut candidates: BinaryHeap<Reverse<(OrdF32, u32)>> = BinaryHeap::with_capacity(ef + 1);
Expand All @@ -60,10 +95,41 @@ pub fn acorn_search(
// candidate, used to gate eviction when the frontier exceeds ef.
let mut farthest_in_beam: BinaryHeap<OrdF32> = BinaryHeap::with_capacity(ef + 1);

let d0 = l2_sq(query, graph.row(entry as usize));
candidates.push(Reverse((OrdF32(d0), entry)));
farthest_in_beam.push(OrdF32(d0));
visited[entry as usize] = true;
// Initial frontier: caller-supplied predicate-aware seeds (contender D), else the
// standard multi-probe entry. Multi-probe distances are counted once (result-identical
// to the original min_by form, which recomputed l2_sq inside the comparator).
let seed_ids: Vec<u32> = match seeds {
Some(s) if !s.is_empty() => s.iter().copied().filter(|&id| (id as usize) < n).collect(),
_ => {
let n_probes = (n as f64).sqrt().ceil() as usize;
let n_probes = n_probes.clamp(4, 64);
let mut entry = 0u32;
let mut best = f32::INFINITY;
for i in 0..n_probes {
let cand = (i * n / n_probes) as u32;
let d = l2_sq(query, graph.row(cand as usize));
*evals += 1;
if d < best {
best = d;
entry = cand;
}
}
vec![entry]
}
};
for &s in &seed_ids {
if visited[s as usize] {
continue;
}
let d = l2_sq(query, graph.row(s as usize));
*evals += 1;
candidates.push(Reverse((OrdF32(d), s)));
farthest_in_beam.push(OrdF32(d));
visited[s as usize] = true;
}
if candidates.is_empty() {
return vec![];
}

while let Some(Reverse((OrdF32(curr_d), curr))) = candidates.pop() {
// Pop curr's mirror entry from the farthest-tracker. Since the two
Expand Down Expand Up @@ -93,6 +159,7 @@ pub fn acorn_search(
}
visited[ni] = true;
let nd = l2_sq(query, graph.row(ni));
*evals += 1;

// Bounded beam: only admit if there's room or the new candidate
// is closer than the worst pending one.
Expand Down Expand Up @@ -129,6 +196,30 @@ pub fn flat_filtered_search(
query: &[f32],
k: usize,
predicate: impl Fn(u32) -> bool,
) -> Vec<(u32, f32)> {
let mut evals = 0u64;
flat_filtered_search_impl(data, query, k, predicate, &mut evals)
}

/// Like [`flat_filtered_search`] but also returns the exact distance-eval count
/// (one `l2_sq` per predicate-passing vector). Results identical.
pub fn flat_filtered_search_counted(
data: &[Vec<f32>],
query: &[f32],
k: usize,
predicate: impl Fn(u32) -> bool,
) -> (Vec<(u32, f32)>, u64) {
let mut evals = 0u64;
let out = flat_filtered_search_impl(data, query, k, predicate, &mut evals);
(out, evals)
}

fn flat_filtered_search_impl(
data: &[Vec<f32>],
query: &[f32],
k: usize,
predicate: impl Fn(u32) -> bool,
evals: &mut u64,
) -> Vec<(u32, f32)> {
let mut heap: BinaryHeap<(OrdF32, u32)> = BinaryHeap::with_capacity(k + 1);

Expand All @@ -137,6 +228,7 @@ pub fn flat_filtered_search(
continue;
}
let d = l2_sq(v, query);
*evals += 1;
if heap.len() < k {
heap.push((OrdF32(d), i as u32));
} else if let Some(&(OrdF32(worst), _)) = heap.peek() {
Expand Down Expand Up @@ -199,6 +291,28 @@ mod tests {
}
}

#[test]
fn counted_variants_match_uncounted_and_count_evals() {
// The benchmark depends on this invariant: *_counted returns identical
// results to the plain fn, plus a positive, finite eval count.
let data = unit_data(40);
let graph = AcornGraph::build(data.clone(), 8).unwrap();
let query = vec![17.0_f32, 0.0];
let pred = |id: u32| id % 3 == 0;

let plain = acorn_search(&graph, &query, 5, 60, pred);
let (counted, evals) = acorn_search_counted(&graph, &query, 5, 60, pred);
assert_eq!(plain, counted, "counted search must match plain search");
assert!(evals > 0, "must record at least the entry probes");

let fplain = flat_filtered_search(&data, &query, 5, pred);
let (fcounted, fevals) = flat_filtered_search_counted(&data, &query, 5, pred);
assert_eq!(fplain, fcounted);
// Flat does exactly one eval per predicate-passing vector.
let n_pass = (0..data.len() as u32).filter(|&i| pred(i)).count() as u64;
assert_eq!(fevals, n_pass, "flat evals == #matches");
}

#[test]
fn acorn_search_half_predicate() {
let data = unit_data(30);
Expand Down
15 changes: 15 additions & 0 deletions crates/ruvector-filtered-bench/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "ruvector-filtered-bench"
version = "0.1.0"
edition = "2021"
description = "BET 2 ⊗ BET 4: region-pruned filtered ANN (IVF cluster-skip) vs tuned ACORN — pre-registered head-to-head on ogbn-arxiv. Self-contained; independent of ruvector-seprag/PR #535."
authors = ["ofershaal", "claude-flow"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/ruvector"
keywords = ["ann", "filtered-search", "ivf", "acorn", "benchmark"]
categories = ["algorithms", "data-structures"]

[dependencies]
ruvector-acorn = { path = "../ruvector-acorn" }
ruvector-rairs = { path = "../ruvector-rairs" }
rand = "0.8"
78 changes: 78 additions & 0 deletions crates/ruvector-filtered-bench/examples/acorn_tune.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! M1 — find ACORN's *tuned* operating point (rule #2: beat the incumbent tuned).
//!
//! Sweeps ef × γ for filtered recall@10 at a representative low selectivity (ρ=1), so the
//! later head-to-head compares against ACORN at its best, not an under-tuned strawman.
//!
//! Run: cargo run --release -p ruvector-filtered-bench --example acorn_tune -- [N] [Q] [sel]

use ruvector_acorn::graph::exact_filtered_knn;
use ruvector_filtered_bench::contenders::{recall, Acorn};
use ruvector_filtered_bench::data::{Dataset, FEAT_100K};
use ruvector_filtered_bench::predicate;

use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::path::Path;

fn main() {
let args: Vec<String> = std::env::args().collect();
let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20_000);
let q_count: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(200);
let sel: f64 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0.01);

if !Path::new(FEAT_100K).exists() {
eprintln!("data not extracted ({FEAT_100K}); skipping.");
return;
}

let k = 10;
let ds = Dataset::load_arxiv(n);
let n = ds.len();
let mut rng = StdRng::seed_from_u64(7);
let pred = predicate::correlated(&ds.labels, sel, 1.0, 0, &mut rng);
let pf = pred.as_fn();
let queries: Vec<usize> = (0..q_count).map(|_| rng.gen_range(0..n)).collect();

// Precompute truth once per query (independent of ef/γ).
let truths: Vec<Vec<u32>> = queries
.iter()
.map(|&qi| {
exact_filtered_knn(&ds.feats, &ds.feats[qi], k + 1, pf)
.into_iter()
.filter(|&id| id as usize != qi)
.take(k)
.collect()
})
.collect();

println!(
"\n=== ACORN tuning: filtered recall@{k} (n={n}, sel={sel}, #match={}, Q={q_count}) ===",
pred.n_match
);
println!("{:>5} {:>6} | {:>10} {:>11}", "γ", "ef", "recall", "evals/q");
println!("{}", "-".repeat(40));

for &gamma in &[2usize, 3] {
let acorn = Acorn::build(&ds.feats, gamma, 64); // ef field unused; we pass ef below
for &ef in &[64usize, 128, 256, 512, 1024] {
let (mut rec, mut ev) = (0.0, 0u64);
for (qi, truth) in queries.iter().zip(&truths) {
let (got, evals) =
ruvector_acorn::search::acorn_search_counted(&acorn.graph, &ds.feats[*qi], k, ef, pf);
let got: Vec<u32> = got
.into_iter()
.map(|(id, _)| id)
.filter(|&id| id as usize != *qi)
.collect();
rec += recall(truth, &got);
ev += evals;
}
let nq = queries.len() as f64;
println!(
"{gamma:>5} {ef:>6} | {:>9.1}% {:>11}",
100.0 * rec / nq,
ev / queries.len() as u64
);
}
}
}
Loading
Loading