use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum Provenance {
ParityVerified,
Extension,
UserComposed,
}
impl Provenance {
pub fn is_benchmark_standard(self) -> bool {
matches!(self, Provenance::ParityVerified)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalReport {
pub task: String,
pub provenance: Provenance,
pub metrics: BTreeMap<String, f64>,
pub per_class: BTreeMap<String, BTreeMap<String, f64>>,
pub per_group: BTreeMap<String, BTreeMap<String, f64>>,
pub curves: BTreeMap<String, Vec<f64>>,
pub params: serde_json::Value,
}
impl EvalReport {
pub fn new(task: impl Into<String>, provenance: Provenance) -> Self {
EvalReport {
task: task.into(),
provenance,
metrics: BTreeMap::new(),
per_class: BTreeMap::new(),
per_group: BTreeMap::new(),
curves: BTreeMap::new(),
params: serde_json::Value::Null,
}
}
pub fn user_composed(task: impl Into<String>) -> Self {
Self::new(task, Provenance::UserComposed)
}
#[must_use]
pub fn with_metrics<K: Into<String>>(
mut self,
metrics: impl IntoIterator<Item = (K, f64)>,
) -> Self {
self.metrics
.extend(metrics.into_iter().map(|(k, v)| (k.into(), v)));
self
}
#[must_use]
pub fn with_class_metric(
mut self,
class: impl Into<String>,
metric: impl Into<String>,
value: f64,
) -> Self {
self.per_class
.entry(class.into())
.or_default()
.insert(metric.into(), value);
self
}
#[must_use]
pub fn with_group_metric(
mut self,
group: impl Into<String>,
metric: impl Into<String>,
value: f64,
) -> Self {
self.per_group
.entry(group.into())
.or_default()
.insert(metric.into(), value);
self
}
#[must_use]
pub fn with_curve(mut self, name: impl Into<String>, values: Vec<f64>) -> Self {
self.curves.insert(name.into(), values);
self
}
#[must_use]
pub fn with_params(mut self, params: serde_json::Value) -> Self {
self.params = params;
self
}
pub fn metric(&self, name: &str) -> Option<f64> {
self.metrics.get(name).copied()
}
pub fn to_json(&self) -> crate::error::Result<String> {
Ok(serde_json::to_string_pretty(self)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn python_facing_constructor_cannot_claim_parity() {
let r = EvalReport::user_composed("my_metric");
assert_eq!(r.provenance, Provenance::UserComposed);
assert!(!r.provenance.is_benchmark_standard());
}
#[test]
fn only_parity_verified_is_benchmark_standard() {
assert!(Provenance::ParityVerified.is_benchmark_standard());
assert!(!Provenance::Extension.is_benchmark_standard());
assert!(!Provenance::UserComposed.is_benchmark_standard());
}
#[test]
fn provenance_survives_serialization() {
for p in [
Provenance::ParityVerified,
Provenance::Extension,
Provenance::UserComposed,
] {
let report = EvalReport::new("detection", p)
.with_metrics([("AP", 0.5)])
.with_curve("pr@0.50", vec![1.0, 0.5, 0.0]);
let json = report.to_json().expect("serialize");
let back: EvalReport = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.provenance, p);
assert_eq!(back.metric("AP"), Some(0.5));
assert_eq!(back.curves["pr@0.50"], vec![1.0, 0.5, 0.0]);
}
}
#[test]
fn with_metrics_extends_instead_of_replacing() {
let r = EvalReport::user_composed("m")
.with_metrics([("AP", 0.5), ("AP50", 0.7)])
.with_metrics([("AR", 0.6), ("AP", 0.55)]);
assert_eq!(r.metric("AP50"), Some(0.7), "earlier batch survives");
assert_eq!(r.metric("AR"), Some(0.6), "later batch lands");
assert_eq!(r.metric("AP"), Some(0.55), "repeated key takes later value");
assert_eq!(r.metrics.len(), 3);
}
#[test]
fn nested_breakdowns_hold_several_metrics_per_key() {
let r = EvalReport::new("detection", Provenance::ParityVerified)
.with_class_metric("person", "AP", 0.6)
.with_class_metric("person", "AR", 0.7)
.with_group_metric("rare", "AP", 0.2);
assert_eq!(r.per_class["person"]["AP"], 0.6);
assert_eq!(r.per_class["person"]["AR"], 0.7);
assert_eq!(r.per_group["rare"]["AP"], 0.2);
}
}