Skip to main content

ai_model_vault/
benchmark.rs

1//! Benchmark metadata — attach evaluation results to model versions.
2//!
3//! Records structured benchmark scores (perplexity, MMLU, HumanEval, latency,
4//! throughput, etc.) alongside model versions and serialises them to JSON.
5
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::Path;
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Result, VaultError};
14
15// ── Types ────────────────────────────────────────────────────────────────────
16
17/// A single benchmark result for a model version.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BenchmarkResult {
20    /// Name of the benchmark (e.g. "MMLU", "HumanEval", "perplexity")
21    pub benchmark: String,
22    /// Numeric score or value
23    pub score: f64,
24    /// Unit of measurement (e.g. "accuracy", "ppl", "tokens/s", "ms")
25    pub unit: String,
26    /// Higher is better? (true for accuracy, false for perplexity/latency)
27    pub higher_is_better: bool,
28    /// Dataset or split used (e.g. "test", "validation", "5-shot")
29    pub dataset: Option<String>,
30    /// Additional key-value metadata
31    pub metadata: BTreeMap<String, String>,
32    /// When the benchmark was recorded
33    pub recorded_at: String,
34}
35
36/// A collection of benchmark results for a specific model version.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BenchmarkRecord {
39    /// Model name
40    pub model_name: String,
41    /// Model version
42    pub version: u64,
43    /// Hardware description (e.g. "NVIDIA A100 80GB", "Apple M2 Max")
44    pub hardware: Option<String>,
45    /// Software environment (e.g. "PyTorch 2.1, CUDA 12.1")
46    pub environment: Option<String>,
47    /// Individual benchmark results
48    pub results: Vec<BenchmarkResult>,
49    /// When this record was created
50    pub created_at: String,
51    /// When this record was last updated
52    pub updated_at: String,
53}
54
55impl BenchmarkRecord {
56    /// Create a new empty benchmark record for a model version.
57    pub fn new(model_name: &str, version: u64) -> Self {
58        let now = Utc::now().to_rfc3339();
59        Self {
60            model_name: model_name.to_string(),
61            version,
62            hardware: None,
63            environment: None,
64            results: Vec::new(),
65            created_at: now.clone(),
66            updated_at: now,
67        }
68    }
69
70    /// Add a benchmark result.
71    pub fn add_result(&mut self, benchmark: &str, score: f64, unit: &str, higher_is_better: bool) {
72        self.results.push(BenchmarkResult {
73            benchmark: benchmark.to_string(),
74            score,
75            unit: unit.to_string(),
76            higher_is_better,
77            dataset: None,
78            metadata: BTreeMap::new(),
79            recorded_at: Utc::now().to_rfc3339(),
80        });
81        self.updated_at = Utc::now().to_rfc3339();
82    }
83
84    /// Add a benchmark result with full details.
85    pub fn add_detailed_result(
86        &mut self,
87        benchmark: &str,
88        score: f64,
89        unit: &str,
90        higher_is_better: bool,
91        dataset: Option<&str>,
92        metadata: BTreeMap<String, String>,
93    ) {
94        self.results.push(BenchmarkResult {
95            benchmark: benchmark.to_string(),
96            score,
97            unit: unit.to_string(),
98            higher_is_better,
99            dataset: dataset.map(|s| s.to_string()),
100            metadata,
101            recorded_at: Utc::now().to_rfc3339(),
102        });
103        self.updated_at = Utc::now().to_rfc3339();
104    }
105
106    /// Get a result by benchmark name.
107    pub fn get_result(&self, benchmark: &str) -> Option<&BenchmarkResult> {
108        self.results
109            .iter()
110            .find(|r| r.benchmark.eq_ignore_ascii_case(benchmark))
111    }
112
113    /// Save benchmark record to a JSON file.
114    pub fn save(&self, path: &Path) -> Result<()> {
115        let json = serde_json::to_string_pretty(self)
116            .map_err(|e| VaultError::SerializationError(e.to_string()))?;
117        fs::write(path, json)?;
118        Ok(())
119    }
120
121    /// Load benchmark record from a JSON file.
122    pub fn load(path: &Path) -> Result<Self> {
123        let data = fs::read_to_string(path)?;
124        let record: Self = serde_json::from_str(&data)
125            .map_err(|e| VaultError::SerializationError(e.to_string()))?;
126        Ok(record)
127    }
128
129    /// Format as a human-readable table.
130    pub fn display(&self) -> String {
131        let mut out = String::new();
132
133        out.push_str(&format!(
134            "Benchmarks for {} v{}\n",
135            self.model_name, self.version
136        ));
137        out.push_str("──────────────────────────────────\n");
138
139        if let Some(hw) = &self.hardware {
140            out.push_str(&format!("Hardware: {}\n", hw));
141        }
142        if let Some(env) = &self.environment {
143            out.push_str(&format!("Environment: {}\n", env));
144        }
145
146        if self.results.is_empty() {
147            out.push_str("No benchmark results recorded.\n");
148            return out;
149        }
150
151        out.push_str(&format!(
152            "\n{:<20} {:>12} {:<12} {:<8}\n",
153            "Benchmark", "Score", "Unit", "Direction"
154        ));
155        out.push_str(&format!("{}\n", "─".repeat(56)));
156
157        for r in &self.results {
158            let direction = if r.higher_is_better { "↑" } else { "↓" };
159            out.push_str(&format!(
160                "{:<20} {:>12.4} {:<12} {:<8}\n",
161                r.benchmark, r.score, r.unit, direction
162            ));
163        }
164
165        out
166    }
167}
168
169// ── Store helper ─────────────────────────────────────────────────────────────
170
171/// Benchmark store — manages benchmark records alongside a vault.
172pub struct BenchmarkStore {
173    /// Directory where benchmark JSON files are stored
174    base_dir: std::path::PathBuf,
175}
176
177impl BenchmarkStore {
178    /// Create a new store rooted at the given directory.
179    pub fn new(base_dir: &Path) -> Result<Self> {
180        fs::create_dir_all(base_dir)?;
181        Ok(Self {
182            base_dir: base_dir.to_path_buf(),
183        })
184    }
185
186    fn record_path(&self, model_name: &str, version: u64) -> std::path::PathBuf {
187        self.base_dir
188            .join(format!("{}__v{}.bench.json", model_name, version))
189    }
190
191    /// Get or create a benchmark record.
192    pub fn get_or_create(&self, model_name: &str, version: u64) -> Result<BenchmarkRecord> {
193        let path = self.record_path(model_name, version);
194        if path.exists() {
195            BenchmarkRecord::load(&path)
196        } else {
197            Ok(BenchmarkRecord::new(model_name, version))
198        }
199    }
200
201    /// Save a benchmark record.
202    pub fn save(&self, record: &BenchmarkRecord) -> Result<()> {
203        let path = self.record_path(&record.model_name, record.version);
204        record.save(&path)
205    }
206
207    /// List all benchmark records for a model.
208    pub fn list_for_model(&self, model_name: &str) -> Result<Vec<BenchmarkRecord>> {
209        let prefix = format!("{}__v", model_name);
210        let mut records = Vec::new();
211
212        if !self.base_dir.exists() {
213            return Ok(records);
214        }
215
216        for entry in fs::read_dir(&self.base_dir)? {
217            let entry = entry?;
218            let name = entry.file_name().to_string_lossy().to_string();
219            if name.starts_with(&prefix) && name.ends_with(".bench.json") {
220                if let Ok(record) = BenchmarkRecord::load(&entry.path()) {
221                    records.push(record);
222                }
223            }
224        }
225
226        records.sort_by_key(|r| r.version);
227        Ok(records)
228    }
229}
230
231// ── Tests ────────────────────────────────────────────────────────────────────
232
233#[cfg(test)]
234// Exact float comparison is intentional here: these assert on literal
235// constants that round-trip bit-for-bit, not on computed results.
236#[allow(clippy::float_cmp)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_benchmark_record_basic() {
242        let mut record = BenchmarkRecord::new("llama-7b", 1);
243        record.add_result("MMLU", 0.654, "accuracy", true);
244        record.add_result("perplexity", 5.12, "ppl", false);
245
246        assert_eq!(record.results.len(), 2);
247        assert_eq!(record.get_result("mmlu").unwrap().score, 0.654);
248        assert!(record.get_result("nonexistent").is_none());
249    }
250
251    #[test]
252    fn test_benchmark_record_save_load() {
253        let dir = tempfile::tempdir().unwrap();
254        let path = dir.path().join("bench.json");
255
256        let mut record = BenchmarkRecord::new("gpt-neo", 3);
257        record.hardware = Some("NVIDIA A100".to_string());
258        record.add_result("HumanEval", 0.482, "pass@1", true);
259        record.save(&path).unwrap();
260
261        let loaded = BenchmarkRecord::load(&path).unwrap();
262        assert_eq!(loaded.model_name, "gpt-neo");
263        assert_eq!(loaded.version, 3);
264        assert_eq!(loaded.results.len(), 1);
265        assert_eq!(loaded.hardware.as_deref(), Some("NVIDIA A100"));
266    }
267
268    #[test]
269    fn test_benchmark_store() {
270        let dir = tempfile::tempdir().unwrap();
271        let store = BenchmarkStore::new(dir.path()).unwrap();
272
273        let mut r1 = store.get_or_create("model-a", 1).unwrap();
274        r1.add_result("MMLU", 0.6, "accuracy", true);
275        store.save(&r1).unwrap();
276
277        let mut r2 = store.get_or_create("model-a", 2).unwrap();
278        r2.add_result("MMLU", 0.7, "accuracy", true);
279        store.save(&r2).unwrap();
280
281        let records = store.list_for_model("model-a").unwrap();
282        assert_eq!(records.len(), 2);
283        assert_eq!(records[0].version, 1);
284        assert_eq!(records[1].version, 2);
285    }
286
287    #[test]
288    fn test_benchmark_display() {
289        let mut record = BenchmarkRecord::new("test", 1);
290        record.add_result("MMLU", 0.75, "accuracy", true);
291        record.add_result("latency", 42.5, "ms", false);
292
293        let display = record.display();
294        assert!(display.contains("MMLU"));
295        assert!(display.contains("latency"));
296        assert!(display.contains("↑"));
297        assert!(display.contains("↓"));
298    }
299
300    #[test]
301    fn test_detailed_result() {
302        let mut record = BenchmarkRecord::new("test", 1);
303        let mut meta = BTreeMap::new();
304        meta.insert("batch_size".to_string(), "32".to_string());
305        record.add_detailed_result(
306            "throughput",
307            1500.0,
308            "tokens/s",
309            true,
310            Some("wikitext"),
311            meta,
312        );
313
314        let r = &record.results[0];
315        assert_eq!(r.benchmark, "throughput");
316        assert_eq!(r.dataset.as_deref(), Some("wikitext"));
317        assert_eq!(r.metadata.get("batch_size").unwrap(), "32");
318    }
319}