Skip to main content

receipt_bench/
suite.rs

1//! Benchmark suite — runs and records benchmarks with provenance receipts.
2
3use std::collections::HashMap;
4use std::time::Instant;
5
6use crate::error::BenchError;
7use crate::receipt::BenchmarkResult;
8use crate::{BenchmarkReceipt, MachineFingerprint};
9
10/// A suite of benchmarks that can be run and recorded as a receipt.
11pub struct BenchmarkSuite {
12    commit_hash: Option<String>,
13    machine_fingerprint: MachineFingerprint,
14    benchmarks: HashMap<String, Box<dyn Fn() -> Result<BenchmarkResult, String> + Send + Sync>>,
15}
16
17impl BenchmarkSuite {
18    /// Create a new benchmark suite with auto-detected commit hash and machine fingerprint.
19    pub fn new() -> Self {
20        let commit_hash = detect_git_commit_hash().ok();
21        Self {
22            commit_hash,
23            machine_fingerprint: MachineFingerprint::generate(),
24            benchmarks: HashMap::new(),
25        }
26    }
27
28    /// Create a new suite with explicit commit hash.
29    pub fn with_commit_hash(commit_hash: impl Into<String>) -> Self {
30        Self {
31            commit_hash: Some(commit_hash.into()),
32            machine_fingerprint: MachineFingerprint::generate(),
33            benchmarks: HashMap::new(),
34        }
35    }
36
37    /// Register a benchmark with a name and callable.
38    ///
39    /// The callable should perform the actual benchmark work and return
40    /// a result with the measured elapsed time and iteration count.
41    pub fn register(
42        &mut self,
43        name: impl Into<String>,
44        bench: impl Fn() -> Result<BenchmarkResult, String> + Send + Sync + 'static,
45    ) {
46        self.benchmarks.insert(name.into(), Box::new(bench));
47    }
48
49    /// Run all registered benchmarks and return a receipt.
50    pub fn run(&self) -> Result<BenchmarkReceipt, BenchError> {
51        let commit_hash = self
52            .commit_hash
53            .clone()
54            .unwrap_or_else(|| "unknown".to_string());
55
56        let mut receipt = BenchmarkReceipt::new(commit_hash, self.machine_fingerprint.clone());
57
58        for (name, bench) in &self.benchmarks {
59            match bench() {
60                Ok(result) => {
61                    let mut r = result;
62                    r.name = name.clone();
63                    receipt.add_result(r);
64                }
65                Err(e) => {
66                    receipt.add_result(BenchmarkResult {
67                        name: name.clone(),
68                        iterations: 0,
69                        elapsed_ns: 0,
70                        ns_per_iter: 0,
71                        throughput: None,
72                        error: Some(e),
73                    });
74                }
75            }
76        }
77
78        Ok(receipt)
79    }
80
81    /// Run a specific named benchmark and return just that result.
82    pub fn run_one(&self, name: &str) -> Result<BenchmarkResult, BenchError> {
83        let bench = self
84            .benchmarks
85            .get(name)
86            .ok_or_else(|| BenchError::Execution(format!("No benchmark named '{}'", name)))?;
87
88        let result = bench()?;
89        Ok(result)
90    }
91
92    /// Get the list of registered benchmark names.
93    pub fn benchmark_names(&self) -> Vec<&String> {
94        self.benchmarks.keys().collect()
95    }
96}
97
98impl Default for BenchmarkSuite {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104/// Detect the current git commit hash.
105fn detect_git_commit_hash() -> Result<String, BenchError> {
106    use std::process::Command;
107
108    let output = Command::new("git")
109        .args(["rev-parse", "HEAD"])
110        .output()
111        .map_err(|e| BenchError::Git(e.to_string()))?;
112
113    if !output.status.success() {
114        return Err(BenchError::NoGitRepo);
115    }
116
117    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
118    if hash.is_empty() {
119        return Err(BenchError::NoGitRepo);
120    }
121
122    Ok(hash)
123}
124
125// -----------------------------------------------------------------------------
126// Built-in benchmark implementations
127// -----------------------------------------------------------------------------
128
129/// Semantic search benchmark — tests vector similarity search on synthetic data.
130///
131/// Uses a simple flat index with cosine similarity to evaluate query performance.
132pub mod semantic_search {
133    use super::*;
134    use crate::receipt::BenchmarkResult;
135
136    /// Run semantic search benchmark with synthetic data.
137    ///
138    /// Creates `dimensions` vectors of `vector_size` dimension, then performs
139    /// `iterations` queries and measures latency.
140    #[allow(dead_code)]
141    pub fn run_benchmark(
142        dimensions: usize,
143        vector_size: usize,
144        iterations: u64,
145    ) -> Result<BenchmarkResult, String> {
146        // Generate synthetic data
147        let vectors: Vec<Vec<f32>> = (0..dimensions)
148            .map(|_| (0..vector_size).map(|_| rand_f32()).collect())
149            .collect();
150
151        let query = (0..vector_size).map(|_| rand_f32()).collect::<Vec<f32>>();
152
153        let start = Instant::now();
154        for _ in 0..iterations {
155            let _ = find_top_k(&vectors, &query, 10);
156        }
157        let elapsed = start.elapsed();
158
159        Ok(BenchmarkResult {
160            name: "semantic_search".to_string(),
161            iterations,
162            elapsed_ns: elapsed.as_nanos() as u64,
163            ns_per_iter: elapsed.as_nanos() as u64 / iterations.max(1),
164            throughput: Some(iterations as f64 / elapsed.as_secs_f64()),
165            error: None,
166        })
167    }
168
169    fn find_top_k(vectors: &[Vec<f32>], query: &[f32], k: usize) -> Vec<(usize, f32)> {
170        let mut scores: Vec<(usize, f32)> = vectors
171            .iter()
172            .enumerate()
173            .map(|(i, v)| (i, cosine_sim(query, v)))
174            .collect();
175        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
176        scores.truncate(k);
177        scores
178    }
179
180    fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
181        let dot = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<f32>();
182        let mag_a = a.iter().map(|x| x * x).sum::<f32>().sqrt();
183        let mag_b = b.iter().map(|x| x * x).sum::<f32>().sqrt();
184        if mag_a == 0.0 || mag_b == 0.0 {
185            0.0
186        } else {
187            dot / (mag_a * mag_b)
188        }
189    }
190
191    fn rand_f32() -> f32 {
192        use std::time::{SystemTime, UNIX_EPOCH};
193        let seed = SystemTime::now()
194            .duration_since(UNIX_EPOCH)
195            .unwrap()
196            .subsec_nanos();
197        // Simple deterministic pseudo-random for reproducibility
198        let x = seed.wrapping_mul(1103515245).wrapping_add(12345);
199        (x as f32 / u32::MAX as f32) * 2.0 - 1.0
200    }
201}
202
203/// Compression round-trip benchmark — tests encode/decode cycle with zlib.
204pub mod compression {
205    use super::*;
206    use crate::receipt::BenchmarkResult;
207    use std::io::{Read, Write};
208
209    /// Run compression benchmark using flate2 (zlib) with synthetic data.
210    ///
211    /// Creates `data_size` bytes of pseudo-random data, compresses it,
212    /// then decompresses to verify integrity. Measures encode/decode cycle time.
213    #[allow(dead_code)]
214    pub fn run_benchmark(data_size: usize, iterations: u64) -> Result<BenchmarkResult, String> {
215        let data = generate_data(data_size);
216
217        let mut total_encode_ns = 0u64;
218        let mut total_decode_ns = 0u64;
219
220        for _ in 0..iterations {
221            // Encode
222            let start_encode = Instant::now();
223            let encoded = encode_zlib(&data).map_err(|e| e.to_string())?;
224            let encode_ns = start_encode.elapsed().as_nanos() as u64;
225            total_encode_ns += encode_ns;
226
227            // Decode
228            let start_decode = Instant::now();
229            let decoded = decode_zlib(&encoded).map_err(|e| e.to_string())?;
230            let decode_ns = start_decode.elapsed().as_nanos() as u64;
231            total_decode_ns += decode_ns;
232
233            // Verify
234            if decoded != data {
235                return Err("Decompressed data mismatch".to_string());
236            }
237        }
238
239        let elapsed_ns = total_encode_ns.saturating_add(total_decode_ns);
240        let ns_per_iter = elapsed_ns / iterations.max(1);
241
242        Ok(BenchmarkResult {
243            name: "compression_round_trip".to_string(),
244            iterations,
245            elapsed_ns,
246            ns_per_iter,
247            throughput: Some((iterations as f64 * data_size as f64) / (elapsed_ns as f64 / 1e9)),
248            error: None,
249        })
250    }
251
252    fn generate_data(size: usize) -> Vec<u8> {
253        use std::time::{SystemTime, UNIX_EPOCH};
254        let seed = SystemTime::now()
255            .duration_since(UNIX_EPOCH)
256            .unwrap()
257            .subsec_nanos();
258
259        (0..size)
260            .map(|i| {
261                ((seed
262                    .wrapping_mul((i as u32).wrapping_add(1))
263                    .wrapping_add(i as u32))
264                    % 256) as u8
265            })
266            .collect()
267    }
268
269    fn encode_zlib(data: &[u8]) -> Result<Vec<u8>, std::io::Error> {
270        let mut encoder = flate2::write::ZlibEncoder::new(
271            Vec::with_capacity(data.len()),
272            flate2::Compression::default(),
273        );
274        encoder.write_all(data)?;
275        encoder.finish()
276    }
277
278    fn decode_zlib(data: &[u8]) -> Result<Vec<u8>, std::io::Error> {
279        use flate2::read::ZlibDecoder;
280        let mut decoder = ZlibDecoder::new(data);
281        let mut result = Vec::new();
282        decoder.read_to_end(&mut result)?;
283        Ok(result)
284    }
285}
286
287/// Memory lookup benchmark — tests random key/value access patterns.
288pub mod memory_lookup {
289    use super::*;
290    use crate::receipt::BenchmarkResult;
291
292    /// Run memory lookup benchmark with random key access.
293    ///
294    /// Creates `num_entries` key-value pairs (keys are strings, values are u64),
295    /// then performs `iterations` random lookups. Measures lookup latency.
296    #[allow(dead_code)]
297    pub fn run_benchmark(num_entries: usize, iterations: u64) -> Result<BenchmarkResult, String> {
298        let entries: Vec<(String, u64)> = (0..num_entries)
299            .map(|i| (format!("key_{:08}", i), i as u64))
300            .collect();
301
302        // Generate random indices
303        let indices: Vec<usize> = {
304            use std::time::{SystemTime, UNIX_EPOCH};
305            let seed = SystemTime::now()
306                .duration_since(UNIX_EPOCH)
307                .unwrap()
308                .subsec_nanos() as u64;
309            (0..iterations)
310                .map(|i| seed.wrapping_mul(i.wrapping_add(1)) as usize % num_entries)
311                .collect()
312        };
313
314        let start = Instant::now();
315        for &idx in &indices {
316            let (ref key, _) = entries[idx];
317            // Simulate a lookup
318            let _ = entries.iter().find(|(k, _)| k == key);
319        }
320        let elapsed = start.elapsed();
321
322        Ok(BenchmarkResult {
323            name: "memory_lookups".to_string(),
324            iterations,
325            elapsed_ns: elapsed.as_nanos() as u64,
326            ns_per_iter: elapsed.as_nanos() as u64 / iterations.max(1),
327            throughput: Some(iterations as f64 / elapsed.as_secs_f64()),
328            error: None,
329        })
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::receipt::BenchmarkResult;
337
338    #[test]
339    fn test_suite_creation() {
340        let suite = BenchmarkSuite::new();
341        assert!(suite.benchmark_names().is_empty());
342    }
343
344    #[test]
345    fn test_suite_register() {
346        let mut suite = BenchmarkSuite::new();
347        suite.register("dummy", || {
348            Ok(BenchmarkResult {
349                name: "dummy".to_string(),
350                iterations: 10,
351                elapsed_ns: 1000,
352                ns_per_iter: 100,
353                throughput: None,
354                error: None,
355            })
356        });
357        assert_eq!(suite.benchmark_names().len(), 1);
358    }
359
360    #[test]
361    fn test_suite_run() {
362        let mut suite = BenchmarkSuite::with_commit_hash("test123");
363        suite.register("dummy", || {
364            Ok(BenchmarkResult {
365                name: "dummy".to_string(),
366                iterations: 10,
367                elapsed_ns: 1000,
368                ns_per_iter: 100,
369                throughput: None,
370                error: None,
371            })
372        });
373        let receipt = suite.run().unwrap();
374        assert_eq!(receipt.commit_hash, "test123");
375        assert_eq!(receipt.results.len(), 1);
376    }
377
378    #[test]
379    fn test_semantic_search_benchmark() {
380        let result = semantic_search::run_benchmark(100, 64, 100).unwrap();
381        assert_eq!(result.name, "semantic_search");
382        assert_eq!(result.iterations, 100);
383        assert!(result.elapsed_ns > 0);
384        assert!(result.ns_per_iter > 0);
385    }
386
387    #[test]
388    fn test_memory_lookup_benchmark() {
389        let result = memory_lookup::run_benchmark(1000, 100).unwrap();
390        assert_eq!(result.name, "memory_lookups");
391        assert_eq!(result.iterations, 100);
392        assert!(result.elapsed_ns > 0);
393    }
394}