mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
9b07dff298
* refactor(train): hoist canonical PCK/OKS to un-gated metrics_core; fold test_metrics onto production (ADR-155 M1 §8) ADR-155 §8 deferred item: test_metrics.rs reference kernels validated production against their OWN reimplementation — a test that cannot catch a canonical-impl bug (both could be wrong the same way). - Extract canonical_torso_size / pck_canonical / oks_canonical / sigmas / bounding_box_diagonal into a new NON-tch-gated `metrics_core` module, so the single metric definition is reachable under `cargo test --no-default-features` (the `metrics` module is tch-gated). `metrics` re-exports every item → still exactly ONE implementation. - Rewrite tests/test_metrics.rs to assert the PRODUCTION pck_canonical / oks_canonical equal hand-computed fixtures (not a reimplementation): canonical_pck_matches_hand_computed_fixture (corr=3/total=4/pck=0.75), hip↔hip normalizer pin, zero-visible⇒0.0, OKS perfect⇒1.0, fake-Gold pin. - Keep an INDEPENDENT raw-threshold reference kernel only as a differential cross-check: test_kernel_agrees_with_canonical asserts it AGREES with canonical where torso==1.0 (genuine cross-check, not duplication). Grade: MEASURED. test_metrics 10→12 tests, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(sensing-server): relabel divergent live PCK/OKS so they're never conflated with canonical (ADR-155 M1 §2.1/§8 Goal C) Goal C named training_api.rs:804 (torso-HEIGHT PCK). Auditing it surfaced TWO findings the ADR-155 §1 table missed: 1. training_api.rs is an ORPHAN file — not declared `mod` in lib.rs OR main.rs, so it does NOT compile into the crate. It does not drive the live server. 2. The REAL live `best_pck`/`best_oks` (main.rs training path → RVF metadata JSON read by model_manager.rs) come from trainer.rs: - `pck_at_threshold` = RAW-threshold PCK, NO torso normalization (the most divergent kind), printed/serialized as bare "PCK@0.2". - `oks_map` calls `oks_single(area=1.0)` = the EXACT fake-Gold pattern ADR-155 §2.1 claimed closed elsewhere — still live here, inflating best_oks. Resolution = RELABEL (torso/raw math is load-bearing on different data; the pub fns can't be renamed without breaking API; sensing-server has no train/ ndarray dep). Honest unify is a tracked §8 backlog item. - training_api.rs: `compute_pck` → `compute_pck_torso_height` + divergence doc; val_pck/best_pck/val_oks struct fields documented as torso-HEIGHT proxies; logs say `pck_torso_h@0.2`. Test torso_pck_is_labelled_distinctly_from_canonical. - trainer.rs (LIVE): `pck_at_threshold` documented raw-unnormalized; `oks_map` area=1.0 flagged fake-Gold; test pck_at_threshold_is_raw_unnormalized_not_canonical. - main.rs: live print relabelled `pck_raw@0.2` / `oks_map(area=1.0 proxy)`. No wire-format field renames (back-compat); no pub-API rename (no silent break). Grade: MEASURED (relabel + divergence pinned). sensing-server 450→451 lib tests, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-155): mark §8 metric items RESOLVED + audit map + honest §1 under-count correction (M1b Goals A/D) - §8.1: full PCK/OKS audit map (every def: file:line, basis, canonical/ legacy/distinct), the two §8 items marked RESOLVED with resolution+why. - Honest finding: §1's "seven divergent metrics" was an UNDER-count — sensing-server's LIVE trainer.rs has a raw-unnormalized PCK and an area=1.0 fake-Gold OKS the table omitted, and the file §8 named (training_api.rs) is orphaned dead code. §9 honest-limits updated. - Goal D: metrics.rs *_v2 variants confirmed caller-less + deprecated; noted for future cleanup, NOT deleted (public API, tch-gated). - CHANGELOG [Unreleased] Fixed entry. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(ruvector): RaBitQ Pass-2 randomized rotation + topk bugfix (ADR-156 §8) Implements the deferred "Multi-bit / Extended RaBitQ Pass 2" backlog item from ADR-156 §8: a deterministic randomized orthogonal rotation applied before sign-quantization, the published RaBitQ construction (Gao & Long, SIGMOD 2024). Rotation construction: Fast Hadamard Transform + seeded ±1 sign flips ("HD" / randomized Hadamard), O(d log d) time and O(d) memory — a dense d×d rotation is O(d²) and infeasible at the 65,535-d the wire format provisions for. Pads to the next power of two; SplitMix64 seeds the sign stream so index-time and query-time rotations are bit-identical. API is additive and backward-compatible: Pass 1 (`from_embedding`) is untouched; Pass 2 is opt-in via `Sketch::from_embedding_rotated` and `SketchBank::with_rotation` (+ `insert_embedding` / `topk_embedding` / `novelty_embedding` helpers that rotate consistently). Default behaviour is unchanged. While building the Pass-2 coverage harness, found and fixed a PRE-EXISTING correctness bug in `SketchBank::topk`: the n>k heap path used `BinaryHeap<Reverse<(d,id)>>` (a min-heap) but treated its peek as the max, so it returned the k FARTHEST sketches as "nearest". The shipped unit tests only exercised the n≤k fast path, so it went unnoticed. Fixed to a plain max-heap; pinned by `topk_heap_path_returns_nearest` and `tight_clusters_give_high_coverage_with_overfetch` (the latter measured 0.072 on the old code). New tests (+17, 100→117 in the crate): rotation determinism/norm-preservation (`rotation_is_deterministic_for_seed`, `rotation_preserves_norm`), Pass-2 shape-compatibility, `pass2_coverage_not_worse_than_pass1`, and a deterministic coverage report. MEASURED top-K coverage (anisotropic planted-cluster fixture, cosine ground truth; dim=128 N=2048 K=8 64 clusters noise=0.35 128 queries): candidate_k=K=8 : Pass1 36.13% -> Pass2 46.39% (both << 90% bar) candidate_k=24 : Pass1 83.89% -> Pass2 91.60% (Pass2 clears 90%) candidate_k=32 : Pass1/Pass2 100% Honest result: rotation consistently helps (+10pp at strict K), but neither pass clears the ADR-084 90% bar at candidate_k==K on this distribution. Pass 2 reaches 90% only with ~3x over-fetch (the ADR-084 "candidate set" deployment pattern). Multi-bit Pass 3 evaluated separately. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(ruvector): multi-bit Pass-3 experiment + ADR-156/084 measured results Adds the multi-bit half of the ADR-156 §8 "Multi-bit / Extended RaBitQ" item as a MEASURED experiment (coverage::measure_multibit): rotate, then b-bit uniform scalar-quantize each coord, rank by L1 over codes — the natural multi-bit generalization of hamming. Measures the bit/coverage tradeoff the backlog item asked for. MEASURED at the strict bar (candidate_k=K=8, anisotropic planted-cluster fixture, cosine ground truth): Pass1 (1-bit, no rot) 36.13% 16 B/vec Pass2 (1-bit, rot) 46.39% 16 B/vec Pass3 (rot, 2-bit) 54.39% 32 B/vec Pass3 (rot, 3-bit) 66.70% 48 B/vec Pass3 (rot, 4-bit) 74.22% 64 B/vec Honest: multi-bit monotonically helps but even 4-bit (4x memory) reaches only 74% at the strict bar — neither rotation nor <=4-bit multi-bit clears the strict-K 90% bar on this distribution. The bar is met via over-fetch (Pass2 @ candidate_k=24). Tests: multibit_tradeoff_report, multibit_1bit_matches_pass2_approx (+ sanity that 1-bit ~= Pass-2). Docs: - ADR-156 §8 item #2 marked RESOLVED-PARTIAL; §5 #2 grade CLAIMED -> MEASURED-on-our-hardware; new §10 with full measured tables, the topk bugfix disclosure, and graded deferred sub-items. - ADR-084: "Pass 2" section answering the rotation open-question with measured numbers + the topk bug note. - CHANGELOG [Unreleased]: Added (Pass-2 milestone) + Fixed (topk heap). Co-Authored-By: claude-flow <ruv@ruv.net>
442 lines
18 KiB
Rust
442 lines
18 KiB
Rust
//! Deterministic top-K **coverage** harness for the RaBitQ sketch
|
||
//! (ADR-084 acceptance bar / ADR-156 §8 Pass-2 measurement).
|
||
//!
|
||
//! Single source of truth for the coverage number quoted in ADR-084 and
|
||
//! ADR-156: both the in-crate regression test (`pass2_coverage_not_worse_…`)
|
||
//! and the criterion bench (`benches/sketch_bench.rs`) call into here, so they
|
||
//! can never silently measure different things.
|
||
//!
|
||
//! **Coverage** is defined exactly as in ADR-084:
|
||
//!
|
||
//! > the Top-K candidate set chosen by the sketch must contain **≥ 90%** of the
|
||
//! > candidates the full-float pass would have picked.
|
||
//!
|
||
//! i.e. `coverage = |sketch_topK ∩ float_topK| / K`, averaged over a set of
|
||
//! queries. The float top-K (squared-euclidean — AETHER's actual metric) is the
|
||
//! ground truth; the sketch top-K is a *candidate* set, so in practice a system
|
||
//! over-fetches `C ≥ K` sketch candidates and refines. We measure at
|
||
//! `candidate_k == K` (the strict bar) by default; the bench also reports an
|
||
//! over-fetch curve.
|
||
//!
|
||
//! # The synthetic distribution — and why it is *anisotropic*
|
||
//!
|
||
//! Pure 1-bit sign quantization (Pass 1) is near-optimal on **isotropic,
|
||
//! zero-centred** embeddings — on such data a rotation barely moves the number,
|
||
//! so testing rotation there proves nothing. ADR-084's "Open questions" and
|
||
//! ADR-156 §8 both flag the *anisotropic / correlated* case (skewed CSI
|
||
//! spectrogram embeddings) as exactly where the rotation is supposed to earn
|
||
//! its keep. So [`make_anisotropic_embedding`] deliberately builds **correlated,
|
||
//! axis-aligned, non-isotropic** vectors: a few dominant low-frequency factors
|
||
//! shared across many coordinates (heavy coordinate correlation) plus a small
|
||
//! per-dim offset that biases signs — the structure that defeats raw
|
||
//! sign-quantization and that a randomized rotation is designed to fix. Every
|
||
//! value derives from a seed via SplitMix64, so the whole harness is
|
||
//! reproducible bit-for-bit.
|
||
|
||
use crate::{Rotation, SketchBank};
|
||
|
||
/// SplitMix64 step — reproducible PRNG for fixture generation (dependency-free).
|
||
#[inline]
|
||
fn split_mix64(state: &mut u64) -> u64 {
|
||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||
let mut z = *state;
|
||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||
z ^ (z >> 31)
|
||
}
|
||
|
||
/// A uniform `f32` in `[0, 1)` from the PRNG state.
|
||
#[inline]
|
||
fn unif01(state: &mut u64) -> f32 {
|
||
let r = split_mix64(state);
|
||
// top 24 bits → [0,1)
|
||
((r >> 40) as f32) / ((1u64 << 24) as f32)
|
||
}
|
||
|
||
/// A standard-normal-ish `f32` via Box–Muller from two uniforms. Deterministic.
|
||
#[inline]
|
||
fn gauss(state: &mut u64) -> f32 {
|
||
let u1 = unif01(state).max(1e-7); // avoid log(0)
|
||
let u2 = unif01(state);
|
||
(-2.0 * u1.ln()).sqrt() * (std::f32::consts::TAU * u2).cos()
|
||
}
|
||
|
||
/// Fixed **anisotropic axis scale** for coordinate `i` of `dim`.
|
||
///
|
||
/// A learned embedding space is not isotropic: a handful of axes carry most of
|
||
/// the variance and the rest are near-flat. We model that with a smoothly
|
||
/// decaying per-axis scale (≈10× spread between the most- and least-energetic
|
||
/// axes). This axis-aligned imbalance is exactly what a 1-bit sign sketch
|
||
/// handles poorly (the low-variance axes' sign bits are noise) and exactly what
|
||
/// a randomized rotation re-balances (it spreads the variance across all axes so
|
||
/// every sign bit carries comparable information). The scale depends only on the
|
||
/// coordinate index, so it is the *same fixed geometry* for every vector.
|
||
#[inline]
|
||
fn axis_scale(i: usize, dim: usize) -> f32 {
|
||
let t = i as f32 / dim.max(1) as f32;
|
||
// exp decay from ~3.0 down to ~0.3 → ~10× anisotropy.
|
||
3.0 * (-2.3 * t).exp() + 0.3
|
||
}
|
||
|
||
/// Build the **planted-cluster** fixture: `n_clusters` random centres in the
|
||
/// anisotropic space. Returned as raw centres (pre-scale); callers add scale +
|
||
/// intra-cluster noise. Deterministic from `seed`.
|
||
fn cluster_centres(dim: usize, n_clusters: usize, seed: u64) -> Vec<Vec<f32>> {
|
||
(0..n_clusters)
|
||
.map(|c| {
|
||
let mut s = seed ^ 0xC0FFEE_u64.wrapping_mul(c as u64 + 1);
|
||
(0..dim).map(|_| gauss(&mut s)).collect()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// One embedding = its cluster centre + small intra-cluster noise, then the
|
||
/// fixed anisotropic axis scale, then a small off-centre bias. This makes the
|
||
/// **cosine top-K meaningful** (same-cluster members are genuine near-neighbours,
|
||
/// not random-noise ties), while keeping the space anisotropic so the rotation
|
||
/// has something real to fix.
|
||
fn realize(centre: &[f32], dim: usize, noise: f32, vec_seed: u64) -> Vec<f32> {
|
||
let mut s = vec_seed ^ 0x5151_5151_5151_5151;
|
||
(0..dim)
|
||
.map(|i| {
|
||
let jitter = gauss(&mut s) * noise;
|
||
let bias = ((i % 11) as f32 - 5.0) * 0.05;
|
||
axis_scale(i, dim) * (centre[i] + jitter) + bias
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Cosine distance `1 - cos(a,b)` — the metric a sign sketch approximates
|
||
/// (hamming over sign bits is a monotone estimate of the angle between vectors).
|
||
/// This is the correct full-float ground truth for top-K *coverage*: the sketch
|
||
/// is an angular sensor, so we grade it against the angular full-float ranking,
|
||
/// per ADR-084's `float_cosine` baseline.
|
||
#[inline]
|
||
fn cosine_distance(a: &[f32], b: &[f32]) -> f32 {
|
||
let mut dot = 0.0f32;
|
||
let mut na = 0.0f32;
|
||
let mut nb = 0.0f32;
|
||
for (&x, &y) in a.iter().zip(b.iter()) {
|
||
dot += x * y;
|
||
na += x * x;
|
||
nb += y * y;
|
||
}
|
||
let denom = (na * nb).sqrt();
|
||
if denom < f32::EPSILON {
|
||
1.0
|
||
} else {
|
||
1.0 - dot / denom
|
||
}
|
||
}
|
||
|
||
/// Full-float cosine top-K ids (ground truth), ascending by cosine distance.
|
||
fn float_topk(bank: &[Vec<f32>], query: &[f32], k: usize) -> Vec<u32> {
|
||
let mut scored: Vec<(u32, f32)> = bank
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, v)| (i as u32, cosine_distance(query, v)))
|
||
.collect();
|
||
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||
scored.truncate(k);
|
||
scored.into_iter().map(|(id, _)| id).collect()
|
||
}
|
||
|
||
/// Parameters for a coverage measurement, documented in the report.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct CoverageParams {
|
||
/// Embedding dimension.
|
||
pub dim: usize,
|
||
/// Number of stored vectors in the bank (N).
|
||
pub n: usize,
|
||
/// Number of distinct query vectors averaged over.
|
||
pub n_queries: usize,
|
||
/// True top-K size (the bar's K).
|
||
pub k: usize,
|
||
/// Sketch candidate-set size to compare against the float top-K. Equal to
|
||
/// `k` for the strict ADR-084 bar; `> k` models over-fetch + refine.
|
||
pub candidate_k: usize,
|
||
/// Number of planted clusters. Same-cluster vectors are genuine near
|
||
/// neighbours, so the cosine top-K is *meaningful* (not random-noise ties).
|
||
pub n_clusters: usize,
|
||
/// Intra-cluster Gaussian jitter (relative to unit-variance centres). Small
|
||
/// jitter → tight, easily-recovered clusters; larger → harder top-K.
|
||
pub noise: f32,
|
||
/// Master seed (the whole fixture derives from this).
|
||
pub seed: u64,
|
||
}
|
||
|
||
impl CoverageParams {
|
||
/// The canonical AETHER-shape fixture used for the ADR-quoted numbers:
|
||
/// 128-d, planted clusters, modest intra-cluster jitter. Override fields
|
||
/// with struct-update syntax (`CoverageParams { candidate_k: 32, ..base }`).
|
||
pub fn aether_default(seed: u64) -> Self {
|
||
Self {
|
||
dim: 128,
|
||
n: 2048,
|
||
n_queries: 128,
|
||
k: 8,
|
||
candidate_k: 8,
|
||
n_clusters: 64,
|
||
noise: 0.35,
|
||
seed,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Result of a coverage measurement.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct CoverageResult {
|
||
/// Mean coverage in `[0, 1]` (fraction of float top-K found in the sketch
|
||
/// candidate set), averaged over queries.
|
||
pub coverage: f64,
|
||
}
|
||
|
||
/// Measure mean top-K coverage of the **Pass-1** (no rotation) sketch against
|
||
/// the full-float top-K, on the anisotropic synthetic distribution.
|
||
pub fn measure_pass1(p: CoverageParams) -> CoverageResult {
|
||
measure_inner(p, None)
|
||
}
|
||
|
||
/// Measure mean top-K coverage of the **Pass-2** (rotated) sketch against the
|
||
/// full-float top-K, on the anisotropic synthetic distribution. `rotation_seed`
|
||
/// fixes the rotation (index and query share it — that is the contract).
|
||
pub fn measure_pass2(p: CoverageParams, rotation_seed: u64) -> CoverageResult {
|
||
let rot = Rotation::new(rotation_seed, p.dim);
|
||
measure_inner(p, Some(rot))
|
||
}
|
||
|
||
/// Measure mean top-K coverage of a **multi-bit (Pass-3)** rotated sketch:
|
||
/// `bits` bits per dimension instead of 1, ranked by L1 distance over the
|
||
/// per-dim codes (the natural multi-bit generalization of hamming). This is the
|
||
/// "Multi-bit / Extended RaBitQ" half of ADR-156 §8 — measured here as an
|
||
/// experiment to decide whether a full `MultiBitSketch` type is worth building.
|
||
///
|
||
/// Quantization: rotate (Pass-2 frame), then map each rotated coordinate through
|
||
/// a uniform mid-rise scalar quantizer with `2^bits` levels over a fixed
|
||
/// symmetric range `[-RANGE, RANGE]` (RANGE chosen from the rotated-coord scale).
|
||
/// `bits == 1` reduces to sign-quantization (sanity: should match Pass-2 within
|
||
/// quantizer-boundary noise). Memory cost is `bits×` the 1-bit sketch.
|
||
///
|
||
/// Returns the measured coverage; the caller reports the bit/coverage tradeoff.
|
||
pub fn measure_multibit(p: CoverageParams, rotation_seed: u64, bits: u32) -> CoverageResult {
|
||
assert!((1..=8).contains(&bits), "bits must be in 1..=8");
|
||
let rot = Rotation::new(rotation_seed, p.dim);
|
||
let levels = 1u32 << bits; // 2^bits codes per dim
|
||
// Rotated AETHER-shape coords after the normalized FHT sit roughly in
|
||
// [-RANGE, RANGE]; clamp out-of-range to the end codes. RANGE picked to
|
||
// cover ~99% of the rotated-coord magnitude on this fixture (empirically
|
||
// ~3.0 after the 1/√m normalization).
|
||
const RANGE: f32 = 3.0;
|
||
let quantize = move |v: &[f32]| -> Vec<u16> {
|
||
rot.apply(v)
|
||
.iter()
|
||
.map(|&x| {
|
||
let t = ((x + RANGE) / (2.0 * RANGE)).clamp(0.0, 1.0); // → [0,1]
|
||
let code = (t * (levels - 1) as f32).round() as u32;
|
||
code.min(levels - 1) as u16
|
||
})
|
||
.collect()
|
||
};
|
||
// L1 distance over per-dim codes.
|
||
let l1 = |a: &[u16], b: &[u16]| -> u32 {
|
||
a.iter()
|
||
.zip(b)
|
||
.map(|(&x, &y)| (x as i32 - y as i32).unsigned_abs())
|
||
.sum()
|
||
};
|
||
|
||
let float_bank = make_fixture(p);
|
||
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
|
||
let coded_bank: Vec<Vec<u16>> = float_bank.iter().map(|v| quantize(v)).collect();
|
||
|
||
let mut total = 0.0f64;
|
||
for q in 0..p.n_queries {
|
||
let c = q % p.n_clusters.max(1);
|
||
let qv = realize(
|
||
¢res[c],
|
||
p.dim,
|
||
p.noise,
|
||
p.seed ^ 0xDEAD_0000_0000 ^ (q as u64).wrapping_mul(0x2545_F491),
|
||
);
|
||
let truth = float_topk(&float_bank, &qv, p.k);
|
||
let qc = quantize(&qv);
|
||
// top candidate_k by L1 over codes.
|
||
let mut scored: Vec<(u32, u32)> = coded_bank
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, code)| (i as u32, l1(&qc, code)))
|
||
.collect();
|
||
scored.sort_by_key(|&(_, d)| d);
|
||
scored.truncate(p.candidate_k);
|
||
let cand_ids: std::collections::HashSet<u32> =
|
||
scored.into_iter().map(|(id, _)| id).collect();
|
||
let hit = truth.iter().filter(|id| cand_ids.contains(id)).count();
|
||
total += hit as f64 / p.k as f64;
|
||
}
|
||
CoverageResult {
|
||
coverage: total / p.n_queries as f64,
|
||
}
|
||
}
|
||
|
||
/// Build the deterministic float bank for `p`: `p.n` vectors, each assigned to
|
||
/// one of `p.n_clusters` planted clusters (round-robin), realized as
|
||
/// `centre + jitter` under the fixed anisotropic axis scale. Returned with the
|
||
/// cluster id of each vector so queries can be drawn from the same clusters.
|
||
pub fn make_fixture(p: CoverageParams) -> Vec<Vec<f32>> {
|
||
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
|
||
(0..p.n)
|
||
.map(|i| {
|
||
let c = i % p.n_clusters.max(1);
|
||
realize(¢res[c], p.dim, p.noise, p.seed ^ (i as u64).wrapping_mul(0x9E37))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn measure_inner(p: CoverageParams, rotation: Option<Rotation>) -> CoverageResult {
|
||
const SV: u16 = 1;
|
||
// Float bank (ground truth) + sketch bank from the SAME vectors, so the
|
||
// only variable is float-vs-sketch (and Pass-1-vs-Pass-2).
|
||
let float_bank = make_fixture(p);
|
||
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
|
||
|
||
let mut bank = match &rotation {
|
||
Some(r) => SketchBank::with_rotation(r.clone()),
|
||
None => SketchBank::new(),
|
||
};
|
||
for (i, v) in float_bank.iter().enumerate() {
|
||
// Use the bank's rotation policy for both Pass-1 and Pass-2 uniformly.
|
||
bank.insert_embedding(i as u32, v, SV)
|
||
.expect("schema-locked insert");
|
||
}
|
||
|
||
let mut total = 0.0f64;
|
||
for q in 0..p.n_queries {
|
||
// Each query is a fresh draw from a planted cluster (disjoint seed
|
||
// range from the bank), so it HAS genuine same-cluster neighbours in
|
||
// the bank — a meaningful top-K, not random-noise ties.
|
||
let c = q % p.n_clusters.max(1);
|
||
let qv = realize(
|
||
¢res[c],
|
||
p.dim,
|
||
p.noise,
|
||
p.seed ^ 0xDEAD_0000_0000 ^ (q as u64).wrapping_mul(0x2545_F491),
|
||
);
|
||
let truth = float_topk(&float_bank, &qv, p.k);
|
||
let cand = bank
|
||
.topk_embedding(&qv, SV, p.candidate_k)
|
||
.expect("schema match");
|
||
let cand_ids: std::collections::HashSet<u32> = cand.into_iter().map(|(id, _)| id).collect();
|
||
let hit = truth.iter().filter(|id| cand_ids.contains(id)).count();
|
||
total += hit as f64 / p.k as f64;
|
||
}
|
||
CoverageResult {
|
||
coverage: total / p.n_queries as f64,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn tight_clusters_give_high_coverage_with_overfetch() {
|
||
// Sanity / regression: on tight clusters with enough over-fetch the
|
||
// sketch MUST recover essentially all of the float cosine top-K — this
|
||
// both proves the harness is correct (a broken topk gives ~random here)
|
||
// and pins the cluster structure as meaningful. Catches the heap
|
||
// inversion bug found during this work (which made this ~6%).
|
||
let p = CoverageParams {
|
||
n: 1024,
|
||
n_queries: 64,
|
||
n_clusters: 64,
|
||
noise: 0.1,
|
||
candidate_k: 64,
|
||
..CoverageParams::aether_default(0x1111)
|
||
};
|
||
let cov = measure_pass1(p).coverage;
|
||
assert!(
|
||
cov > 0.95,
|
||
"tight clusters + 8× over-fetch should recover >95% of top-K, got {:.3}",
|
||
cov
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn multibit_tradeoff_report() {
|
||
// ADR-156 §8 "Multi-bit / Extended RaBitQ" measurement: bit/coverage
|
||
// tradeoff at the STRICT bar (candidate_k == K). Reports b=1..4 bits
|
||
// per dim alongside Pass-1 / Pass-2 (1-bit) baselines. Run with
|
||
// --nocapture to see the table.
|
||
let base = CoverageParams::aether_default(0xAD00_0084);
|
||
let rot_seed = 0x5EED_C0DE_1234_5678u64;
|
||
let p1 = measure_pass1(base).coverage;
|
||
let p2 = measure_pass2(base, rot_seed).coverage;
|
||
println!("\n=== ADR-156 §8 multi-bit tradeoff (strict candidate_k=K={}) ===", base.k);
|
||
println!("dim={} N={} clusters={} noise={} bar=90%", base.dim, base.n, base.n_clusters, base.noise);
|
||
println!(" Pass1 (no rot, 1-bit) : {:6.2}%", p1 * 100.0);
|
||
println!(" Pass2 (rot, 1-bit) : {:6.2}%", p2 * 100.0);
|
||
for bits in 1..=4u32 {
|
||
let cov = measure_multibit(base, rot_seed, bits).coverage;
|
||
let bytes_per_vec = base.dim * bits as usize / 8;
|
||
println!(
|
||
" Pass3 (rot, {bits}-bit, {bytes_per_vec:>3} B/vec): {:6.2}% {}",
|
||
cov * 100.0,
|
||
if cov >= 0.90 { "≥90%" } else { "" }
|
||
);
|
||
}
|
||
println!("=================================================================\n");
|
||
assert!((0.0..=1.0).contains(&p1));
|
||
}
|
||
|
||
#[test]
|
||
fn multibit_1bit_matches_pass2_approx() {
|
||
// Sanity: 1-bit multi-bit quantization is essentially sign-quantization,
|
||
// so its coverage should track Pass-2 (rotated 1-bit) closely. (Not
|
||
// exact: the mid-rise quantizer's 0/1 boundary is at the RANGE midpoint,
|
||
// which equals the sign boundary, so they should match very closely.)
|
||
let p = CoverageParams {
|
||
n: 256,
|
||
n_queries: 16,
|
||
n_clusters: 16,
|
||
..CoverageParams::aether_default(0x55)
|
||
};
|
||
let rot_seed = 0xABCDu64;
|
||
let p2 = measure_pass2(p, rot_seed).coverage;
|
||
let mb1 = measure_multibit(p, rot_seed, 1).coverage;
|
||
assert!(
|
||
(p2 - mb1).abs() < 0.05,
|
||
"1-bit multibit {mb1:.3} should track Pass-2 {p2:.3}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fixture_is_deterministic() {
|
||
let p = CoverageParams::aether_default(12345);
|
||
let a = make_fixture(p);
|
||
let b = make_fixture(p);
|
||
assert_eq!(a, b);
|
||
assert_eq!(a.len(), p.n);
|
||
assert_eq!(a[0].len(), p.dim);
|
||
let c = make_fixture(CoverageParams::aether_default(12346));
|
||
assert_ne!(a[0], c[0]);
|
||
}
|
||
|
||
#[test]
|
||
fn coverage_harness_runs_and_is_in_range() {
|
||
// Small fixed fixture — fast, deterministic, in [0,1].
|
||
let p = CoverageParams {
|
||
n: 256,
|
||
n_queries: 16,
|
||
n_clusters: 16,
|
||
..CoverageParams::aether_default(0xABCD)
|
||
};
|
||
let c1 = measure_pass1(p);
|
||
let c2 = measure_pass2(p, 0x1234_5678);
|
||
assert!((0.0..=1.0).contains(&c1.coverage));
|
||
assert!((0.0..=1.0).contains(&c2.coverage));
|
||
// Determinism: same params → same number.
|
||
assert_eq!(measure_pass1(p).coverage, c1.coverage);
|
||
assert_eq!(measure_pass2(p, 0x1234_5678).coverage, c2.coverage);
|
||
}
|
||
}
|