Skip to main content

quant_eval/
receipt.rs

1//! Benchmark receipt — timestamped provenance record for benchmark runs.
2//!
3//! Results are compatible with `receipt-bench::BenchmarkReceipt`.
4
5use crate::error::QuantEvalError;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10/// A benchmark receipt captures the results and context of a single benchmark run.
11///
12/// Receipts are keyed to:
13/// - A commit hash (git SHA)
14/// - A machine fingerprint
15/// - A timestamp (UTC)
16///
17/// This enables reproducible diffs between runs across different machines.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BenchmarkReceipt {
20    /// Timestamp of the benchmark run (UTC)
21    pub timestamp: DateTime<Utc>,
22
23    /// Git commit SHA of the code being benchmarked
24    pub commit_hash: String,
25
26    /// Machine fingerprint (hostname + username + arch + OS + cpu count + machine-id)
27    pub machine_fingerprint: String,
28
29    /// Benchmark results indexed by name
30    pub results: Vec<BenchmarkResult>,
31
32    /// Optional note about the run environment
33    pub note: Option<String>,
34}
35
36/// A single benchmark result within a receipt.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BenchmarkResult {
39    /// Name of the benchmark (e.g., "semantic_search", "compression_round_trip")
40    pub name: String,
41
42    /// Number of iterations performed
43    pub iterations: u64,
44
45    /// Total elapsed time in nanoseconds
46    pub elapsed_ns: u64,
47
48    /// Computed nanoseconds per iteration
49    pub ns_per_iter: u64,
50
51    /// Optional throughput metric (items per second)
52    pub throughput: Option<f64>,
53
54    /// Optional error message if benchmark failed
55    pub error: Option<String>,
56}
57
58/// Comparison result between two benchmark receipts.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ReceiptDiff {
61    /// Receipt hash of the baseline (first) receipt
62    pub baseline_hash: String,
63
64    /// Receipt hash of the target (second) receipt
65    pub target_hash: String,
66
67    /// Timestamp of the diff computation
68    pub computed_at: DateTime<Utc>,
69
70    /// Per-benchmark differences
71    pub benchmark_diffs: Vec<BenchmarkDiff>,
72}
73
74/// Individual benchmark difference.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct BenchmarkDiff {
77    pub name: String,
78    pub baseline_ns_per_iter: u64,
79    pub target_ns_per_iter: u64,
80    /// Difference in ns per iteration (target - baseline)
81    pub diff_ns: i64,
82    /// Percentage change (positive = slower, negative = faster)
83    pub percent_change: f64,
84}
85
86impl BenchmarkReceipt {
87    /// Create a new receipt with the current timestamp.
88    pub fn new(commit_hash: String, machine_fingerprint: String) -> Self {
89        Self {
90            timestamp: Utc::now(),
91            commit_hash,
92            machine_fingerprint,
93            results: Vec::new(),
94            note: None,
95        }
96    }
97
98    /// Create a new receipt from a fingerprint struct.
99    pub fn with_fingerprint(commit_hash: String, fingerprint: &super::MachineFingerprint) -> Self {
100        Self {
101            timestamp: Utc::now(),
102            commit_hash,
103            machine_fingerprint: fingerprint.as_str().to_string(),
104            results: Vec::new(),
105            note: None,
106        }
107    }
108
109    /// Add a benchmark result to this receipt.
110    pub fn add_result(&mut self, result: BenchmarkResult) {
111        self.results.push(result);
112    }
113
114    /// Compute a short hash of this receipt for quick comparison.
115    ///
116    /// Uses only the commit hash, machine fingerprint, and result names/values.
117    /// Timestamp is excluded to allow comparing logically equivalent runs.
118    pub fn receipt_hash(&self) -> String {
119        let mut hasher = Sha256::new();
120        hasher.update(self.commit_hash.as_bytes());
121        hasher.update(self.machine_fingerprint.as_bytes());
122        for result in &self.results {
123            hasher.update(result.name.as_bytes());
124            hasher.update(result.ns_per_iter.to_le_bytes());
125        }
126        hex::encode(&hasher.finalize()[..16])
127    }
128
129    /// Set an optional note about this run.
130    pub fn set_note(&mut self, note: impl Into<String>) {
131        self.note = Some(note.into());
132    }
133
134    /// Serialize this receipt to JSON bytes.
135    pub fn to_json(&self) -> Result<Vec<u8>, QuantEvalError> {
136        serde_json::to_vec(self).map_err(|e| QuantEvalError::Serialization(e.to_string()))
137    }
138
139    /// Deserialize a receipt from JSON bytes.
140    pub fn from_json(bytes: &[u8]) -> Result<Self, QuantEvalError> {
141        serde_json::from_slice(bytes).map_err(|e| QuantEvalError::Serialization(e.to_string()))
142    }
143}
144
145impl ReceiptDiff {
146    /// Compare two receipts and produce a diff.
147    pub fn compare(baseline: &BenchmarkReceipt, target: &BenchmarkReceipt) -> Self {
148        let mut benchmark_diffs = Vec::new();
149
150        // Index baseline results by name
151        let baseline_map: std::collections::HashMap<_, _> = baseline
152            .results
153            .iter()
154            .map(|r| (r.name.as_str(), r))
155            .collect();
156
157        for target_result in &target.results {
158            if let Some(baseline_result) = baseline_map.get(target_result.name.as_str()) {
159                let diff_ns = target_result.ns_per_iter as i64 - baseline_result.ns_per_iter as i64;
160                let percent_change = if baseline_result.ns_per_iter > 0 {
161                    (diff_ns as f64 / baseline_result.ns_per_iter as f64) * 100.0
162                } else {
163                    0.0
164                };
165
166                benchmark_diffs.push(BenchmarkDiff {
167                    name: target_result.name.clone(),
168                    baseline_ns_per_iter: baseline_result.ns_per_iter,
169                    target_ns_per_iter: target_result.ns_per_iter,
170                    diff_ns,
171                    percent_change,
172                });
173            }
174        }
175
176        Self {
177            baseline_hash: baseline.receipt_hash(),
178            target_hash: target.receipt_hash(),
179            computed_at: Utc::now(),
180            benchmark_diffs,
181        }
182    }
183
184    /// Serialize this diff to JSON bytes.
185    pub fn to_json(&self) -> Result<Vec<u8>, QuantEvalError> {
186        serde_json::to_vec(self).map_err(|e| QuantEvalError::Serialization(e.to_string()))
187    }
188
189    /// Deserialize a diff from JSON bytes.
190    pub fn from_json(bytes: &[u8]) -> Result<Self, QuantEvalError> {
191        serde_json::from_slice(bytes).map_err(|e| QuantEvalError::Serialization(e.to_string()))
192    }
193}
194
195// Simple hex encoder
196mod hex {
197    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
198
199    pub fn encode(data: impl AsRef<[u8]>) -> String {
200        let bytes = data.as_ref();
201        let mut s = String::with_capacity(bytes.len() * 2);
202        for &b in bytes {
203            s.push(HEX_CHARS[(b >> 4) as usize] as char);
204            s.push(HEX_CHARS[(b & 0xf) as usize] as char);
205        }
206        s
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn dummy_fingerprint() -> String {
215        "0".repeat(64)
216    }
217
218    #[test]
219    fn test_receipt_creation() {
220        let receipt = BenchmarkReceipt::new("abc123".to_string(), dummy_fingerprint());
221        assert_eq!(receipt.commit_hash, "abc123");
222        assert!(receipt.results.is_empty());
223    }
224
225    #[test]
226    fn test_receipt_add_result() {
227        let mut receipt = BenchmarkReceipt::new("abc123".to_string(), dummy_fingerprint());
228        receipt.add_result(BenchmarkResult {
229            name: "test_bench".to_string(),
230            iterations: 1000,
231            elapsed_ns: 1_000_000,
232            ns_per_iter: 1000,
233            throughput: Some(1_000_000.0),
234            error: None,
235        });
236        assert_eq!(receipt.results.len(), 1);
237        assert_eq!(receipt.results[0].name, "test_bench");
238    }
239
240    #[test]
241    fn test_receipt_serialization() {
242        let mut receipt = BenchmarkReceipt::new("abc123".to_string(), dummy_fingerprint());
243        receipt.add_result(BenchmarkResult {
244            name: "test_bench".to_string(),
245            iterations: 1000,
246            elapsed_ns: 1_000_000,
247            ns_per_iter: 1000,
248            throughput: Some(1_000_000.0),
249            error: None,
250        });
251
252        let json = receipt.to_json().expect("serialization should work");
253        let deserialized = BenchmarkReceipt::from_json(&json).expect("deserialization should work");
254        assert_eq!(deserialized.commit_hash, receipt.commit_hash);
255        assert_eq!(deserialized.results.len(), 1);
256    }
257
258    #[test]
259    fn test_diff_comparison() {
260        let mut baseline = BenchmarkReceipt::new("abc123".to_string(), dummy_fingerprint());
261        baseline.add_result(BenchmarkResult {
262            name: "test_bench".to_string(),
263            iterations: 1000,
264            elapsed_ns: 1_000_000,
265            ns_per_iter: 1000,
266            throughput: Some(1_000_000.0),
267            error: None,
268        });
269
270        let mut target = BenchmarkReceipt::new("def456".to_string(), dummy_fingerprint());
271        target.add_result(BenchmarkResult {
272            name: "test_bench".to_string(),
273            iterations: 1000,
274            elapsed_ns: 1_200_000,
275            ns_per_iter: 1200,
276            throughput: Some(833_333.0),
277            error: None,
278        });
279
280        let diff = ReceiptDiff::compare(&baseline, &target);
281        assert_eq!(diff.benchmark_diffs.len(), 1);
282        assert_eq!(diff.benchmark_diffs[0].name, "test_bench");
283        assert_eq!(diff.benchmark_diffs[0].diff_ns, 200);
284        assert!((diff.benchmark_diffs[0].percent_change - 20.0).abs() < 0.01);
285    }
286}