use crate::error::EvalResult;
use datasynth_core::models::{LabeledAnomaly, ObservabilityClass};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectabilityReport {
pub total: usize,
pub by_class: BTreeMap<String, usize>,
pub fraction_by_class: BTreeMap<String, f64>,
pub memory_only_fraction: f64,
pub per_je_density_fraction: f64,
pub relational_graph_fraction: f64,
pub temporal_fraction: f64,
pub by_class_and_category: BTreeMap<String, BTreeMap<String, usize>>,
}
#[derive(Debug, Clone, Default)]
pub struct DetectabilityAnalyzer;
impl DetectabilityAnalyzer {
pub fn new() -> Self {
Self
}
pub fn analyze(&self, labels: &[LabeledAnomaly]) -> EvalResult<DetectabilityReport> {
let total = labels.len();
let mut by_class: BTreeMap<String, usize> = BTreeMap::new();
let mut by_class_and_category: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
for c in [
ObservabilityClass::PerJeDensity,
ObservabilityClass::RelationalGraph,
ObservabilityClass::Temporal,
ObservabilityClass::MemoryOnly,
] {
by_class.insert(c.as_str().to_string(), 0);
}
for l in labels {
let cls = l.observability.as_str().to_string();
*by_class.entry(cls.clone()).or_default() += 1;
*by_class_and_category
.entry(cls)
.or_default()
.entry(l.anomaly_type.category().to_string())
.or_default() += 1;
}
let denom = total.max(1) as f64;
let fraction_by_class: BTreeMap<String, f64> = by_class
.iter()
.map(|(k, v)| (k.clone(), *v as f64 / denom))
.collect();
let frac = |c: ObservabilityClass| *by_class.get(c.as_str()).unwrap_or(&0) as f64 / denom;
Ok(DetectabilityReport {
total,
memory_only_fraction: frac(ObservabilityClass::MemoryOnly),
per_je_density_fraction: frac(ObservabilityClass::PerJeDensity),
relational_graph_fraction: frac(ObservabilityClass::RelationalGraph),
temporal_fraction: frac(ObservabilityClass::Temporal),
by_class,
fraction_by_class,
by_class_and_category,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use datasynth_core::models::{AnomalyType, ErrorType, FraudType, RelationalAnomalyType};
fn label(at: AnomalyType) -> LabeledAnomaly {
LabeledAnomaly::new(
"A".to_string(),
at,
"JE".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
)
}
#[test]
fn profiles_population_across_observability_arms() {
let labels = vec![
label(AnomalyType::Fraud(FraudType::RoundDollarManipulation)), label(AnomalyType::Fraud(FraudType::DuplicatePayment)), label(AnomalyType::Relational(
RelationalAnomalyType::CircularTransaction,
)), label(AnomalyType::Error(ErrorType::WrongPeriod)), ];
let report = DetectabilityAnalyzer::new().analyze(&labels).unwrap();
assert_eq!(report.total, 4);
assert_eq!(report.by_class["per_je_density"], 1);
assert_eq!(report.by_class["memory_only"], 1);
assert_eq!(report.by_class["relational_graph"], 1);
assert_eq!(report.by_class["temporal"], 1);
assert!((report.memory_only_fraction - 0.25).abs() < 1e-9);
assert!((report.per_je_density_fraction - 0.25).abs() < 1e-9);
assert_eq!(report.by_class_and_category["memory_only"]["Fraud"], 1);
}
#[test]
fn empty_population_has_stable_zeroed_schema() {
let report = DetectabilityAnalyzer::new().analyze(&[]).unwrap();
assert_eq!(report.total, 0);
assert_eq!(report.by_class.len(), 4);
assert_eq!(report.memory_only_fraction, 0.0);
assert_eq!(report.by_class["relational_graph"], 0);
}
}