Skip to main content

receipt_bench/
receipt.rs

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