1use std::collections::HashSet;
8use std::time::Instant;
9
10use serde::{Deserialize, Serialize};
11
12use crate::{metrics, FibCodeV1, FibQuantizer, Result};
13
14pub const BENCHMARK_SCHEMA: &str = "fib_benchmark_v1";
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct FibBenchmarkCorpus {
24 pub vectors: Vec<Vec<f32>>,
26 pub queries: Vec<Vec<f32>>,
28 pub ground_truth_topk: Vec<Vec<usize>>,
30 pub k: usize,
32 pub label: String,
34}
35
36impl FibBenchmarkCorpus {
37 pub fn exact_topk(&self, query: &[f32], k: usize) -> Result<Vec<usize>> {
40 let mut sims: Vec<(usize, f64)> = Vec::with_capacity(self.vectors.len());
41 for (idx, v) in self.vectors.iter().enumerate() {
42 let sim = metrics::cosine_similarity(query, v)?;
43 sims.push((idx, sim));
44 }
45 sims.sort_by(|a, b| {
47 b.1.partial_cmp(&a.1)
48 .unwrap_or(std::cmp::Ordering::Equal)
49 .then(a.0.cmp(&b.0))
50 });
51 Ok(sims.into_iter().take(k).map(|(idx, _)| idx).collect())
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct FibBenchmarkReceiptV1 {
58 pub schema_version: String,
60 pub corpus_label: String,
62 pub profile_digest: String,
64 pub codebook_digest: String,
66 pub rotation_digest: String,
68 pub vector_count: usize,
70 pub query_count: usize,
72 pub k: usize,
74 pub compression_ratio: f64,
76 pub mean_cosine_similarity: f64,
78 pub mean_mse: f64,
80 pub recall_at_k: f64,
82 pub ndcg_at_k: f64,
84 pub encode_elapsed_micros: u128,
86 pub decode_elapsed_micros: u128,
88 pub notes: Vec<String>,
90}
91
92pub fn recall_at_k(exact_topk: &[usize], approx_topk: &[usize], k: usize) -> f64 {
97 if k == 0 || exact_topk.is_empty() || approx_topk.is_empty() {
98 return 0.0;
99 }
100 let exact_set: HashSet<usize> = exact_topk.iter().take(k).copied().collect();
101 let hits = approx_topk
102 .iter()
103 .take(k)
104 .filter(|idx| exact_set.contains(idx))
105 .count();
106 let denom = exact_topk.len().min(k);
107 if denom == 0 {
108 0.0
109 } else {
110 hits as f64 / denom as f64
111 }
112}
113
114pub fn ndcg_at_k(exact_scores: &[f64], approx_ranking: &[usize], k: usize) -> f64 {
132 if k == 0 || exact_scores.is_empty() || approx_ranking.is_empty() {
133 return 0.0;
134 }
135 let cap = k.min(approx_ranking.len()).min(exact_scores.len());
136 if cap == 0 {
137 return 0.0;
138 }
139
140 let dcg_approx: f64 = (0..cap)
150 .map(|i| {
151 let gain = exact_scores[i].max(0.0);
152 gain / ((i as f64 + 2.0).log2())
153 })
154 .sum();
155
156 let mut ideal_scores: Vec<f64> = exact_scores.to_vec();
157 ideal_scores.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
158
159 let dcg_ideal: f64 = (0..cap)
160 .map(|i| {
161 let gain = ideal_scores[i].max(0.0);
162 gain / ((i as f64 + 2.0).log2())
163 })
164 .sum();
165
166 if dcg_ideal == 0.0 {
167 0.0
168 } else {
169 dcg_approx / dcg_ideal
170 }
171}
172
173pub fn run_benchmark(
179 corpus: &FibBenchmarkCorpus,
180 quantizer: &FibQuantizer,
181) -> Result<FibBenchmarkReceiptV1> {
182 if corpus.vectors.is_empty() {
183 return Err(crate::FibQuantError::CorruptPayload(
184 "benchmark corpus has no vectors".into(),
185 ));
186 }
187 if corpus.queries.is_empty() {
188 return Err(crate::FibQuantError::CorruptPayload(
189 "benchmark corpus has no queries".into(),
190 ));
191 }
192 if corpus.k == 0 {
193 return Err(crate::FibQuantError::CorruptPayload(
194 "benchmark corpus k must be > 0".into(),
195 ));
196 }
197 if corpus.ground_truth_topk.len() != corpus.queries.len() {
198 return Err(crate::FibQuantError::CorruptPayload(format!(
199 "ground_truth_topk len {} != queries len {}",
200 corpus.ground_truth_topk.len(),
201 corpus.queries.len()
202 )));
203 }
204
205 let dim = quantizer.profile().ambient_dim as usize;
206 let original_bytes_per_vec = dim * std::mem::size_of::<f32>();
207
208 let encode_start = Instant::now();
210 let codes: Vec<FibCodeV1> = corpus
211 .vectors
212 .iter()
213 .map(|v| quantizer.encode(v))
214 .collect::<Result<Vec<_>>>()?;
215 let encode_elapsed = encode_start.elapsed();
216
217 let decode_start = Instant::now();
219 let decoded_vectors = quantizer.decode_batch_fast(&codes)?;
220 let decode_elapsed = decode_start.elapsed();
221
222 let total_original_bytes = corpus.vectors.len() * original_bytes_per_vec;
224 let total_encoded_bytes: usize = codes.iter().map(|c| c.compact_size()).sum();
225 let compression_ratio = if total_encoded_bytes == 0 {
226 0.0
227 } else {
228 total_original_bytes as f64 / total_encoded_bytes as f64
229 };
230
231 let mut cos_sum = 0.0f64;
233 let mut mse_sum = 0.0f64;
234 for (orig, recon) in corpus.vectors.iter().zip(decoded_vectors.iter()) {
235 cos_sum += metrics::cosine_similarity(orig, recon)?;
236 mse_sum += metrics::mse(orig, recon)?;
237 }
238 let n = corpus.vectors.len() as f64;
239 let mean_cosine = cos_sum / n;
240 let mean_mse = mse_sum / n;
241
242 let mut recall_sum = 0.0f64;
244 let mut ndcg_sum = 0.0f64;
245 for (qi, query) in corpus.queries.iter().enumerate() {
246 let gt = &corpus.ground_truth_topk[qi];
247
248 let mut approx_sims: Vec<(usize, f64)> = Vec::with_capacity(decoded_vectors.len());
251 for (db_idx, recon) in decoded_vectors.iter().enumerate() {
252 let sim = metrics::cosine_similarity(query, recon)?;
253 approx_sims.push((db_idx, sim));
254 }
255 approx_sims.sort_by(|a, b| {
256 b.1.partial_cmp(&a.1)
257 .unwrap_or(std::cmp::Ordering::Equal)
258 .then(a.0.cmp(&b.0))
259 });
260 let approx_topk: Vec<usize> = approx_sims
261 .iter()
262 .take(corpus.k)
263 .map(|(idx, _)| *idx)
264 .collect();
265
266 recall_sum += recall_at_k(gt, &approx_topk, corpus.k);
268
269 let approx_scores: Vec<f64> = approx_topk
273 .iter()
274 .map(|&db_idx| {
275 metrics::cosine_similarity(query, &corpus.vectors[db_idx]).unwrap_or(0.0)
276 })
277 .collect();
278 ndcg_sum += ndcg_at_k(&approx_scores, &approx_topk, corpus.k);
279 }
280
281 let n_queries = corpus.queries.len() as f64;
282 let mean_recall = recall_sum / n_queries;
283 let mean_ndcg = ndcg_sum / n_queries;
284
285 let profile_digest = quantizer.profile().digest()?;
287 let codebook_digest = quantizer.codebook_digest().to_string();
288 let rotation_digest = quantizer.rotation_digest().to_string();
289
290 Ok(FibBenchmarkReceiptV1 {
291 schema_version: BENCHMARK_SCHEMA.into(),
292 corpus_label: corpus.label.clone(),
293 profile_digest,
294 codebook_digest,
295 rotation_digest,
296 vector_count: corpus.vectors.len(),
297 query_count: corpus.queries.len(),
298 k: corpus.k,
299 compression_ratio,
300 mean_cosine_similarity: mean_cosine,
301 mean_mse,
302 recall_at_k: mean_recall,
303 ndcg_at_k: mean_ndcg,
304 encode_elapsed_micros: encode_elapsed.as_micros(),
305 decode_elapsed_micros: decode_elapsed.as_micros(),
306 notes: Vec::new(),
307 })
308}
309
310#[cfg(test)]
313mod tests {
314 use super::*;
315
316 fn make_tiny_corpus() -> FibBenchmarkCorpus {
317 let vectors: Vec<Vec<f32>> = vec![
319 vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875],
320 vec![-0.3, 0.6, -0.9, -1.1, 1.3, -0.4, 0.2, 0.85],
321 vec![0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5],
322 vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
323 vec![-0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5],
324 vec![0.9, -0.3, 0.7, -0.5, 0.2, 0.1, -0.4, 0.6],
325 ];
326 let queries: Vec<Vec<f32>> = vec![
327 vec![0.3, -0.4, 0.6, 0.9, -0.8, 0.3, 0.2, -0.5],
328 vec![0.4, 0.4, 0.4, 0.4, -0.4, -0.4, -0.4, -0.4],
329 ];
330 let k = 3;
332 let gt: Vec<Vec<usize>> = queries
333 .iter()
334 .map(|q| {
335 let mut sims: Vec<(usize, f64)> = vectors
336 .iter()
337 .enumerate()
338 .map(|(i, v)| (i, metrics::cosine_similarity(q, v).unwrap()))
339 .collect();
340 sims.sort_by(|a, b| {
341 b.1.partial_cmp(&a.1)
342 .unwrap_or(std::cmp::Ordering::Equal)
343 .then(a.0.cmp(&b.0))
344 });
345 sims.iter().take(k).map(|(i, _)| *i).collect()
346 })
347 .collect();
348 FibBenchmarkCorpus {
349 vectors,
350 queries,
351 ground_truth_topk: gt,
352 k,
353 label: "tiny_test".into(),
354 }
355 }
356
357 fn make_quantizer() -> FibQuantizer {
358 let mut profile = crate::FibQuantProfileV1::paper_default(8, 2, 16, 7).unwrap();
359 profile.training_samples = 128;
360 profile.lloyd_restarts = 1;
361 profile.lloyd_iterations = 2;
362 FibQuantizer::new(profile).unwrap()
363 }
364
365 #[test]
366 fn test_recall_at_k_perfect_match() {
367 let exact = vec![0usize, 1, 2];
368 let approx = vec![0usize, 1, 2];
369 assert!((recall_at_k(&exact, &approx, 3) - 1.0).abs() < 1e-12);
370 let approx_reordered = vec![2, 0, 1];
372 assert!((recall_at_k(&exact, &approx_reordered, 3) - 1.0).abs() < 1e-12);
373 }
374
375 #[test]
376 fn test_recall_at_k_no_match() {
377 let exact = vec![0usize, 1, 2];
378 let approx = vec![3usize, 4, 5];
379 assert!((recall_at_k(&exact, &approx, 3) - 0.0).abs() < 1e-12);
380 }
381
382 #[test]
383 fn test_recall_at_k_partial() {
384 let exact = vec![0usize, 1, 2];
385 let approx = vec![0usize, 3, 2];
386 assert!((recall_at_k(&exact, &approx, 3) - (2.0 / 3.0)).abs() < 1e-12);
388 }
389
390 #[test]
391 fn test_recall_at_k_edge_cases() {
392 assert_eq!(recall_at_k(&[0, 1], &[0, 1], 0), 0.0);
394 assert_eq!(recall_at_k(&[], &[0], 3), 0.0);
396 assert_eq!(recall_at_k(&[0], &[], 3), 0.0);
397 let exact = vec![0usize, 1];
399 let approx = vec![0usize, 1];
400 assert!((recall_at_k(&exact, &approx, 10) - 1.0).abs() < 1e-12);
401 }
402
403 #[test]
404 fn test_ndcg_at_k_perfect_order() {
405 let exact_scores = vec![0.9, 0.8, 0.7];
407 let approx_ranking = vec![0usize, 1, 2];
408 let score = ndcg_at_k(&exact_scores, &approx_ranking, 3);
409 assert!(
410 (score - 1.0).abs() < 1e-9,
411 "perfect nDCG should be 1.0, got {}",
412 score
413 );
414 }
415
416 #[test]
417 fn test_ndcg_at_k_reversed_order() {
418 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);
427 assert!(
428 score > 0.0 && score < 1.0,
429 "reversed nDCG should be in (0,1), got {}",
430 score
431 );
432 }
433
434 #[test]
435 fn test_ndcg_at_k_edge_cases() {
436 assert_eq!(ndcg_at_k(&[0.5], &[0], 0), 0.0);
437 assert_eq!(ndcg_at_k(&[], &[0], 3), 0.0);
438 assert_eq!(ndcg_at_k(&[0.5], &[], 3), 0.0);
439 }
440
441 #[test]
442 fn test_run_benchmark_produces_valid_receipt() {
443 let corpus = make_tiny_corpus();
444 let quantizer = make_quantizer();
445 let receipt = run_benchmark(&corpus, &quantizer).expect("benchmark should succeed");
446
447 assert_eq!(receipt.schema_version, BENCHMARK_SCHEMA);
449 assert_eq!(receipt.corpus_label, "tiny_test");
450 assert_eq!(receipt.vector_count, 6);
451 assert_eq!(receipt.query_count, 2);
452 assert_eq!(receipt.k, 3);
453
454 assert!(
456 receipt.compression_ratio.is_finite(),
457 "compression_ratio not finite"
458 );
459 assert!(
460 receipt.compression_ratio > 0.0,
461 "compression_ratio should be positive, got {}",
462 receipt.compression_ratio
463 );
464 assert!(
465 receipt.mean_cosine_similarity.is_finite(),
466 "mean_cosine_similarity not finite"
467 );
468 assert!(receipt.mean_mse.is_finite(), "mean_mse not finite");
469 assert!(receipt.mean_mse >= 0.0, "mean_mse should be non-negative");
470 assert!(receipt.recall_at_k.is_finite(), "recall_at_k not finite");
471 assert!(
472 (0.0..=1.0).contains(&receipt.recall_at_k),
473 "recall_at_k should be in [0,1], got {}",
474 receipt.recall_at_k
475 );
476 assert!(receipt.ndcg_at_k.is_finite(), "ndcg_at_k not finite");
477 assert!(
478 (0.0..=1.0).contains(&receipt.ndcg_at_k),
479 "ndcg_at_k should be in [0,1], got {}",
480 receipt.ndcg_at_k
481 );
482
483 assert!(receipt.encode_elapsed_micros > 0 || receipt.decode_elapsed_micros > 0);
485
486 assert!(!receipt.profile_digest.is_empty());
488 assert!(!receipt.codebook_digest.is_empty());
489 assert!(!receipt.rotation_digest.is_empty());
490
491 assert!(receipt.notes.is_empty());
493 }
494
495 #[test]
496 fn test_run_benchmark_receipt_serializable() {
497 let corpus = make_tiny_corpus();
498 let quantizer = make_quantizer();
499 let receipt = run_benchmark(&corpus, &quantizer).unwrap();
500 let json = serde_json::to_string(&receipt).expect("should serialize to JSON");
501 let restored: FibBenchmarkReceiptV1 =
502 serde_json::from_str(&json).expect("should deserialize from JSON");
503 assert_eq!(receipt, restored);
504 }
505
506 #[test]
507 fn test_exact_topk_consistency() {
508 let corpus = make_tiny_corpus();
509 for (qi, query) in corpus.queries.iter().enumerate() {
510 let computed = corpus.exact_topk(query, corpus.k).unwrap();
511 let expected = &corpus.ground_truth_topk[qi];
512 assert_eq!(
513 computed, *expected,
514 "exact_topk mismatch for query {}: computed {:?} vs expected {:?}",
515 qi, computed, expected
516 );
517 }
518 }
519}