use datasynth_core::models::{
AnomalyType, ContributingFactor, ErrorType, FactorType, FraudType, ProcessIssueType,
RelationalAnomalyType, StatisticalAnomalyType,
};
use rust_decimal::Decimal;
#[derive(Debug, Clone)]
pub struct ConfidenceConfig {
pub pattern_clarity_weight: f64,
pub strength_weight: f64,
pub detectability_weight: f64,
pub context_weight: f64,
pub materiality_threshold: Decimal,
}
impl Default for ConfidenceConfig {
fn default() -> Self {
Self {
pattern_clarity_weight: 0.30,
strength_weight: 0.25,
detectability_weight: 0.25,
context_weight: 0.20,
materiality_threshold: Decimal::new(10000, 0), }
}
}
impl ConfidenceConfig {
pub fn validate(&self) -> Result<(), String> {
let sum = self.pattern_clarity_weight
+ self.strength_weight
+ self.detectability_weight
+ self.context_weight;
if (sum - 1.0).abs() > 0.01 {
return Err(format!("Confidence weights must sum to 1.0, got {sum}"));
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfidenceContext {
pub amount: Option<Decimal>,
pub expected_amount: Option<Decimal>,
pub prior_anomaly_count: usize,
pub entity_risk_score: f64,
pub auto_detected: bool,
pub evidence_count: usize,
pub pattern_confidence: f64,
pub timing_score: f64,
}
#[derive(Debug, Clone)]
pub struct ConfidenceCalculator {
config: ConfidenceConfig,
}
impl ConfidenceCalculator {
pub fn new() -> Self {
Self {
config: ConfidenceConfig::default(),
}
}
pub fn with_config(config: ConfidenceConfig) -> Self {
Self { config }
}
pub fn calculate(
&self,
anomaly_type: &AnomalyType,
context: &ConfidenceContext,
) -> (f64, Vec<ContributingFactor>) {
let mut factors = Vec::new();
let pattern_clarity = self.calculate_pattern_clarity(anomaly_type, context);
factors.push(ContributingFactor::new(
FactorType::PatternMatch,
pattern_clarity,
0.5, true,
self.config.pattern_clarity_weight,
&format!("Pattern clarity score: {pattern_clarity:.2}"),
));
let strength = self.calculate_anomaly_strength(anomaly_type, context);
factors.push(ContributingFactor::new(
FactorType::AmountDeviation,
strength,
0.3, true,
self.config.strength_weight,
&format!("Anomaly strength: {strength:.2}"),
));
let detectability = self.calculate_detectability(anomaly_type, context);
factors.push(ContributingFactor::new(
FactorType::PatternMatch,
detectability,
0.5,
true,
self.config.detectability_weight,
&format!("Auto-detectability: {detectability:.2}"),
));
let context_match = self.calculate_context_match(context);
factors.push(ContributingFactor::new(
FactorType::EntityRisk,
context_match,
0.3,
true,
self.config.context_weight,
&format!("Context match score: {context_match:.2}"),
));
let confidence = pattern_clarity * self.config.pattern_clarity_weight
+ strength * self.config.strength_weight
+ detectability * self.config.detectability_weight
+ context_match * self.config.context_weight;
(confidence.clamp(0.0, 1.0), factors)
}
fn calculate_pattern_clarity(
&self,
anomaly_type: &AnomalyType,
context: &ConfidenceContext,
) -> f64 {
let base_clarity = match anomaly_type {
AnomalyType::Fraud(fraud_type) => match fraud_type {
FraudType::DuplicatePayment => 0.95, FraudType::SelfApproval => 0.90,
FraudType::SegregationOfDutiesViolation => 0.85,
FraudType::JustBelowThreshold => 0.80,
FraudType::RoundDollarManipulation => 0.70,
FraudType::FictitiousVendor => 0.60, FraudType::CollusiveApproval => 0.50, _ => 0.65,
},
AnomalyType::Error(error_type) => match error_type {
ErrorType::DuplicateEntry => 0.95,
ErrorType::ReversedAmount => 0.90,
ErrorType::UnbalancedEntry => 0.95,
ErrorType::MissingField => 0.85,
_ => 0.75,
},
AnomalyType::ProcessIssue(process_type) => match process_type {
ProcessIssueType::SkippedApproval => 0.90,
ProcessIssueType::MissingDocumentation => 0.85,
ProcessIssueType::ManualOverride => 0.80,
_ => 0.70,
},
AnomalyType::Statistical(stat_type) => match stat_type {
StatisticalAnomalyType::BenfordViolation => 0.75,
StatisticalAnomalyType::StatisticalOutlier => 0.70,
StatisticalAnomalyType::UnusuallyHighAmount => 0.65,
_ => 0.60,
},
AnomalyType::Relational(rel_type) => match rel_type {
RelationalAnomalyType::CircularTransaction => 0.85,
RelationalAnomalyType::DormantAccountActivity => 0.80,
_ => 0.65,
},
AnomalyType::Custom(_) => 0.50,
};
let adjusted = base_clarity * 0.7 + context.pattern_confidence * 0.3;
adjusted.clamp(0.0, 1.0)
}
fn calculate_anomaly_strength(
&self,
anomaly_type: &AnomalyType,
context: &ConfidenceContext,
) -> f64 {
let amount_strength =
if let (Some(amount), Some(expected)) = (context.amount, context.expected_amount) {
let deviation = (amount - expected).abs();
let expected_f64: f64 = expected.try_into().unwrap_or(1.0);
let deviation_f64: f64 = deviation.try_into().unwrap_or(0.0);
if expected_f64.abs() > 0.01 {
(deviation_f64 / expected_f64.abs()).min(2.0) / 2.0 } else {
0.5
}
} else {
0.5 };
let type_modifier = match anomaly_type {
AnomalyType::Fraud(_) => 1.2, AnomalyType::Statistical(_) => 1.0,
AnomalyType::Relational(_) => 1.1,
AnomalyType::Error(_) => 0.9,
AnomalyType::ProcessIssue(_) => 0.85,
AnomalyType::Custom(_) => 1.0,
};
(amount_strength * type_modifier).clamp(0.0, 1.0)
}
fn calculate_detectability(
&self,
anomaly_type: &AnomalyType,
context: &ConfidenceContext,
) -> f64 {
let base_detectability = match anomaly_type {
AnomalyType::Error(error_type) => match error_type {
ErrorType::UnbalancedEntry => 1.0, ErrorType::DuplicateEntry => 0.95,
ErrorType::MissingField => 0.90,
_ => 0.80,
},
AnomalyType::Fraud(fraud_type) => match fraud_type {
FraudType::DuplicatePayment => 0.90,
FraudType::SelfApproval => 0.85,
FraudType::JustBelowThreshold => 0.75,
FraudType::CollusiveApproval => 0.40, FraudType::FictitiousVendor => 0.45,
_ => 0.60,
},
AnomalyType::ProcessIssue(_) => 0.70,
AnomalyType::Statistical(_) => 0.65,
AnomalyType::Relational(_) => 0.55,
AnomalyType::Custom(_) => 0.50,
};
let auto_detect_boost: f64 = if context.auto_detected { 0.2 } else { 0.0 };
(base_detectability + auto_detect_boost).clamp(0.0, 1.0)
}
fn calculate_context_match(&self, context: &ConfidenceContext) -> f64 {
let mut score = 0.0;
score += context.entity_risk_score * 0.4;
let prior_contribution = (context.prior_anomaly_count as f64 / 5.0).min(1.0) * 0.3;
score += prior_contribution;
let evidence_contribution = (context.evidence_count as f64 / 3.0).min(1.0) * 0.2;
score += evidence_contribution;
score += context.timing_score * 0.1;
score.clamp(0.0, 1.0)
}
}
impl Default for ConfidenceCalculator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_confidence_calculator_basic() {
let calculator = ConfidenceCalculator::new();
let anomaly_type = AnomalyType::Fraud(FraudType::DuplicatePayment);
let context = ConfidenceContext::default();
let (confidence, factors) = calculator.calculate(&anomaly_type, &context);
assert!((0.0..=1.0).contains(&confidence));
assert!(!factors.is_empty());
}
#[test]
fn test_confidence_with_amount_context() {
let calculator = ConfidenceCalculator::new();
let anomaly_type = AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount);
let context = ConfidenceContext {
amount: Some(dec!(100000)),
expected_amount: Some(dec!(10000)),
..Default::default()
};
let (confidence, _) = calculator.calculate(&anomaly_type, &context);
assert!(confidence > 0.3);
}
#[test]
fn test_confidence_with_entity_risk() {
let calculator = ConfidenceCalculator::new();
let anomaly_type = AnomalyType::Fraud(FraudType::FictitiousVendor);
let low_risk_context = ConfidenceContext {
entity_risk_score: 0.1,
..Default::default()
};
let high_risk_context = ConfidenceContext {
entity_risk_score: 0.9,
prior_anomaly_count: 5,
..Default::default()
};
let (low_confidence, _) = calculator.calculate(&anomaly_type, &low_risk_context);
let (high_confidence, _) = calculator.calculate(&anomaly_type, &high_risk_context);
assert!(high_confidence > low_confidence);
}
#[test]
fn test_config_validation() {
let valid_config = ConfidenceConfig::default();
assert!(valid_config.validate().is_ok());
let invalid_config = ConfidenceConfig {
pattern_clarity_weight: 0.5,
strength_weight: 0.5,
detectability_weight: 0.5,
context_weight: 0.5, ..Default::default()
};
assert!(invalid_config.validate().is_err());
}
#[test]
fn test_auto_detected_boost() {
let calculator = ConfidenceCalculator::new();
let anomaly_type = AnomalyType::Error(ErrorType::DuplicateEntry);
let not_detected = ConfidenceContext {
auto_detected: false,
..Default::default()
};
let detected = ConfidenceContext {
auto_detected: true,
..Default::default()
};
let (conf_not, _) = calculator.calculate(&anomaly_type, ¬_detected);
let (conf_detected, _) = calculator.calculate(&anomaly_type, &detected);
assert!(conf_detected > conf_not);
}
}