use std::collections::HashSet;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::{metrics, FibCodeV1, FibQuantizer, Result};
pub const BENCHMARK_SCHEMA: &str = "fib_benchmark_v1";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FibBenchmarkCorpus {
pub vectors: Vec<Vec<f32>>,
pub queries: Vec<Vec<f32>>,
pub ground_truth_topk: Vec<Vec<usize>>,
pub k: usize,
pub label: String,
}
impl FibBenchmarkCorpus {
pub fn exact_topk(&self, query: &[f32], k: usize) -> Result<Vec<usize>> {
let mut sims: Vec<(usize, f64)> = Vec::with_capacity(self.vectors.len());
for (idx, v) in self.vectors.iter().enumerate() {
let sim = metrics::cosine_similarity(query, v)?;
sims.push((idx, sim));
}
sims.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
Ok(sims.into_iter().take(k).map(|(idx, _)| idx).collect())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FibBenchmarkReceiptV1 {
pub schema_version: String,
pub corpus_label: String,
pub profile_digest: String,
pub codebook_digest: String,
pub rotation_digest: String,
pub vector_count: usize,
pub query_count: usize,
pub k: usize,
pub compression_ratio: f64,
pub mean_cosine_similarity: f64,
pub mean_mse: f64,
pub recall_at_k: f64,
pub ndcg_at_k: f64,
pub encode_elapsed_micros: u128,
pub decode_elapsed_micros: u128,
pub notes: Vec<String>,
}
pub fn recall_at_k(exact_topk: &[usize], approx_topk: &[usize], k: usize) -> f64 {
if k == 0 || exact_topk.is_empty() || approx_topk.is_empty() {
return 0.0;
}
let exact_set: HashSet<usize> = exact_topk.iter().take(k).copied().collect();
let hits = approx_topk
.iter()
.take(k)
.filter(|idx| exact_set.contains(idx))
.count();
let denom = exact_topk.len().min(k);
if denom == 0 {
0.0
} else {
hits as f64 / denom as f64
}
}
pub fn ndcg_at_k(exact_scores: &[f64], approx_ranking: &[usize], k: usize) -> f64 {
if k == 0 || exact_scores.is_empty() || approx_ranking.is_empty() {
return 0.0;
}
let cap = k.min(approx_ranking.len()).min(exact_scores.len());
if cap == 0 {
return 0.0;
}
let dcg_approx: f64 = (0..cap)
.map(|i| {
let gain = exact_scores[i].max(0.0);
gain / ((i as f64 + 2.0).log2())
})
.sum();
let mut ideal_scores: Vec<f64> = exact_scores.to_vec();
ideal_scores.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
let dcg_ideal: f64 = (0..cap)
.map(|i| {
let gain = ideal_scores[i].max(0.0);
gain / ((i as f64 + 2.0).log2())
})
.sum();
if dcg_ideal == 0.0 {
0.0
} else {
dcg_approx / dcg_ideal
}
}
pub fn run_benchmark(
corpus: &FibBenchmarkCorpus,
quantizer: &FibQuantizer,
) -> Result<FibBenchmarkReceiptV1> {
if corpus.vectors.is_empty() {
return Err(crate::FibQuantError::CorruptPayload(
"benchmark corpus has no vectors".into(),
));
}
if corpus.queries.is_empty() {
return Err(crate::FibQuantError::CorruptPayload(
"benchmark corpus has no queries".into(),
));
}
if corpus.k == 0 {
return Err(crate::FibQuantError::CorruptPayload(
"benchmark corpus k must be > 0".into(),
));
}
if corpus.ground_truth_topk.len() != corpus.queries.len() {
return Err(crate::FibQuantError::CorruptPayload(format!(
"ground_truth_topk len {} != queries len {}",
corpus.ground_truth_topk.len(),
corpus.queries.len()
)));
}
let dim = quantizer.profile().ambient_dim as usize;
let original_bytes_per_vec = dim * std::mem::size_of::<f32>();
let encode_start = Instant::now();
let codes: Vec<FibCodeV1> = corpus
.vectors
.iter()
.map(|v| quantizer.encode(v))
.collect::<Result<Vec<_>>>()?;
let encode_elapsed = encode_start.elapsed();
let decode_start = Instant::now();
let decoded_vectors = quantizer.decode_batch_fast(&codes)?;
let decode_elapsed = decode_start.elapsed();
let total_original_bytes = corpus.vectors.len() * original_bytes_per_vec;
let total_encoded_bytes: usize = codes.iter().map(|c| c.compact_size()).sum();
let compression_ratio = if total_encoded_bytes == 0 {
0.0
} else {
total_original_bytes as f64 / total_encoded_bytes as f64
};
let mut cos_sum = 0.0f64;
let mut mse_sum = 0.0f64;
for (orig, recon) in corpus.vectors.iter().zip(decoded_vectors.iter()) {
cos_sum += metrics::cosine_similarity(orig, recon)?;
mse_sum += metrics::mse(orig, recon)?;
}
let n = corpus.vectors.len() as f64;
let mean_cosine = cos_sum / n;
let mean_mse = mse_sum / n;
let mut recall_sum = 0.0f64;
let mut ndcg_sum = 0.0f64;
for (qi, query) in corpus.queries.iter().enumerate() {
let gt = &corpus.ground_truth_topk[qi];
let mut approx_sims: Vec<(usize, f64)> = Vec::with_capacity(decoded_vectors.len());
for (db_idx, recon) in decoded_vectors.iter().enumerate() {
let sim = metrics::cosine_similarity(query, recon)?;
approx_sims.push((db_idx, sim));
}
approx_sims.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
let approx_topk: Vec<usize> = approx_sims
.iter()
.take(corpus.k)
.map(|(idx, _)| *idx)
.collect();
recall_sum += recall_at_k(gt, &approx_topk, corpus.k);
let approx_scores: Vec<f64> = approx_topk
.iter()
.map(|&db_idx| {
metrics::cosine_similarity(query, &corpus.vectors[db_idx]).unwrap_or(0.0)
})
.collect();
ndcg_sum += ndcg_at_k(&approx_scores, &approx_topk, corpus.k);
}
let n_queries = corpus.queries.len() as f64;
let mean_recall = recall_sum / n_queries;
let mean_ndcg = ndcg_sum / n_queries;
let profile_digest = quantizer.profile().digest()?;
let codebook_digest = quantizer.codebook_digest().to_string();
let rotation_digest = quantizer.rotation_digest().to_string();
Ok(FibBenchmarkReceiptV1 {
schema_version: BENCHMARK_SCHEMA.into(),
corpus_label: corpus.label.clone(),
profile_digest,
codebook_digest,
rotation_digest,
vector_count: corpus.vectors.len(),
query_count: corpus.queries.len(),
k: corpus.k,
compression_ratio,
mean_cosine_similarity: mean_cosine,
mean_mse,
recall_at_k: mean_recall,
ndcg_at_k: mean_ndcg,
encode_elapsed_micros: encode_elapsed.as_micros(),
decode_elapsed_micros: decode_elapsed.as_micros(),
notes: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tiny_corpus() -> FibBenchmarkCorpus {
let vectors: Vec<Vec<f32>> = vec![
vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875],
vec![-0.3, 0.6, -0.9, -1.1, 1.3, -0.4, 0.2, 0.85],
vec![0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5],
vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
vec![-0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5],
vec![0.9, -0.3, 0.7, -0.5, 0.2, 0.1, -0.4, 0.6],
];
let queries: Vec<Vec<f32>> = vec![
vec![0.3, -0.4, 0.6, 0.9, -0.8, 0.3, 0.2, -0.5],
vec![0.4, 0.4, 0.4, 0.4, -0.4, -0.4, -0.4, -0.4],
];
let k = 3;
let gt: Vec<Vec<usize>> = queries
.iter()
.map(|q| {
let mut sims: Vec<(usize, f64)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i, metrics::cosine_similarity(q, v).unwrap()))
.collect();
sims.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
sims.iter().take(k).map(|(i, _)| *i).collect()
})
.collect();
FibBenchmarkCorpus {
vectors,
queries,
ground_truth_topk: gt,
k,
label: "tiny_test".into(),
}
}
fn make_quantizer() -> FibQuantizer {
let mut profile = crate::FibQuantProfileV1::paper_default(8, 2, 16, 7).unwrap();
profile.training_samples = 128;
profile.lloyd_restarts = 1;
profile.lloyd_iterations = 2;
FibQuantizer::new(profile).unwrap()
}
#[test]
fn test_recall_at_k_perfect_match() {
let exact = vec![0usize, 1, 2];
let approx = vec![0usize, 1, 2];
assert!((recall_at_k(&exact, &approx, 3) - 1.0).abs() < 1e-12);
let approx_reordered = vec![2, 0, 1];
assert!((recall_at_k(&exact, &approx_reordered, 3) - 1.0).abs() < 1e-12);
}
#[test]
fn test_recall_at_k_no_match() {
let exact = vec![0usize, 1, 2];
let approx = vec![3usize, 4, 5];
assert!((recall_at_k(&exact, &approx, 3) - 0.0).abs() < 1e-12);
}
#[test]
fn test_recall_at_k_partial() {
let exact = vec![0usize, 1, 2];
let approx = vec![0usize, 3, 2];
assert!((recall_at_k(&exact, &approx, 3) - (2.0 / 3.0)).abs() < 1e-12);
}
#[test]
fn test_recall_at_k_edge_cases() {
assert_eq!(recall_at_k(&[0, 1], &[0, 1], 0), 0.0);
assert_eq!(recall_at_k(&[], &[0], 3), 0.0);
assert_eq!(recall_at_k(&[0], &[], 3), 0.0);
let exact = vec![0usize, 1];
let approx = vec![0usize, 1];
assert!((recall_at_k(&exact, &approx, 10) - 1.0).abs() < 1e-12);
}
#[test]
fn test_ndcg_at_k_perfect_order() {
let exact_scores = vec![0.9, 0.8, 0.7];
let approx_ranking = vec![0usize, 1, 2];
let score = ndcg_at_k(&exact_scores, &approx_ranking, 3);
assert!(
(score - 1.0).abs() < 1e-9,
"perfect nDCG should be 1.0, got {}",
score
);
}
#[test]
fn test_ndcg_at_k_reversed_order() {
let exact_scores = vec![0.7, 0.8, 0.9]; let approx_ranking = vec![2usize, 1, 0]; let score = ndcg_at_k(&exact_scores, &approx_ranking, 3);
assert!(
score > 0.0 && score < 1.0,
"reversed nDCG should be in (0,1), got {}",
score
);
}
#[test]
fn test_ndcg_at_k_edge_cases() {
assert_eq!(ndcg_at_k(&[0.5], &[0], 0), 0.0);
assert_eq!(ndcg_at_k(&[], &[0], 3), 0.0);
assert_eq!(ndcg_at_k(&[0.5], &[], 3), 0.0);
}
#[test]
fn test_run_benchmark_produces_valid_receipt() {
let corpus = make_tiny_corpus();
let quantizer = make_quantizer();
let receipt = run_benchmark(&corpus, &quantizer).expect("benchmark should succeed");
assert_eq!(receipt.schema_version, BENCHMARK_SCHEMA);
assert_eq!(receipt.corpus_label, "tiny_test");
assert_eq!(receipt.vector_count, 6);
assert_eq!(receipt.query_count, 2);
assert_eq!(receipt.k, 3);
assert!(
receipt.compression_ratio.is_finite(),
"compression_ratio not finite"
);
assert!(
receipt.compression_ratio > 0.0,
"compression_ratio should be positive, got {}",
receipt.compression_ratio
);
assert!(
receipt.mean_cosine_similarity.is_finite(),
"mean_cosine_similarity not finite"
);
assert!(receipt.mean_mse.is_finite(), "mean_mse not finite");
assert!(receipt.mean_mse >= 0.0, "mean_mse should be non-negative");
assert!(receipt.recall_at_k.is_finite(), "recall_at_k not finite");
assert!(
(0.0..=1.0).contains(&receipt.recall_at_k),
"recall_at_k should be in [0,1], got {}",
receipt.recall_at_k
);
assert!(receipt.ndcg_at_k.is_finite(), "ndcg_at_k not finite");
assert!(
(0.0..=1.0).contains(&receipt.ndcg_at_k),
"ndcg_at_k should be in [0,1], got {}",
receipt.ndcg_at_k
);
assert!(receipt.encode_elapsed_micros > 0 || receipt.decode_elapsed_micros > 0);
assert!(!receipt.profile_digest.is_empty());
assert!(!receipt.codebook_digest.is_empty());
assert!(!receipt.rotation_digest.is_empty());
assert!(receipt.notes.is_empty());
}
#[test]
fn test_run_benchmark_receipt_serializable() {
let corpus = make_tiny_corpus();
let quantizer = make_quantizer();
let receipt = run_benchmark(&corpus, &quantizer).unwrap();
let json = serde_json::to_string(&receipt).expect("should serialize to JSON");
let restored: FibBenchmarkReceiptV1 =
serde_json::from_str(&json).expect("should deserialize from JSON");
assert_eq!(receipt, restored);
}
#[test]
fn test_exact_topk_consistency() {
let corpus = make_tiny_corpus();
for (qi, query) in corpus.queries.iter().enumerate() {
let computed = corpus.exact_topk(query, corpus.k).unwrap();
let expected = &corpus.ground_truth_topk[qi];
assert_eq!(
computed, *expected,
"exact_topk mismatch for query {}: computed {:?} vs expected {:?}",
qi, computed, expected
);
}
}
}