use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct VariantData {
pub variant_id: String,
pub case_count: usize,
pub is_happy_path: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariantThresholds {
pub min_entropy: f64,
pub max_happy_path_concentration: f64,
pub min_variant_count: usize,
}
impl Default for VariantThresholds {
fn default() -> Self {
Self {
min_entropy: 1.0,
max_happy_path_concentration: 0.95,
min_variant_count: 2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariantAnalysis {
pub variant_count: usize,
pub total_cases: usize,
pub variant_entropy: f64,
pub happy_path_concentration: f64,
pub top_variants: Vec<(String, f64)>,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct VariantAnalyzer {
thresholds: VariantThresholds,
}
impl VariantAnalyzer {
pub fn new() -> Self {
Self {
thresholds: VariantThresholds::default(),
}
}
pub fn with_thresholds(thresholds: VariantThresholds) -> Self {
Self { thresholds }
}
pub fn analyze(&self, variants: &[VariantData]) -> EvalResult<VariantAnalysis> {
let mut issues = Vec::new();
if variants.is_empty() {
return Ok(VariantAnalysis {
variant_count: 0,
total_cases: 0,
variant_entropy: 0.0,
happy_path_concentration: 0.0,
top_variants: Vec::new(),
passes: true,
issues: Vec::new(),
});
}
let total_cases: usize = variants.iter().map(|v| v.case_count).sum();
let variant_count = variants.len();
let variant_entropy = if total_cases > 0 {
let mut entropy = 0.0_f64;
for v in variants {
if v.case_count > 0 {
let p = v.case_count as f64 / total_cases as f64;
entropy -= p * p.ln();
}
}
entropy
} else {
0.0
};
let happy_cases: usize = variants
.iter()
.filter(|v| v.is_happy_path)
.map(|v| v.case_count)
.sum();
let happy_path_concentration = if total_cases > 0 {
happy_cases as f64 / total_cases as f64
} else {
0.0
};
let mut sorted: Vec<&VariantData> = variants.iter().collect();
sorted.sort_by(|a, b| b.case_count.cmp(&a.case_count));
let top_variants: Vec<(String, f64)> = sorted
.iter()
.take(5)
.map(|v| {
(
v.variant_id.clone(),
if total_cases > 0 {
v.case_count as f64 / total_cases as f64
} else {
0.0
},
)
})
.collect();
if variant_count < self.thresholds.min_variant_count {
issues.push(format!(
"Only {} variants (minimum {})",
variant_count, self.thresholds.min_variant_count
));
}
if variant_entropy < self.thresholds.min_entropy && variant_count > 1 {
issues.push(format!(
"Variant entropy {:.3} < {:.3}",
variant_entropy, self.thresholds.min_entropy
));
}
if happy_path_concentration > self.thresholds.max_happy_path_concentration {
issues.push(format!(
"Happy path concentration {:.3} > {:.3}",
happy_path_concentration, self.thresholds.max_happy_path_concentration
));
}
let passes = issues.is_empty();
Ok(VariantAnalysis {
variant_count,
total_cases,
variant_entropy,
happy_path_concentration,
top_variants,
passes,
issues,
})
}
}
impl Default for VariantAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_diverse_variants() {
let analyzer = VariantAnalyzer::new();
let variants = vec![
VariantData {
variant_id: "A->B->C".to_string(),
case_count: 50,
is_happy_path: true,
},
VariantData {
variant_id: "A->B->D->C".to_string(),
case_count: 30,
is_happy_path: false,
},
VariantData {
variant_id: "A->E->C".to_string(),
case_count: 20,
is_happy_path: false,
},
];
let result = analyzer.analyze(&variants).unwrap();
assert!(result.passes);
assert_eq!(result.variant_count, 3);
assert!(result.variant_entropy > 0.0);
}
#[test]
fn test_all_happy_path() {
let analyzer = VariantAnalyzer::new();
let variants = vec![
VariantData {
variant_id: "A->B->C".to_string(),
case_count: 100,
is_happy_path: true,
},
VariantData {
variant_id: "A->B->D".to_string(),
case_count: 1,
is_happy_path: false,
},
];
let result = analyzer.analyze(&variants).unwrap();
assert!(!result.passes);
assert!(result.happy_path_concentration > 0.95);
}
#[test]
fn test_single_variant() {
let analyzer = VariantAnalyzer::new();
let variants = vec![VariantData {
variant_id: "A->B".to_string(),
case_count: 100,
is_happy_path: true,
}];
let result = analyzer.analyze(&variants).unwrap();
assert!(!result.passes); }
#[test]
fn test_empty() {
let analyzer = VariantAnalyzer::new();
let result = analyzer.analyze(&[]).unwrap();
assert!(result.passes);
}
}