use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use crate::eval::latency::LatencySummary;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub schema_version: u32,
pub generated: String,
pub asr_model: String,
pub languages: BTreeMap<String, LanguageBaseline>,
pub tolerances: Tolerances,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageBaseline {
pub samples: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wer: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cer: Option<f64>,
pub asr_latency_ms: LatencyRecord,
pub e2e_latency_ms: LatencyRecord,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rtf: Option<f64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LatencyRecord {
pub p50: f64,
pub p95: f64,
}
impl From<LatencySummary> for LatencyRecord {
fn from(s: LatencySummary) -> Self {
Self {
p50: round2(s.p50_ms),
p95: round2(s.p95_ms),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Tolerances {
pub wer_absolute_warn: f64,
pub wer_absolute_fail: f64,
pub wer_relative_warn: f64,
pub wer_relative_fail: f64,
pub latency_p50_relative_warn: f64,
pub latency_p50_relative_fail: f64,
pub e2e_latency_p50_relative_warn: f64,
pub e2e_latency_p50_relative_fail: f64,
#[serde(default = "default_rtf_warn")]
pub rtf_relative_warn: f64,
#[serde(default = "default_rtf_fail")]
pub rtf_relative_fail: f64,
#[serde(default = "default_rtf_absolute_warn")]
pub rtf_absolute_warn: f64,
#[serde(default = "default_latency_absolute_warn_ms")]
pub asr_latency_absolute_warn_ms: f64,
#[serde(default = "default_latency_absolute_warn_ms")]
pub e2e_latency_absolute_warn_ms: f64,
}
fn default_rtf_warn() -> f64 {
0.50
}
fn default_rtf_fail() -> f64 {
1.50
}
fn default_rtf_absolute_warn() -> f64 {
1.00
}
fn default_latency_absolute_warn_ms() -> f64 {
1000.0
}
impl Default for Tolerances {
fn default() -> Self {
Self {
wer_absolute_warn: 0.03,
wer_absolute_fail: 0.07,
wer_relative_warn: 0.10,
wer_relative_fail: 0.30,
latency_p50_relative_warn: 0.50, latency_p50_relative_fail: 1.50, e2e_latency_p50_relative_warn: 0.30,
e2e_latency_p50_relative_fail: 1.00,
rtf_relative_warn: default_rtf_warn(),
rtf_relative_fail: default_rtf_fail(),
rtf_absolute_warn: default_rtf_absolute_warn(),
asr_latency_absolute_warn_ms: default_latency_absolute_warn_ms(),
e2e_latency_absolute_warn_ms: default_latency_absolute_warn_ms(),
}
}
}
impl Baseline {
pub fn load(path: &Path) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, bytes)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
Pass,
Warn(String),
Fail(String),
}
impl Verdict {
pub fn is_fail(&self) -> bool {
matches!(self, Verdict::Fail(_))
}
}
pub fn check_language(
measured: &LanguageBaseline,
baseline: &LanguageBaseline,
tol: &Tolerances,
) -> Vec<(String, Verdict)> {
let mut out = Vec::new();
if let (Some(m), Some(b)) = (measured.wer, baseline.wer) {
out.push(("wer".to_string(), check_error_rate(m, b, tol)));
}
if let (Some(m), Some(b)) = (measured.cer, baseline.cer) {
out.push(("cer".to_string(), check_error_rate(m, b, tol)));
}
out.push((
"asr_latency_p50_ms".to_string(),
check_latency(
measured.asr_latency_ms.p50,
baseline.asr_latency_ms.p50,
tol.latency_p50_relative_warn,
tol.latency_p50_relative_fail,
),
));
out.push((
"e2e_latency_p50_ms".to_string(),
check_latency(
measured.e2e_latency_ms.p50,
baseline.e2e_latency_ms.p50,
tol.e2e_latency_p50_relative_warn,
tol.e2e_latency_p50_relative_fail,
),
));
if let (Some(m), Some(b)) = (measured.rtf, baseline.rtf) {
out.push((
"rtf".to_string(),
check_latency(m, b, tol.rtf_relative_warn, tol.rtf_relative_fail),
));
}
if let Some(m) = measured.rtf {
out.push((
"rtf_absolute".to_string(),
check_absolute_max(m, tol.rtf_absolute_warn, |x| format!("{x:.3}")),
));
}
out.push((
"asr_latency_p50_ms_absolute".to_string(),
check_absolute_max(
measured.asr_latency_ms.p50,
tol.asr_latency_absolute_warn_ms,
|x| format!("{x:.0}ms"),
),
));
out.push((
"e2e_latency_p50_ms_absolute".to_string(),
check_absolute_max(
measured.e2e_latency_ms.p50,
tol.e2e_latency_absolute_warn_ms,
|x| format!("{x:.0}ms"),
),
));
out
}
fn check_absolute_max(measured: f64, warn_at: f64, fmt: impl Fn(f64) -> String) -> Verdict {
if measured.is_nan() || warn_at <= 0.0 {
return Verdict::Pass;
}
if measured >= warn_at {
Verdict::Warn(format!("{} ≥ {}", fmt(measured), fmt(warn_at)))
} else {
Verdict::Pass
}
}
fn check_error_rate(measured: f64, baseline: f64, tol: &Tolerances) -> Verdict {
if measured.is_nan() || baseline.is_nan() {
return Verdict::Fail(format!(
"NaN encountered: measured={measured}, baseline={baseline}"
));
}
let abs_delta = measured - baseline;
let rel_delta = if baseline > 0.0 {
abs_delta / baseline
} else {
0.0
};
if abs_delta >= tol.wer_absolute_fail || rel_delta >= tol.wer_relative_fail {
Verdict::Fail(format!(
"{:.4} → {:.4} (Δ {:+.4}, rel {:+.1}%)",
baseline,
measured,
abs_delta,
rel_delta * 100.0
))
} else if abs_delta >= tol.wer_absolute_warn || rel_delta >= tol.wer_relative_warn {
Verdict::Warn(format!(
"{:.4} → {:.4} (Δ {:+.4}, rel {:+.1}%)",
baseline,
measured,
abs_delta,
rel_delta * 100.0
))
} else {
Verdict::Pass
}
}
fn check_latency(measured: f64, baseline: f64, warn_rel: f64, fail_rel: f64) -> Verdict {
if baseline <= 0.0 {
return Verdict::Pass;
}
let rel = (measured - baseline) / baseline;
if rel >= fail_rel {
Verdict::Fail(format!(
"{:.1}ms → {:.1}ms ({:+.1}%)",
baseline,
measured,
rel * 100.0
))
} else if rel >= warn_rel {
Verdict::Warn(format!(
"{:.1}ms → {:.1}ms ({:+.1}%)",
baseline,
measured,
rel * 100.0
))
} else {
Verdict::Pass
}
}
fn round2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerBaseline {
pub schema_version: u32,
pub generated: String,
pub languages: BTreeMap<String, LanguageLayerBaseline>,
pub tolerances: LayerTolerances,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageLayerBaseline {
pub fixtures: usize,
pub ablation: BTreeMap<String, f64>,
pub layer_latency_us: BTreeMap<String, LatencyMicrosRecord>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LatencyMicrosRecord {
pub p50: f64,
pub p95: f64,
}
impl From<LatencySummary> for LatencyMicrosRecord {
fn from(s: LatencySummary) -> Self {
Self {
p50: round2(s.p50_ms * 1000.0),
p95: round2(s.p95_ms * 1000.0),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LayerTolerances {
pub ablation_absolute_warn: f64,
pub ablation_absolute_fail: f64,
pub layer_latency_p50_relative_warn: f64,
pub layer_latency_p50_relative_fail: f64,
#[serde(default = "default_layer_latency_absolute_warn_us")]
pub layer_latency_absolute_warn_us: f64,
}
fn default_layer_latency_absolute_warn_us() -> f64 {
1_000_000.0
}
impl Default for LayerTolerances {
fn default() -> Self {
Self {
ablation_absolute_warn: 0.02,
ablation_absolute_fail: 0.05,
layer_latency_p50_relative_warn: 1.50,
layer_latency_p50_relative_fail: 3.50,
layer_latency_absolute_warn_us: default_layer_latency_absolute_warn_us(),
}
}
}
impl LayerBaseline {
pub fn load(path: &Path) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, bytes)?;
Ok(())
}
}
pub fn check_language_layers(
measured: &LanguageLayerBaseline,
baseline: &LanguageLayerBaseline,
tol: &LayerTolerances,
) -> Vec<(String, Verdict)> {
let mut out = Vec::new();
for (key, m_val) in &measured.ablation {
let Some(b_val) = baseline.ablation.get(key) else {
out.push((
format!("ablation/{key}"),
Verdict::Warn(format!("no baseline entry for {key}")),
));
continue;
};
let abs_delta = (m_val - b_val).abs();
let v = if abs_delta >= tol.ablation_absolute_fail {
Verdict::Fail(format!(
"{:.4} → {:.4} (|Δ| {:.4})",
b_val, m_val, abs_delta
))
} else if abs_delta >= tol.ablation_absolute_warn {
Verdict::Warn(format!(
"{:.4} → {:.4} (|Δ| {:.4})",
b_val, m_val, abs_delta
))
} else {
Verdict::Pass
};
out.push((format!("ablation/{key}"), v));
}
for (layer, m_lat) in &measured.layer_latency_us {
let Some(b_lat) = baseline.layer_latency_us.get(layer) else {
out.push((
format!("latency/{layer}"),
Verdict::Warn(format!("no baseline entry for {layer}")),
));
continue;
};
let v = check_latency(
m_lat.p50,
b_lat.p50,
tol.layer_latency_p50_relative_warn,
tol.layer_latency_p50_relative_fail,
);
out.push((format!("latency/{layer}_p50_us"), v));
out.push((
format!("latency/{layer}_p50_us_absolute"),
check_absolute_max(m_lat.p50, tol.layer_latency_absolute_warn_us, |x| {
format!("{x:.0}μs")
}),
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn bl(wer: Option<f64>, cer: Option<f64>) -> LanguageBaseline {
LanguageBaseline {
samples: 10,
wer,
cer,
asr_latency_ms: LatencyRecord {
p50: 100.0,
p95: 200.0,
},
e2e_latency_ms: LatencyRecord {
p50: 150.0,
p95: 250.0,
},
rtf: Some(0.20),
}
}
#[test]
fn equal_measurement_passes_all_checks() {
let baseline = bl(Some(0.20), None);
let measured = baseline.clone();
let tol = Tolerances::default();
let results = check_language(&measured, &baseline, &tol);
for (name, v) in &results {
assert_eq!(v, &Verdict::Pass, "{name} expected Pass, got {v:?}");
}
}
#[test]
fn wer_absolute_regression_warns_then_fails() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
let tol = Tolerances::default();
measured.wer = Some(0.24); let results = check_language(&measured, &baseline, &tol);
let wer_v = &results.iter().find(|(k, _)| k == "wer").unwrap().1;
assert!(matches!(wer_v, Verdict::Warn(_)), "got {wer_v:?}");
measured.wer = Some(0.28); let results = check_language(&measured, &baseline, &tol);
let wer_v = &results.iter().find(|(k, _)| k == "wer").unwrap().1;
assert!(matches!(wer_v, Verdict::Fail(_)), "got {wer_v:?}");
}
#[test]
fn wer_improvement_is_pass() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
measured.wer = Some(0.10);
let tol = Tolerances::default();
let results = check_language(&measured, &baseline, &tol);
let wer_v = &results.iter().find(|(k, _)| k == "wer").unwrap().1;
assert_eq!(wer_v, &Verdict::Pass);
}
#[test]
fn latency_regression_classified_correctly() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
let tol = Tolerances::default();
measured.asr_latency_ms.p50 = 200.0; let results = check_language(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "asr_latency_p50_ms")
.unwrap()
.1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
measured.asr_latency_ms.p50 = 280.0; let results = check_language(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "asr_latency_p50_ms")
.unwrap()
.1;
assert!(matches!(v, Verdict::Fail(_)), "got {v:?}");
}
#[test]
fn rtf_regression_classified() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
let tol = Tolerances::default();
measured.rtf = Some(0.50); let results = check_language(&measured, &baseline, &tol);
let v = &results.iter().find(|(k, _)| k == "rtf").unwrap().1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
measured.rtf = Some(0.70); let results = check_language(&measured, &baseline, &tol);
let v = &results.iter().find(|(k, _)| k == "rtf").unwrap().1;
assert!(matches!(v, Verdict::Fail(_)), "got {v:?}");
}
#[test]
fn rtf_absolute_threshold_warns_at_one() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
let tol = Tolerances::default();
measured.rtf = Some(0.50); let results = check_language(&measured, &baseline, &tol);
let v = &results.iter().find(|(k, _)| k == "rtf_absolute").unwrap().1;
assert_eq!(v, &Verdict::Pass, "0.50 < 1.0 should be absolute pass");
measured.rtf = Some(1.20);
let results = check_language(&measured, &baseline, &tol);
let v = &results.iter().find(|(k, _)| k == "rtf_absolute").unwrap().1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
}
#[test]
fn latency_absolute_threshold_warns_at_one_second() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
let tol = Tolerances::default();
measured.e2e_latency_ms.p50 = 1500.0;
let results = check_language(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "e2e_latency_p50_ms_absolute")
.unwrap()
.1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
measured.e2e_latency_ms.p50 = 800.0;
let results = check_language(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "e2e_latency_p50_ms_absolute")
.unwrap()
.1;
assert_eq!(v, &Verdict::Pass);
}
#[test]
fn rtf_missing_skips_check() {
let baseline = bl(Some(0.20), None);
let mut measured = baseline.clone();
measured.rtf = None;
let tol = Tolerances::default();
let results = check_language(&measured, &baseline, &tol);
assert!(results.iter().all(|(k, _)| k != "rtf"));
}
#[test]
fn round_trip_serde() {
let mut langs = BTreeMap::new();
langs.insert("en".to_string(), bl(Some(0.20), None));
langs.insert("ja".to_string(), bl(None, Some(0.30)));
let b = Baseline {
schema_version: 1,
generated: "2026-04-25T00:00:00Z".to_string(),
asr_model: "ggml-tiny.en.bin".to_string(),
languages: langs,
tolerances: Tolerances::default(),
};
let json = serde_json::to_string(&b).unwrap();
let parsed: Baseline = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.languages.len(), 2);
assert_eq!(parsed.schema_version, 1);
}
fn lbl() -> LanguageLayerBaseline {
let mut ablation = BTreeMap::new();
ablation.insert("full".to_string(), 0.10);
ablation.insert("without_filler".to_string(), 0.20);
let mut layer_latency = BTreeMap::new();
layer_latency.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 500.0,
p95: 1000.0,
},
);
LanguageLayerBaseline {
fixtures: 25,
ablation,
layer_latency_us: layer_latency,
}
}
#[test]
fn layer_ablation_drift_classification() {
let baseline = lbl();
let mut measured = baseline.clone();
let tol = LayerTolerances::default();
let results = check_language_layers(&measured, &baseline, &tol);
for (k, v) in &results {
assert_eq!(v, &Verdict::Pass, "{k}: expected pass, got {v:?}");
}
measured.ablation.insert("without_filler".to_string(), 0.23);
let results = check_language_layers(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "ablation/without_filler")
.unwrap()
.1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
measured.ablation.insert("without_filler".to_string(), 0.26);
let results = check_language_layers(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "ablation/without_filler")
.unwrap()
.1;
assert!(matches!(v, Verdict::Fail(_)), "got {v:?}");
}
#[test]
fn layer_latency_drift_classification() {
let baseline = lbl();
let mut measured = baseline.clone();
let tol = LayerTolerances::default();
measured.layer_latency_us.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 1500.0,
p95: 1500.0,
}, );
let results = check_language_layers(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "latency/filler_p50_us")
.unwrap()
.1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
measured.layer_latency_us.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 3000.0,
p95: 3000.0,
}, );
let results = check_language_layers(&measured, &baseline, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "latency/filler_p50_us")
.unwrap()
.1;
assert!(matches!(v, Verdict::Fail(_)), "got {v:?}");
}
#[test]
fn layer_latency_absolute_threshold_warns_at_one_second() {
let baseline = lbl();
let mut measured = baseline.clone();
let tol = LayerTolerances::default();
measured.layer_latency_us.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 1_500_000.0,
p95: 1_500_000.0,
},
);
let mut bl_match = baseline.clone();
bl_match.layer_latency_us.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 1_500_000.0,
p95: 1_500_000.0,
},
);
let results = check_language_layers(&measured, &bl_match, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "latency/filler_p50_us_absolute")
.unwrap()
.1;
assert!(matches!(v, Verdict::Warn(_)), "got {v:?}");
measured.layer_latency_us.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 500.0,
p95: 1000.0,
},
);
bl_match.layer_latency_us.insert(
"filler".to_string(),
LatencyMicrosRecord {
p50: 500.0,
p95: 1000.0,
},
);
let results = check_language_layers(&measured, &bl_match, &tol);
let v = &results
.iter()
.find(|(k, _)| k == "latency/filler_p50_us_absolute")
.unwrap()
.1;
assert_eq!(v, &Verdict::Pass);
}
#[test]
fn layer_baseline_round_trip_serde() {
let mut langs = BTreeMap::new();
langs.insert("en".to_string(), lbl());
let b = LayerBaseline {
schema_version: 1,
generated: "2026-04-25T00:00:00Z".to_string(),
languages: langs,
tolerances: LayerTolerances::default(),
};
let json = serde_json::to_string(&b).unwrap();
let parsed: LayerBaseline = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.languages.len(), 1);
}
}