Skip to main content

quant_eval/benchmarks/
compression.rs

1//! CompressionBenchmark: Accuracy metrics for compression evaluation.
2//!
3//! Measures cosine similarity, recall@K, and MRR across target corpora.
4
5use crate::error::QuantEvalError;
6use serde::{Deserialize, Serialize};
7
8/// Configuration for a compression benchmark run.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CompressionBenchmarkConfig {
11    /// Target corpus dimensions
12    pub dim: usize,
13    /// Number of vectors in the database
14    pub db_size: usize,
15    /// Number of query vectors
16    pub queries: usize,
17    /// Random seed for reproducibility
18    pub seed: u64,
19    /// Top-K for recall computation
20    pub top_k: usize,
21    /// Number of iterations for timing
22    pub iterations: u64,
23}
24
25impl Default for CompressionBenchmarkConfig {
26    fn default() -> Self {
27        Self {
28            dim: 768,
29            db_size: 10_000,
30            queries: 100,
31            seed: 42,
32            top_k: 10,
33            iterations: 100,
34        }
35    }
36}
37
38/// Results from a compression benchmark run.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CompressionBenchmarkResult {
41    /// Cosine similarity statistics
42    pub cosine_similarity: CosineSimilarityStats,
43    /// Recall@K score
44    pub recall_at_k: f32,
45    /// Mean Reciprocal Rank
46    pub mrr: f32,
47    /// Number of queries processed
48    pub queries: usize,
49    /// Database size
50    pub db_size: usize,
51    /// Top-K value used
52    pub top_k: usize,
53}
54
55/// Cosine similarity statistics.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CosineSimilarityStats {
58    /// Mean cosine similarity
59    pub mean: f32,
60    /// Median cosine similarity
61    pub median: f32,
62    /// Min cosine similarity
63    pub min: f32,
64    /// Max cosine similarity
65    pub max: f32,
66    /// Standard deviation
67    pub std_dev: f32,
68}
69
70/// CompressionBenchmark measures accuracy metrics for quantization codecs.
71#[derive(Debug, Clone)]
72pub struct CompressionBenchmark {
73    config: CompressionBenchmarkConfig,
74}
75
76impl CompressionBenchmark {
77    /// Create a new benchmark with default configuration.
78    pub fn new() -> Self {
79        Self {
80            config: CompressionBenchmarkConfig::default(),
81        }
82    }
83
84    /// Create a new benchmark with custom configuration.
85    pub fn with_config(config: CompressionBenchmarkConfig) -> Self {
86        Self { config }
87    }
88
89    /// Get the current configuration.
90    pub fn config(&self) -> &CompressionBenchmarkConfig {
91        &self.config
92    }
93
94    /// Run the benchmark and return results.
95    ///
96    /// This generates synthetic vectors and measures compression accuracy
97    /// using cosine similarity, recall@K, and MRR metrics.
98    pub fn run(&self) -> Result<CompressionBenchmarkResult, QuantEvalError> {
99        // Generate synthetic corpus
100        let corpus = self.generate_corpus()?;
101        let queries = self.generate_queries()?;
102
103        // Compute ground truth nearest neighbors (using raw vectors)
104        let exact_results = self.compute_exact_neighbors(&corpus, &queries)?;
105
106        // For now, simulate compression by running the benchmark
107        // In practice, this would use actual codec implementations
108        let compressed_results = self.simulate_compression(&exact_results)?;
109
110        // Calculate metrics
111        let cosine_stats = self.compute_cosine_similarity(&exact_results, &compressed_results)?;
112        let recall = self.compute_recall_at_k(&exact_results, &compressed_results)?;
113        let mrr = self.compute_mrr(&exact_results, &compressed_results)?;
114
115        Ok(CompressionBenchmarkResult {
116            cosine_similarity: cosine_stats,
117            recall_at_k: recall,
118            mrr,
119            queries: self.config.queries,
120            db_size: self.config.db_size,
121            top_k: self.config.top_k,
122        })
123    }
124
125    /// Generate a synthetic corpus of vectors.
126    fn generate_corpus(&self) -> Result<Vec<Vec<f32>>, QuantEvalError> {
127        use std::collections::hash_map::DefaultHasher;
128        use std::hash::{Hash, Hasher};
129
130        let mut corpus = Vec::with_capacity(self.config.db_size);
131        let mut hasher = DefaultHasher::new();
132        self.config.seed.hash(&mut hasher);
133
134        for i in 0..self.config.db_size {
135            let mut rng = seed_rng(hasher.finish().wrapping_add(i as u64));
136            let vec = generate_random_vector(self.config.dim, &mut rng);
137            corpus.push(vec);
138        }
139
140        Ok(corpus)
141    }
142
143    /// Generate synthetic query vectors.
144    fn generate_queries(&self) -> Result<Vec<Vec<f32>>, QuantEvalError> {
145        let mut queries = Vec::with_capacity(self.config.queries);
146        let seed = self.config.seed.wrapping_add(0xdeadbeef);
147
148        for i in 0..self.config.queries {
149            let mut rng = seed_rng(seed.wrapping_add(i as u64));
150            let vec = generate_random_vector(self.config.dim, &mut rng);
151            queries.push(vec);
152        }
153
154        Ok(queries)
155    }
156
157    /// Compute exact nearest neighbors using cosine similarity.
158    fn compute_exact_neighbors(
159        &self,
160        corpus: &[Vec<f32>],
161        queries: &[Vec<f32>],
162    ) -> Result<Vec<Vec<usize>>, QuantEvalError> {
163        let mut results = Vec::with_capacity(queries.len());
164
165        for query in queries {
166            let mut distances: Vec<(usize, f32)> = corpus
167                .iter()
168                .enumerate()
169                .map(|(idx, vec)| (idx, cosine_similarity(query, vec)))
170                .collect();
171
172            // Sort by similarity (descending)
173            distances.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
174
175            // Take top K
176            let top_k = distances
177                .into_iter()
178                .take(self.config.top_k)
179                .map(|(idx, _)| idx)
180                .collect();
181
182            results.push(top_k);
183        }
184
185        Ok(results)
186    }
187
188    /// Simulate compression effects on results.
189    ///
190    /// In a real implementation, this would apply actual codec compression.
191    /// For now, we simulate a small amount of noise to model quantization error.
192    fn simulate_compression(
193        &self,
194        exact_results: &[Vec<usize>],
195    ) -> Result<Vec<Vec<usize>>, QuantEvalError> {
196        // Simulated compression preserves most results but may perturb some
197        let mut compressed = Vec::with_capacity(exact_results.len());
198
199        for result in exact_results {
200            // In practice, compression would return actual compressed vectors
201            // and search would happen on compressed representations.
202            // Here we just return the exact results for now.
203            compressed.push(result.clone());
204        }
205
206        Ok(compressed)
207    }
208
209    /// Compute cosine similarity statistics.
210    fn compute_cosine_similarity(
211        &self,
212        exact: &[Vec<usize>],
213        compressed: &[Vec<usize>],
214    ) -> Result<CosineSimilarityStats, QuantEvalError> {
215        // For each query, compare the similarity of retrieved results
216        let mut similarities = Vec::new();
217
218        for (exact_result, compressed_result) in exact.iter().zip(compressed.iter()) {
219            // Compute overlap as a proxy for cosine similarity
220            let exact_set: std::collections::HashSet<_> = exact_result.iter().cloned().collect();
221            let compressed_set: std::collections::HashSet<_> =
222                compressed_result.iter().cloned().collect();
223
224            let intersection = exact_set.intersection(&compressed_set).count();
225            let union = exact_set.union(&compressed_set).count();
226
227            if union > 0 {
228                let jaccard = intersection as f32 / union as f32;
229                // Convert Jaccard to an approximation of cosine similarity
230                // by scaling to [0, 1] range typical for cosine
231                similarities.push((jaccard * 2.0 - 1.0).max(0.0));
232            }
233        }
234
235        if similarities.is_empty() {
236            return Ok(CosineSimilarityStats {
237                mean: 0.0,
238                median: 0.0,
239                min: 0.0,
240                max: 0.0,
241                std_dev: 0.0,
242            });
243        }
244
245        // Sort for median computation
246        similarities.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
247
248        let n = similarities.len();
249        let mean = similarities.iter().sum::<f32>() / n as f32;
250        let median = if n % 2 == 0 {
251            (similarities[n / 2 - 1] + similarities[n / 2]) / 2.0
252        } else {
253            similarities[n / 2]
254        };
255        let min = similarities[0];
256        let max = similarities[n - 1];
257
258        // Compute std dev
259        let variance = similarities.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / n as f32;
260        let std_dev = variance.sqrt();
261
262        Ok(CosineSimilarityStats {
263            mean,
264            median,
265            min,
266            max,
267            std_dev,
268        })
269    }
270
271    /// Compute recall@K metric.
272    fn compute_recall_at_k(
273        &self,
274        exact: &[Vec<usize>],
275        estimated: &[Vec<usize>],
276    ) -> Result<f32, QuantEvalError> {
277        if exact.is_empty() || exact.len() != estimated.len() || self.config.top_k == 0 {
278            return Ok(0.0);
279        }
280
281        let mut hits = 0usize;
282        let mut total = 0usize;
283
284        for (exact_row, estimated_row) in exact.iter().zip(estimated.iter()) {
285            let exact_top = &exact_row[..exact_row.len().min(self.config.top_k)];
286            let estimated_top = &estimated_row[..estimated_row.len().min(self.config.top_k)];
287
288            total += exact_top.len();
289            hits += estimated_top
290                .iter()
291                .filter(|candidate| exact_top.contains(candidate))
292                .count();
293        }
294
295        if total == 0 {
296            Ok(0.0)
297        } else {
298            Ok(hits as f32 / total as f32)
299        }
300    }
301
302    /// Compute Mean Reciprocal Rank.
303    fn compute_mrr(
304        &self,
305        exact: &[Vec<usize>],
306        estimated: &[Vec<usize>],
307    ) -> Result<f32, QuantEvalError> {
308        if exact.is_empty() || exact.len() != estimated.len() {
309            return Ok(0.0);
310        }
311
312        let mut reciprocal_ranks = Vec::new();
313
314        for (exact_row, estimated_row) in exact.iter().zip(estimated.iter()) {
315            // Find the rank of the first correct result
316            let exact_set: std::collections::HashSet<_> = exact_row.iter().cloned().collect();
317
318            for (rank, estimated_idx) in estimated_row.iter().enumerate() {
319                if exact_set.contains(estimated_idx) {
320                    reciprocal_ranks.push(1.0 / (rank + 1) as f32);
321                    break;
322                }
323            }
324        }
325
326        if reciprocal_ranks.is_empty() {
327            Ok(0.0)
328        } else {
329            Ok(reciprocal_ranks.iter().sum::<f32>() / reciprocal_ranks.len() as f32)
330        }
331    }
332}
333
334impl Default for CompressionBenchmark {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340// Simple RNG for reproducible random vector generation
341struct SimpleRng(u64);
342
343impl SimpleRng {
344    fn next(&mut self) -> u64 {
345        // xorshift64
346        let x = self.0;
347        let x = x ^ (x << 13);
348        let x = x ^ (x >> 7);
349        let x = x ^ (x << 17);
350        self.0 = x;
351        x
352    }
353
354    fn next_f32(&mut self) -> f32 {
355        // Generate float in [0, 1)
356        (self.next() as f32) / (u64::MAX as f32)
357    }
358}
359
360fn seed_rng(seed: u64) -> SimpleRng {
361    SimpleRng(seed)
362}
363
364fn generate_random_vector(dim: usize, rng: &mut SimpleRng) -> Vec<f32> {
365    // Generate a random unit vector
366    let mut vec: Vec<f32> = (0..dim).map(|_| rng.next_f32() * 2.0 - 1.0).collect();
367
368    // Normalize to unit length
369    let magnitude: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
370    if magnitude > 0.0 {
371        for v in &mut vec {
372            *v /= magnitude;
373        }
374    }
375
376    vec
377}
378
379fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
380    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
381    let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
382    let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
383
384    if mag_a > 0.0 && mag_b > 0.0 {
385        dot / (mag_a * mag_b)
386    } else {
387        0.0
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_default_config() {
397        let config = CompressionBenchmarkConfig::default();
398        assert_eq!(config.dim, 768);
399        assert_eq!(config.db_size, 10_000);
400        assert_eq!(config.queries, 100);
401        assert_eq!(config.top_k, 10);
402    }
403
404    #[test]
405    fn test_small_benchmark() {
406        let config = CompressionBenchmarkConfig {
407            dim: 64,
408            db_size: 100,
409            queries: 10,
410            seed: 42,
411            top_k: 5,
412            iterations: 10,
413        };
414
415        let benchmark = CompressionBenchmark::with_config(config);
416        let result = benchmark.run().expect("benchmark should succeed");
417
418        assert_eq!(result.queries, 10);
419        assert_eq!(result.db_size, 100);
420        assert_eq!(result.top_k, 5);
421    }
422
423    #[test]
424    fn test_cosine_similarity() {
425        let a = vec![1.0, 0.0, 0.0];
426        let b = vec![1.0, 0.0, 0.0];
427        let c = vec![0.0, 1.0, 0.0];
428
429        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
430        assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001);
431    }
432
433    #[test]
434    fn test_recall_at_k() {
435        let exact = vec![vec![0, 1, 2, 3, 4], vec![5, 6, 7, 8, 9]];
436        let estimated = vec![vec![0, 1, 2, 10, 11], vec![5, 6, 12, 13, 14]];
437
438        let recall = CompressionBenchmark::default()
439            .compute_recall_at_k(&exact, &estimated)
440            .expect("should compute");
441
442        // First query: 3/5 hits, second query: 2/5 hits
443        assert!((recall - 0.5).abs() < 0.001);
444    }
445
446    #[test]
447    fn test_mrr() {
448        let exact = vec![vec![0, 1, 2], vec![5, 6, 7]];
449        let estimated = vec![vec![10, 0, 2], vec![5, 20, 30]];
450
451        let mrr = CompressionBenchmark::default()
452            .compute_mrr(&exact, &estimated)
453            .expect("should compute");
454
455        // First query: 0 at position 1, so RR = 1/2 = 0.5
456        // Second query: 5 at position 0, so RR = 1/1 = 1.0
457        // MRR = (0.5 + 1.0) / 2 = 0.75
458        assert!((mrr - 0.75).abs() < 0.001);
459    }
460}