metrale_bench/benchmarks/
baseline.rs1use std::collections::BTreeMap;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use anyhow::{Context, Result};
17use serde::{Deserialize, Serialize};
18
19use crate::artifacts::ArtifactStore;
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct Baseline {
23 pub recorded_at: u64,
25 pub target: String,
29 pub model: String,
30 pub metrics: BTreeMap<String, f64>,
31}
32
33impl Baseline {
34 pub fn get(&self, key: &str) -> Option<f64> {
35 self.metrics.get(key).copied()
36 }
37
38 pub fn age_text(&self) -> String {
40 let now = now_secs();
41 let secs = now.saturating_sub(self.recorded_at);
42 match secs {
43 0..=90 => "just now".into(),
44 91..=5400 => format!("{} min old", secs / 60),
45 5401..=172_800 => format!("{} h old", secs / 3600),
46 _ => format!("{} d old", secs / 86_400),
47 }
48 }
49}
50
51fn now_secs() -> u64 {
52 SystemTime::now()
53 .duration_since(UNIX_EPOCH)
54 .map(|d| d.as_secs())
55 .unwrap_or(0)
56}
57
58fn path(
63 store: &ArtifactStore,
64 benchmark_id: &str,
65 model: Option<&str>,
66) -> Result<std::path::PathBuf> {
67 let name = match model {
68 Some(m) => format!("baseline-{}.json", crate::gate::record::variant_slug(m)),
69 None => "baseline.json".to_string(),
70 };
71 Ok(store.runs_dir(benchmark_id)?.join(name))
72}
73
74pub fn load(store: &ArtifactStore, benchmark_id: &str) -> Option<Baseline> {
78 if let Ok(p) = path(store, benchmark_id, None)
81 && let Ok(text) = std::fs::read_to_string(p)
82 && let Ok(b) = serde_json::from_str::<Baseline>(&text)
83 {
84 return Some(b);
85 }
86 let dir = store.runs_dir(benchmark_id).ok()?;
87 let mut candidate = None;
88 for e in std::fs::read_dir(dir).ok()? {
89 let path = e.ok()?.path();
90 let name = path.file_name()?.to_string_lossy().to_string();
91 if !(name.starts_with("baseline-") && name.ends_with(".json")) {
92 continue;
93 }
94 if candidate.is_some() {
95 return None;
96 }
97 candidate = Some(path);
98 }
99 let text = std::fs::read_to_string(candidate?).ok()?;
100 serde_json::from_str(&text).ok()
101}
102
103pub fn load_for(
107 store: &ArtifactStore,
108 benchmark_id: &str,
109 model: Option<&str>,
110) -> Option<Baseline> {
111 if let Some(m) = model
112 && let Ok(p) = path(store, benchmark_id, Some(m))
113 && let Ok(text) = std::fs::read_to_string(p)
114 && let Ok(b) = serde_json::from_str::<Baseline>(&text)
115 && b.model == m
116 {
117 return Some(b);
118 }
119 let p = path(store, benchmark_id, None).ok()?;
120 let text = std::fs::read_to_string(p).ok()?;
121 let b: Baseline = serde_json::from_str(&text).ok()?;
122 match model {
123 Some(m) if b.model != m => None,
124 _ => Some(b),
125 }
126}
127
128pub fn save(
132 store: &ArtifactStore,
133 benchmark_id: &str,
134 target: &str,
135 model: &str,
136 metrics: BTreeMap<String, f64>,
137) -> Result<()> {
138 let baseline = Baseline {
139 recorded_at: now_secs(),
140 target: target.to_string(),
141 model: model.to_string(),
142 metrics,
143 };
144 let p = path(store, benchmark_id, Some(model))?;
145 std::fs::write(&p, serde_json::to_string_pretty(&baseline)?)
146 .with_context(|| format!("writing {}", p.display()))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn store(name: &str) -> ArtifactStore {
154 let d =
155 std::env::temp_dir().join(format!("metrale-baseline-{name}-{}", std::process::id()));
156 let _ = std::fs::remove_dir_all(&d);
157 ArtifactStore::with_root(d)
158 }
159
160 #[test]
162 fn two_models_of_one_gate_keep_separate_baselines() {
163 let s = store("variants");
164 let mut a = BTreeMap::new();
165 a.insert("median_ms".into(), 1677.9);
166 save(&s, "ttft-cold", "http://h:1", "Qwen/Qwen3.6-35B-A3B-FP8", a).unwrap();
167 let mut b = BTreeMap::new();
168 b.insert("median_ms".into(), 442.3);
169 save(
170 &s,
171 "ttft-cold",
172 "http://h:1",
173 "nvidia/Qwen3.6-35B-A3B-NVFP4",
174 b,
175 )
176 .unwrap();
177 let fp8 = load_for(&s, "ttft-cold", Some("Qwen/Qwen3.6-35B-A3B-FP8")).unwrap();
178 assert_eq!(fp8.get("median_ms"), Some(1677.9));
179 let nv = load_for(&s, "ttft-cold", Some("nvidia/Qwen3.6-35B-A3B-NVFP4")).unwrap();
180 assert_eq!(nv.get("median_ms"), Some(442.3));
181 }
182
183 #[test]
186 fn legacy_shared_baseline_is_not_inherited_by_another_model() {
187 let s = store("legacy");
188 let p = s.runs_dir("ttft-warm").unwrap().join("baseline.json");
189 let legacy = Baseline {
190 recorded_at: now_secs(),
191 target: "http://h:1".into(),
192 model: "Qwen/Qwen3.6-35B-A3B-FP8".into(),
193 metrics: BTreeMap::from([("median_ms".to_string(), 1600.0)]),
194 };
195 std::fs::write(&p, serde_json::to_string(&legacy).unwrap()).unwrap();
196 assert!(load_for(&s, "ttft-warm", Some("Qwen/Qwen3.6-35B-A3B-FP8")).is_some());
197 assert!(load_for(&s, "ttft-warm", Some("nvidia/Qwen3.6-35B-A3B-NVFP4")).is_none());
198 }
199
200 #[test]
201 fn round_trips_and_reports_the_target_it_came_from() {
202 let s = store("rt");
203 assert!(load(&s, "ttft-warm").is_none());
204 let mut m = BTreeMap::new();
205 m.insert("median_ms".into(), 812.5);
206 save(&s, "ttft-warm", "http://127.0.0.1:8888", "qwen", m).unwrap();
207 let b = load(&s, "ttft-warm").unwrap();
208 assert_eq!(b.get("median_ms"), Some(812.5));
209 assert_eq!(b.target, "http://127.0.0.1:8888");
210 assert_eq!(b.model, "qwen");
211 assert_eq!(b.age_text(), "just now");
212 }
213
214 #[test]
215 fn a_model_keyed_file_must_belong_to_the_model_in_its_name() {
216 let s = store("misfiled");
217 let p = path(&s, "ttft-warm", Some("wanted-model")).unwrap();
218 let wrong = Baseline {
219 recorded_at: now_secs(),
220 target: "http://h:1".into(),
221 model: "another-model".into(),
222 metrics: BTreeMap::from([("median_ms".to_string(), 1.0)]),
223 };
224 std::fs::write(p, serde_json::to_string(&wrong).unwrap()).unwrap();
225 assert!(load_for(&s, "ttft-warm", Some("wanted-model")).is_none());
226 }
227
228 #[test]
229 fn an_unkeyed_load_never_guesses_among_multiple_model_files() {
230 let s = store("ambiguous");
231 let corrupt = path(&s, "ttft-warm", Some("created-first")).unwrap();
232 std::fs::write(corrupt, "{ not json").unwrap();
233 save(
234 &s,
235 "ttft-warm",
236 "http://h:1",
237 "created-second",
238 BTreeMap::from([("median_ms".to_string(), 1.0)]),
239 )
240 .unwrap();
241 assert!(
242 load(&s, "ttft-warm").is_none(),
243 "two model-keyed files are ambiguous even when only one parses"
244 );
245 }
246
247 #[test]
248 fn a_corrupt_baseline_reads_as_absent() {
249 let s = store("corrupt");
250 let p = s.runs_dir("x").unwrap().join("baseline.json");
251 std::fs::write(p, "{ not json").unwrap();
252 assert!(load(&s, "x").is_none());
253 }
254}