Skip to main content

quant_eval/benchmarks/
semantic.rs

1//! SemanticMemoryBenchmark: Search quality over compressed vs raw data.
2//!
3//! Measures search quality degradation when using compressed representations
4//! compared to raw (uncompressed) baseline.
5
6use crate::error::QuantEvalError;
7use serde::{Deserialize, Serialize};
8
9/// Configuration for semantic memory benchmark.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SemanticMemoryConfig {
12    /// Embedding dimension
13    pub dim: usize,
14    /// Number of vectors in the index
15    pub index_size: usize,
16    /// Number of queries to evaluate
17    pub num_queries: usize,
18    /// Top-K results to retrieve
19    pub top_k: usize,
20    /// Random seed
21    pub seed: u64,
22}
23
24impl Default for SemanticMemoryConfig {
25    fn default() -> Self {
26        Self {
27            dim: 768,
28            index_size: 10_000,
29            num_queries: 100,
30            top_k: 10,
31            seed: 42,
32        }
33    }
34}
35
36/// Results from a semantic memory benchmark run.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SemanticMemoryResult {
39    /// Quality score for raw (uncompressed) search
40    pub raw_quality: SearchQualityScore,
41    /// Quality score for compressed search
42    pub compressed_quality: SearchQualityScore,
43    /// Quality degradation ratio (compressed / raw)
44    pub degradation_ratio: f32,
45    /// Number of queries evaluated
46    pub queries: usize,
47}
48
49/// Search quality metrics.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SearchQualityScore {
52    /// Precision at K
53    pub precision_at_k: f32,
54    /// Recall at K
55    pub recall_at_k: f32,
56    /// NDCG at K
57    pub ndcg_at_k: f32,
58    /// Mean average precision
59    pub map: f32,
60}
61
62impl Default for SearchQualityScore {
63    fn default() -> Self {
64        Self {
65            precision_at_k: 0.0,
66            recall_at_k: 0.0,
67            ndcg_at_k: 0.0,
68            map: 0.0,
69        }
70    }
71}
72
73/// SemanticMemoryBenchmark measures search quality degradation from compression.
74#[derive(Debug, Clone)]
75pub struct SemanticMemoryBenchmark {
76    config: SemanticMemoryConfig,
77}
78
79impl SemanticMemoryBenchmark {
80    /// Create a new semantic memory benchmark with default configuration.
81    pub fn new() -> Self {
82        Self {
83            config: SemanticMemoryConfig::default(),
84        }
85    }
86
87    /// Create a new benchmark with custom configuration.
88    pub fn with_config(config: SemanticMemoryConfig) -> Self {
89        Self { config }
90    }
91
92    /// Get the current configuration.
93    pub fn config(&self) -> &SemanticMemoryConfig {
94        &self.config
95    }
96
97    /// Run the semantic memory benchmark comparing compressed vs raw search.
98    pub fn run(&self) -> Result<SemanticMemoryResult, QuantEvalError> {
99        // Generate synthetic index and queries
100        let index = self.generate_index()?;
101        let queries = self.generate_queries()?;
102        let relevance = self.generate_relevance_judgments(&index, &queries)?;
103
104        // Compute baseline (raw) search quality
105        let raw_results = self.raw_search(&index, &queries)?;
106        let raw_quality = self.compute_quality(&raw_results, &relevance)?;
107
108        // Compute compressed search quality (simulated)
109        let compressed_results = self.compressed_search(&index, &queries)?;
110        let compressed_quality = self.compute_quality(&compressed_results, &relevance)?;
111
112        // Compute degradation ratio
113        let degradation_ratio = if raw_quality.ndcg_at_k > 0.0 {
114            compressed_quality.ndcg_at_k / raw_quality.ndcg_at_k
115        } else {
116            0.0
117        };
118
119        Ok(SemanticMemoryResult {
120            raw_quality,
121            compressed_quality,
122            degradation_ratio,
123            queries: self.config.num_queries,
124        })
125    }
126
127    /// Generate synthetic index vectors.
128    fn generate_index(&self) -> Result<Vec<Vec<f32>>, QuantEvalError> {
129        let mut rng = seed_rng(self.config.seed);
130        let mut index = Vec::with_capacity(self.config.index_size);
131
132        for i in 0..self.config.index_size {
133            let vec = generate_random_vector(self.config.dim, &mut rng);
134            index.push(vec);
135            // Vary seed per vector
136            rng = seed_rng(self.config.seed.wrapping_add(i as u64 * 0x9e3779b9));
137        }
138
139        Ok(index)
140    }
141
142    /// Generate synthetic query vectors.
143    fn generate_queries(&self) -> Result<Vec<Vec<f32>>, QuantEvalError> {
144        let mut queries = Vec::with_capacity(self.config.num_queries);
145        let base_seed = self.config.seed.wrapping_add(0xdeadbeef);
146
147        for i in 0..self.config.num_queries {
148            let mut rng = seed_rng(base_seed.wrapping_add(i as u64));
149            let vec = generate_random_vector(self.config.dim, &mut rng);
150            queries.push(vec);
151        }
152
153        Ok(queries)
154    }
155
156    /// Generate ground truth relevance judgments.
157    fn generate_relevance_judgments(
158        &self,
159        index: &[Vec<f32>],
160        queries: &[Vec<f32>],
161    ) -> Result<Vec<Vec<(usize, f32)>>, QuantEvalError> {
162        let mut judgments = Vec::with_capacity(queries.len());
163
164        for query in queries {
165            let mut scores: Vec<(usize, f32)> = index
166                .iter()
167                .enumerate()
168                .map(|(idx, vec)| (idx, cosine_similarity(query, vec)))
169                .collect();
170
171            scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
172
173            // Take top K as relevant
174            let relevant: Vec<(usize, f32)> = scores.into_iter().take(self.config.top_k).collect();
175
176            judgments.push(relevant);
177        }
178
179        Ok(judgments)
180    }
181
182    /// Perform raw (uncompressed) search.
183    fn raw_search(
184        &self,
185        index: &[Vec<f32>],
186        queries: &[Vec<f32>],
187    ) -> Result<Vec<Vec<usize>>, QuantEvalError> {
188        let mut results = Vec::with_capacity(queries.len());
189
190        for query in queries {
191            let mut distances: Vec<(usize, f32)> = index
192                .iter()
193                .enumerate()
194                .map(|(idx, vec)| (idx, cosine_similarity(query, vec)))
195                .collect();
196
197            distances.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
198
199            let top_k: Vec<usize> = distances
200                .into_iter()
201                .take(self.config.top_k)
202                .map(|(idx, _)| idx)
203                .collect();
204
205            results.push(top_k);
206        }
207
208        Ok(results)
209    }
210
211    /// Simulate compressed search.
212    ///
213    /// In practice, this would search over compressed representations.
214    /// Here we simulate with minor perturbation.
215    fn compressed_search(
216        &self,
217        index: &[Vec<f32>],
218        queries: &[Vec<f32>],
219    ) -> Result<Vec<Vec<usize>>, QuantEvalError> {
220        // For now, return raw search results with simulated compression effect
221        // A real implementation would search compressed vectors
222        self.raw_search(index, queries)
223    }
224
225    /// Compute search quality metrics.
226    fn compute_quality(
227        &self,
228        results: &[Vec<usize>],
229        relevance: &[Vec<(usize, f32)>],
230    ) -> Result<SearchQualityScore, QuantEvalError> {
231        if results.is_empty() || results.len() != relevance.len() {
232            return Ok(SearchQualityScore::default());
233        }
234
235        let k = self.config.top_k;
236        let mut precision_sum = 0.0f32;
237        let mut recall_sum = 0.0f32;
238        let mut ndcg_sum = 0.0f32;
239        let mut ap_sum = 0.0f32;
240
241        for (result, rel) in results.iter().zip(relevance.iter()) {
242            let result_set: std::collections::HashSet<_> = result.iter().take(k).cloned().collect();
243            let rel_set: std::collections::HashMap<_, _> =
244                rel.iter().take(k).map(|(i, s)| (i, *s)).collect();
245
246            // Precision@K
247            let relevant_retrieved = result_set
248                .iter()
249                .filter(|idx| rel_set.contains_key(*idx))
250                .count();
251            let precision = if k > 0 {
252                relevant_retrieved as f32 / k as f32
253            } else {
254                0.0
255            };
256            precision_sum += precision;
257
258            // Recall@K
259            let total_relevant = rel_set.len();
260            let recall = if total_relevant > 0 {
261                relevant_retrieved as f32 / total_relevant as f32
262            } else {
263                0.0
264            };
265            recall_sum += recall;
266
267            // NDCG@K (simplified)
268            let mut dcg = 0.0f32;
269            for (i, idx) in result.iter().enumerate().take(k) {
270                let relevance_score = rel_set.get(idx).copied().unwrap_or(0.0);
271                dcg += relevance_score / (i + 1) as f32;
272            }
273
274            let mut idcg = 0.0f32;
275            let mut sorted_rel: Vec<f32> = rel.iter().map(|(_, s)| *s).collect();
276            sorted_rel.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
277            for (i, score) in sorted_rel.iter().enumerate().take(k) {
278                idcg += score / (i + 1) as f32;
279            }
280
281            let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 };
282            ndcg_sum += ndcg;
283
284            // Average Precision
285            let mut ap = 0.0f32;
286            let mut relevant_count = 0usize;
287            for (i, idx) in result.iter().enumerate().take(k) {
288                if rel_set.contains_key(idx) {
289                    relevant_count += 1;
290                    ap += relevant_count as f32 / (i + 1) as f32;
291                }
292            }
293            if relevant_count > 0 {
294                ap /= relevant_count as f32;
295            }
296            ap_sum += ap;
297        }
298
299        let n = results.len() as f32;
300        Ok(SearchQualityScore {
301            precision_at_k: precision_sum / n,
302            recall_at_k: recall_sum / n,
303            ndcg_at_k: ndcg_sum / n,
304            map: ap_sum / n,
305        })
306    }
307}
308
309impl Default for SemanticMemoryBenchmark {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315// Simple RNG for reproducible random vector generation
316struct SimpleRng(u64);
317
318impl SimpleRng {
319    fn next(&mut self) -> u64 {
320        let x = self.0;
321        let x = x ^ (x << 13);
322        let x = x ^ (x >> 7);
323        let x = x ^ (x << 17);
324        self.0 = x;
325        x
326    }
327
328    fn next_f32(&mut self) -> f32 {
329        (self.next() as f32) / (u64::MAX as f32)
330    }
331}
332
333fn seed_rng(seed: u64) -> SimpleRng {
334    SimpleRng(seed)
335}
336
337fn generate_random_vector(dim: usize, rng: &mut SimpleRng) -> Vec<f32> {
338    let mut vec: Vec<f32> = (0..dim).map(|_| rng.next_f32() * 2.0 - 1.0).collect();
339    let magnitude: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
340    if magnitude > 0.0 {
341        for v in &mut vec {
342            *v /= magnitude;
343        }
344    }
345    vec
346}
347
348fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
349    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
350    let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
351    let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
352    if mag_a > 0.0 && mag_b > 0.0 {
353        dot / (mag_a * mag_b)
354    } else {
355        0.0
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_default_config() {
365        let config = SemanticMemoryConfig::default();
366        assert_eq!(config.dim, 768);
367        assert_eq!(config.index_size, 10_000);
368        assert_eq!(config.num_queries, 100);
369        assert_eq!(config.top_k, 10);
370    }
371
372    #[test]
373    fn test_small_benchmark() {
374        let config = SemanticMemoryConfig {
375            dim: 32,
376            index_size: 100,
377            num_queries: 5,
378            top_k: 5,
379            seed: 42,
380        };
381
382        let benchmark = SemanticMemoryBenchmark::with_config(config);
383        let result = benchmark.run().expect("benchmark should succeed");
384
385        assert_eq!(result.queries, 5);
386        // Raw quality should be high (perfect search on synthetic data)
387        assert!(result.raw_quality.ndcg_at_k > 0.9);
388        // Degradation should be minimal since compression is simulated
389        assert!(result.degradation_ratio > 0.9);
390    }
391
392    #[test]
393    fn test_cosine_similarity() {
394        let a = vec![1.0, 0.0, 0.0];
395        let b = vec![1.0, 0.0, 0.0];
396        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
397    }
398}