m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe + POST /vector_search (standalone + region node, merge-by-distance) + tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit). Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs. Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force. m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference vector, related=seed embedding (similar_to), graceful scan-fallback. Cached per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains --feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms. m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion, shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs. Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%). Recall corpus is now clustered (Gaussian mixture) in grid + harness.
533 lines
20 KiB
Rust
533 lines
20 KiB
Rust
//! The m12p1 recall oracle + open-loop read-recall ramp.
|
||
//!
|
||
//! Every perf claim before m12p1 was latency-only: recall — the fraction of the
|
||
//! true nearest neighbors an ANN query actually returns — was unmeasured at the
|
||
//! production shape (1536-dim, 100k–1M items). This module closes that gap. It
|
||
//! is the harness the whole milestone steers against.
|
||
//!
|
||
//! # How the oracle works
|
||
//!
|
||
//! 1. The corpus is seeded with **deterministic, id-keyed** embeddings
|
||
//! ([`embedding_for`]), so the generator can reconstruct, bit-for-bit, the
|
||
//! exact vectors the engine indexed — no need to capture them over the wire.
|
||
//! 2. [`GroundTruth`] holds that corpus in RAM and computes **brute-force cosine
|
||
//! top-k** for any query — the exact answer the ANN index is approximating.
|
||
//! (Order by cosine == order by the engine's L2-on-normalized distance, so the
|
||
//! two are directly comparable; see `storage::vector`.)
|
||
//! 3. A [`QueryPool`] of realistic queries (corpus points perturbed by noise) is
|
||
//! precomputed once, each with its ground-truth top-k, so the hot request path
|
||
//! does only a set-overlap to score `recall@k`.
|
||
//! 4. [`run_recall_stage`] fires `/vector_search` probes open-loop (the same
|
||
//! coordinated-omission-corrected methodology as the signal ramp) and records,
|
||
//! per request, the true latency AND the achieved recall.
|
||
//!
|
||
//! The verdict reports per-stage **true p99** (not a closed-loop mean) and **mean
|
||
//! recall@10**, and finds the **read-knee**: the highest sustained QPS at which
|
||
//! `p99 ≤ target AND recall@10 ≥ target` both hold.
|
||
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
use std::time::Duration;
|
||
|
||
use tokio::sync::{Semaphore, mpsc};
|
||
use tokio::time::Instant;
|
||
|
||
use crate::client::HttpClient;
|
||
use crate::metrics::{LatencyHistogram, StatusClass};
|
||
use crate::scheduler::Stage;
|
||
|
||
// ── Deterministic corpus + query generation ──────────────────────────────────
|
||
|
||
/// SplitMix64 — a tiny, fast, dependency-free deterministic generator. Seeding it
|
||
/// from an entity id makes the whole corpus reproducible from the id alone, which
|
||
/// is what lets the generator hold the ground truth without ever reading it back
|
||
/// from the engine. The same scheme is mirrored in the engine-side recall test.
|
||
fn splitmix64(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)
|
||
}
|
||
|
||
/// One uniform `f32` in `[-0.5, 0.5)` drawn from `state`.
|
||
fn next_f32(state: &mut u64) -> f32 {
|
||
// Top 24 bits → a uniform mantissa in [0,1), then recentre to [-0.5, 0.5).
|
||
let bits = (splitmix64(state) >> 40) as u32;
|
||
(bits as f32 / 16_777_216.0) - 0.5
|
||
}
|
||
|
||
/// Corpus ids per cluster center. ~100/cluster mirrors the neighbourhood size of
|
||
/// a real embedding manifold and guarantees ≥ `k` genuine neighbours per query
|
||
/// for any `k` ≤ 100.
|
||
const CLUSTER_SIZE: u64 = 100;
|
||
|
||
/// Per-component spread of the Gaussian mixture (`vector = center + SPREAD·noise`,
|
||
/// both unit). `SPREAD = 0.5` ⇒ intra-cluster cosine ≈ 0.89, inter-cluster ≈ 0 —
|
||
/// the clear neighbour structure of real embeddings.
|
||
const CLUSTER_SPREAD: f32 = 0.5;
|
||
|
||
/// A normalized pseudo-random unit vector seeded by `seed`.
|
||
fn unit_seeded(seed: u64, dim: usize) -> Vec<f32> {
|
||
let mut state = seed;
|
||
let mut v: Vec<f32> = (0..dim).map(|_| next_f32(&mut state)).collect();
|
||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||
if norm > f32::EPSILON {
|
||
for x in &mut v {
|
||
*x /= norm;
|
||
}
|
||
} else if let Some(first) = v.first_mut() {
|
||
*first = 1.0;
|
||
}
|
||
v
|
||
}
|
||
|
||
/// The deterministic content embedding for an item id — a **clustered**
|
||
/// (Gaussian-mixture) vector.
|
||
///
|
||
/// `id` is assigned to cluster `id / CLUSTER_SIZE`; the vector is that cluster's
|
||
/// unit center plus `CLUSTER_SPREAD ×` a per-id unit noise vector. The result is
|
||
/// returned raw (un-normalized); the engine L2-normalizes on write and the oracle
|
||
/// ranks by cosine, both scale-invariant, so the cluster structure is identical
|
||
/// on both sides.
|
||
///
|
||
/// **Why clustered, not uniform-random.** Uniform-random high-dimensional vectors
|
||
/// are pathological for recall@k: by concentration of measure every pair sits at
|
||
/// cosine ≈ 0, so beyond a tiny perturbation a query's true top-k is an arbitrary
|
||
/// draw from a thick equidistant shell — recall@k then measures impossible
|
||
/// tie-breaking, not index quality, and (measured) *falls* as the corpus grows
|
||
/// (≈0.97 at 10k → ≈0.54 at 100k at 1536-D). Real text/image embeddings live on a
|
||
/// low-dimensional manifold with clusters; this models that so recall@k is a
|
||
/// meaningful index-quality metric at scale (m12p3).
|
||
#[must_use]
|
||
pub fn embedding_for(id: u64, dim: usize) -> Vec<f32> {
|
||
let cluster = id / CLUSTER_SIZE;
|
||
// Distinct stream constants for the center vs the per-id noise so they are
|
||
// independent; offset+odd-multiply keeps adjacent ids/clusters well-separated.
|
||
let center = unit_seeded(
|
||
cluster
|
||
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
||
.wrapping_add(0x00C0_FFEE),
|
||
dim,
|
||
);
|
||
let noise = unit_seeded(id.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1), dim);
|
||
center
|
||
.iter()
|
||
.zip(&noise)
|
||
.map(|(c, n)| c + CLUSTER_SPREAD * n)
|
||
.collect()
|
||
}
|
||
|
||
// ── Brute-force ground truth ─────────────────────────────────────────────────
|
||
|
||
/// The seeded corpus held in RAM for exact nearest-neighbor computation.
|
||
///
|
||
/// Stored as one flat `Vec<f32>` (id `i` occupies `[(i-1)*dim, i*dim)`) plus a
|
||
/// per-vector L2 norm so cosine is a single dot-product + divide. At 1536-dim
|
||
/// this costs `n * dim * 4` bytes (~6 GB at 1M) — the honest price of a real
|
||
/// brute-force oracle; smaller corpora (100k ≈ 600 MB) fit comfortably.
|
||
pub struct GroundTruth {
|
||
flat: Vec<f32>,
|
||
norms: Vec<f32>,
|
||
dim: usize,
|
||
n: u64,
|
||
}
|
||
|
||
impl GroundTruth {
|
||
/// Build the deterministic corpus for ids `1..=n` at `dim` dimensions.
|
||
#[must_use]
|
||
pub fn build(n: u64, dim: usize) -> Self {
|
||
let mut flat = Vec::with_capacity((n as usize) * dim);
|
||
let mut norms = Vec::with_capacity(n as usize);
|
||
for id in 1..=n {
|
||
let v = embedding_for(id, dim);
|
||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||
norms.push(norm);
|
||
flat.extend_from_slice(&v);
|
||
}
|
||
Self {
|
||
flat,
|
||
norms,
|
||
dim,
|
||
n,
|
||
}
|
||
}
|
||
|
||
#[must_use]
|
||
pub const fn n(&self) -> u64 {
|
||
self.n
|
||
}
|
||
|
||
#[must_use]
|
||
pub const fn dim(&self) -> usize {
|
||
self.dim
|
||
}
|
||
|
||
/// The raw stored vector for `id` (1-based).
|
||
#[must_use]
|
||
fn vector(&self, id: u64) -> &[f32] {
|
||
let start = ((id - 1) as usize) * self.dim;
|
||
&self.flat[start..start + self.dim]
|
||
}
|
||
|
||
/// The `k` ids whose corpus vector is most cosine-similar to `query`,
|
||
/// best-first. The exact answer the ANN index approximates.
|
||
#[must_use]
|
||
pub fn top_k(&self, query: &[f32], k: usize) -> Vec<u64> {
|
||
let q_norm = query.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||
if q_norm == 0.0 || k == 0 {
|
||
return Vec::new();
|
||
}
|
||
// A bounded top-k kept as a min-by-score Vec (k is tiny, ~10): cheaper and
|
||
// allocation-lighter than a full sort of n scored pairs per query.
|
||
let mut top: Vec<(f32, u64)> = Vec::with_capacity(k + 1);
|
||
for id in 1..=self.n {
|
||
let xn = self.norms[(id - 1) as usize];
|
||
if xn == 0.0 {
|
||
continue;
|
||
}
|
||
let dot: f32 = query.iter().zip(self.vector(id)).map(|(a, b)| a * b).sum();
|
||
let score = dot / (q_norm * xn); // cosine; higher = nearer
|
||
if top.len() < k {
|
||
top.push((score, id));
|
||
if top.len() == k {
|
||
// Smallest score first so the worst-kept is at index 0.
|
||
top.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
}
|
||
} else if score > top[0].0 {
|
||
top[0] = (score, id);
|
||
// Re-sink the new minimum to the front (k is tiny).
|
||
top.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
}
|
||
}
|
||
// Return best-first.
|
||
top.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
top.into_iter().map(|(_, id)| id).collect()
|
||
}
|
||
}
|
||
|
||
// ── Query pool (queries + precomputed ground truth) ──────────────────────────
|
||
|
||
/// A pool of realistic query vectors, each paired with its brute-force
|
||
/// ground-truth top-k.
|
||
///
|
||
/// Precomputing the (expensive) ground truth once decouples it from the hot
|
||
/// request path, where scoring a response is then just a set overlap.
|
||
pub struct QueryPool {
|
||
pub queries: Vec<Vec<f32>>,
|
||
pub truth: Vec<Vec<u64>>,
|
||
pub k: usize,
|
||
}
|
||
|
||
impl QueryPool {
|
||
/// Build `pool_size` queries and their ground-truth top-`k`.
|
||
///
|
||
/// Each query is a corpus point (spread across the catalog) perturbed by
|
||
/// deterministic noise, so it has a well-defined nearest cluster — the shape
|
||
/// of a real preference-vector read, and far more informative than uniformly
|
||
/// random high-dimensional queries whose neighbors are near-equidistant.
|
||
///
|
||
/// Ground truth is computed in parallel across the available cores; the work
|
||
/// is embarrassingly parallel (one independent brute-force scan per query).
|
||
#[must_use]
|
||
pub fn build(gt: &GroundTruth, pool_size: usize, k: usize, noise: f32) -> Self {
|
||
let n = gt.n().max(1);
|
||
// Spread base points across the corpus with a large odd stride so the pool
|
||
// is not clustered on the low ids.
|
||
let queries: Vec<Vec<f32>> = (0..pool_size)
|
||
.map(|i| {
|
||
let base = ((i as u64).wrapping_mul(2_654_435_761) % n) + 1;
|
||
let mut q = gt.vector(base).to_vec();
|
||
let mut state = (i as u64)
|
||
.wrapping_mul(0x100_0000_01B3)
|
||
.wrapping_add(0xABCD);
|
||
for c in &mut q {
|
||
*c += noise * next_f32(&mut state);
|
||
}
|
||
q
|
||
})
|
||
.collect();
|
||
|
||
let threads = std::thread::available_parallelism()
|
||
.map(std::num::NonZeroUsize::get)
|
||
.unwrap_or(4)
|
||
.min(pool_size.max(1));
|
||
let chunk = pool_size.div_ceil(threads.max(1));
|
||
let truth: Vec<Vec<u64>> = std::thread::scope(|s| {
|
||
// Spawn every chunk BEFORE joining any (the collect is load-bearing —
|
||
// joining inline would serialize the scans), then concatenate in order.
|
||
#[allow(clippy::needless_collect)]
|
||
let handles: Vec<_> = queries
|
||
.chunks(chunk.max(1))
|
||
.map(|qs| s.spawn(move || qs.iter().map(|q| gt.top_k(q, k)).collect::<Vec<_>>()))
|
||
.collect();
|
||
handles
|
||
.into_iter()
|
||
.flat_map(|h| h.join().unwrap_or_default())
|
||
.collect()
|
||
});
|
||
|
||
Self { queries, truth, k }
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn len(&self) -> usize {
|
||
self.queries.len()
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn is_empty(&self) -> bool {
|
||
self.queries.is_empty()
|
||
}
|
||
}
|
||
|
||
/// `recall@k`: the fraction of the ground-truth top-k that the engine returned.
|
||
///
|
||
/// `returned` may be shorter than `k` (a thin corpus or a degraded index); the
|
||
/// denominator is the ground-truth size, never the returned size, so a short
|
||
/// answer is correctly penalised.
|
||
#[must_use]
|
||
pub fn recall_at_k(returned: &[u64], truth: &[u64]) -> f64 {
|
||
if truth.is_empty() {
|
||
return 1.0; // nothing to find ⇒ vacuously perfect
|
||
}
|
||
let truth_set: std::collections::HashSet<u64> = truth.iter().copied().collect();
|
||
let hits = returned.iter().filter(|id| truth_set.contains(id)).count();
|
||
hits as f64 / truth.len() as f64
|
||
}
|
||
|
||
// ── Open-loop recall stage ───────────────────────────────────────────────────
|
||
|
||
/// One recall request's result, sent from a worker to the collector.
|
||
struct RecallOutcome {
|
||
class: StatusClass,
|
||
/// CO-corrected latency (from the request's intended send time).
|
||
latency: Duration,
|
||
/// Achieved recall@k, present only when the response carried a usable id list.
|
||
recall: Option<f64>,
|
||
}
|
||
|
||
/// Everything one recall ramp stage produced.
|
||
pub struct RecallStageStats {
|
||
pub hist: LatencyHistogram,
|
||
pub ok: u64,
|
||
pub errors: u64,
|
||
pub total: u64,
|
||
pub recall_sum: f64,
|
||
pub recall_count: u64,
|
||
pub elapsed: Duration,
|
||
pub client_shed: u64,
|
||
pub p99_schedule_lag: Duration,
|
||
pub max_schedule_lag: Duration,
|
||
}
|
||
|
||
impl RecallStageStats {
|
||
fn new() -> Self {
|
||
Self {
|
||
hist: LatencyHistogram::default(),
|
||
ok: 0,
|
||
errors: 0,
|
||
total: 0,
|
||
recall_sum: 0.0,
|
||
recall_count: 0,
|
||
elapsed: Duration::ZERO,
|
||
client_shed: 0,
|
||
p99_schedule_lag: Duration::ZERO,
|
||
max_schedule_lag: Duration::ZERO,
|
||
}
|
||
}
|
||
|
||
fn record(&mut self, o: &RecallOutcome) {
|
||
self.total += 1;
|
||
if o.class == StatusClass::Ok {
|
||
self.ok += 1;
|
||
self.hist.record(o.latency);
|
||
} else {
|
||
self.errors += 1;
|
||
}
|
||
if let Some(r) = o.recall {
|
||
self.recall_sum += r;
|
||
self.recall_count += 1;
|
||
}
|
||
}
|
||
|
||
/// Achieved throughput: completed requests per second over the stage.
|
||
#[must_use]
|
||
pub fn achieved_rps(&self) -> f64 {
|
||
let s = self.elapsed.as_secs_f64();
|
||
if s <= 0.0 { 0.0 } else { self.total as f64 / s }
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn error_rate(&self) -> f64 {
|
||
if self.total == 0 {
|
||
0.0
|
||
} else {
|
||
self.errors as f64 / self.total as f64
|
||
}
|
||
}
|
||
|
||
/// Mean recall@k across the stage's successfully-parsed responses.
|
||
#[must_use]
|
||
pub fn mean_recall(&self) -> f64 {
|
||
if self.recall_count == 0 {
|
||
0.0
|
||
} else {
|
||
self.recall_sum / self.recall_count as f64
|
||
}
|
||
}
|
||
|
||
/// True p99 latency (bucket-estimated, but a genuine tail over an open loop —
|
||
/// NOT a closed-loop mean).
|
||
#[must_use]
|
||
pub fn p99(&self) -> Duration {
|
||
self.hist.percentile(0.99)
|
||
}
|
||
}
|
||
|
||
/// Run one recall ramp stage open-loop and return its aggregated stats.
|
||
///
|
||
/// Mirrors [`crate::scheduler::run_stage`]'s constant-arrival-rate methodology
|
||
/// (fire at the target rate regardless of outstanding responses; measure each
|
||
/// latency from its intended send time; count — never block on — an in-flight
|
||
/// shed), specialised to the single `/vector_search` op and extended to score
|
||
/// recall against the precomputed pool.
|
||
pub async fn run_recall_stage(
|
||
pool: Arc<QueryPool>,
|
||
bases: Arc<Vec<String>>,
|
||
client: Arc<HttpClient>,
|
||
stage: &Stage,
|
||
k: usize,
|
||
ef_search: Option<usize>,
|
||
max_inflight: usize,
|
||
) -> RecallStageStats {
|
||
let (tx, mut rx) = mpsc::unbounded_channel::<RecallOutcome>();
|
||
let collector = tokio::spawn(async move {
|
||
let mut stats = RecallStageStats::new();
|
||
while let Some(o) = rx.recv().await {
|
||
stats.record(&o);
|
||
}
|
||
stats
|
||
});
|
||
|
||
let sem = Arc::new(Semaphore::new(max_inflight));
|
||
let next_base = AtomicUsize::new(0);
|
||
let mut shed: u64 = 0;
|
||
let mut lag_hist = LatencyHistogram::default();
|
||
|
||
let start = Instant::now();
|
||
let deadline = start + stage.duration;
|
||
let period = Duration::from_secs_f64(1.0 / stage.target_rps.max(1e-9));
|
||
let mut next = start;
|
||
let mut dispatched: u64 = 0;
|
||
|
||
loop {
|
||
let now = Instant::now();
|
||
if now >= deadline {
|
||
break;
|
||
}
|
||
if next <= now {
|
||
match sem.clone().try_acquire_owned() {
|
||
Ok(permit) => {
|
||
let intended = next;
|
||
lag_hist.record(now.saturating_duration_since(intended));
|
||
|
||
// Pick the query (round-robin over the pool) and a target base
|
||
// (round-robin over the gateways — every replica holds the full
|
||
// corpus at the S=1 recall shape).
|
||
let qi = (dispatched as usize) % pool.len().max(1);
|
||
let base_idx = next_base.fetch_add(1, Ordering::Relaxed) % bases.len().max(1);
|
||
|
||
let pool = pool.clone();
|
||
let bases = bases.clone();
|
||
let client = client.clone();
|
||
let tx = tx.clone();
|
||
tokio::spawn(async move {
|
||
let _permit = permit;
|
||
let query = &pool.queries[qi];
|
||
let truth = &pool.truth[qi];
|
||
let base = &bases[base_idx];
|
||
let (class, ids) =
|
||
client.vector_search_ids(base, query, k, ef_search).await;
|
||
let latency = Instant::now().saturating_duration_since(intended);
|
||
let recall = ids.map(|ids| recall_at_k(&ids, truth));
|
||
let _ = tx.send(RecallOutcome {
|
||
class,
|
||
latency,
|
||
recall,
|
||
});
|
||
});
|
||
}
|
||
Err(_) => shed += 1,
|
||
}
|
||
next += period;
|
||
dispatched += 1;
|
||
if dispatched.is_multiple_of(256) {
|
||
tokio::task::yield_now().await;
|
||
}
|
||
} else {
|
||
tokio::time::sleep_until(next).await;
|
||
}
|
||
}
|
||
let elapsed = start.elapsed();
|
||
|
||
drop(tx);
|
||
let mut stats = collector.await.unwrap_or_else(|_| RecallStageStats::new());
|
||
stats.elapsed = elapsed;
|
||
stats.client_shed = shed;
|
||
stats.p99_schedule_lag = lag_hist.percentile(0.99);
|
||
stats.max_schedule_lag = lag_hist.max();
|
||
stats
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn embedding_is_deterministic_and_sized() {
|
||
let a = embedding_for(7, 32);
|
||
let b = embedding_for(7, 32);
|
||
assert_eq!(a, b, "same id+dim must reproduce the same vector");
|
||
assert_eq!(a.len(), 32);
|
||
assert_ne!(embedding_for(7, 32), embedding_for(8, 32), "ids differ");
|
||
// Non-zero norm (the engine rejects zero-norm embeddings).
|
||
assert!(a.iter().map(|x| x * x).sum::<f32>() > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn ground_truth_ranks_self_first() {
|
||
let gt = GroundTruth::build(200, 16);
|
||
// Querying with a corpus vector returns that id first (cosine 1.0).
|
||
let q = embedding_for(42, 16);
|
||
let top = gt.top_k(&q, 10);
|
||
assert_eq!(top.len(), 10);
|
||
assert_eq!(top[0], 42, "an item's own vector is its nearest neighbor");
|
||
}
|
||
|
||
#[test]
|
||
fn recall_at_k_counts_overlap_over_truth_size() {
|
||
let truth = vec![1, 2, 3, 4, 5];
|
||
// 3 of 5 truth ids present (extra/ordering ignored).
|
||
assert!((recall_at_k(&[1, 2, 3, 99, 100], &truth) - 0.6).abs() < 1e-9);
|
||
assert!((recall_at_k(&[], &truth) - 0.0).abs() < 1e-9);
|
||
assert!((recall_at_k(&[1, 2, 3, 4, 5], &truth) - 1.0).abs() < 1e-9);
|
||
// Empty truth ⇒ vacuously perfect (nothing to find).
|
||
assert!((recall_at_k(&[], &[]) - 1.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn query_pool_truth_aligns_with_queries() {
|
||
let gt = GroundTruth::build(500, 16);
|
||
let pool = QueryPool::build(>, 20, 10, 0.05);
|
||
assert_eq!(pool.queries.len(), 20);
|
||
assert_eq!(pool.truth.len(), 20, "every query has a ground-truth list");
|
||
for t in &pool.truth {
|
||
assert_eq!(t.len(), 10, "ground truth is top-k");
|
||
}
|
||
// A perturbed corpus point's nearest neighbor should be its own base or a
|
||
// close id — the brute force must return a non-empty, sane list.
|
||
assert!(pool.truth.iter().all(|t| !t.is_empty()));
|
||
}
|
||
}
|