1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BenchmarkResult {
20 pub benchmark: String,
22 pub score: f64,
24 pub unit: String,
26 pub higher_is_better: bool,
28 pub dataset: Option<String>,
30 pub metadata: BTreeMap<String, String>,
32 pub recorded_at: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BenchmarkRecord {
39 pub model_name: String,
41 pub version: u64,
43 pub hardware: Option<String>,
45 pub environment: Option<String>,
47 pub results: Vec<BenchmarkResult>,
49 pub created_at: String,
51 pub updated_at: String,
53}
54
55impl BenchmarkRecord {
56 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 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 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 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 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 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 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
169pub struct BenchmarkStore {
173 base_dir: std::path::PathBuf,
175}
176
177impl BenchmarkStore {
178 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 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 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 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#[cfg(test)]
234#[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}