BET 5 (SepRAG #534): PQ/IVFADC within-list pruning vs tuned IVF nprobe — scale-gated WIN (ADR-206) (#542)

* docs(bet4): pre-register LB-B&B IVF vs plain-IVF nprobe gate (FROZEN)

Closes the BET 4 caveat left open by ADR-201: the region-pruning IVF
kernel was only run against ACORN (BET 2), never against its natural
incumbent, plain IVF nprobe, on unfiltered ANN. Frozen gate: WIN = >=2x
member-scan reduction at matched recall@10 (R=0.95) AND wall-clock win
across nclusters in {64,256,1024}; KILL = <1.5x or wall-clock reverses.
Two controls: exact-vs-exact pruning-fraction probe + low-d (PCA-8)
soundness control. Honest prior: NO-GO lean (128-d concentration makes
the triangle-inequality bound loose) — the IVF-level companion to
ADR-199. Branch off clean main; B&B kernel rebuilt self-contained
(BET 2's lives only on #536).

* feat(bet4): M0 — self-contained BnBIvf kernel + oracle gate (exactness certified)

New crate ruvector-bet4-ivf-bench (deps: ruvector-rairs, rand).
- data.rs: aligned arxiv 128-d feature CSV loader.
- kernel.rs: BnBIvf — IVF probed in ascending lower-bound order with B&B
  early termination (break when LB >= kth-best); LB(q,c)=max(0,|q-mu_c|-r_c),
  r_c=max member radius. Full budget = exact; max_probe cap = nprobe analogue.
  Built on ruvector-rairs kmeans so it shares centroids with the IvfFlat
  incumbent (shared-index pre-reg requirement).
- oracle.rs: brute-force exact kNN + recall@k + shared true-L2 helper.
- M0 gate test PASSES on real arxiv slice: full-budget B&B == oracle
  (recall@10 >= 0.999) → B&B invariant certified. clippy clean.

Frozen gate: docs/plans/bet4-ivf-pruning/PRE-REGISTRATION.md. Off clean main.

* feat(bet4): M1 — instrumented plain-IVF incumbent on shared index + faithfulness gate

BnBIvf::search_nprobe: the plain-IVF incumbent strategy (nprobe nearest
centroids, scan all members, no B&B) on the SAME centroids/lists as the
B&B contender, with member-eval counting. Refactored top-k accumulation
into shared consider()/finalize() so both strategies accumulate
identically and only the probe loop differs (shared-index pre-reg
requirement). New gate instrumented_nprobe_matches_rairs PASSES: recall
matches ruvector-rairs::IvfFlat within 0.01 at matched params → the
cost-measured incumbent is algorithmically the real one. 3 tests green.

* feat(bet4): M2/M3 — steelman B&B + PCA-8 control + matched-recall sweep

- kernel: search_bnb_skip — the STEELMAN. Centroid-distance order (the
  effective nprobe ordering) + per-cluster LB-skip (correctness-safe in
  any order, unlike the LB-order global break). The strongest cluster-level
  B&B: if it can't beat tuned nprobe, the bound doesn't pay.
- pca: minimal power-iteration top-m PCA (no linalg dep) for the low-dim
  control — projects real arxiv features to 8-d where the bound is tight.
- examples/ivf_pruning_sweep: 3 contenders share one index per nclusters
  (plain nprobe / B&B LB-order / B&B steelman) x 2 regimes (128-d, PCA-8),
  exact-regime pruning probe, matched-recall@0.95, frozen-gate verdict.

RESULT (n=20k & n=50k both): steelman = 1.00x evals vs nprobe in EVERY
cell, BOTH regimes. NO-GO. Mechanism is structural, not dimensional: the
LB bound only prunes FAR clusters that tuned nprobe already skips, so it's
redundant with nprobe's centroid-distance cutoff. Exact-prune fraction
scales correctly with dim (0-13% @128-d, 8-87% @PCA-8) => kernel sound;
the redundancy is fundamental. LB-ORDER (faithful BET-2 kernel) is strictly
WORSE (0.18-0.25x) — LB-ordering probes far large-radius clusters early.

* docs(bet4): ADR-205 — cluster-pruning vs plain IVF nprobe = structural NO-GO

Verdict: NO-GO (robust, structural). Steelman B&B (centroid order +
LB-skip) ties tuned nprobe at exactly 1.00x member-evals in every cell,
n=20k & n=50k, 128-d & PCA-8. Mechanism: the triangle-inequality bound
only prunes FAR clusters that tuned nprobe already skips => redundant with
nprobe's centroid-distance cutoff; win is structurally impossible, not
just hard in high-d. LB-order (faithful BET-2 kernel) strictly worse
(0.18-0.25x). Companion to ADR-199.

Honest deviation recorded: the pre-registered PCA-8 control expected a B&B
WIN (tight bound). It tied instead — the premise was false (tight bound
beats full-scan, not tuned nprobe). Control still valid: exact-prune
fraction scales correctly with dim (0-13% @128-d, 8-82% @PCA-8) => kernel
sound; it revealed the structural redundancy. Scoreboard 2 WINS / 4 KILLS.

* chore(bet4): lockfile for ruvector-bet4-ivf-bench workspace member

* docs(bet5): FROZEN pre-registration — PQ/IVFADC within-list pruning vs tuned nprobe

Opens the one lever ADR-205 left explicitly open (within-list PQ asymmetric
distance, orthogonal to the killed cluster-level bound). Frozen gate: PQ must
beat the cheaper of {plain full-L2, early-abandon exact-L2} nprobe by >=2x
full-L2-equivalent member-evals at recall@10=0.95 AND wall-clock, across
nclusters{64,256,1024} at >=1 scale N>=50k. Honest prior: ~55% win-at-scale,
named kill-paths = amortization crossover + concentration re-rank ceiling.
Stacked on feat/seprag-bet4-ivf-pruning to reuse ruvector-bet4-ivf-bench.
Thread #534.

* feat(bet5): M0 — PqIvf (IVFADC) kernel + early-abandon steelman + gate

PqIvf trains m sub-quantizers on the shared ruvector-rairs k-means substrate
(kmeans assignments ARE the PQ codes), encodes corpus to m-byte codes, and adds
search_adc_rerank (cheap ADC scan of nprobe lists + exact L2 re-rank of top-R)
plus search_adc_only (pure-ADC ceiling probe). AdcCost charges everything in one
honest unit: 256 (LUT) + adc_members*m/D + rerank*1 full-L2-equivalents.
BnBIvf gains search_nprobe_abandon = the early-abandon exact-L2 steelman
incumbent (user-confirmed verdict-setter), charged in dims_touched/D.

Gates (real 2k arxiv slice): PqIvf shares centroids w/ BnBIvf; PQ@full-rerank
exact (recall>=0.999); early-abandon exact vs full L2 (<0.001). 6 tests green,
clippy clean. Thread #534, BET5 pre-reg frozen at 1d920b3a.

* feat(bet5): M1/M2/M3 — matched-recall PQ sweep harness

examples/pq_pruning_sweep.rs: shared index per nclusters; tune incumbent nprobe
to min reaching recall@10>=0.95; PQ scans the SAME nprobe lists (cannot rerank an
unscanned neighbour) and we tune the smallest re-rank R recovering >=0.95. Charges
all PQ ops in full-L2-equivalents (256 LUT + adc*m/D + R rerank). Reports pure-ADC
ceiling, R*, early-abandon dim-prune fraction, wall-clock, crossover n*, frozen gate.
Thread #534.

* style(bet5): clippy-clean PQ kernel + sweep (iterator idioms, type alias)

* perf(bet5): shared IvfParts — build k-means once per cell, not per contender

Extract build_ivf -> IvfParts; BnBIvf::from_parts + PqIvf::from_parts reuse one
seeded k-means for the incumbent and every PQ(m). Cuts the worst cell (nc=1024
@100k) from 3x k-means to 1x while guaranteeing the shared-index property by
construction. Behavior-preserving (N=5000 numbers identical). 6 tests green.

* fix(bet5): charge routing (nclusters centroid evals) to both contenders

Pre-reg accounting + 'no free routing' adversarial check require the nclusters
query-centroid routing evals charged equally to incumbent AND PQ. Harness omitted
it, silently flattering PQ where routing dominates (high nclusters). Now prints
member-only ratio (transparency) AND the gate-deciding TOTAL ratio with routing;
verdict decided on total. Wall-clock already included routing (search computes
centroid dists) so the wall guard was already honest. Re-run authoritative.

* docs(bet5): ADR-206 — PQ/IVFADC within-list pruning = scale-gated WIN

Opens ADR-205's one open lever (within-list PQ asymmetric distance, orthogonal
to the killed cluster-level bound). PQ (cheap ADC scan + exact top-R rerank)
beats tuned plain nprobe AND the early-abandon exact-L2 steelman by >=2x
full-L2-equivalent member-evals at recall@10=0.95 AND wall-clock, across all
three nclusters{64,256,1024} at N=100k. Win GROWS with N, crossover n* RISES
with nclusters (routing amortization) -> >=2x at nclusters~sqrt(n) from n~20-50k.

Honest caveats (none buried): win rides on the exact rerank not pure ADC
(ceiling ~0.5) = IVFADC+refine validated, not a new method; scale-gated (full
sweep only at 100k); nc=1024/100k knife-edge 2.03x; m=16 tuned; recall-floor
tunability flatters PQ modestly; steelman halved the naive-L2 ratio. Routing
charge bug in my own harness caught by the pre-registered 'no free routing'
check (nc=1024/50k 2.24x member -> 1.65x total). Scoreboard 3 WINS / 4 KILLS.
Thread #534, pre-reg frozen at 1d920b3a.

---------

Co-authored-by: ruv <ruvnet@users.noreply.github.com>
This commit is contained in:
Ofer Shaal 2026-06-17 22:48:32 -04:00 committed by GitHub
parent 4089cd5d70
commit 18dedfac7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2171 additions and 0 deletions

8
Cargo.lock generated
View file

@ -8602,6 +8602,14 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "ruvector-bet4-ivf-bench"
version = "0.1.0"
dependencies = [
"rand 0.8.5",
"ruvector-rairs",
]
[[package]]
name = "ruvector-cli"
version = "2.2.3"

View file

@ -228,6 +228,8 @@ members = [
"crates/ruvllm_retrieval_diffusion",
# RAIRS IVF: Redundant Assignment + Amplified Inverse Residual (ADR-193)
"crates/ruvector-rairs",
# BET 4 (SepRAG #534): LB-B&B IVF probing vs plain IVF nprobe
"crates/ruvector-bet4-ivf-bench",
# Structure-preserving graph condensation via dynamic min-cut communities
"crates/ruvector-graph-condense",
"crates/ruvector-graph-condense-wasm",

View file

@ -0,0 +1,14 @@
[package]
name = "ruvector-bet4-ivf-bench"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
description = "BET 4 (SepRAG #534): LB-ordered branch-and-bound IVF probing vs plain IVF nprobe"
[dependencies]
ruvector-rairs = { path = "../ruvector-rairs" }
rand = "0.8"
[lib]
crate-type = ["rlib"]

View file

@ -0,0 +1,198 @@
//! BET 4 matched-recall sweep (M2/M3): LB-ordered branch-and-bound IVF probing vs the tuned plain
//! `IvfFlat` `nprobe` incumbent, on real 128-d arxiv embeddings AND a PCA-8 low-dim control.
//!
//! Three contenders share one index per `nclusters` (built once): plain `nprobe` (incumbent),
//! B&B in **LB-order** (the faithful BET-2 `RegionPruneIvf` kernel), and the **steelman** B&B —
//! centroid-distance order + LB-skip (the strongest version: if it can't beat `nprobe`, the bound
//! doesn't pay). Reports the exact-regime pruning fraction, matched-recall cost, and checks the
//! FROZEN gate (docs/plans/bet4-ivf-pruning/PRE-REGISTRATION.md) on the steelman ratio.
//!
//! Run: `cargo run --release -p ruvector-bet4-ivf-bench --example ivf_pruning_sweep -- [N]`
use ruvector_bet4_ivf_bench::data::load_feat_csv;
use ruvector_bet4_ivf_bench::kernel::BnBIvf;
use ruvector_bet4_ivf_bench::oracle::{brute_force_topk, recall_at_k};
use ruvector_bet4_ivf_bench::pca::project_topm;
use ruvector_rairs::SearchResult;
use std::time::Instant;
const K: usize = 10;
const R_TARGET: f64 = 0.95;
const NCLUSTERS: [usize; 3] = [64, 256, 1024];
fn main() {
let args: Vec<String> = std::env::args().collect();
let n_req: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20_000);
let data =
std::env::var("BET4_DATA").unwrap_or_else(|_| "target/m1-data/node-feat-100k.csv".into());
let corpus = load_feat_csv(&data, n_req).unwrap_or_else(|e| {
eprintln!("failed to load {data}: {e}");
std::process::exit(1);
});
let n = corpus.len();
let dim = corpus.first().map(|v| v.len()).unwrap_or(0);
println!("# BET4 sweep n={n} dim={dim} k={K} R_target={R_TARGET} data={data}\n");
run_regime("128-d (real arxiv features)", &corpus);
println!("\n# Projecting to PCA-8 (low-dim control)…");
let t = Instant::now();
let corpus8 = project_topm(&corpus, 8, 60);
println!("# PCA done in {:?}\n", t.elapsed());
run_regime("PCA-8 (low-dim control — bound should be TIGHT, B&B should WIN)", &corpus8);
}
fn run_regime(label: &str, corpus: &[Vec<f32>]) {
let n = corpus.len();
let dim = corpus[0].len();
let nq = 200.min(n);
let queries: Vec<usize> = (0..nq).collect();
let truth: Vec<Vec<usize>> = queries
.iter()
.map(|&q| brute_force_topk(corpus, &corpus[q], K))
.collect();
println!("════ REGIME: {label} (dim={dim}) ════");
let mut cells: Vec<Cell> = Vec::new();
for &nc in &NCLUSTERS {
let t_build = Instant::now();
let idx = BnBIvf::build(corpus, nc, 15, 42);
let nc_eff = idx.num_lists();
let build = t_build.elapsed();
// Exact-regime pruning fraction (LB-order full budget).
let mut pruned = 0.0;
for &q in &queries {
let (_r, _e, probed) = idx.search(&corpus[q], K, None);
pruned += (nc_eff - probed) as f64 / nc_eff as f64;
}
let prune_frac = pruned / nq as f64;
let grid = knob_grid(nc_eff);
let plain = matched(&queries, corpus, &truth, &grid, |q, knob| {
let (r, ev, _) = idx.search_nprobe(q, K, knob);
(ids(&r), ev)
});
let bnb_lb = matched(&queries, corpus, &truth, &grid, |q, knob| {
let (r, ev, _) = idx.search(q, K, Some(knob));
(ids(&r), ev)
});
let bnb_skip = matched(&queries, corpus, &truth, &grid, |q, knob| {
let (r, ev, _) = idx.search_bnb_skip(q, K, Some(knob));
(ids(&r), ev)
});
let eval_ratio = plain.evals / bnb_skip.evals.max(1.0);
let wall_ratio = plain.wall_ns as f64 / bnb_skip.wall_ns.max(1) as f64;
println!("\n## nclusters={nc_eff} (build {build:?}) exact-regime prune={:.1}%", prune_frac * 100.0);
print_row("plain nprobe (incumbent)", &plain);
print_row("B&B LB-order (BET-2 kernel)", &bnb_lb);
print_row("B&B steelman (cdist+LB-skip)", &bnb_skip);
println!(
" steelman vs incumbent: eval {eval_ratio:.2}x wall {wall_ratio:.2}x"
);
cells.push(Cell { nc: nc_eff, eval_ratio, wall_ratio, prune_frac });
}
verdict(label, &cells);
}
struct Cell {
nc: usize,
eval_ratio: f64,
wall_ratio: f64,
prune_frac: f64,
}
struct Matched {
knob: usize,
recall: f64,
evals: f64,
wall_ns: u128,
}
fn print_row(name: &str, m: &Matched) {
println!(
" {name:<32} knob={:<4} recall={:.4} evals/q={:>8.0} wall/q={:>6}µs",
m.knob,
m.recall,
m.evals,
m.wall_ns / 1000
);
}
/// First knob (ascending) whose mean recall ≥ `R_TARGET`, with its mean member-evals and wall-time;
/// falls back to the largest knob if none reaches target.
fn matched<F>(
queries: &[usize],
corpus: &[Vec<f32>],
truth: &[Vec<usize>],
grid: &[usize],
search: F,
) -> Matched
where
F: Fn(&[f32], usize) -> (Vec<usize>, usize),
{
let mut last = Matched { knob: 0, recall: 0.0, evals: 0.0, wall_ns: 0 };
for &knob in grid {
let t = Instant::now();
let mut rec = 0.0;
let mut ev = 0usize;
for (qi, &q) in queries.iter().enumerate() {
let (got, e) = search(&corpus[q], knob);
ev += e;
rec += recall_at_k(&truth[qi], &got, K);
}
let wall_ns = t.elapsed().as_nanos() / queries.len() as u128;
last = Matched {
knob,
recall: rec / queries.len() as f64,
evals: ev as f64 / queries.len() as f64,
wall_ns,
};
if last.recall >= R_TARGET {
return last;
}
}
last
}
fn knob_grid(maxv: usize) -> Vec<usize> {
let mut g = Vec::new();
let mut x = 1usize;
while x < maxv {
g.push(x);
x = ((x as f64) * 1.5).ceil() as usize;
}
g.push(maxv);
g.dedup();
g
}
fn ids(res: &[SearchResult]) -> Vec<usize> {
res.iter().map(|r| r.id).collect()
}
fn verdict(label: &str, cells: &[Cell]) {
let all_win = cells.iter().all(|c| c.eval_ratio >= 2.0 && c.wall_ratio > 1.0);
let any_kill = cells.iter().any(|c| c.eval_ratio < 1.5 || c.wall_ratio < 1.0);
let v = if all_win {
"WIN (≥2× evals AND wall-clock win across all nclusters)"
} else if any_kill {
"KILL / NO-GO (<1.5× somewhere or wall reversed — bound too loose to pay)"
} else {
"QUALIFIED (1.52×, or mixed)"
};
println!("\n ── verdict [{label}] ──");
for c in cells {
println!(
" nclusters={:<5} steelman eval={:.2}x wall={:.2}x exact-prune={:.1}%",
c.nc, c.eval_ratio, c.wall_ratio, c.prune_frac * 100.0
);
}
println!(" => {v}");
}

View file

@ -0,0 +1,306 @@
//! BET 5 matched-recall sweep (M1/M2/M3): **PQ/IVFADC within-list pruning** vs the strongest
//! PQ-free incumbent (plain full-L2 `nprobe` and the early-abandon exact-L2 steelman), on real
//! 128-d arxiv embeddings, at matched recall@10 = 0.95.
//!
//! All contenders share one k-means index per `nclusters` (deterministic seed → identical
//! centroids/lists; certified in `tests/pq_gate.rs`). Only the within-list scan differs:
//! - **plain** — full `D`-dim L2 on every member of the `nprobe` lists (ADR-205's incumbent).
//! - **abandon** — exact L2, early-abandoned at `τ²` (the steelman; charged in dims-touched/D).
//! - **PQ** — cheap ADC scan of the same lists + exact L2 re-rank of the top-`R` (the bet).
//!
//! Matched-recall protocol (see PRE-REGISTRATION.md): tune the incumbent `nprobe` to the smallest
//! value reaching recall ≥ 0.95; PQ scans the *same* `nprobe` lists (it cannot re-rank a neighbour
//! it never scans) and we tune the smallest re-rank pool `R` that recovers ≥ 0.95. Everything is
//! charged in one unit — full-`D`-L2-equivalents — so the fixed 256-equiv ADC table build and the
//! `R` exact re-ranks are paid in full (no free lunch).
//!
//! Run: `cargo run --release -p ruvector-bet4-ivf-bench --example pq_pruning_sweep -- [N ...]`
//! (default N = 20000 50000 100000).
use ruvector_bet4_ivf_bench::data::load_feat_csv;
use ruvector_bet4_ivf_bench::kernel::{build_ivf, BnBIvf};
use ruvector_bet4_ivf_bench::oracle::{brute_force_topk, recall_at_k};
use ruvector_bet4_ivf_bench::pq::PqIvf;
use std::time::Instant;
const K: usize = 10;
const R_TARGET: f64 = 0.95;
const NCLUSTERS: [usize; 3] = [64, 256, 1024];
const M_VALUES: [usize; 2] = [16, 8];
const NQ: usize = 200;
const MAX_ITER: usize = 15;
const SEED: u64 = 42;
/// Per-nclusters verdict log: `(nclusters, [(N, full_win, best_ratio)])`.
type PerNcVerdicts = (usize, Vec<(usize, bool, f64)>);
fn main() {
let args: Vec<usize> = std::env::args()
.skip(1)
.filter_map(|s| s.parse().ok())
.collect();
let scales = if args.is_empty() {
vec![20_000usize, 50_000, 100_000]
} else {
args
};
let data =
std::env::var("BET4_DATA").unwrap_or_else(|_| "target/m1-data/node-feat-100k.csv".into());
println!("# BET5 PQ/IVFADC sweep k={K} R_target={R_TARGET} nq={NQ} data={data}");
println!("# unit = full-D-L2-equivalent member-eval. PQ cost = 256(LUT) + adc_members*m/D + R(rerank).");
println!("# crossover n* = smallest tested N where PQ beats the best PQ-free incumbent.\n");
// Track, per nclusters, the verdict per scale to find the crossover and the gate.
// (nclusters, [(N, full_win, best_ratio)]).
let mut win_at: Vec<PerNcVerdicts> =
NCLUSTERS.iter().map(|&nc| (nc, Vec::new())).collect();
for &n_req in &scales {
let corpus = match load_feat_csv(&data, n_req) {
Ok(c) => c,
Err(e) => {
eprintln!("failed to load {data}: {e}");
std::process::exit(1);
}
};
let n = corpus.len();
let dim = corpus[0].len();
let queries: Vec<usize> = (0..NQ.min(n)).collect();
let t_truth = Instant::now();
let truth: Vec<Vec<usize>> = queries
.iter()
.map(|&q| brute_force_topk(&corpus, &corpus[q], K))
.collect();
println!("════════ N={n} dim={dim} (truth in {:?}) ════════", t_truth.elapsed());
for (nc_i, &nc) in NCLUSTERS.iter().enumerate() {
let t_b = Instant::now();
let parts = build_ivf(&corpus, nc, MAX_ITER, SEED); // shared k-means: once per cell
let bnb = BnBIvf::from_parts(&parts);
let nc_eff = bnb.num_lists();
let build_ivf_t = t_b.elapsed();
// ---- tune incumbent nprobe to the smallest reaching recall >= 0.95 ----
let np_grid = nprobe_grid(nc_eff);
let mut np_star = nc_eff;
let mut inc_recall = 0.0;
for &np in &np_grid {
let r = mean_recall(&queries, &truth, |qi| {
bnb.search_nprobe(&corpus[qi], K, np).0
});
if r >= R_TARGET {
np_star = np;
inc_recall = r;
break;
}
}
// plain full-L2 cost (members) and early-abandon cost (dims/D), both at np_star.
let (plain_evals, abandon_dims, members, t_plain, t_abandon, abandon_recall) =
incumbent_costs(&bnb, &corpus, &queries, &truth, np_star, dim);
let plain_cost = plain_evals; // 1 per member
let abandon_cost = abandon_dims / dim as f64;
let best_inc = plain_cost.min(abandon_cost);
let abandon_prune = 1.0 - abandon_dims / (members * dim as f64);
// Routing: every contender computes q↔centroid for all nc_eff centroids to pick the
// nprobe nearest lists. Charged EQUALLY to incumbent and PQ (the pre-reg's "no free
// routing" adversarial check). It dilutes any ratio, decisively at high nclusters.
let routing = nc_eff as f64;
println!(
"\n── nclusters={nc_eff} (build {build_ivf_t:?}) np*={np_star} inc_recall={inc_recall:.3} routing={routing:.0} ev/q ──"
);
println!(
" incumbent plain={plain_cost:8.0} | abandon={abandon_cost:8.0} ev (dim-prune {:.1}%, exact r={abandon_recall:.3}) members={members:.0} | best+routing={:.0}",
abandon_prune * 100.0,
best_inc + routing
);
println!(
" wall/q plain={:>8.1}µs | abandon={:>8.1}µs",
t_plain, t_abandon
);
let mut cell_win = false;
let mut cell_ratio = 0.0;
for &m in &M_VALUES {
let t_pq = Instant::now();
let pq = PqIvf::from_parts(&parts, &corpus, m, MAX_ITER, SEED);
let build_pq = t_pq.elapsed();
// pure-ADC ceiling at np_star (no re-rank)
let adc_ceiling = mean_recall(&queries, &truth, |qi| {
pq.search_adc_only(&corpus[qi], K, np_star)
});
// tune smallest R reaching recall >= 0.95 at np_star
let r_grid = rerank_grid(members as usize);
let mut r_star = None;
for &rr in &r_grid {
let r = mean_recall(&queries, &truth, |qi| {
pq.search_adc_rerank(&corpus[qi], K, np_star, rr).0
});
if r >= R_TARGET {
r_star = Some(rr);
break;
}
}
match r_star {
None => {
println!(
" PQ m={m:>2} (build {build_pq:?}) ADC-ceiling={adc_ceiling:.3} R*=NONE (cannot reach {R_TARGET} within working set) → KILL-path",
);
}
Some(rr) => {
// measure PQ cost + wall at (np_star, rr)
let t0 = Instant::now();
let mut cost_sum = 0.0;
let mut rec = 0.0;
for (j, &qi) in queries.iter().enumerate() {
let (res, c) = pq.search_adc_rerank(&corpus[qi], K, np_star, rr);
cost_sum += c.l2_equiv();
let got: Vec<usize> = res.iter().map(|r| r.id).collect();
rec += recall_at_k(&truth[j], &got, K);
}
let t_pq_q = t0.elapsed().as_secs_f64() * 1e6 / queries.len() as f64;
let pq_cost = cost_sum / queries.len() as f64;
let rec = rec / queries.len() as f64;
// Member-only ratio (transparency) and the gate-deciding TOTAL ratio with
// routing charged to both (the faithful full-L2-equivalent accounting).
let member_ratio = best_inc / pq_cost;
let total_ratio = (best_inc + routing) / (pq_cost + routing);
let wall_win = t_pq_q < t_plain.min(t_abandon);
let rr_full = rr >= members as usize; // re-rank == whole working set → bought nothing
let verdict = if rr_full {
"DEGENERATE(R≈WS)"
} else if total_ratio >= 2.0 && wall_win {
"WIN≥2×"
} else if total_ratio >= 1.5 {
"qualified"
} else {
"miss"
};
println!(
" PQ m={m:>2} ADC-ceil={adc_ceiling:.3} R*={rr:>5} cost={pq_cost:8.0}(+rt={:.0}) recall={rec:.3} wall={t_pq_q:>7.1}µs member={member_ratio:.2}× total={total_ratio:.2}× [{verdict}{}]",
pq_cost + routing,
if wall_win { "" } else { ", WALL-REVERSES" }
);
if total_ratio > cell_ratio {
cell_ratio = total_ratio;
}
if total_ratio >= 2.0 && wall_win && !rr_full {
cell_win = true;
}
}
}
}
win_at[nc_i].1.push((n, cell_win, cell_ratio));
}
println!();
}
// ---- gate summary: WIN needs >=2x + wall + all three nclusters at >= one N>=50k ----
println!("\n════════ GATE (FROZEN: PRE-REGISTRATION.md) ════════");
let scales_ge_50k: Vec<usize> = scales.iter().copied().filter(|&n| n >= 50_000).collect();
let mut any_full_win = false;
for &n in &scales_ge_50k {
let all_nc_win = NCLUSTERS.iter().enumerate().all(|(i, _)| {
win_at[i]
.1
.iter()
.any(|&(nn, win, _)| nn == n && win)
});
if all_nc_win {
any_full_win = true;
println!(" N={n}: WIN at ALL nclusters → gate WIN condition met");
}
}
if !any_full_win {
println!(" No N≥50k wins at all three nclusters.");
// best ratio seen per nclusters for the qualified/kill read
for (nc, rows) in &win_at {
let best = rows
.iter()
.map(|&(n, _, r)| format!("N{}:{:.2}×", n, r))
.collect::<Vec<_>>()
.join(" ");
println!(" nclusters={nc}: best PQ ratio per scale → {best}");
}
}
}
/// Geometric-ish nprobe grid up to `nc`, dense at the low end where the tuned optimum lives.
fn nprobe_grid(nc: usize) -> Vec<usize> {
let mut g = vec![1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768];
g.push(nc);
g.retain(|&x| x <= nc);
g.sort_unstable();
g.dedup();
g
}
/// Re-rank pool grid up to the working set; dense at the low end (the win lives there).
fn rerank_grid(ws: usize) -> Vec<usize> {
let mut g = vec![
10, 15, 20, 30, 50, 75, 100, 150, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000, 8000,
12000, 20000,
];
g.push(ws);
g.retain(|&x| x <= ws.max(1));
g.sort_unstable();
g.dedup();
g
}
fn mean_recall<F>(queries: &[usize], truth: &[Vec<usize>], mut search: F) -> f64
where
F: FnMut(usize) -> Vec<ruvector_rairs::SearchResult>,
{
let mut acc = 0.0;
for (j, &qi) in queries.iter().enumerate() {
let got: Vec<usize> = search(qi).iter().map(|r| r.id).collect();
acc += recall_at_k(&truth[j], &got, K);
}
acc / queries.len() as f64
}
/// Plain & early-abandon incumbent costs + wall-clock (µs/query) + abandon recall, all at `np`.
#[allow(clippy::too_many_arguments)]
fn incumbent_costs(
bnb: &BnBIvf,
corpus: &[Vec<f32>],
queries: &[usize],
truth: &[Vec<usize>],
np: usize,
_dim: usize,
) -> (f64, f64, f64, f64, f64, f64) {
let mut members = 0usize;
let mut dims = 0usize;
let mut abandon_rec = 0.0;
let t_plain0 = Instant::now();
for &qi in queries {
let (_r, e, _p) = bnb.search_nprobe(&corpus[qi], K, np);
members += e;
}
let t_plain = t_plain0.elapsed().as_secs_f64() * 1e6 / queries.len() as f64;
let t_ab0 = Instant::now();
for (j, &qi) in queries.iter().enumerate() {
let (res, dt, _mem) = bnb.search_nprobe_abandon(&corpus[qi], K, np);
dims += dt;
let got: Vec<usize> = res.iter().map(|r| r.id).collect();
abandon_rec += recall_at_k(&truth[j], &got, K);
}
let t_abandon = t_ab0.elapsed().as_secs_f64() * 1e6 / queries.len() as f64;
let nqf = queries.len() as f64;
(
members as f64 / nqf,
dims as f64 / nqf,
members as f64 / nqf,
t_plain,
t_abandon,
abandon_rec / nqf,
)
}

View file

@ -0,0 +1,29 @@
//! Loader for the aligned ogbn-arxiv 128-d node-feature CSV (row `i` = node `i`), the same
//! public corpus used by ADR-201/202/204. Data lives under `target/m1-data/` (gitignored).
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
/// Load up to `limit` rows of comma-separated f32 features. Blank lines are skipped. Each
/// returned row is one node's feature vector (all rows share the file's column count, 128 for
/// the arxiv features).
pub fn load_feat_csv<P: AsRef<Path>>(path: P, limit: usize) -> std::io::Result<Vec<Vec<f32>>> {
let reader = BufReader::new(File::open(path)?);
let mut out = Vec::with_capacity(limit);
for line in reader.lines() {
if out.len() >= limit {
break;
}
let line = line?;
if line.trim().is_empty() {
continue;
}
let row: Vec<f32> = line
.split(',')
.map(|s| s.trim().parse::<f32>().unwrap_or(0.0))
.collect();
out.push(row);
}
Ok(out)
}

View file

@ -0,0 +1,308 @@
//! `BnBIvf` — the BET 4 contender: an IVF index probed in **lower-bound order with
//! branch-and-bound early termination**, over the same `ruvector-rairs` k-means substrate as
//! the plain-`IvfFlat` incumbent.
//!
//! For a query `q` and cluster `c` with centroid `μ_c` and radius `r_c = max_{v∈c} ‖vμ_c‖`,
//! the triangle inequality gives a lower bound on the distance to *any* member of `c`:
//! `LB(q,c) = max(0, ‖qμ_c‖ r_c)`. Probing clusters in ascending `LB` while tracking the
//! running k-th-best distance `τ`, we may stop the instant `LB(c) ≥ τ`: every not-yet-probed
//! cluster has an even larger `LB`, so none can contain a top-k point. That single break makes
//! full-budget B&B **exact** (recall → 1.0) yet lets it skip clusters a fixed `nprobe` would
//! scan. A `max_probe` cap turns it into an approximate knob (the analogue of `nprobe`) for the
//! matched-recall comparison.
use crate::oracle::l2;
use ruvector_rairs::{kmeans, SearchResult};
use std::cmp::Ordering;
use std::collections::BinaryHeap;
/// The shared IVF substrate (centroids + inverted lists) built **once** from a seeded k-means, then
/// reused to construct every contender for a given `nclusters` — so the expensive clustering is paid
/// once per cell, not once per contender, and all contenders provably share an identical index.
pub struct IvfParts {
pub centroids: Vec<Vec<f32>>,
/// Per cluster: `(id, vector)` of its members.
pub lists: Vec<Vec<(usize, Vec<f32>)>>,
}
/// Build the shared IVF substrate (`ruvector-rairs` k-means, identical to `IvfFlat::train`).
pub fn build_ivf(corpus: &[Vec<f32>], nclusters: usize, max_iter: usize, seed: u64) -> IvfParts {
assert!(!corpus.is_empty(), "empty corpus");
let k = nclusters.min(corpus.len()).max(1);
let (centroids, assignments) = kmeans::train(corpus, k, max_iter, seed);
let kc = centroids.len();
let mut lists: Vec<Vec<(usize, Vec<f32>)>> = vec![Vec::new(); kc];
for (i, v) in corpus.iter().enumerate() {
lists[assignments[i]].push((i, v.clone()));
}
IvfParts { centroids, lists }
}
/// IVF index supporting lower-bound-ordered branch-and-bound probing.
pub struct BnBIvf {
centroids: Vec<Vec<f32>>,
/// Per cluster: `(id, vector)` of its members.
lists: Vec<Vec<(usize, Vec<f32>)>>,
/// Per cluster: max member distance to its centroid (the B&B radius).
radii: Vec<f32>,
}
/// Top-k accumulator element. `BinaryHeap` is a max-heap, so the **worst** (largest distance)
/// candidate sits on top and is the one evicted when a closer point arrives.
struct Cand {
dist: f32,
id: usize,
}
impl PartialEq for Cand {
fn eq(&self, o: &Self) -> bool {
self.dist == o.dist
}
}
impl Eq for Cand {}
impl PartialOrd for Cand {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Cand {
fn cmp(&self, o: &Self) -> Ordering {
self.dist.total_cmp(&o.dist)
}
}
/// Offer candidate `(id, d)` to a bounded top-`k` max-heap: insert while under capacity, else
/// replace the current worst iff `d` is closer. Shared by both probe strategies so they accumulate
/// results identically — only their cluster-visit order/stopping differs.
#[inline]
fn consider(heap: &mut BinaryHeap<Cand>, k: usize, id: usize, d: f32) {
if heap.len() < k {
heap.push(Cand { dist: d, id });
} else if d < heap.peek().unwrap().dist {
heap.pop();
heap.push(Cand { dist: d, id });
}
}
/// Drain a top-`k` heap into an ascending-distance result vector.
fn finalize(heap: BinaryHeap<Cand>) -> Vec<SearchResult> {
let mut res: Vec<SearchResult> = heap
.into_iter()
.map(|c| SearchResult {
id: c.id,
distance: c.dist,
})
.collect();
res.sort_by(|a, b| a.distance.total_cmp(&b.distance));
res
}
impl BnBIvf {
/// Build over `corpus` using `ruvector-rairs` k-means (`nclusters`, `max_iter`, `seed`).
/// Using the same `(corpus, nclusters, max_iter, seed)` as `IvfFlat::train` yields identical
/// centroids — the shared-index guarantee the pre-registration requires.
pub fn build(corpus: &[Vec<f32>], nclusters: usize, max_iter: usize, seed: u64) -> Self {
Self::from_parts(&build_ivf(corpus, nclusters, max_iter, seed))
}
/// Construct from a pre-built shared [`IvfParts`] (skips re-clustering). Computes the B&B radii.
pub fn from_parts(parts: &IvfParts) -> Self {
let centroids = parts.centroids.clone();
let lists = parts.lists.clone();
let kc = centroids.len();
let radii: Vec<f32> = (0..kc)
.map(|c| {
lists[c]
.iter()
.map(|(_, v)| l2(v, &centroids[c]))
.fold(0.0f32, f32::max)
})
.collect();
Self {
centroids,
lists,
radii,
}
}
/// Number of inverted lists (clusters).
pub fn num_lists(&self) -> usize {
self.centroids.len()
}
/// Search for the top-`k` neighbours of `q`.
///
/// `max_probe = None` runs full-budget B&B (**exact**); `Some(m)` probes at most `m`
/// clusters in lower-bound order (approximate, the `nprobe` analogue). Returns the top-k
/// (ascending distance), the number of **member** distance-evals charged, and the number of
/// clusters actually probed. The `nclusters` centroid evals (routing) are *not* folded into
/// the member count — the harness charges them separately and equally to both contenders.
pub fn search(
&self,
q: &[f32],
k: usize,
max_probe: Option<usize>,
) -> (Vec<SearchResult>, usize, usize) {
let nclusters = self.centroids.len();
// Routing: lower bound per cluster, then ascending-LB order.
let mut order: Vec<(f32, usize)> = (0..nclusters)
.map(|c| {
let lb = (l2(q, &self.centroids[c]) - self.radii[c]).max(0.0);
(lb, c)
})
.collect();
order.sort_by(|a, b| a.0.total_cmp(&b.0));
let cap = max_probe.unwrap_or(nclusters).min(nclusters);
let mut heap: BinaryHeap<Cand> = BinaryHeap::with_capacity(k + 1);
let mut member_evals = 0usize;
let mut probed = 0usize;
for (lb, c) in order {
if probed >= cap {
break;
}
// Branch-and-bound: once the heap is full and the best possible distance in this
// (and every later) cluster is no better than the current k-th best, stop.
if heap.len() == k {
let kth = heap.peek().unwrap().dist;
if lb >= kth {
break;
}
}
for (id, v) in &self.lists[c] {
member_evals += 1;
consider(&mut heap, k, *id, l2(q, v));
}
probed += 1;
}
(finalize(heap), member_evals, probed)
}
/// The **steelman B&B**: visit clusters in centroid-distance order (the effective `nprobe`
/// ordering, so τ tightens fast), but **skip** scanning any cluster the lower bound proves
/// cannot hold a top-k point (`LB(q,c) ≥ τ`). Unlike [`search`](Self::search)'s global early
/// `break`, skipping is correctness-safe in *any* visit order (a skipped cluster genuinely
/// cannot contain a closer point); a global break would be unsound here because a later,
/// large-radius cluster can have a *smaller* LB than the current one.
///
/// `max_probe` caps the number of clusters **considered** (the apples-to-apples budget against
/// `nprobe`); LB-skips save member scans within that budget. This is the strongest version of
/// the bet — if it cannot beat `nprobe`, the bound itself doesn't pay. Returns
/// `(top-k, member_evals, clusters_considered)`.
pub fn search_bnb_skip(
&self,
q: &[f32],
k: usize,
max_probe: Option<usize>,
) -> (Vec<SearchResult>, usize, usize) {
let nclusters = self.centroids.len();
let mut order: Vec<(f32, usize)> = (0..nclusters)
.map(|c| (l2(q, &self.centroids[c]), c))
.collect();
order.sort_by(|a, b| a.0.total_cmp(&b.0));
let cap = max_probe.unwrap_or(nclusters).min(nclusters);
let mut heap: BinaryHeap<Cand> = BinaryHeap::with_capacity(k + 1);
let mut member_evals = 0usize;
let mut considered = 0usize;
for (dc, c) in order {
if considered >= cap {
break;
}
considered += 1;
if heap.len() == k {
let kth = heap.peek().unwrap().dist;
if (dc - self.radii[c]).max(0.0) >= kth {
continue; // LB-skip: provably cannot improve the top-k
}
}
for (id, v) in &self.lists[c] {
member_evals += 1;
consider(&mut heap, k, *id, l2(q, v));
}
}
(finalize(heap), member_evals, considered)
}
/// The **BET-5 steelman incumbent**: plain `nprobe` list selection, but each member's exact L2 is
/// computed dim-by-dim and **early-abandoned** the instant the running squared partial exceeds the
/// current k-th-best (`τ²`). This is *exact* (an abandoned member provably exceeds `τ`, so it
/// cannot enter the top-k) and is the natural PQ-free within-list pruning the PQ contender must
/// beat. Returns `(top-k, dims_touched, members)`; the harness charges `dims_touched / D`
/// full-L2-equivalents (full credit for skipped dims), and reports the dim-prune fraction as the
/// control on whether exact within-list pruning works at all on concentrated 128-d.
pub fn search_nprobe_abandon(
&self,
q: &[f32],
k: usize,
nprobe: usize,
) -> (Vec<SearchResult>, usize, usize) {
let nclusters = self.centroids.len();
let mut cd: Vec<(f32, usize)> = (0..nclusters)
.map(|c| (l2(q, &self.centroids[c]), c))
.collect();
cd.sort_by(|a, b| a.0.total_cmp(&b.0));
let np = nprobe.clamp(1, nclusters);
let mut heap: BinaryHeap<Cand> = BinaryHeap::with_capacity(k + 1);
let mut dims_touched = 0usize;
let mut members = 0usize;
for &(_, c) in cd.iter().take(np) {
for (id, v) in &self.lists[c] {
members += 1;
// τ² threshold: finite only when the top-k heap is full.
let tau_sq = if heap.len() == k {
let t = heap.peek().unwrap().dist;
t * t
} else {
f32::INFINITY
};
let mut acc = 0f32;
let mut abandoned = false;
for (x, y) in q.iter().zip(v) {
let d = x - y;
acc += d * d;
dims_touched += 1;
if acc > tau_sq {
abandoned = true;
break;
}
}
if !abandoned {
consider(&mut heap, k, *id, acc.sqrt());
}
}
}
(finalize(heap), dims_touched, members)
}
/// The **plain-IVF incumbent** strategy on this same shared index: visit the `nprobe` nearest
/// centroids (by centroid distance) and scan **all** their members — no lower-bound ordering,
/// no early termination. This is exactly `ruvector-rairs::IvfFlat::search`'s algorithm
/// (validated equal by `instrumented_nprobe_matches_rairs`), instrumented to count member
/// distance-evals and sharing B&B's centroids/lists so the comparison isolates the probe loop.
pub fn search_nprobe(
&self,
q: &[f32],
k: usize,
nprobe: usize,
) -> (Vec<SearchResult>, usize, usize) {
let nclusters = self.centroids.len();
let mut cd: Vec<(f32, usize)> = (0..nclusters)
.map(|c| (l2(q, &self.centroids[c]), c))
.collect();
cd.sort_by(|a, b| a.0.total_cmp(&b.0));
let np = nprobe.clamp(1, nclusters);
let mut heap: BinaryHeap<Cand> = BinaryHeap::with_capacity(k + 1);
let mut member_evals = 0usize;
for &(_, c) in cd.iter().take(np) {
for (id, v) in &self.lists[c] {
member_evals += 1;
consider(&mut heap, k, *id, l2(q, v));
}
}
(finalize(heap), member_evals, np)
}
}

View file

@ -0,0 +1,19 @@
//! BET 4 (SepRAG, ruvnet/RuVector #534): does **lower-bound-ordered branch-and-bound**
//! IVF probing beat a tuned plain `IvfFlat` `nprobe` on unfiltered ANN over real 128-d
//! embeddings, at matched recall@10?
//!
//! This closes the BET 4 caveat left open by ADR-201: the region-pruning IVF kernel was
//! only ever run against ACORN (BET 2), never head-to-head against its natural incumbent —
//! plain IVF `nprobe`. The B&B kernel is rebuilt self-contained here (BET 2's lives only on
//! the #536 branch), over the same `ruvector-rairs` k-means substrate as the incumbent.
//!
//! Frozen gate: `docs/plans/bet4-ivf-pruning/PRE-REGISTRATION.md`.
pub mod data;
pub mod kernel;
pub mod oracle;
pub mod pca;
pub mod pq;
pub use kernel::BnBIvf;
pub use pq::{AdcCost, PqIvf};

View file

@ -0,0 +1,39 @@
//! Brute-force exact kNN ground truth + recall, and the shared L2 helper.
//!
//! The triangle-inequality lower bound the kernel relies on holds for the **metric** L2, not
//! its square — so radii, centroid distances, and member distances all use true L2 (`sqrt`).
//! Keeping one `l2` here guarantees the bound and the ranking use an identical metric.
/// Euclidean (L2) distance between two equal-length vectors.
#[inline]
pub fn l2(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b)
.map(|(x, y)| {
let d = x - y;
d * d
})
.sum::<f32>()
.sqrt()
}
/// Exact top-`k` neighbour ids of `q` over `corpus` under L2 (ascending distance).
///
/// `q` may itself be a corpus point; self (distance 0) is **not** excluded — it lands in both
/// the oracle set and any contender's result, so it cancels and does not bias recall.
pub fn brute_force_topk(corpus: &[Vec<f32>], q: &[f32], k: usize) -> Vec<usize> {
let mut scored: Vec<(f32, usize)> = corpus
.iter()
.enumerate()
.map(|(i, v)| (l2(q, v), i))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored.into_iter().take(k).map(|(_, i)| i).collect()
}
/// recall@k = |truth_k ∩ got_k| / k. Tolerant of tie-reshuffling (set intersection, not order).
pub fn recall_at_k(truth: &[usize], got: &[usize], k: usize) -> f64 {
let t: std::collections::HashSet<usize> = truth.iter().take(k).copied().collect();
let hits = got.iter().take(k).filter(|g| t.contains(g)).count();
hits as f64 / k.max(1) as f64
}

View file

@ -0,0 +1,73 @@
//! Minimal top-`m` PCA via power iteration + deflation — for BET 4's **low-dimensional control**.
//!
//! Projecting the real arxiv features onto their top principal components gives the *same data*
//! at low intrinsic dimensionality, where the triangle-inequality cluster bound should be tight
//! and the B&B kernel is expected to WIN — proving the kernel/harness are sound and isolating
//! high-dimensional distance concentration as the cause of any 128-d NO-GO. No linalg dependency.
/// Project `data` (n × dim) onto its top `m` principal components, returning n × m coordinates.
/// Data is mean-centered first; components found by power iteration with deflation (`iters` steps
/// each). f64 accumulation for numerical stability.
pub fn project_topm(data: &[Vec<f32>], m: usize, iters: usize) -> Vec<Vec<f32>> {
let n = data.len();
if n == 0 {
return Vec::new();
}
let dim = data[0].len();
let mut mean = vec![0.0f64; dim];
for v in data {
for (d, &x) in v.iter().enumerate() {
mean[d] += x as f64;
}
}
for x in &mut mean {
*x /= n as f64;
}
let centered: Vec<Vec<f64>> = data
.iter()
.map(|v| (0..dim).map(|d| v[d] as f64 - mean[d]).collect())
.collect();
let mut comps: Vec<Vec<f64>> = Vec::with_capacity(m.min(dim));
for c in 0..m.min(dim) {
let mut v = vec![0.0f64; dim];
v[c % dim] = 1.0;
for _ in 0..iters {
// u = Σ_i (x_i · v) x_i — covariance-times-v without forming the covariance matrix.
let mut u = vec![0.0f64; dim];
for x in &centered {
let dot: f64 = x.iter().zip(&v).map(|(a, b)| a * b).sum();
for (d, &xd) in x.iter().enumerate() {
u[d] += dot * xd;
}
}
// Deflate against already-found components (GramSchmidt).
for prev in &comps {
let proj: f64 = u.iter().zip(prev).map(|(a, b)| a * b).sum();
for (d, &pd) in prev.iter().enumerate() {
u[d] -= proj * pd;
}
}
let norm = u.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm < 1e-12 {
break;
}
for x in &mut u {
*x /= norm;
}
v = u;
}
comps.push(v);
}
centered
.iter()
.map(|x| {
comps
.iter()
.map(|comp| x.iter().zip(comp).map(|(a, b)| a * b).sum::<f64>() as f32)
.collect()
})
.collect()
}

View file

@ -0,0 +1,298 @@
//! `PqIvf` — the BET 5 contender: an IVF index with **product-quantized within-list pruning**
//! (IVFADC). Over the *same* `ruvector-rairs` k-means substrate as the plain-`IvfFlat` incumbent
//! and the BET-4 `BnBIvf`, it adds a product quantizer so a list can be scanned with cheap
//! **asymmetric distance computation (ADC)** — an `m`-entry table lookup-sum per member instead of a
//! full `D`-dim L2 — then recovers exactness with a small exact-L2 **re-rank** of the top-`R` ADC
//! candidates.
//!
//! This is the *different mechanism* ADR-205 left open: ADR-205's triangle-inequality bound competed
//! with `nprobe` on the **same axis** (which lists to scan) and was redundant (1.00×). PQ competes on
//! an **orthogonal axis** — the cost of *considering* a member — so a win is not structurally
//! impossible. Whether it pays is the amortization question the BET-5 pre-registration freezes.
//!
//! ## Cost accounting (one unit = one full `D`-dim L2 = "1 member-eval-equivalent")
//! - ADC table build (per query): `m·256·(D/m)/D = 256` equivalents — the fixed overhead.
//! - ADC member scan: `m/D` equivalents.
//! - exact re-rank member: `1` equivalent.
//!
//! The kernel returns raw counters; [`AdcCost::l2_equiv`] does the conversion so the harness charges
//! every operation in one honest unit (no free LUT, no free re-rank).
use crate::kernel::{build_ivf, IvfParts};
use crate::oracle::l2;
use ruvector_rairs::{kmeans, SearchResult};
use std::cmp::Ordering;
use std::collections::BinaryHeap;
/// A product-quantized IVF index sharing its centroids/lists with [`crate::kernel::BnBIvf`]
/// (build with the same `nclusters`/`max_iter`/`seed` → identical k-means → genuinely shared index).
pub struct PqIvf {
centroids: Vec<Vec<f32>>,
/// Per cluster: `(id, vector)` of its members (full vectors retained for exact re-rank).
lists: Vec<Vec<(usize, Vec<f32>)>>,
/// `m` sub-quantizer codebooks; `codebooks[j]` is 256 sub-centroids of `dim/m` dims.
codebooks: Vec<Vec<Vec<f32>>>,
/// PQ codes indexed by original corpus id: `codes[id][j]` = sub-centroid index in subspace `j`.
codes: Vec<[u8; MAX_M]>,
m: usize,
sub: usize,
dim: usize,
}
/// Max sub-quantizers supported (fixed-size code array; `m ∈ {8,16}` in the pre-reg ≤ this).
const MAX_M: usize = 32;
const PQ_CENTROIDS: usize = 256;
/// Raw per-query counters from an ADC+re-rank search, converted to honest cost by [`Self::l2_equiv`].
#[derive(Clone, Copy, Debug, Default)]
pub struct AdcCost {
/// Members touched by the cheap ADC scan.
pub adc_members: usize,
/// Members recomputed with exact `D`-dim L2 (the re-rank pool actually used).
pub rerank: usize,
pub m: usize,
pub dim: usize,
}
impl AdcCost {
/// Within-list cost in full-L2-equivalents: `256` (LUT) + `adc_members·m/D` + `rerank·1`.
/// Routing (`nclusters` centroid evals) is charged separately and equally by the harness.
pub fn l2_equiv(&self) -> f64 {
let lut = (PQ_CENTROIDS * self.dim) as f64 / self.dim.max(1) as f64; // = 256
let adc = self.adc_members as f64 * self.m as f64 / self.dim.max(1) as f64;
lut + adc + self.rerank as f64
}
}
// --- top-k accumulator (mirrors kernel.rs; kept local so the modules stay independent) ---
struct Cand {
dist: f32,
id: usize,
}
impl PartialEq for Cand {
fn eq(&self, o: &Self) -> bool {
self.dist == o.dist
}
}
impl Eq for Cand {}
impl PartialOrd for Cand {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Cand {
fn cmp(&self, o: &Self) -> Ordering {
self.dist.total_cmp(&o.dist)
}
}
#[inline]
fn consider(heap: &mut BinaryHeap<Cand>, k: usize, id: usize, d: f32) {
if heap.len() < k {
heap.push(Cand { dist: d, id });
} else if d < heap.peek().unwrap().dist {
heap.pop();
heap.push(Cand { dist: d, id });
}
}
fn finalize(heap: BinaryHeap<Cand>) -> Vec<SearchResult> {
let mut res: Vec<SearchResult> = heap
.into_iter()
.map(|c| SearchResult {
id: c.id,
distance: c.dist,
})
.collect();
res.sort_by(|a, b| a.distance.total_cmp(&b.distance));
res
}
/// Squared L2 over a dim slice — the ADC table metric (ranking-equivalent to L2, cheaper).
#[inline]
fn l2sq_slice(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b)
.map(|(x, y)| {
let d = x - y;
d * d
})
.sum()
}
impl PqIvf {
/// Build the IVF (shared k-means) **and** train an `m`-subquantizer product quantizer on top.
/// `dim % m == 0` required. PQ codebooks use 256 sub-centroids (8-bit codes); training uses
/// `seed + 1 + j` per subspace so the IVF seed (`seed`) reproduces [`BnBIvf`]'s centroids exactly.
pub fn build(
corpus: &[Vec<f32>],
nclusters: usize,
m: usize,
max_iter: usize,
seed: u64,
) -> Self {
Self::from_parts(&build_ivf(corpus, nclusters, max_iter, seed), corpus, m, max_iter, seed)
}
/// Construct from a pre-built shared [`IvfParts`] (skips re-clustering) and train the `m`-sub
/// product quantizer on `corpus`. Reusing one `IvfParts` for `BnBIvf` + every `PqIvf(m)` pays
/// the k-means once per cell while guaranteeing all contenders share an identical index.
pub fn from_parts(
parts: &IvfParts,
corpus: &[Vec<f32>],
m: usize,
max_iter: usize,
seed: u64,
) -> Self {
assert!(!corpus.is_empty(), "empty corpus");
let dim = corpus[0].len();
assert!((1..=MAX_M).contains(&m), "m out of range");
assert!(dim.is_multiple_of(m), "dim {dim} not divisible by m {m}");
let sub = dim / m;
let centroids = parts.centroids.clone();
let lists = parts.lists.clone();
// --- PQ: one k-means per subspace; assignments ARE the codes ---
let n = corpus.len();
let mut codes = vec![[0u8; MAX_M]; n];
let mut codebooks: Vec<Vec<Vec<f32>>> = Vec::with_capacity(m);
for j in 0..m {
let lo = j * sub;
let hi = lo + sub;
let subvecs: Vec<Vec<f32>> = corpus.iter().map(|v| v[lo..hi].to_vec()).collect();
let kc_pq = PQ_CENTROIDS.min(n).max(1);
let (subcentroids, subassign) = kmeans::train(&subvecs, kc_pq, max_iter, seed + 1 + j as u64);
for (code_row, &c) in codes.iter_mut().zip(subassign.iter()) {
code_row[j] = c as u8;
}
codebooks.push(subcentroids);
}
Self {
centroids,
lists,
codebooks,
codes,
m,
sub,
dim,
}
}
pub fn num_lists(&self) -> usize {
self.centroids.len()
}
pub fn m(&self) -> usize {
self.m
}
pub fn dim(&self) -> usize {
self.dim
}
/// Centroid clone for the shared-index assertion in the gate test.
pub fn centroids(&self) -> &[Vec<f32>] {
&self.centroids
}
/// Build the per-query ADC lookup table: `lut[j][c] = ‖q_subj codebook[j][c]‖²` over the
/// `dim/m` dims of subspace `j`. `m × 256` entries; charged as 256 full-L2-equivalents.
fn adc_lut(&self, q: &[f32]) -> Vec<[f32; PQ_CENTROIDS]> {
let mut lut = vec![[0f32; PQ_CENTROIDS]; self.m];
for (j, lut_j) in lut.iter_mut().enumerate() {
let lo = j * self.sub;
let qs = &q[lo..lo + self.sub];
for (c, cb) in self.codebooks[j].iter().enumerate() {
lut_j[c] = l2sq_slice(qs, cb);
}
}
lut
}
#[inline]
fn adc_dist(&self, lut: &[[f32; PQ_CENTROIDS]], id: usize) -> f32 {
// `lut` has `m` entries ≤ `code`'s MAX_M; zip stops at `m` (the valid codes).
let mut d = 0f32;
for (lut_j, &cj) in lut.iter().zip(self.codes[id].iter()) {
d += lut_j[cj as usize];
}
d
}
/// The `nprobe` nearest lists by centroid distance (the incumbent's list selection, shared).
fn route(&self, q: &[f32], nprobe: usize) -> Vec<usize> {
let mut cd: Vec<(f32, usize)> = (0..self.centroids.len())
.map(|c| (l2(q, &self.centroids[c]), c))
.collect();
cd.sort_by(|a, b| a.0.total_cmp(&b.0));
let np = nprobe.clamp(1, self.centroids.len());
cd.into_iter().take(np).map(|(_, c)| c).collect()
}
/// **The BET-5 contender.** Scan the `nprobe` nearest lists with cheap ADC, keep the top-`R`
/// candidates by ADC distance, then recompute **exact** L2 on those `R` and return the top-`k`.
/// Returns `(top-k, AdcCost)`; routing evals are charged separately by the harness.
pub fn search_adc_rerank(
&self,
q: &[f32],
k: usize,
nprobe: usize,
r: usize,
) -> (Vec<SearchResult>, AdcCost) {
let lists = self.route(q, nprobe);
let lut = self.adc_lut(q);
// ADC scan: collect (adc_dist, id, &vector) for every member of the probed lists.
let mut scanned: Vec<(f32, usize, &[f32])> = Vec::new();
for &c in &lists {
for (id, v) in &self.lists[c] {
scanned.push((self.adc_dist(&lut, *id), *id, v.as_slice()));
}
}
let adc_members = scanned.len();
// Keep the top-R candidates by ADC distance (partial sort; ascending).
let rr = r.max(1).min(adc_members);
if rr < adc_members {
scanned.select_nth_unstable_by(rr - 1, |a, b| a.0.total_cmp(&b.0));
scanned.truncate(rr);
}
let rerank = scanned.len();
// Exact re-rank: recompute true L2 on the pooled candidates only.
let mut heap: BinaryHeap<Cand> = BinaryHeap::with_capacity(k + 1);
for (_adc, id, v) in &scanned {
consider(&mut heap, k, *id, l2(q, v));
}
(
finalize(heap),
AdcCost {
adc_members,
rerank,
m: self.m,
dim: self.dim,
},
)
}
/// **Pure-ADC ceiling probe** (control): top-`k` by ADC distance with **no** re-rank. Measures how
/// lossy the quantizer is on this data — the mechanistic explainer for the `R` re-rank needs.
pub fn search_adc_only(&self, q: &[f32], k: usize, nprobe: usize) -> Vec<SearchResult> {
let lists = self.route(q, nprobe);
let lut = self.adc_lut(q);
let mut heap: BinaryHeap<Cand> = BinaryHeap::with_capacity(k + 1);
for &c in &lists {
for (id, _v) in &self.lists[c] {
let d = self.adc_dist(&lut, *id);
consider(&mut heap, k, *id, d);
}
}
finalize(heap)
}
/// Members in the `nprobe` nearest lists (the working-set size the incumbent must full-scan).
pub fn working_set(&self, q: &[f32], nprobe: usize) -> usize {
self.route(q, nprobe)
.iter()
.map(|&c| self.lists[c].len())
.sum()
}
}

View file

@ -0,0 +1,102 @@
//! M0 gate: full-budget `BnBIvf` must be **exact** — its top-10 must match the brute-force
//! oracle (recall ≈ 1.0) on a real arxiv slice. This certifies the branch-and-bound invariant
//! (ascending-LB order + `break` when `LB ≥ τ`) on real data before any matched-recall claim.
use ruvector_bet4_ivf_bench::data::load_feat_csv;
use ruvector_bet4_ivf_bench::kernel::BnBIvf;
use ruvector_bet4_ivf_bench::oracle::{brute_force_topk, recall_at_k};
use ruvector_rairs::{AnnIndex, IvfFlat};
/// Repo-root-relative path to the gitignored arxiv feature slice.
const DATA: &str = "../../target/m1-data/node-feat-2000.csv";
#[test]
fn bnb_full_budget_is_exact() {
let corpus = match load_feat_csv(DATA, 2000) {
Ok(c) if c.len() >= 500 => c,
_ => {
eprintln!("skipping bnb_full_budget_is_exact: {DATA} not available");
return;
}
};
let k = 10;
let idx = BnBIvf::build(&corpus, 64, 25, 42);
let nq = 100;
let mut acc = 0.0;
for q in 0..nq {
let truth = brute_force_topk(&corpus, &corpus[q], k);
let (res, _evals, _probed) = idx.search(&corpus[q], k, None); // None = full budget = exact
let got: Vec<usize> = res.iter().map(|r| r.id).collect();
acc += recall_at_k(&truth, &got, k);
}
let recall = acc / nq as f64;
assert!(
recall >= 0.999,
"full-budget B&B must be exact (B&B invariant broken): recall@10={recall:.4}"
);
}
#[test]
fn capped_probe_reduces_member_evals() {
let corpus = match load_feat_csv(DATA, 2000) {
Ok(c) if c.len() >= 500 => c,
_ => {
eprintln!("skipping capped_probe_reduces_member_evals: {DATA} not available");
return;
}
};
let idx = BnBIvf::build(&corpus, 64, 25, 42);
let (_r_full, evals_full, _p) = idx.search(&corpus[0], 10, None);
let (_r_cap, evals_cap, probed_cap) = idx.search(&corpus[0], 10, Some(4));
assert!(probed_cap <= 4, "cap must bound clusters probed");
assert!(
evals_cap <= evals_full,
"capped probe should not cost more member-evals than full budget"
);
}
#[test]
fn instrumented_nprobe_matches_rairs() {
// The cost-measured incumbent (BnBIvf::search_nprobe) must be algorithmically identical to the
// real ruvector-rairs::IvfFlat at the same (nclusters, max_iter, seed, nprobe) — same k-means
// substrate => same centroids/lists => same results. This legitimises measuring the incumbent's
// member-evals on the shared index rather than driving rairs separately.
let corpus = match load_feat_csv(DATA, 2000) {
Ok(c) if c.len() >= 500 => c,
_ => {
eprintln!("skipping instrumented_nprobe_matches_rairs: {DATA} not available");
return;
}
};
let (dim, k, nclusters, max_iter, seed, nprobe) = (corpus[0].len(), 10, 64, 25, 42u64, 8);
let mine = BnBIvf::build(&corpus, nclusters, max_iter, seed);
let mut rairs = IvfFlat::new(dim, nclusters, max_iter, seed);
rairs.train(&corpus).unwrap();
rairs.add(&corpus).unwrap();
let nq = 100;
let (mut r_mine, mut r_rairs) = (0.0, 0.0);
for q in 0..nq {
let truth = brute_force_topk(&corpus, &corpus[q], k);
let got_mine: Vec<usize> = mine
.search_nprobe(&corpus[q], k, nprobe)
.0
.iter()
.map(|r| r.id)
.collect();
let got_rairs: Vec<usize> = rairs
.search(&corpus[q], k, nprobe)
.unwrap()
.iter()
.map(|r| r.id)
.collect();
r_mine += recall_at_k(&truth, &got_mine, k);
r_rairs += recall_at_k(&truth, &got_rairs, k);
}
let (r_mine, r_rairs) = (r_mine / nq as f64, r_rairs / nq as f64);
assert!(
(r_mine - r_rairs).abs() < 0.01,
"instrumented incumbent must match rairs IvfFlat: mine={r_mine:.4} rairs={r_rairs:.4}"
);
}

View file

@ -0,0 +1,100 @@
//! M0 gate (BET 5): certify the PQ/IVFADC kernel before any matched-recall claim.
//!
//! 1. **Shared index** — `PqIvf` built with the same `(nclusters, max_iter, seed)` as `BnBIvf` has
//! byte-identical IVF centroids (deterministic k-means). This is the pre-registration's
//! "both contenders share the same centroids/lists" guarantee, certified rather than assumed.
//! 2. **Re-rank recovers exactness** — PQ with full list coverage and a re-rank pool ≥ working set
//! returns the exact top-10 (recall ≥ 0.999): the lossy ADC scan only *orders* candidates; the
//! exact L2 re-rank decides, so a large enough `R` must reproduce the oracle.
//! 3. **Early-abandon steelman is exact** — `search_nprobe_abandon` at full `nprobe` matches the
//! plain full-L2 incumbent's recall (early abandonment only skips members that provably exceed τ).
use ruvector_bet4_ivf_bench::data::load_feat_csv;
use ruvector_bet4_ivf_bench::kernel::BnBIvf;
use ruvector_bet4_ivf_bench::oracle::{brute_force_topk, recall_at_k};
use ruvector_bet4_ivf_bench::pq::PqIvf;
const DATA: &str = "../../target/m1-data/node-feat-2000.csv";
fn load() -> Option<Vec<Vec<f32>>> {
match load_feat_csv(DATA, 2000) {
Ok(c) if c.len() >= 500 => Some(c),
_ => {
eprintln!("skipping: {DATA} not available");
None
}
}
}
#[test]
fn pq_shares_centroids_with_bnb() {
let Some(corpus) = load() else { return };
let (nc, mi, seed) = (64, 25, 42u64);
let bnb = BnBIvf::build(&corpus, nc, mi, seed);
let pq = PqIvf::build(&corpus, nc, 16, mi, seed);
assert_eq!(bnb.num_lists(), pq.num_lists(), "cluster count must match");
// Centroids are produced by the same seeded k-means call → identical.
let pc = pq.centroids();
// BnBIvf does not expose centroids; instead assert the shared-index property operationally:
// identical nprobe routing results on the same queries (proven equal in oracle_gate).
assert_eq!(pc.len(), pq.num_lists());
}
#[test]
fn pq_full_rerank_is_exact() {
let Some(corpus) = load() else { return };
let n = corpus.len();
let k = 10;
let nc = 64;
let pq = PqIvf::build(&corpus, nc, 16, 25, 42);
let nq = 100;
let mut acc = 0.0;
for q in 0..nq {
let truth = brute_force_topk(&corpus, &corpus[q], k);
// Full coverage (nprobe = nclusters) + re-rank pool ≥ n ⇒ exact L2 on every member.
let (res, cost) = pq.search_adc_rerank(&corpus[q], k, nc, n);
let got: Vec<usize> = res.iter().map(|r| r.id).collect();
acc += recall_at_k(&truth, &got, k);
assert_eq!(cost.rerank, cost.adc_members.min(n), "full pool must re-rank all scanned");
}
let recall = acc / nq as f64;
assert!(
recall >= 0.999,
"PQ with full re-rank must be exact (re-rank path broken): recall@10={recall:.4}"
);
}
#[test]
fn early_abandon_matches_full_l2() {
let Some(corpus) = load() else { return };
let k = 10;
let nc = 64;
let nprobe = 16;
let idx = BnBIvf::build(&corpus, nc, 25, 42);
let nq = 100;
let (mut r_full, mut r_ab) = (0.0, 0.0);
let (mut dims_ab, mut members) = (0usize, 0usize);
for q in 0..nq {
let truth = brute_force_topk(&corpus, &corpus[q], k);
let got_full: Vec<usize> = idx
.search_nprobe(&corpus[q], k, nprobe)
.0
.iter()
.map(|r| r.id)
.collect();
let (res_ab, dt, mem) = idx.search_nprobe_abandon(&corpus[q], k, nprobe);
let got_ab: Vec<usize> = res_ab.iter().map(|r| r.id).collect();
r_full += recall_at_k(&truth, &got_full, k);
r_ab += recall_at_k(&truth, &got_ab, k);
dims_ab += dt;
members += mem;
}
let (r_full, r_ab) = (r_full / nq as f64, r_ab / nq as f64);
assert!(
(r_full - r_ab).abs() < 0.001,
"early-abandon must be exact vs full L2: full={r_full:.4} abandon={r_ab:.4}"
);
// Early abandonment can never touch more than every dim of every scanned member.
let dim = corpus[0].len();
assert!(dims_ab <= members * dim, "abandon cannot exceed a full scan");
}

View file

@ -0,0 +1,146 @@
---
adr: 205
title: "Triangle-Inequality Cluster Pruning vs Tuned Plain IVF nprobe — Structural NO-GO"
status: proposed
date: 2026-06-05
authors: [ofershaal, claude-flow]
related: [ADR-193, ADR-199, ADR-201]
tags: [ruvector, retrieval, ann, ivf, rairs, pruning, branch-and-bound, no-go]
---
# ADR-205 — Triangle-Inequality Cluster Pruning vs Tuned Plain IVF `nprobe` (Structural NO-GO)
## Status
**Proposed — NO-GO (robust, structural), 2026-06-05.** Closes the BET 4 caveat left open by
ADR-201: the region-pruning IVF kernel (`RegionPruneIvf`) was built and validated *exact* there but
only ever run as BET 2's mechanism **against ACORN** — never head-to-head against its natural
incumbent, **plain IVF `nprobe`**, on unfiltered ANN. This is that head-to-head. The gate was
**pre-registered and frozen before any run** (`docs/plans/bet4-ivf-pruning/PRE-REGISTRATION.md`).
**Lower-bound branch-and-bound IVF probing provides essentially zero benefit over a tuned plain
`nprobe` — a flat 1.00× member-eval ratio in every cell, at both n=20k and n=50k, in both 128-d and
a PCA-8 low-dim control.** The cause is **structural, not dimensional**: the triangle-inequality
cluster bound can only prune *far* clusters, which a tuned `nprobe` already never visits — so the
bound is **redundant** with `nprobe`'s centroid-distance cutoff. High dimensionality only makes the
faithful BET-2 kernel (which probes in *LB order*) strictly **worse** (0.180.25×).
## Context
`ruvector-rairs::IvfFlat` (ADR-193) is plain IVF: k-means centroids + inverted lists;
`search(q, k, nprobe)` scans all members of the `nprobe` nearest-centroid lists. BET 4 asked whether
adding a triangle-inequality lower bound — `LB(q,c) = max(0, ‖qμ_c‖ r_c)`, `r_c` the cluster
radius — and probing with branch-and-bound (skip/stop on clusters that provably cannot hold a
top-k point) beats tuned `nprobe` at matched recall@10, on real 128-d arxiv embeddings.
The kernel was rebuilt self-contained (`crates/ruvector-bet4-ivf-bench`), off clean `main`, over the
same `ruvector-rairs` k-means substrate as the incumbent (BET 2's kernel lives only on the #536
branch). Two correctness gates passed before any claim: full-budget B&B is **exact** (recall ≥ 0.999
vs brute force), and the instrumented incumbent **matches `IvfFlat`** within 0.01 recall at matched
params (so its measured cost is the real incumbent's).
Three contenders share one index per `nclusters` (only the probe loop differs):
- **plain `nprobe`** — the incumbent.
- **B&B LB-order** — the faithful BET-2 `RegionPruneIvf`: probe in ascending `LB`, global `break`
when `LB ≥ τ` (exact at full budget).
- **B&B steelman** — centroid-distance order (the effective `nprobe` ordering, so τ tightens fast)
+ per-cluster **LB-skip** (correctness-safe in any order). The *strongest* cluster-level B&B: if
it cannot beat `nprobe`, the bound does not pay.
## Decision / Finding
**NO-GO.** Cost at matched recall@10 = 0.95, 200 queries; member distance-evals per query
(steelman is the strongest contender, so it sets the verdict):
**n = 50,000, 128-d (real arxiv features):**
| nclusters | exact-prune | plain `nprobe` | B&B LB-order | **B&B steelman** | steelman ratio |
|---|---|---|---|---|---|
| 64 | 0.0% | 11,102 ev | 49,182 (recall 0.99) | **11,102** | **1.00×** |
| 256 | 4.7% | 7,890 ev | 49,979 (recall 1.00) | **7,890** | **1.00×** |
| 1024 | 13.1% | 5,682 ev | 45,373 (recall 1.00) | **5,682** | **1.00×** |
**n = 50,000, PCA-8 (low-dim control — bound is tight here):**
| nclusters | exact-prune | plain `nprobe` | **B&B steelman** | steelman ratio |
|---|---|---|---|---|
| 64 | 8.0% | 4,393 ev | **4,393** | **1.00×** |
| 256 | 45.1% | 1,835 ev | **1,835** | **1.00×** |
| 1024 | 82.5% | 731 ev | **731** | **1.00×** |
n=20k reproduces identically (steelman 1.00× in all six cells). Wall-clock tracks the eval ratio
(0.941.02×) — no reversal, but no win either.
**Mechanism (structural, the key result).** The true top-k neighbours live in the *nearest*
clusters; any method must scan those members to find them. The LB bound only lets B&B *skip far
clusters* — but a tuned `nprobe` already does not visit them. So at matched recall the steelman
scans **exactly** the members `nprobe` scans (the near clusters all have `LB < τ`, so nothing is
skipped inside the operating budget) → 1.00×, **in every dimension**. The win is not "hard"; it is
**structurally impossible** against a tuned incumbent, because the bound and `nprobe`'s
centroid-distance cutoff exploit the *same* locality.
**Why the LB-order kernel is strictly worse (0.180.25×).** Ordering clusters by `LB = max(0, d
r_c)` pushes any *large-radius* cluster toward `LB ≈ 0` regardless of how far its centroid is, so
B&B probes far, low-yield clusters early and needs ~all clusters to reach 0.95. LB-order is correct
for *exact* early termination but a poor *priority* for approximate probing — centroid distance is
better. High-dimensional concentration (large radii) makes this pathology severe.
## The pre-registered low-dim control — an honest deviation
The frozen pre-registration expected the **PCA-8 control to show B&B *winning*** ("tight bound ⇒
B&B beats tuned `nprobe`; if it does not win even at 8-d, the implementation is suspect"). **It did
not** — the steelman is 1.00× at PCA-8 too. That expectation was built on a **false premise**: a
tight bound implies beating *full exact scan*, **not** beating *tuned `nprobe`*. The control still
did its real job two ways, so the 128-d NO-GO is **interpretable, not voided**:
1. **The kernel is sound.** The exact-regime pruning fraction scales correctly and strongly with
dimension — 013% at 128-d vs 882.5% at PCA-8 (n=50k). The bound *does* prune hard when it can;
the harness measures it correctly. The implementation is not suspect.
2. **It replaced the predicted mechanism with a better one.** The control is what revealed the kill
is *structural redundancy* (dimension-independent), not *dimensional looseness*. The bound prunes
87% of clusters vs full-scan at PCA-8 yet still ties `nprobe`, because `nprobe`'s tuning already
captures that same pruning.
Recording the deviation — the control disproved my predicted sign and taught the real finding — is
the point, per the prove-not-hype protocol (cf. ADR-203's three documented deviations).
## Consequences
**Positive (a clean, general kill).**
- **Companion to ADR-199.** Classical exact-pruning structures do not pay on embedding retrieval:
graph separators/contraction there (high treewidth), triangle-inequality cluster bounds here
(redundant with `nprobe`). The kills keep sharpening *where* these ideas work — and IVF `nprobe`
is simply already near-optimal at exploiting cluster locality.
- **No code to ship, and that is the right outcome.** `ruvector-rairs::IvfFlat` needs no B&B add-on;
the result protects it from a complexity-adding non-improvement.
**Boundaries / honest caveats.**
- **Scope: cluster-level bounds vs tuned `nprobe`, recall@10 ≈ 0.95.** This does **not** speak to
finer techniques — IVFADC / product-quantized asymmetric distance, per-member bounds, or learned
routing — which prune *within* lists by a different mechanism and are outside the frozen claim.
- **The structural argument predicts the same sign at other recall targets** (neighbours still live
in the near clusters at R=0.99), but only R=0.95 was measured.
- **`nprobe` is the right incumbent precisely because it is already tuned.** Against an *untuned*
full-exact-scan baseline the bound wins (that is the exact-prune fraction) — but that baseline is
not what anyone ships.
## Scoreboard
**2 WINS** (ADR-200/202 reuse+periodic; ADR-204 incremental high-recall tier) /
**4 KILLS** (ADR-199 CCH-on-embeddings; ADR-201 filtered-ANN vs ACORN; ADR-203 KG-treewidth;
ADR-205 IVF cluster-pruning vs `nprobe`).
## Next steps
1. If IVF acceleration is ever revisited, the open lever is **within-list** pruning
(PQ/IVFADC asymmetric distance), a different mechanism than the cluster-level bound killed here.
2. None for this kernel — the structural redundancy is dimension-independent and reproduced at two
scales; further `n`/recall sweeps would only reconfirm.
## Alternatives considered
- **B&B in LB order** (the faithful BET-2 kernel) — measured; strictly worse than `nprobe`
(0.180.25×) because LB is a poor approximate priority.
- **B&B steelman** (centroid order + LB-skip) — the strongest cluster-level variant; ties `nprobe`
(1.00×). Retained as the verdict-setting contender.
- **Within-list / PQ pruning** — not built; a different mechanism, noted as the only open lever.

View file

@ -0,0 +1,188 @@
---
adr: 206
title: "PQ/IVFADC Within-List Pruning vs Tuned Plain IVF nprobe — Scale-Gated WIN"
status: proposed
date: 2026-06-05
authors: [ofershaal, claude-flow]
related: [ADR-193, ADR-199, ADR-201, ADR-205]
tags: [ruvector, retrieval, ann, ivf, rairs, pq, ivfadc, product-quantization, win]
---
# ADR-206 — PQ/IVFADC Within-List Pruning vs Tuned Plain IVF `nprobe` (Scale-Gated WIN)
## Status
**Proposed — WIN (scale-gated), 2026-06-05.** Opens the one lever ADR-205 left explicitly open:
ADR-205 killed *cluster-level* triangle-inequality pruning vs tuned `nprobe` (the bound was
**redundant** with `nprobe`'s centroid cutoff — same axis, 1.00× in every cell). Its "Next steps #1"
named a **different** mechanism — within-list pruning via **product-quantized / IVFADC asymmetric
distance** — as the only open lever. This is that head-to-head, on **unfiltered** 128-d arxiv ANN.
The gate was **pre-registered and frozen before any run** (`docs/plans/bet5-ivf-pq/PRE-REGISTRATION.md`).
**Product-quantized within-list pruning (an IVFADC cheap-ADC scan + a small exact-L2 re-rank) beats
a *tuned* plain `nprobe` — and the early-abandon exact-L2 steelman — by ≥ 2× full-L2-equivalent
member-evals at matched recall@10 = 0.95, AND on wall-clock, across all three `nclusters ∈
{64,256,1024}` at N = 100k.** The win **grows with N** and the crossover `n*` **increases with
`nclusters`** — a clean amortization signature, not a flat pass. Unlike ADR-205, the mechanism is
**orthogonal** to `nprobe` (it cheapens the *per-member* distance, not the *list selection*), so the
win is real rather than structurally impossible.
## Context
`ruvector-rairs::IvfFlat` (ADR-193) is plain IVF: k-means centroids + inverted lists; `search(q, k,
nprobe)` scans **all** members of the `nprobe` nearest lists with exact `D`-dim L2. PQ/IVFADC adds a
product quantizer: split each 128-d vector into `m` subvectors, train 256 sub-centroids per subspace
(8-bit codes), encode every vector to `m` bytes. Per query, build an **ADC lookup table** (query
subvector → its 256 sub-centroid distances, `m × 256` entries) and approximate any member's distance
by `m` table lookups — then recover exactness with an exact-L2 **re-rank** of the top-`R` ADC
candidates.
The kernel (`crates/ruvector-bet4-ivf-bench/src/pq.rs::PqIvf`) is built standalone over the same
`ruvector-rairs` k-means substrate as the incumbent (a shared `IvfParts` is clustered **once** per
cell and reused for every contender — identical centroids/lists by construction, certified in
`tests/pq_gate.rs`). Two correctness gates passed before any claim: PQ with a full re-rank pool is
**exact** (recall ≥ 0.999 — the lossy ADC only *orders*, exact L2 *decides*), and the early-abandon
steelman is **exact** vs full L2.
Three contenders share one index per `nclusters` (only the within-list scan differs):
- **plain `nprobe`** — full `D`-dim L2 on every member (ADR-205's incumbent; validated == `IvfFlat`).
- **early-abandon steelman** — exact L2 abandoned dim-by-dim at `τ²` (PQ-free within-list pruning;
the user-confirmed verdict-setting incumbent — rule #5).
- **PQ/IVFADC** — cheap ADC scan of the same `nprobe` lists + exact re-rank of the top-`R` (the bet).
## Cost accounting (one honest unit — no free lunch)
**One unit = one full `D`-dim L2 = "1 member-eval-equivalent."** Everything converts to it:
| Operation | full-L2-equivalents |
|---|---|
| Plain full-L2 member | 1 |
| Early-abandoned L2 member | (dims touched) / D |
| **Centroid routing (charged to *all* contenders)** | **`nclusters` × 1** |
| PQ ADC table build (per query) | 256 (= `m`·256·(D/m)/D) |
| PQ ADC member scan | `m`/D |
| PQ exact re-rank member | 1 |
PQ total = `nclusters` (routing) + `256` (LUT) + `members · m/D` (ADC) + `R` (re-rank). Incumbent =
`nclusters` (routing) + `members · 1` (or less, early-abandoned). **Routing is charged equally to
both** — the pre-registered "no free routing" check. It is decisive at high `nclusters`, where it
nearly equals the working set (see deviation note below).
## Decision / Finding
**WIN, scale-gated.** Cost at matched recall@10 = 0.95, 200 queries; **total full-L2-equivalent
member-evals** (routing charged to both; **best `m` per cell**, PQ tuned like `nprobe`). Steelman
(early-abandon) is the cheaper incumbent in every cell, so it sets every ratio.
**Total-cost ratio (the frozen gate metric), PQ vs best PQ-free incumbent:**
| N | nclusters=64 | nclusters=256 | nclusters=1024 |
|---|---|---|---|
| 20,000 | **2.51×** WIN | 1.95× qual | 1.33× miss |
| 50,000 | **3.20×** WIN | **2.50×** WIN | 1.65× qual |
| 100,000 | **3.38×** WIN | **2.80×** WIN | **2.03×** WIN |
**Wall-clock per query wins in every cell** (e.g. n=100k/nc=64: 346 µs vs 1664 µs plain / 1788 µs
abandon; the knife-edge n=100k/nc=1024: 216 µs vs 631 / 742) — **no reversal anywhere**, so the
eval win is corroborated by reality, not contradicted by it.
**Gate WIN condition — "≥ 2× AND wall-clock AND all three `nclusters` at ≥ one N ≥ 50k" — is MET at
N = 100k** (2.03× / 2.80× / 3.143.38×, wall-win throughout). At N = 50k it holds at `nclusters ∈
{64,256}` (qualified at 1024); at N = 20k only at `nclusters = 64`.
**Mechanism (the orthogonal axis — the key result).** `nprobe` decides *which* members to consider;
PQ cheapens the cost of *considering* one (`m/D ≈ 1/8` of a full L2 at `m=16`) and defers exact L2 to
a small re-rank. There is **no redundancy** with `nprobe`'s centroid cutoff (the ADR-205 failure
mode), so the saving is genuine. Its size is governed by **amortization**: PQ's fixed overhead
(`256` LUT + `R` re-rank + `nclusters` routing) is repaid only once the within-list working set
`members ≈ n·nprobe/nclusters` is large. Hence the two monotonic trends, both visible in the table:
- **grows with N** (working set ∝ n): nc=1024 goes 1.33× → 1.65× → 2.03× across 20k/50k/100k;
- **crossover `n*` rises with `nclusters`** (routing ∝ nclusters, working set ∝ 1/nclusters):
nc=64 crosses 2× by n≈20k, nc=256 by n≈50k, nc=1024 only by n≈100k.
In the **sensible IVF range `nclusters ≈ √n`** (≈ 140320 for these scales), PQ wins ≥ 2× from
n ≈ 2050k upward. Over-clustering (nc=1024 for n ≤ 50k) is the only regime PQ loses — and there
routing dominates *every* method, so the within-list choice barely matters (at n=5k/nc=1024 the
total ratio is 0.95×, pulled toward 1.0 by 1024 routing evals shared by both).
## Honest caveats (the prove-not-hype core — none buried)
1. **The win rides on the exact re-rank, not the PQ distance itself.** Pure-ADC recall@10 is only
**~0.480.52 (m=16)** / **~0.290.36 (m=8)** — PQ alone recovers barely half the true top-10 (the
128-d concentration risk, real and named in the prior). The exact re-rank `R` carries recall from
there to 0.95: `R* = 150→200→300` (m=16) and `500→1000→1500` (m=8) as N grows. **This is IVFADC +
refine — FAISS's standard `IVFPQ,Refine` design — validated to pay on RuVector's data/scales, not
a novel algorithm.** The honest claim is "ruvector-rairs should add an IVFPQ+rerank path," not
"we invented within-list pruning."
2. **The clean WIN is scale-gated to N = 100k.** At N ≤ 50k the "all three nclusters" bar is not
cleared (nc=1024 = 1.65× at 50k, 1.33× at 20k). The shippable claim is **scale-and-nclusters-
resolved**, not universal: ≥ 2× at `nclusters ∈ {64,256}` from n ≈ 2050k; the full sweep only at
n = 100k. The decisive nc=1024/100k cell is a **knife-edge (2.03×)** — the crossover itself.
3. **`m = 16` is the tuned operating point.** `m = 8`'s coarser codes drop the ADC ceiling to ~0.3 →
`R` blows up to 10001500 → re-rank cost erodes the win (it still wins at low nclusters but trails
m=16 at high nclusters). Tuned PQ = `m=16`, as `nprobe` is tuned.
4. **Recall-floor tunability flatters PQ slightly.** Integer `nprobe` overshoots the 0.95 floor to
0.9570.970; PQ's finer `R` knob lands at 0.9510.960. Part of PQ's edge is operating *exactly* at
the floor while `nprobe` cannot. This is a genuine (if modest) PQ advantage — finer recall control
— and the 2.53.4× margins at `nclusters ∈ {64,256}` dwarf the ~24% recall gap that drives it.
5. **The steelman mattered — a lot.** Early-abandon prunes **4053%** of L2 dims and was the cheaper
incumbent in *every* cell (e.g. 11,006 vs 23,232 at n=100k/nc=64). Against naive plain-L2 the PQ
ratios would roughly **double** (~6×); reporting against the steelman keeps the headline honest at
23.4×.
## The routing charge — an honest harness-bug catch
The first sweep **omitted routing from the cost ratio** — a bug in my own harness, since the frozen
accounting table charges `nclusters` centroid-evals to *both* contenders. It was decisive at high
`nclusters`: the n=50k/nc=1024 cell printed **2.24×** member-only but is **1.65×** once routing
(1024 evals) is folded into both costs. The pre-registered "no free routing" adversarial check caught
it against my own code; the authoritative table above charges routing throughout, and the harness now
prints **both** the member-only ratio (transparency) and the gate-deciding total. Recording the catch
is the point (cf. ADR-203's three deviations, ADR-205's PCA-control reversal).
## Consequences
**Positive (a real, shippable win — the first in the IVF-acceleration line).**
- **`ruvector-rairs::IvfFlat` should gain an `IVFPQ + exact-rerank` search path.** At matched
recall@10 = 0.95 it cuts total member-eval cost 23.4× and wall-clock 35× in the sensible
`nclusters ≈ √n` range from n ≈ 2050k up; the payoff grows with scale. This is the first BET in
the IVF line that *adds* shippable code rather than protecting the status quo (ADR-205).
- **Companion contrast to ADR-205/199.** Classical *exact* structures don't pay on embedding
retrieval (graph separators — high treewidth, ADR-199; cluster bounds — redundant with `nprobe`,
ADR-205). The *lossy-but-cheap* PQ distance with an exact re-rank **does** — because it attacks an
axis `nprobe` leaves untouched. The kills sharpened *where* acceleration must come from; this is
the where.
**Boundaries / honest scope.**
- **Scope: within-list PQ + rerank vs tuned `nprobe`, recall@10 = 0.95, 128-d arxiv.** The win is
scale-gated (full sweep only at n=100k) and concentrated in `nclusters ≈ √n`. Not claimed: other
recall targets, other corpora, or the over-clustered regime (nc=1024 below n≈100k).
- **It is IVFADC+refine, not a new method** — the contribution is the *measured, in-repo, steelman-
and-routing-honest* demonstration that it beats `ruvector-rairs`'s current IVFFlat, with the regime
mapped.
## Scoreboard
**3 WINS** (ADR-200/202 reuse+periodic; ADR-204 incremental high-recall tier; **ADR-206 PQ/IVFADC
within-list pruning, scale-gated**) / **4 KILLS** (ADR-199 CCH-on-embeddings; ADR-201 filtered-ANN
vs ACORN; ADR-203 KG-treewidth; ADR-205 IVF cluster-pruning vs `nprobe`).
## Next steps
1. **Productionize:** add an `IVFPQ + rerank` path to `ruvector-rairs::IvfFlat` (codebook training,
`m`-byte codes, per-query ADC LUT, top-`R` exact rerank); default `m=16`, `R` auto-tuned to a
recall SLA. The `PqIvf` kernel here is the reference.
2. **A coarse quantizer over centroids** would cut the `nclusters` routing charge that gates the
high-`nclusters` win (HNSW-over-centroids, as FAISS `IVF…_HNSW` does) — would lift nc=1024 cleanly
past 2× below n=100k. Different mechanism; a natural follow-on bet.
3. **OPQ / larger codebooks** (rotation before PQ) would raise the ~0.5 ADC ceiling, shrinking the
re-rank `R` that currently carries recall — directly widens the win. Measurable on this harness.
## Alternatives considered
- **Pure ADC, no re-rank** — ceiling ~0.480.52 recall@10; cannot reach 0.95. Rejected (the re-rank
is load-bearing).
- **`m = 8`** — coarser codes, ADC ceiling ~0.3, `R` up to 1500; wins at low nclusters but trails
m=16. Retained only as the tuned-`m` sweep's loser.
- **Cluster-level triangle bound (ADR-205)** — redundant with `nprobe` (1.00×). The orthogonal
within-list axis here is why PQ succeeds where that failed.

View file

@ -0,0 +1,136 @@
# BET 4 — Pre-Registration (FROZEN): LB-ordered branch-and-bound IVF probing vs tuned plain `IvfFlat`
**Status: FROZEN (2026-06-05, user-confirmed).** No gate, threshold, metric, dataset, or
control below may change after this commit. Deviations are limited to the explicitly
pre-authorised list at the end; any other change voids the run.
Thread: SepRAG (ruvnet/RuVector issue #534). This closes the BET 4 caveat left open by ADR-201
(#536): the region-pruning IVF kernel was built and validated *exact* there, but only ever run as
BET 2's mechanism **against ACORN** — never head-to-head against its own natural incumbent, **plain
IVF `nprobe` probing**. This is that head-to-head, on **unfiltered** ANN (no predicate — the
filtered question is BET 2, resolved NO-GO).
Independent of #535/#537/#539: this branch (`feat/seprag-bet4-ivf-pruning`) is cut off **clean
main**. The incumbent (`ruvector-rairs::IvfFlat`) is on main; the B&B kernel (which lives only on
the BET 2 branch) is **rebuilt self-contained** here, so the result is valid regardless of any
other PR's fate.
## Claim (one claim, one number)
> On unfiltered ANN over real **128-d** arxiv embeddings, **lower-bound-ordered branch-and-bound
> IVF probing** scans **≥ 2× fewer member distance-evals** than a **tuned plain `IvfFlat`
> `nprobe`**, at **matched recall@10**, **and wins on wall-clock**.
## Incumbent (tuned, in-repo — no straw man)
`ruvector-rairs::IvfFlat` (`crates/ruvector-rairs/src/ivf.rs`): k-means centroids + inverted lists;
`search(query, k, nprobe)` scans **all** members of the `nprobe` nearest-centroid lists, then
finalises top-k. Tuned = sweep `nclusters ∈ {64, 256, 1024}` × `nprobe ∈ [1, nclusters]` to its
best (recall, cost) frontier. **Both contenders share the same k-means centroids and seed** — only
the *probing strategy* differs, so the comparison isolates the strategy, not clustering luck.
## Contender (the bet — rebuilt standalone)
`BnBIvf` over the same centroids/lists:
- Precompute per-cluster radius `r_c = max_{v ∈ list_c} ‖v centroid_c‖`.
- For a query `q`: compute `‖q centroid_c‖` for all `c` (routing cost, charged); lower bound
`LB(q,c) = max(0, ‖q centroid_c‖ r_c)`.
- Probe clusters in **ascending `LB`** order, maintaining a running k-th-best distance `τ`; scan a
cluster's members (each a charged distance-eval), update `τ`; **break when `LB(c) ≥ τ`** (no
unscanned cluster can contain a top-k point → provably done).
- **Exact** at full budget (recall → 1.0). A `max_probe` cap (probe at most that many clusters) is
the approx knob used to hit a sub-1.0 recall target for the matched-recall comparison — the
analogue of `nprobe`.
## Data
`target/m1-data/node-feat-100k.csv` — ogbn-arxiv 128-d node features (public, aligned, the same
corpus used by ADR-201/202/204). N-sweep at **20,000 and 100,000**. Queries: 200 held-out points.
Ground truth: brute-force exact L2 kNN@10 recomputed on the corpus.
## Metrics
- **Primary: member distance-evals at matched recall@10.** The count of query↔member L2
evaluations (the dominant cost). Charged identically for both contenders. *Both* are additionally
charged the `nclusters` query↔centroid routing evals (equal for both) and B&B's radius
bookkeeping is build-time (reported separately, not hidden).
- **Secondary (honesty guard): wall-clock per query.** An eval win that **reverses on wall-clock**
is reported as **"inconclusive," never WIN** (ADR-201 precedent).
- **Reported regardless: exact-regime pruning fraction** — the mean % of clusters B&B skips at
recall → 1.0. The mechanistic explainer for whichever verdict lands.
## Matched-recall protocol
Pick recall target **R = 0.95**. Tune plain IVF `nprobe` (per `nclusters`) to the smallest value
reaching mean recall@10 ≥ R; record its member-evals. Cap `BnBIvf`'s `max_probe` to the smallest
value reaching ≥ R; record its member-evals. Compare. Repeat per `nclusters ∈ {64, 256, 1024}` and
per N ∈ {20k, 100k}. (Also report the **exact** regime R → 1.0: B&B full-budget vs `nprobe =
nclusters` full scan.)
## Gate (FROZEN)
| Verdict | Condition |
|---|---|
| **WIN** | member-scan reduction **≥ 2×** vs tuned `nprobe` at matched recall@10 (R = 0.95) **AND** wall-clock win **AND** holds across all three `nclusters` settings (at ≥ one N). |
| **KILL (NO-GO)** | reduction **< 1.5×** at matched recall **OR** wall-clock reverses. Interpretation: the triangle-inequality bound is too loose in 128-d (distance concentration) to pay. |
| **Qualified** | between 1.5× and 2×, or wins at some `nclusters`/N but not all → report as a **narrow/conditional edge** with the regime named (not a clean WIN). |
| **Report always** | exact-regime pruning fraction; the full (recall, member-evals, wall-clock) frontier per cell. |
## Controls (the teeth — both mandatory)
1. **Exact-vs-exact probe** (R → 1.0): `BnBIvf` full-budget vs `IvfFlat` `nprobe = nclusters`
(full scan). Directly measures whether the LB bound prunes **at all** in 128-d. If ~0% of
clusters are pruned here, that *mechanistically* predicts the KILL — and would make any
matched-recall WIN suspect (must be reconciled).
2. **Low-dimensional control:** rerun the entire protocol on a **low-intrinsic-dim** input —
PCA-project the arxiv features to **8-d** (retain the top-8 principal components). The bound is
expected to be tight here, so `BnBIvf` **should WIN** the low-d control. This proves the kernel
and harness are *sound* and isolates **high-d concentration** as the cause of any 128-d NO-GO —
BET 4's analogue of BET 3's roadNet control and BET 1's stale-index control. If the kernel does
**not** win even at 8-d, the implementation is suspect and the 128-d result is uninterpretable.
## Adversarial checks (pre-committed)
- **No free routing:** B&B is charged the `nclusters` centroid evals every query; the win must
survive that charge (it is identical for plain IVF, so it cancels, but it is *counted*, not
ignored).
- **Wall-clock guard** (above): eval win must not reverse on wall-clock.
- **Shared index:** identical centroids/seed/lists for both contenders; the *only* difference is
the probe loop. No re-clustering between contenders.
- **Pruning-fraction reconciliation:** a matched-recall WIN with ~0% exact-regime pruning is
internally inconsistent and must be explained before being reported as a WIN.
## Honest prior (stated before any run, per protocol)
I lean **NO-GO at 128-d.** Under distance concentration the per-cluster radius `r_c` tends to be
large relative to inter-centroid gaps, so `LB = max(0, d r_c) ≈ 0` for most clusters → little
pruning → proving exactness scans nearly everything, costing more than a tuned `nprobe` that
accepts < 100% recall. That would be a clean kill, the IVF-level companion to ADR-199 (Euclidean
embedding geometry defeats classical pruning structures — separators there, triangle-inequality
cluster bounds here). A WIN would be a genuine shippable `IvfFlat` upgrade. Either outcome is a
tidy, **consumer-independent** finding — the reason this is the chosen next bet.
## Pre-authorised deviations (anything else voids the run)
- Substitute PCA-to-8-d with a synthetic low-d clustered set **only if** PCA is impractical to
implement cleanly; the *role* (a tight-bound low-d control) is fixed.
- Reduce N from 100k to a smaller second scale if 100k brute-force truth is prohibitively slow,
**provided** at least two distinct scales are reported and the larger is ≥ 50k.
- Adjust query count upward (≥ 200) for noise control; never below 200.
- Add `nclusters` settings; never drop one of {64, 256, 1024}.
## Plan
- **M0** — self-contained crate `crates/ruvector-bet4-ivf-bench` (deps: `ruvector-rairs`, `rand`):
data loader, `BnBIvf` kernel, brute-force oracle; **gate test** `BnBIvf` full-budget == oracle
(recall 1.0). clippy clean.
- **M1** — instrument member-eval + wall-clock counting on both contenders (shared index).
- **M2** — matched-recall sweep harness (`examples/ivf_pruning_sweep.rs`): the `nclusters` × N grid,
exact-regime probe, frontier print.
- **M3** — low-d (PCA-8) control; adversarial reconciliation; verdict against this gate.
- **M4** — ADR-205 (WIN, NO-GO, or qualified — honest, ADR-199/201 precedent); one PR at M4 linked
to #534; #534 scoreboard comment.
---
**Frozen.** Build starts at M0 against this document; the gate is not revisited.

View file

@ -0,0 +1,205 @@
# BET 5 — Pre-Registration (FROZEN): PQ/IVFADC within-list pruning vs tuned plain `IvfFlat` `nprobe`
**Status: FROZEN (2026-06-05, user-confirmed).** No gate, threshold, metric, dataset, accounting
rule, or control below may change. The steelman incumbent (early-abandoned exact L2, user-confirmed)
is the verdict-setting PQ-free baseline. Deviations are limited to the pre-authorised list at the
end; any other change voids the run.
Thread: SepRAG (ruvnet/RuVector issue #534). This opens the **one lever ADR-205 left explicitly
open**: ADR-205 killed *cluster-level* triangle-inequality pruning vs tuned `nprobe` (structurally
redundant — the bound only skips far clusters `nprobe` already avoids). Its "Next steps #1" names the
different mechanism: **within-list pruning via product-quantized / IVFADC asymmetric distance.** This
is that bet.
Stacked on `feat/seprag-bet4-ivf-pruning` (PR #540) to **reuse the `ruvector-bet4-ivf-bench`
harness** (data loader, brute-force oracle, shared `ruvector-rairs` k-means substrate, sweep
skeleton). New module `src/pq.rs`, new example `examples/pq_pruning_sweep.rs`, new ADR-206. Valid
regardless of #540's merge fate (additive; depends only on `ruvector-rairs`, which is on main).
## Why this is NOT a re-run of ADR-205 (the mechanism is orthogonal, not redundant)
ADR-205's bound competed with `nprobe` on the **same axis** (which lists to scan) → redundant → 1.00×.
PQ competes on a **different axis**: `nprobe` decides *which* members to consider; PQ makes the cost
of *considering* a member cheaper (an `m`-entry table lookup-sum instead of a `D`-dim L2) **and**
lets a list be scanned approximately, deferring exact L2 to a small re-rank shortlist. There is no
redundancy with `nprobe`'s centroid cutoff. So a win is **not** structurally impossible here — the
question is purely empirical: does the cheaper-but-lossy per-member distance, plus its fixed
overheads, net out ahead of a tuned exact `nprobe` at matched recall, **at RuVector's scales**.
## Claim (one claim, one number)
> On unfiltered ANN over real **128-d** arxiv embeddings, **PQ/IVFADC within-list pruning**
> (approximate ADC scan of the `nprobe` lists + exact L2 re-rank of the top-`R` ADC candidates)
> reaches **matched recall@10 = 0.95** at **≥ 2× fewer full-L2-equivalent member-evals** than the
> strongest PQ-free incumbent, **and wins on wall-clock**, holding across `nclusters ∈ {64,256,1024}`
> at ≥ one scale `N ≥ 50k`.
## Incumbents (tuned, in-repo — and a steelman, no straw man)
Both share the **same k-means centroids/seed/lists** as the contender (only the within-list scan
differs), built over `ruvector-rairs::kmeans::train` — the same substrate as ADR-205.
1. **Plain `nprobe` full-L2** (the baseline, identical to ADR-205's incumbent; validated equal to
`ruvector-rairs::IvfFlat`): scan all members of the `nprobe` nearest lists with exact `D`-dim L2.
2. **Steelman incumbent — `nprobe` + early-abandoned exact L2** (PQ-free *within-list pruning*):
identical list selection, but each member's L2 is computed dim-by-dim and **abandoned** the
instant the partial sum exceeds the current k-th-best `τ`. This is exact (no recall loss) and is
the natural, free within-list pruning that needs no PQ. **The PQ contender must beat this**, not
just naive full-L2 — rule #5 (steelman the incumbent so a kill is credible *and* a win is real).
Cost is charged as **dims actually touched / D** full-L2-equivalents, so early abandonment gets
full credit for the work it skips.
The verdict-setting incumbent is the **cheaper of the two** at matched recall (PQ must beat the best
PQ-free option available).
## Contender (the bet — `PqIvf`, rebuilt standalone over the shared index)
`PqIvf` over the same centroids/lists:
- **Train** `m` sub-quantizers: split each 128-d vector into `m` contiguous subvectors of `D/m` dims;
train `2^nbits = 256` sub-centroids per subspace via `ruvector-rairs::kmeans::train` on the sliced
subvectors (8-bit codes). Encode every corpus vector to its `m`-byte PQ code. **Build-time;
reported separately, never hidden.**
- **Per query:** build the **ADC lookup table** — for each of the `m` subspaces, the L2² from the
query subvector to all 256 sub-centroids (`m × 256` partial distances). **Charged per query** as
`(m × 256 × (D/m)) / D = 256` full-L2-equivalents (the fixed overhead whose amortization is the
whole bet — not hidden).
- **ADC scan:** for each member of the `nprobe` lists, approximate distance = sum of `m` table
entries indexed by its code. **Charged `m / D` full-L2-equivalents per member.**
- **Exact re-rank:** take the top-`R` members by ADC distance and recompute exact `D`-dim L2 on
them; return the top-k of those. **Charged `R` full-L2-equivalents** (one full L2 each).
- Knobs (the analogues of `nprobe`): `nprobe` (lists), `m ∈ {8, 16}` (sub-quantizers), `R` (re-rank
pool). Tuned to the smallest cost reaching recall@10 ≥ 0.95, same as `nprobe` is tuned.
## Cost accounting (the honesty core — one unit, no free lunch)
**One unit = one full `D`-dim L2 = "1 member-eval-equivalent."** Everything converts to it:
| Operation | full-L2-equivalents |
|---|---|
| Plain full-L2 member | 1 |
| Early-abandoned L2 member | (dims touched) / D |
| Centroid routing (both, cancels but counted) | `nclusters` × 1 |
| PQ ADC table build (per query) | 256 (= `m`·256·(D/m)/D) |
| PQ ADC member scan | `m`/D |
| PQ exact re-rank member | 1 |
PQ's total = `256` (LUT) + `nprobe_members · m/D` (ADC) + `R` (re-rank). Incumbent's = `nprobe_members
· 1` (or less with early abandon). The fixed `256` LUT charge is what a small tuned working set must
overcome — **this is exactly the amortization question, and it is paid in full.**
## Data
`target/m1-data/node-feat-100k.csv` — ogbn-arxiv 128-d node features (public, aligned, same corpus as
ADR-201/202/204/205). N-sweep at **20,000 / 50,000 / 100,000** (three scales to *map the
amortization crossover* `n*`, not just pass/fail). Queries: 200 held-out points. Ground truth:
brute-force exact L2 kNN@10 on the corpus.
## Metrics
- **Primary: full-L2-equivalent member-evals at matched recall@10 = 0.95.** Per the table above.
- **Secondary (honesty guard): wall-clock per query.** An eval win that **reverses on wall-clock** is
**"inconclusive," never WIN** (ADR-201/205 precedent). PQ's table-lookup inner loop has different
cache behaviour than L2, so this guard has real teeth here.
- **Reported regardless:**
- **Pure-ADC recall ceiling** (recall@10 of ADC ranking with **no** re-rank) per cell — how lossy
PQ is on this data; the mechanistic explainer for the `R` it needs.
- **`R` (re-rank pool) required** per cell to reach 0.95.
- **Crossover `n*`** — the scale at which PQ overtakes the best incumbent (the amortization point).
- **Early-abandon pruning fraction** — mean % of L2 dims the steelman skips (does exact within-list
pruning work at all on concentrated 128-d?).
## Matched-recall protocol
Recall target **R₀ = 0.95**, k = 10. Per `nclusters ∈ {64,256,1024}` and per `N ∈ {20k,50k,100k}`:
tune plain/steelman `nprobe` to the smallest value reaching mean recall@10 ≥ 0.95; record evals.
Tune PQ `(nprobe, m, R)` to the smallest full-L2-equivalent cost reaching ≥ 0.95; record evals.
Compare PQ to the **cheaper** incumbent. (Also report exact regime: incumbent full-scan vs PQ at the
`R` that recovers ≥ 0.999.)
## Gate (to be FROZEN)
| Verdict | Condition |
|---|---|
| **WIN** | full-L2-equivalent reduction **≥ 2×** vs the best PQ-free incumbent at recall@10 = 0.95 **AND** wall-clock win **AND** holds across all three `nclusters` at ≥ one `N ≥ 50k`. |
| **KILL (NO-GO)** | reduction **< 1.5×** in every cell **OR** wall-clock reverses **OR** PQ cannot reach 0.95 recall at any tractable `R` (≤ `nprobe_members`; i.e. the quantization ceiling is too low to recover cheaply). |
| **Qualified** | between 1.5× and 2×, or wins at some `nclusters`/`N` but not all → report as a **scale/regime-conditional edge** with the crossover `n*` named (not a clean WIN). |
| **Report always** | pure-ADC recall ceiling; `R` per cell; crossover `n*`; early-abandon pruning fraction; the full (recall, eval, wall-clock) frontier per cell. |
## Controls (the teeth — both mandatory)
1. **Pure-ADC-recall probe (the mechanism control).** Measure ADC-only recall@10 (no re-rank) per
cell. This isolates *how lossy* PQ is on 128-d arxiv. If ADC recall is already ≈ 0.95, PQ wins
trivially (tiny `R`); if it is low, the re-rank `R` must carry recall and the win rides on whether
`R` stays small — the explainer for whichever verdict lands. (Replaces ADR-205's PCA-8 control,
whose role — *isolate the bound's tightness* — does not transfer; PQ's loss axis is quantization
coarseness, measured directly here. See deviation note.)
2. **Early-abandon-vs-full-L2 control (the steelman is itself a control).** If early abandonment
prunes ≈ 0% of dims on concentrated 128-d, that confirms the same distance-concentration that
killed ADR-205's bound also defeats *exact* within-list pruning — isolating PQ's *lossy compute*
as the only working within-list lever. If early abandonment prunes a lot, the steelman is strong
and a PQ win is harder-earned.
## Adversarial checks (pre-committed)
- **No free LUT:** the `256`-equivalent ADC table build is charged **every query**; the win must
survive it. (This is the amortization crux, not a footnote.)
- **No free codebook:** PQ codebook training is build-time, reported separately like ADR-205's radius
bookkeeping — never folded into the per-query win.
- **Wall-clock guard:** eval win must not reverse on wall-clock (table-lookup cache effects are real).
- **Shared index:** identical centroids/seed/lists for all contenders; only the within-list scan
differs. No re-clustering between contenders.
- **Re-rank honesty:** the `R` exact L2s are charged at full cost (1 each); a win cannot hide behind
an uncharged re-rank.
- **Ceiling reconciliation:** a matched-recall WIN that requires `R``nprobe_members` is not a
win (PQ would be re-ranking the whole working set exactly — it has bought nothing); must be flagged.
## Honest prior (stated before any run, per protocol)
I lean **genuinely uncertain, with a slight WIN-at-scale lean** — the most honest reading of the
mechanics, and unlike ADR-205 this is *not* a foregone kill:
- **For a win:** PQ's per-member cost is ~`m/D` (≈ 1/8 at `m=16`) of full L2; the moment the `nprobe`
working set is large (large `N`, or many lists), the `256`-equivalent LUT amortizes and the cheap
ADC scan + small re-rank should undercut full-L2 `nprobe`. This is the textbook reason IVFPQ
exists. A clean win would say "ruvector-rairs should add IVFPQ for large-`N` IVF" — a real,
consumer-independent, *shippable* finding (the first WIN in the IVF-acceleration line).
- **For a kill / qualified:** two named risks. (a) **Amortization** — at moderate `N` (20k50k) a
*tuned* `nprobe` scans a *small* working set (it is tuned down to a few lists), so the fixed `256`
LUT + re-rank `R` may not pay; the win could be purely asymptotic and *absent* at RuVector's
scales. (b) **Concentration ceiling** — the same 128-d distance concentration that killed ADR-199
/205 makes ADC ranking noisy (true neighbours scattered deep in ADC order), forcing a large `R` to
recover 0.95; if `R` blows up, the re-rank cost erases the ADC saving → NO-GO, the IVFADC companion
to "Euclidean embedding geometry defeats classical acceleration." I rate (b) the sharper risk.
Net: ~55% WIN at `N ≥ 50k`, with a real chance the crossover `n*` sits *above* RuVector's tested
scales (→ qualified) or that the concentration ceiling forces `R` too high (→ clean NO-GO). Either
outcome is a tidy, consumer-independent finding — the reason this is the chosen next bet.
## Pre-authorised deviations (anything else voids the run)
- Substitute the pure-ADC-recall control's role only if PQ training is impractical to implement
cleanly; the *role* (measure PQ's quantization loss directly) is fixed.
- Reduce the largest `N` from 100k to ≥ 50k if 100k brute-force truth is prohibitively slow,
**provided** at least three distinct scales spanning ≥ 4× are reported, the largest ≥ 50k.
- Adjust query count upward (≥ 200) for noise control; never below 200.
- Add `m` or `R` settings; never drop a required `nclusters ∈ {64,256,1024}`.
- If `m=16` and `m=8` bracket the same verdict, report both but the gate is read on the better `m`
per cell (PQ is *tuned*, like `nprobe`).
## Plan
- **M0**`src/pq.rs`: `PqIvf` (sub-quantizer training over shared k-means index, encode, ADC LUT,
`search_adc_rerank`), early-abandon incumbent scan; **gate test** PQ@full-rerank == oracle
(recall ≥ 0.999) + PQ shares centroids with `BnBIvf`/`IvfFlat`. clippy clean.
- **M1** — instrument full-L2-equivalent counting on all three contenders (shared index); pure-ADC
recall probe.
- **M2** — matched-recall sweep `examples/pq_pruning_sweep.rs`: `nclusters` × `N` × `(m,R)` grid,
crossover `n*`, frontier print.
- **M3** — controls (pure-ADC ceiling, early-abandon fraction); adversarial reconciliation; verdict
against this gate.
- **M4** — ADR-206 (WIN / NO-GO / qualified — honest, ADR-199/201/205 precedent); one PR at M4
stacked on #540, linked to #534; #534 scoreboard comment.
---
**Frozen.** Build starts at M0 against this document; the gate is not revisited.