use serde::{Deserialize, Serialize};
pub const METRIC_INTEGRITY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MetricOutcome {
Value(f64),
TrueZero,
#[default]
NoData,
AggregateFailed,
SchemaIncompatible,
RebuildRequired,
InvalidInput,
}
impl MetricOutcome {
pub fn finite(value: f64) -> Self {
if value.is_finite() {
MetricOutcome::Value(value)
} else {
MetricOutcome::InvalidInput
}
}
pub const fn kind_str(self) -> &'static str {
match self {
MetricOutcome::Value(_) => "value",
MetricOutcome::TrueZero => "true-zero",
MetricOutcome::NoData => "no-data",
MetricOutcome::AggregateFailed => "aggregate-failed",
MetricOutcome::SchemaIncompatible => "schema-incompatible",
MetricOutcome::RebuildRequired => "rebuild-required",
MetricOutcome::InvalidInput => "invalid-input",
}
}
pub fn as_value(self) -> Option<f64> {
match self {
MetricOutcome::Value(v) => Some(v),
MetricOutcome::TrueZero => Some(0.0),
_ => None,
}
}
pub fn is_numeric(self) -> bool {
matches!(self, MetricOutcome::Value(_) | MetricOutcome::TrueZero)
}
pub fn is_no_data(self) -> bool {
matches!(
self,
MetricOutcome::NoData
| MetricOutcome::AggregateFailed
| MetricOutcome::SchemaIncompatible
| MetricOutcome::RebuildRequired
)
}
}
pub fn safe_ratio(numerator: f64, denominator: f64) -> MetricOutcome {
if !numerator.is_finite() || !denominator.is_finite() || denominator < 0.0 {
return MetricOutcome::InvalidInput;
}
if denominator == 0.0 {
return MetricOutcome::NoData;
}
let result = numerator / denominator;
if !result.is_finite() {
return MetricOutcome::InvalidInput;
}
if result == 0.0 {
MetricOutcome::TrueZero
} else {
MetricOutcome::Value(result)
}
}
pub fn classify_aggregate(row_count: u64, sum: f64) -> MetricOutcome {
if row_count == 0 {
return MetricOutcome::NoData;
}
if !sum.is_finite() {
return MetricOutcome::InvalidInput;
}
if sum == 0.0 {
MetricOutcome::TrueZero
} else {
MetricOutcome::Value(sum)
}
}
pub fn bakeoff_quality_ratio(baseline: f64, candidate: f64) -> MetricOutcome {
if !baseline.is_finite() || !candidate.is_finite() {
return MetricOutcome::InvalidInput;
}
if baseline <= 0.0 {
return MetricOutcome::NoData;
}
safe_ratio(candidate, baseline)
}
pub fn improvement_pct(baseline: f64, candidate: f64) -> MetricOutcome {
if !baseline.is_finite() || !candidate.is_finite() {
return MetricOutcome::InvalidInput;
}
if baseline <= 0.0 {
return MetricOutcome::NoData;
}
MetricOutcome::finite((candidate - baseline) / baseline * 100.0)
}
pub fn chart_cell(outcome: MetricOutcome) -> String {
match outcome {
MetricOutcome::Value(v) => format_value(v),
MetricOutcome::TrueZero => "0".to_string(),
_ => "—".to_string(),
}
}
fn format_value(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{v:.4}")
}
}
pub fn saturating_age_ms(now_ms: u64, last_ms: u64) -> u64 {
now_ms.saturating_sub(last_ms)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggregate_over_zero_rows_is_no_data_not_zero() {
let outcome = classify_aggregate(0, 0.0);
assert_eq!(outcome, MetricOutcome::NoData);
assert!(outcome.is_no_data());
assert!(!outcome.is_numeric());
assert_eq!(outcome.as_value(), None, "missing must never read as 0");
}
#[test]
fn aggregate_over_present_rows_summing_zero_is_true_zero() {
let outcome = classify_aggregate(5, 0.0);
assert_eq!(outcome, MetricOutcome::TrueZero);
assert!(outcome.is_numeric());
assert!(!outcome.is_no_data());
assert_eq!(outcome.as_value(), Some(0.0));
}
#[test]
fn aggregate_with_a_real_sum_is_a_value() {
assert_eq!(classify_aggregate(3, 1234.0), MetricOutcome::Value(1234.0));
}
#[test]
fn aggregate_with_non_finite_sum_is_invalid() {
assert_eq!(classify_aggregate(3, f64::NAN), MetricOutcome::InvalidInput);
assert_eq!(
classify_aggregate(3, f64::INFINITY),
MetricOutcome::InvalidInput
);
}
#[test]
fn ratio_by_zero_is_no_data_never_nan_or_inf() {
let outcome = safe_ratio(5.0, 0.0);
assert_eq!(outcome, MetricOutcome::NoData);
assert_eq!(outcome.as_value(), None);
}
#[test]
fn ratio_zero_over_positive_is_true_zero() {
assert_eq!(safe_ratio(0.0, 4.0), MetricOutcome::TrueZero);
}
#[test]
fn ratio_negative_denominator_and_non_finite_are_invalid() {
assert_eq!(safe_ratio(1.0, -2.0), MetricOutcome::InvalidInput);
assert_eq!(safe_ratio(f64::NAN, 2.0), MetricOutcome::InvalidInput);
assert_eq!(safe_ratio(1.0, f64::INFINITY), MetricOutcome::InvalidInput);
}
#[test]
fn ratio_finite_value_is_a_value() {
assert_eq!(safe_ratio(3.0, 4.0), MetricOutcome::Value(0.75));
}
#[test]
fn bakeoff_zero_or_negative_baseline_is_no_data_not_a_passing_score() {
assert_eq!(bakeoff_quality_ratio(0.0, 0.9), MetricOutcome::NoData);
assert_eq!(bakeoff_quality_ratio(-1.0, 0.9), MetricOutcome::NoData);
assert_eq!(improvement_pct(0.0, 0.9), MetricOutcome::NoData);
assert_eq!(bakeoff_quality_ratio(0.0, 0.9).as_value(), None);
}
#[test]
fn bakeoff_real_baseline_computes_ratio_and_improvement() {
assert_eq!(bakeoff_quality_ratio(4.0, 3.0), MetricOutcome::Value(0.75));
let improved = improvement_pct(0.8, 0.88)
.as_value()
.expect("a real-baseline improvement is a value");
assert!((improved - 10.0).abs() < 1e-9, "got {improved}");
}
#[test]
fn bakeoff_non_finite_inputs_are_invalid() {
assert_eq!(
bakeoff_quality_ratio(f64::NAN, 1.0),
MetricOutcome::InvalidInput
);
assert_eq!(
improvement_pct(1.0, f64::INFINITY),
MetricOutcome::InvalidInput
);
}
#[test]
fn finite_constructor_routes_non_finite_to_invalid() {
assert_eq!(MetricOutcome::finite(3.5), MetricOutcome::Value(3.5));
assert_eq!(MetricOutcome::finite(f64::NAN), MetricOutcome::InvalidInput);
assert_eq!(
MetricOutcome::finite(f64::INFINITY),
MetricOutcome::InvalidInput
);
}
#[test]
fn chart_cell_shows_dash_for_missing_and_zero_for_true_zero() {
assert_eq!(chart_cell(MetricOutcome::NoData), "—");
assert_eq!(chart_cell(MetricOutcome::AggregateFailed), "—");
assert_eq!(chart_cell(MetricOutcome::SchemaIncompatible), "—");
assert_eq!(chart_cell(MetricOutcome::RebuildRequired), "—");
assert_eq!(chart_cell(MetricOutcome::InvalidInput), "—");
assert_eq!(chart_cell(MetricOutcome::TrueZero), "0");
assert_eq!(chart_cell(MetricOutcome::Value(42.0)), "42");
assert_eq!(chart_cell(MetricOutcome::Value(0.75)), "0.7500");
}
#[test]
fn no_data_variants_are_classified_as_no_data() {
for o in [
MetricOutcome::NoData,
MetricOutcome::AggregateFailed,
MetricOutcome::SchemaIncompatible,
MetricOutcome::RebuildRequired,
] {
assert!(o.is_no_data(), "{} should be no-data", o.kind_str());
assert!(!o.is_numeric());
assert_eq!(o.as_value(), None);
}
assert!(!MetricOutcome::InvalidInput.is_no_data());
assert!(!MetricOutcome::InvalidInput.is_numeric());
}
#[test]
fn saturating_age_handles_clock_rollback() {
assert_eq!(saturating_age_ms(1_000, 400), 600);
assert_eq!(saturating_age_ms(400, 1_000), 0);
assert_eq!(saturating_age_ms(0, u64::MAX), 0);
}
#[test]
fn outcome_serializes_with_stable_kebab_kinds_and_round_trips() {
assert_eq!(
serde_json::to_string(&MetricOutcome::NoData).expect("ser"),
"\"no-data\""
);
assert_eq!(
serde_json::to_string(&MetricOutcome::SchemaIncompatible).expect("ser"),
"\"schema-incompatible\""
);
let v = serde_json::to_value(MetricOutcome::Value(12.5)).expect("to_value");
assert_eq!(v["value"], 12.5);
let back: MetricOutcome = serde_json::from_value(v).expect("round-trip");
assert_eq!(back, MetricOutcome::Value(12.5));
assert_eq!(MetricOutcome::TrueZero.kind_str(), "true-zero");
assert_eq!(
MetricOutcome::RebuildRequired.kind_str(),
"rebuild-required"
);
}
}