use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReliabilityScore {
pub overall: f32,
pub schema_completeness: f32,
pub transformation_success: f32,
pub retry_penalty: f32,
pub band: ReliabilityBand,
pub reasons: Vec<String>,
}
impl ReliabilityScore {
pub const HIGH_THRESHOLD: f32 = 0.85;
pub const MEDIUM_THRESHOLD: f32 = 0.50;
#[must_use]
pub fn from_overall(overall: f32) -> Self {
let overall = clamp_unit(overall);
Self {
overall,
schema_completeness: overall,
transformation_success: overall,
retry_penalty: 0.0,
band: ReliabilityBand::from_overall(overall),
reasons: Vec::new(),
}
}
#[must_use]
pub fn with_reasons(mut self, reasons: Vec<String>) -> Self {
self.reasons = reasons;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReliabilityBand {
High,
Medium,
Low,
}
impl ReliabilityBand {
#[must_use]
pub fn from_overall(overall: f32) -> Self {
if overall >= ReliabilityScore::HIGH_THRESHOLD {
Self::High
} else if overall >= ReliabilityScore::MEDIUM_THRESHOLD {
Self::Medium
} else {
Self::Low
}
}
}
impl std::fmt::Display for ReliabilityBand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::High => f.write_str("high"),
Self::Medium => f.write_str("medium"),
Self::Low => f.write_str("low"),
}
}
}
#[inline]
#[must_use]
pub(crate) const fn clamp_unit(value: f32) -> f32 {
if value.is_nan() {
0.0
} else {
value.clamp(0.0, 1.0)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < f32::EPSILON
}
#[test]
fn test_from_overall_clamps_to_unit_interval() {
assert!(approx_eq(ReliabilityScore::from_overall(-0.5).overall, 0.0));
assert!(approx_eq(ReliabilityScore::from_overall(1.5).overall, 1.0));
assert!(approx_eq(
ReliabilityScore::from_overall(0.42).overall,
0.42
));
}
#[test]
fn test_from_overall_nan_maps_to_zero() {
assert!(approx_eq(
ReliabilityScore::from_overall(f32::NAN).overall,
0.0
));
}
#[test]
fn test_band_boundaries() {
assert_eq!(
ReliabilityBand::from_overall(ReliabilityScore::HIGH_THRESHOLD),
ReliabilityBand::High
);
assert_eq!(
ReliabilityBand::from_overall(ReliabilityScore::MEDIUM_THRESHOLD),
ReliabilityBand::Medium
);
assert_eq!(
ReliabilityBand::from_overall(ReliabilityScore::MEDIUM_THRESHOLD - 0.01),
ReliabilityBand::Low
);
}
#[test]
fn test_band_serde_is_lowercase() {
let json = serde_json::to_string(&ReliabilityBand::High).unwrap();
assert_eq!(json, "\"high\"");
let roundtrip: ReliabilityBand = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, ReliabilityBand::High);
}
#[test]
fn test_with_reasons_preserves_other_fields() {
let mut score = ReliabilityScore::from_overall(0.6);
score.schema_completeness = 0.5;
score.transformation_success = 0.7;
let updated = score.clone().with_reasons(vec!["a".into(), "b".into()]);
assert_eq!(updated.reasons.len(), 2);
assert!((updated.overall - score.overall).abs() < f32::EPSILON);
assert!((updated.schema_completeness - 0.5).abs() < f32::EPSILON);
assert!((updated.transformation_success - 0.7).abs() < f32::EPSILON);
}
#[test]
fn test_band_display() {
assert_eq!(ReliabilityBand::High.to_string(), "high");
assert_eq!(ReliabilityBand::Medium.to_string(), "medium");
assert_eq!(ReliabilityBand::Low.to_string(), "low");
}
}