Skip to main content

fib_quant/
eval.rs

1//! Benchmark and evaluation harness for FibQuant.
2//!
3//! Provides a typed benchmark receipt (`FibBenchmarkReceiptV1`) capturing
4//! recall@K, nDCG@K, compression ratio, cosine similarity, MSE, and timing
5//! for a corpus of vectors queried against a FibQuant-quantized database.
6
7use std::collections::HashSet;
8use std::time::Instant;
9
10use serde::{Deserialize, Serialize};
11
12use crate::{metrics, FibCodeV1, FibQuantizer, Result};
13
14/// Schema version stamp for benchmark receipts.
15pub const BENCHMARK_SCHEMA: &str = "fib_benchmark_v1";
16
17/// A corpus of database vectors, queries, and ground-truth top-K labels.
18///
19/// The `ground_truth_topk` must have the same length as `queries`, and each
20/// entry must contain the indices of the `k` most similar database vectors
21/// (by exact cosine similarity) for the corresponding query.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct FibBenchmarkCorpus {
24    /// Database vectors to be encoded.
25    pub vectors: Vec<Vec<f32>>,
26    /// Query vectors.
27    pub queries: Vec<Vec<f32>>,
28    /// For each query, the indices of the true top-K most similar database vectors.
29    pub ground_truth_topk: Vec<Vec<usize>>,
30    /// K value for recall@K.
31    pub k: usize,
32    /// Human-readable corpus identifier.
33    pub label: String,
34}
35
36impl FibBenchmarkCorpus {
37    /// Compute exact cosine similarity between a query and all database vectors,
38    /// returning the top-K indices sorted by descending similarity.
39    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        // Sort by descending similarity, tie-break by index for determinism.
46        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/// Typed benchmark receipt produced by [`run_benchmark`].
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct FibBenchmarkReceiptV1 {
58    /// Schema version stamp.
59    pub schema_version: String,
60    /// Corpus label.
61    pub corpus_label: String,
62    /// Profile digest (BLAKE3 hex).
63    pub profile_digest: String,
64    /// Codebook digest (BLAKE3 hex).
65    pub codebook_digest: String,
66    /// Rotation digest (BLAKE3 hex).
67    pub rotation_digest: String,
68    /// Number of database vectors encoded.
69    pub vector_count: usize,
70    /// Number of queries evaluated.
71    pub query_count: usize,
72    /// K value used for recall@K and nDCG@K.
73    pub k: usize,
74    /// Compression ratio (total original bytes / total encoded bytes).
75    pub compression_ratio: f64,
76    /// Mean cosine similarity between original and reconstructed vectors.
77    pub mean_cosine_similarity: f64,
78    /// Mean reconstruction MSE across all database vectors.
79    pub mean_mse: f64,
80    /// Recall@K averaged over all queries.
81    pub recall_at_k: f64,
82    /// nDCG@K averaged over all queries.
83    pub ndcg_at_k: f64,
84    /// Total encode time in microseconds.
85    pub encode_elapsed_micros: u128,
86    /// Total decode time in microseconds.
87    pub decode_elapsed_micros: u128,
88    /// Free-form notes about the benchmark run.
89    pub notes: Vec<String>,
90}
91
92/// Recall@K: fraction of true top-K items found in the approximate top-K.
93///
94/// Returns 0.0 if `k` is 0 or either slice is empty.
95/// Returns the ratio of intersection size to K.
96pub 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
114/// nDCG@K: normalized Discounted Cumulative Gain at K.
115///
116/// Given the exact relevance scores (sorted by descending relevance) and the
117/// approximate ranking (indices into the database), compute nDCG@K using the
118/// standard formula:
119///
120/// ```text
121/// DCG@K = sum_{i=1}^{K} rel_i / log2(i + 1)
122/// nDCG@K = DCG_approx / DCG_ideal
123/// ```
124///
125/// `exact_scores` should be the true relevance scores of the approximate
126/// ranking's items (i.e., the cosine similarities of the approx-ranked items
127/// to the query). `approx_ranking` is the list of database indices in the
128/// approximate order. `k` caps the evaluation depth.
129///
130/// Returns 0.0 if `k` is 0 or inputs are empty.
131pub 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    // Standard ANN evaluation nDCG@K:
141    //   exact_scores[i] is the relevance (e.g. cosine similarity) of the
142    //   i-th item in the approximate ranking. approx_ranking[i] is the
143    //   database index of that item. DCG is computed over the approximate
144    //   ordering; ideal DCG is the same scores sorted descending.
145    //
146    // DCG@K  = sum_{i=0}^{K-1} rel_i / log2(i + 2)
147    // nDCG@K = DCG_approx / DCG_ideal
148
149    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
173/// Run a full benchmark on a corpus with a FibQuantizer.
174///
175/// Encodes all corpus vectors, decodes them, measures reconstruction quality,
176/// then evaluates recall@K and nDCG@K for each query against the ground truth.
177/// Timing covers the encode and decode phases separately.
178pub 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    // ── Encode phase ──
209    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    // ── Decode phase ──
218    let decode_start = Instant::now();
219    let decoded_vectors = quantizer.decode_batch_fast(&codes)?;
220    let decode_elapsed = decode_start.elapsed();
221
222    // ── Compression ratio ──
223    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    // ── Reconstruction quality ──
232    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    // ── Recall@K and nDCG@K ──
243    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        // Approximate top-K: compute cosine similarity between query and
249        // all decoded (reconstructed) vectors, then take the top-K.
250        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@K
267        recall_sum += recall_at_k(gt, &approx_topk, corpus.k);
268
269        // nDCG@K: build the relevance scores for the approximate ranking.
270        // For each item in approx_topk, its relevance is the exact cosine
271        // similarity between the query and the *original* database vector.
272        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    // ── Digests ──
286    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// ─────────────────────────── Tests ───────────────────────────
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn make_tiny_corpus() -> FibBenchmarkCorpus {
317        // 6 vectors of dim 8
318        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        // Compute exact top-3 for each query
331        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        // Order shouldn't matter
371        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        // 2 out of 3 hits
387        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        // k=0 → 0
393        assert_eq!(recall_at_k(&[0, 1], &[0, 1], 0), 0.0);
394        // empty → 0
395        assert_eq!(recall_at_k(&[], &[0], 3), 0.0);
396        assert_eq!(recall_at_k(&[0], &[], 3), 0.0);
397        // k > len → cap at min
398        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        // If approx ranking matches ideal ordering, nDCG = 1.0
406        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        // Worst case: reversed order. nDCG should be < 1.0 but > 0.
419        let exact_scores = vec![0.7, 0.8, 0.9]; // relevance in approx order
420        let approx_ranking = vec![2usize, 1, 0]; // but the ranking is reversed
421                                                 // Actually, exact_scores[i] is the relevance of item approx_ranking[i].
422                                                 // So in the approx order, item 2 has relevance 0.7 (worst),
423                                                 // item 1 has 0.8, item 0 has 0.9 (best). The approx ranking puts
424                                                 // worst first. DCG_approx uses exact_scores in order: 0.7, 0.8, 0.9.
425                                                 // DCG_ideal sorts desc: 0.9, 0.8, 0.7.
426        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        // Check basic fields
448        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        // Receipt fields should be finite
455        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        // Timing should be non-negative
484        assert!(receipt.encode_elapsed_micros > 0 || receipt.decode_elapsed_micros > 0);
485
486        // Digests should be non-empty hex strings
487        assert!(!receipt.profile_digest.is_empty());
488        assert!(!receipt.codebook_digest.is_empty());
489        assert!(!receipt.rotation_digest.is_empty());
490
491        // Notes should be empty by default
492        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}