Skip to main content

datasynth_generators/anomaly/
severity.rs

1//! Severity calculation for anomaly detection.
2//!
3//! This module provides contextual severity scoring based on:
4//! - Base type severity (static from AnomalyType)
5//! - Monetary impact (normalized by materiality)
6//! - Frequency factor (repeated anomalies = higher severity)
7//! - Scope factor (number of affected entities)
8//! - Timing factor (period-end = higher severity)
9
10use chrono::{Datelike, NaiveDate};
11use datasynth_core::models::{
12    AnomalyType, ContributingFactor, ErrorType, FactorType, FraudType, ProcessIssueType,
13    RelationalAnomalyType,
14};
15use rust_decimal::Decimal;
16
17/// Configuration for severity calculation.
18#[derive(Debug, Clone)]
19pub struct SeverityConfig {
20    /// Weight for base type severity component.
21    pub base_type_weight: f64,
22    /// Weight for monetary impact component.
23    pub monetary_weight: f64,
24    /// Weight for frequency factor component.
25    pub frequency_weight: f64,
26    /// Weight for scope factor component.
27    pub scope_weight: f64,
28    /// Weight for timing factor component.
29    pub timing_weight: f64,
30    /// Materiality threshold for monetary impact normalization.
31    pub materiality_threshold: Decimal,
32    /// Number of anomalies considered "high frequency".
33    pub high_frequency_threshold: usize,
34    /// Number of entities considered "broad scope".
35    pub broad_scope_threshold: usize,
36}
37
38impl Default for SeverityConfig {
39    fn default() -> Self {
40        Self {
41            base_type_weight: 0.25,
42            monetary_weight: 0.30,
43            frequency_weight: 0.20,
44            scope_weight: 0.15,
45            timing_weight: 0.10,
46            materiality_threshold: Decimal::new(10000, 0), // 10,000
47            high_frequency_threshold: 5,
48            broad_scope_threshold: 3,
49        }
50    }
51}
52
53impl SeverityConfig {
54    /// Validates that weights sum to 1.0.
55    pub fn validate(&self) -> Result<(), String> {
56        let sum = self.base_type_weight
57            + self.monetary_weight
58            + self.frequency_weight
59            + self.scope_weight
60            + self.timing_weight;
61
62        if (sum - 1.0).abs() > 0.01 {
63            return Err(format!("Severity weights must sum to 1.0, got {}", sum));
64        }
65
66        Ok(())
67    }
68}
69
70/// Context for severity calculation.
71#[derive(Debug, Clone)]
72pub struct SeverityContext {
73    /// Monetary impact of the anomaly.
74    pub monetary_impact: Option<Decimal>,
75    /// Number of times this anomaly type has occurred.
76    pub occurrence_count: usize,
77    /// Number of entities affected.
78    pub affected_entity_count: usize,
79    /// Date of the anomaly.
80    pub anomaly_date: Option<NaiveDate>,
81    /// Is this a month-end period.
82    pub is_month_end: bool,
83    /// Is this a quarter-end period.
84    pub is_quarter_end: bool,
85    /// Is this a year-end period.
86    pub is_year_end: bool,
87    /// Is this during an audit period.
88    pub is_audit_period: bool,
89    /// Custom severity modifier (multiplier).
90    pub custom_modifier: f64,
91}
92
93impl Default for SeverityContext {
94    fn default() -> Self {
95        Self {
96            monetary_impact: None,
97            occurrence_count: 0,
98            affected_entity_count: 0,
99            anomaly_date: None,
100            is_month_end: false,
101            is_quarter_end: false,
102            is_year_end: false,
103            is_audit_period: false,
104            custom_modifier: 1.0, // Multiplier should default to 1.0, not 0.0
105        }
106    }
107}
108
109impl SeverityContext {
110    /// Creates context from a date, auto-detecting period-end flags.
111    pub fn from_date(date: NaiveDate) -> Self {
112        let day = date.day();
113        let month = date.month();
114
115        let is_month_end = day >= 28;
116        let is_quarter_end = is_month_end && matches!(month, 3 | 6 | 9 | 12);
117        let is_year_end = month == 12 && day >= 28;
118
119        Self {
120            anomaly_date: Some(date),
121            is_month_end,
122            is_quarter_end,
123            is_year_end,
124            custom_modifier: 1.0,
125            ..Default::default()
126        }
127    }
128}
129
130/// Calculator for anomaly severity scores.
131#[derive(Debug, Clone)]
132pub struct SeverityCalculator {
133    config: SeverityConfig,
134}
135
136impl SeverityCalculator {
137    /// Creates a new severity calculator with default config.
138    pub fn new() -> Self {
139        Self {
140            config: SeverityConfig::default(),
141        }
142    }
143
144    /// Creates a new severity calculator with custom config.
145    pub fn with_config(config: SeverityConfig) -> Self {
146        Self { config }
147    }
148
149    /// Calculates severity score for an anomaly.
150    ///
151    /// Returns a tuple of (severity_score, contributing_factors).
152    pub fn calculate(
153        &self,
154        anomaly_type: &AnomalyType,
155        context: &SeverityContext,
156    ) -> (f64, Vec<ContributingFactor>) {
157        let mut factors = Vec::new();
158
159        // Component 1: Base Type Severity
160        let base_severity = self.calculate_base_severity(anomaly_type);
161        factors.push(ContributingFactor::new(
162            FactorType::PatternMatch,
163            base_severity,
164            0.5,
165            true,
166            self.config.base_type_weight,
167            &format!("Base type severity: {:.2}", base_severity),
168        ));
169
170        // Component 2: Monetary Impact
171        let monetary_severity = self.calculate_monetary_severity(context);
172        if monetary_severity > 0.0 {
173            factors.push(ContributingFactor::new(
174                FactorType::AmountDeviation,
175                monetary_severity,
176                0.3,
177                true,
178                self.config.monetary_weight,
179                &format!("Monetary impact severity: {:.2}", monetary_severity),
180            ));
181        }
182
183        // Component 3: Frequency Factor
184        let frequency_severity = self.calculate_frequency_severity(context);
185        if frequency_severity > 0.0 {
186            factors.push(ContributingFactor::new(
187                FactorType::FrequencyDeviation,
188                frequency_severity,
189                0.3,
190                true,
191                self.config.frequency_weight,
192                &format!(
193                    "Frequency factor (count={}): {:.2}",
194                    context.occurrence_count, frequency_severity
195                ),
196            ));
197        }
198
199        // Component 4: Scope Factor
200        let scope_severity = self.calculate_scope_severity(context);
201        if scope_severity > 0.0 {
202            factors.push(ContributingFactor::new(
203                FactorType::RelationshipAnomaly,
204                scope_severity,
205                0.3,
206                true,
207                self.config.scope_weight,
208                &format!(
209                    "Scope factor (entities={}): {:.2}",
210                    context.affected_entity_count, scope_severity
211                ),
212            ));
213        }
214
215        // Component 5: Timing Factor
216        let timing_severity = self.calculate_timing_severity(context);
217        factors.push(ContributingFactor::new(
218            FactorType::TimingAnomaly,
219            timing_severity,
220            0.3,
221            true,
222            self.config.timing_weight,
223            &format!("Timing factor: {:.2}", timing_severity),
224        ));
225
226        // Calculate weighted sum
227        let severity = base_severity * self.config.base_type_weight
228            + monetary_severity * self.config.monetary_weight
229            + frequency_severity * self.config.frequency_weight
230            + scope_severity * self.config.scope_weight
231            + timing_severity * self.config.timing_weight;
232
233        // Apply custom modifier
234        let final_severity = (severity * context.custom_modifier).clamp(0.0, 1.0);
235
236        (final_severity, factors)
237    }
238
239    /// Calculates base severity from anomaly type.
240    fn calculate_base_severity(&self, anomaly_type: &AnomalyType) -> f64 {
241        // Convert 1-5 severity scale to 0.0-1.0
242        let base_score = anomaly_type.severity() as f64 / 5.0;
243
244        // Apply type-specific modifiers
245        let modifier = match anomaly_type {
246            AnomalyType::Fraud(fraud_type) => match fraud_type {
247                FraudType::CollusiveApproval => 1.2,
248                FraudType::RevenueManipulation => 1.2,
249                FraudType::FictitiousVendor => 1.15,
250                FraudType::AssetMisappropriation => 1.1,
251                _ => 1.0,
252            },
253            AnomalyType::Error(error_type) => match error_type {
254                ErrorType::UnbalancedEntry => 1.1, // Material misstatement
255                ErrorType::CurrencyError => 1.05,
256                _ => 1.0,
257            },
258            AnomalyType::ProcessIssue(process_type) => match process_type {
259                ProcessIssueType::SystemBypass => 1.1,
260                ProcessIssueType::IncompleteAuditTrail => 1.05,
261                _ => 1.0,
262            },
263            AnomalyType::Statistical(_) => 0.9, // Generally less severe
264            AnomalyType::Relational(rel_type) => match rel_type {
265                RelationalAnomalyType::CircularTransaction => 1.1,
266                RelationalAnomalyType::TransferPricingAnomaly => 1.1,
267                _ => 1.0,
268            },
269            AnomalyType::Custom(_) => 1.0,
270        };
271
272        (base_score * modifier).clamp(0.0, 1.0)
273    }
274
275    /// Calculates monetary severity based on impact and materiality.
276    fn calculate_monetary_severity(&self, context: &SeverityContext) -> f64 {
277        match context.monetary_impact {
278            Some(impact) => {
279                let impact_f64: f64 = impact.abs().try_into().unwrap_or(0.0);
280                let materiality_f64: f64 = self
281                    .config
282                    .materiality_threshold
283                    .try_into()
284                    .unwrap_or(10000.0);
285
286                if materiality_f64 > 0.0 {
287                    // Use log scale for impact relative to materiality
288                    let ratio = impact_f64 / materiality_f64;
289
290                    if ratio < 0.1 {
291                        0.1 // Immaterial
292                    } else if ratio < 0.5 {
293                        0.3 // Low materiality
294                    } else if ratio < 1.0 {
295                        0.5 // Approaching materiality
296                    } else if ratio < 2.0 {
297                        0.7 // At materiality
298                    } else if ratio < 5.0 {
299                        0.85 // Significant
300                    } else {
301                        1.0 // Highly material
302                    }
303                } else {
304                    0.5
305                }
306            }
307            None => 0.3, // Default when no monetary impact
308        }
309    }
310
311    /// Calculates frequency severity based on occurrence count.
312    fn calculate_frequency_severity(&self, context: &SeverityContext) -> f64 {
313        let count = context.occurrence_count;
314        let threshold = self.config.high_frequency_threshold;
315
316        if count == 0 {
317            0.1 // First occurrence
318        } else if count < threshold / 2 {
319            0.3 // Low frequency
320        } else if count < threshold {
321            0.5 // Moderate frequency
322        } else if count < threshold * 2 {
323            0.7 // High frequency
324        } else {
325            0.9 // Very high frequency (repeat offender)
326        }
327    }
328
329    /// Calculates scope severity based on affected entities.
330    fn calculate_scope_severity(&self, context: &SeverityContext) -> f64 {
331        let count = context.affected_entity_count;
332        let threshold = self.config.broad_scope_threshold;
333
334        if count <= 1 {
335            0.2 // Single entity
336        } else if count < threshold {
337            0.4 // Limited scope
338        } else if count < threshold * 2 {
339            0.6 // Moderate scope
340        } else if count < threshold * 3 {
341            0.8 // Broad scope
342        } else {
343            1.0 // Pervasive
344        }
345    }
346
347    /// Calculates timing severity based on period-end flags.
348    fn calculate_timing_severity(&self, context: &SeverityContext) -> f64 {
349        let mut severity: f64 = 0.2; // Base timing severity
350
351        if context.is_audit_period {
352            severity += 0.3;
353        }
354
355        if context.is_year_end {
356            severity += 0.3;
357        } else if context.is_quarter_end {
358            severity += 0.2;
359        } else if context.is_month_end {
360            severity += 0.1;
361        }
362
363        severity.clamp(0.0, 1.0)
364    }
365}
366
367impl Default for SeverityCalculator {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373/// Combined confidence and severity calculator.
374#[derive(Debug, Clone, Default)]
375pub struct AnomalyScoreCalculator {
376    confidence_calculator: super::confidence::ConfidenceCalculator,
377    severity_calculator: SeverityCalculator,
378}
379
380impl AnomalyScoreCalculator {
381    /// Creates a new combined calculator with default configs.
382    pub fn new() -> Self {
383        Self {
384            confidence_calculator: super::confidence::ConfidenceCalculator::new(),
385            severity_calculator: SeverityCalculator::new(),
386        }
387    }
388
389    /// Calculates both confidence and severity for an anomaly.
390    pub fn calculate(
391        &self,
392        anomaly_type: &AnomalyType,
393        confidence_context: &super::confidence::ConfidenceContext,
394        severity_context: &SeverityContext,
395    ) -> AnomalyScores {
396        let (confidence, confidence_factors) = self
397            .confidence_calculator
398            .calculate(anomaly_type, confidence_context);
399        let (severity, severity_factors) = self
400            .severity_calculator
401            .calculate(anomaly_type, severity_context);
402
403        // Combine factors
404        let mut all_factors = confidence_factors;
405        all_factors.extend(severity_factors);
406
407        // Calculate risk score (geometric mean of confidence and severity)
408        let risk_score = (confidence * severity).sqrt();
409
410        AnomalyScores {
411            confidence,
412            severity,
413            risk_score,
414            contributing_factors: all_factors,
415        }
416    }
417}
418
419/// Combined anomaly scores.
420#[derive(Debug, Clone)]
421pub struct AnomalyScores {
422    /// Confidence score (0.0 - 1.0).
423    pub confidence: f64,
424    /// Severity score (0.0 - 1.0).
425    pub severity: f64,
426    /// Combined risk score (0.0 - 1.0).
427    pub risk_score: f64,
428    /// All contributing factors.
429    pub contributing_factors: Vec<ContributingFactor>,
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use rust_decimal_macros::dec;
436
437    #[test]
438    fn test_severity_calculator_basic() {
439        let calculator = SeverityCalculator::new();
440        let anomaly_type = AnomalyType::Fraud(FraudType::DuplicatePayment);
441        let context = SeverityContext::default();
442
443        let (severity, factors) = calculator.calculate(&anomaly_type, &context);
444
445        assert!(severity >= 0.0 && severity <= 1.0);
446        assert!(!factors.is_empty());
447    }
448
449    #[test]
450    fn test_severity_with_monetary_impact() {
451        let calculator = SeverityCalculator::new();
452        let anomaly_type = AnomalyType::Fraud(FraudType::DuplicatePayment);
453
454        let low_impact_context = SeverityContext {
455            monetary_impact: Some(dec!(100)),
456            ..Default::default()
457        };
458
459        let high_impact_context = SeverityContext {
460            monetary_impact: Some(dec!(100000)),
461            ..Default::default()
462        };
463
464        let (low_severity, _) = calculator.calculate(&anomaly_type, &low_impact_context);
465        let (high_severity, _) = calculator.calculate(&anomaly_type, &high_impact_context);
466
467        // Higher impact should have higher severity
468        assert!(high_severity > low_severity);
469    }
470
471    #[test]
472    fn test_severity_with_frequency() {
473        let calculator = SeverityCalculator::new();
474        let anomaly_type = AnomalyType::Error(ErrorType::DuplicateEntry);
475
476        let first_time = SeverityContext {
477            occurrence_count: 0,
478            ..Default::default()
479        };
480
481        let repeat_offender = SeverityContext {
482            occurrence_count: 10,
483            ..Default::default()
484        };
485
486        let (first_severity, _) = calculator.calculate(&anomaly_type, &first_time);
487        let (repeat_severity, _) = calculator.calculate(&anomaly_type, &repeat_offender);
488
489        // Repeat occurrence should have higher severity
490        assert!(repeat_severity > first_severity);
491    }
492
493    #[test]
494    fn test_severity_with_timing() {
495        let calculator = SeverityCalculator::new();
496        let anomaly_type = AnomalyType::Fraud(FraudType::JustBelowThreshold);
497
498        let normal_day = SeverityContext {
499            is_month_end: false,
500            is_quarter_end: false,
501            is_year_end: false,
502            ..Default::default()
503        };
504
505        let year_end = SeverityContext {
506            is_year_end: true,
507            is_audit_period: true,
508            ..Default::default()
509        };
510
511        let (normal_severity, _) = calculator.calculate(&anomaly_type, &normal_day);
512        let (year_end_severity, _) = calculator.calculate(&anomaly_type, &year_end);
513
514        // Year-end during audit should have higher severity
515        assert!(year_end_severity > normal_severity);
516    }
517
518    #[test]
519    fn test_context_from_date() {
520        let year_end_date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
521        let context = SeverityContext::from_date(year_end_date);
522
523        assert!(context.is_month_end);
524        assert!(context.is_quarter_end);
525        assert!(context.is_year_end);
526
527        let mid_month = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
528        let mid_context = SeverityContext::from_date(mid_month);
529
530        assert!(!mid_context.is_month_end);
531        assert!(!mid_context.is_quarter_end);
532        assert!(!mid_context.is_year_end);
533    }
534
535    #[test]
536    fn test_config_validation() {
537        let valid_config = SeverityConfig::default();
538        assert!(valid_config.validate().is_ok());
539
540        let invalid_config = SeverityConfig {
541            base_type_weight: 0.5,
542            monetary_weight: 0.5,
543            frequency_weight: 0.5,
544            scope_weight: 0.5,
545            timing_weight: 0.5, // Sum = 2.5
546            ..Default::default()
547        };
548        assert!(invalid_config.validate().is_err());
549    }
550
551    #[test]
552    fn test_combined_calculator() {
553        let calculator = AnomalyScoreCalculator::new();
554        let anomaly_type = AnomalyType::Fraud(FraudType::CollusiveApproval);
555
556        let conf_context = super::super::confidence::ConfidenceContext {
557            entity_risk_score: 0.8,
558            prior_anomaly_count: 3,
559            ..Default::default()
560        };
561
562        let sev_context = SeverityContext {
563            monetary_impact: Some(dec!(50000)),
564            occurrence_count: 2,
565            is_year_end: true,
566            ..Default::default()
567        };
568
569        let scores = calculator.calculate(&anomaly_type, &conf_context, &sev_context);
570
571        assert!(scores.confidence >= 0.0 && scores.confidence <= 1.0);
572        assert!(scores.severity >= 0.0 && scores.severity <= 1.0);
573        assert!(scores.risk_score >= 0.0 && scores.risk_score <= 1.0);
574        assert!(!scores.contributing_factors.is_empty());
575    }
576}