1use chrono::{Datelike, NaiveDate};
11use datasynth_core::models::{
12 AnomalyType, ContributingFactor, ErrorType, FactorType, FraudType, ProcessIssueType,
13 RelationalAnomalyType,
14};
15use rust_decimal::Decimal;
16
17#[derive(Debug, Clone)]
19pub struct SeverityConfig {
20 pub base_type_weight: f64,
22 pub monetary_weight: f64,
24 pub frequency_weight: f64,
26 pub scope_weight: f64,
28 pub timing_weight: f64,
30 pub materiality_threshold: Decimal,
32 pub high_frequency_threshold: usize,
34 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), high_frequency_threshold: 5,
48 broad_scope_threshold: 3,
49 }
50 }
51}
52
53impl SeverityConfig {
54 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#[derive(Debug, Clone)]
72pub struct SeverityContext {
73 pub monetary_impact: Option<Decimal>,
75 pub occurrence_count: usize,
77 pub affected_entity_count: usize,
79 pub anomaly_date: Option<NaiveDate>,
81 pub is_month_end: bool,
83 pub is_quarter_end: bool,
85 pub is_year_end: bool,
87 pub is_audit_period: bool,
89 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, }
106 }
107}
108
109impl SeverityContext {
110 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#[derive(Debug, Clone)]
132pub struct SeverityCalculator {
133 config: SeverityConfig,
134}
135
136impl SeverityCalculator {
137 pub fn new() -> Self {
139 Self {
140 config: SeverityConfig::default(),
141 }
142 }
143
144 pub fn with_config(config: SeverityConfig) -> Self {
146 Self { config }
147 }
148
149 pub fn calculate(
153 &self,
154 anomaly_type: &AnomalyType,
155 context: &SeverityContext,
156 ) -> (f64, Vec<ContributingFactor>) {
157 let mut factors = Vec::new();
158
159 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 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 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 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 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 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 let final_severity = (severity * context.custom_modifier).clamp(0.0, 1.0);
235
236 (final_severity, factors)
237 }
238
239 fn calculate_base_severity(&self, anomaly_type: &AnomalyType) -> f64 {
241 let base_score = anomaly_type.severity() as f64 / 5.0;
243
244 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, 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, 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 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 let ratio = impact_f64 / materiality_f64;
289
290 if ratio < 0.1 {
291 0.1 } else if ratio < 0.5 {
293 0.3 } else if ratio < 1.0 {
295 0.5 } else if ratio < 2.0 {
297 0.7 } else if ratio < 5.0 {
299 0.85 } else {
301 1.0 }
303 } else {
304 0.5
305 }
306 }
307 None => 0.3, }
309 }
310
311 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 } else if count < threshold / 2 {
319 0.3 } else if count < threshold {
321 0.5 } else if count < threshold * 2 {
323 0.7 } else {
325 0.9 }
327 }
328
329 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 } else if count < threshold {
337 0.4 } else if count < threshold * 2 {
339 0.6 } else if count < threshold * 3 {
341 0.8 } else {
343 1.0 }
345 }
346
347 fn calculate_timing_severity(&self, context: &SeverityContext) -> f64 {
349 let mut severity: f64 = 0.2; 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#[derive(Debug, Clone, Default)]
375pub struct AnomalyScoreCalculator {
376 confidence_calculator: super::confidence::ConfidenceCalculator,
377 severity_calculator: SeverityCalculator,
378}
379
380impl AnomalyScoreCalculator {
381 pub fn new() -> Self {
383 Self {
384 confidence_calculator: super::confidence::ConfidenceCalculator::new(),
385 severity_calculator: SeverityCalculator::new(),
386 }
387 }
388
389 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 let mut all_factors = confidence_factors;
405 all_factors.extend(severity_factors);
406
407 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#[derive(Debug, Clone)]
421pub struct AnomalyScores {
422 pub confidence: f64,
424 pub severity: f64,
426 pub risk_score: f64,
428 pub contributing_factors: Vec<ContributingFactor>,
430}
431
432#[cfg(test)]
433#[allow(clippy::unwrap_used)]
434mod tests {
435 use super::*;
436 use rust_decimal_macros::dec;
437
438 #[test]
439 fn test_severity_calculator_basic() {
440 let calculator = SeverityCalculator::new();
441 let anomaly_type = AnomalyType::Fraud(FraudType::DuplicatePayment);
442 let context = SeverityContext::default();
443
444 let (severity, factors) = calculator.calculate(&anomaly_type, &context);
445
446 assert!((0.0..=1.0).contains(&severity));
447 assert!(!factors.is_empty());
448 }
449
450 #[test]
451 fn test_severity_with_monetary_impact() {
452 let calculator = SeverityCalculator::new();
453 let anomaly_type = AnomalyType::Fraud(FraudType::DuplicatePayment);
454
455 let low_impact_context = SeverityContext {
456 monetary_impact: Some(dec!(100)),
457 ..Default::default()
458 };
459
460 let high_impact_context = SeverityContext {
461 monetary_impact: Some(dec!(100000)),
462 ..Default::default()
463 };
464
465 let (low_severity, _) = calculator.calculate(&anomaly_type, &low_impact_context);
466 let (high_severity, _) = calculator.calculate(&anomaly_type, &high_impact_context);
467
468 assert!(high_severity > low_severity);
470 }
471
472 #[test]
473 fn test_severity_with_frequency() {
474 let calculator = SeverityCalculator::new();
475 let anomaly_type = AnomalyType::Error(ErrorType::DuplicateEntry);
476
477 let first_time = SeverityContext {
478 occurrence_count: 0,
479 ..Default::default()
480 };
481
482 let repeat_offender = SeverityContext {
483 occurrence_count: 10,
484 ..Default::default()
485 };
486
487 let (first_severity, _) = calculator.calculate(&anomaly_type, &first_time);
488 let (repeat_severity, _) = calculator.calculate(&anomaly_type, &repeat_offender);
489
490 assert!(repeat_severity > first_severity);
492 }
493
494 #[test]
495 fn test_severity_with_timing() {
496 let calculator = SeverityCalculator::new();
497 let anomaly_type = AnomalyType::Fraud(FraudType::JustBelowThreshold);
498
499 let normal_day = SeverityContext {
500 is_month_end: false,
501 is_quarter_end: false,
502 is_year_end: false,
503 ..Default::default()
504 };
505
506 let year_end = SeverityContext {
507 is_year_end: true,
508 is_audit_period: true,
509 ..Default::default()
510 };
511
512 let (normal_severity, _) = calculator.calculate(&anomaly_type, &normal_day);
513 let (year_end_severity, _) = calculator.calculate(&anomaly_type, &year_end);
514
515 assert!(year_end_severity > normal_severity);
517 }
518
519 #[test]
520 fn test_context_from_date() {
521 let year_end_date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
522 let context = SeverityContext::from_date(year_end_date);
523
524 assert!(context.is_month_end);
525 assert!(context.is_quarter_end);
526 assert!(context.is_year_end);
527
528 let mid_month = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
529 let mid_context = SeverityContext::from_date(mid_month);
530
531 assert!(!mid_context.is_month_end);
532 assert!(!mid_context.is_quarter_end);
533 assert!(!mid_context.is_year_end);
534 }
535
536 #[test]
537 fn test_config_validation() {
538 let valid_config = SeverityConfig::default();
539 assert!(valid_config.validate().is_ok());
540
541 let invalid_config = SeverityConfig {
542 base_type_weight: 0.5,
543 monetary_weight: 0.5,
544 frequency_weight: 0.5,
545 scope_weight: 0.5,
546 timing_weight: 0.5, ..Default::default()
548 };
549 assert!(invalid_config.validate().is_err());
550 }
551
552 #[test]
553 fn test_combined_calculator() {
554 let calculator = AnomalyScoreCalculator::new();
555 let anomaly_type = AnomalyType::Fraud(FraudType::CollusiveApproval);
556
557 let conf_context = super::super::confidence::ConfidenceContext {
558 entity_risk_score: 0.8,
559 prior_anomaly_count: 3,
560 ..Default::default()
561 };
562
563 let sev_context = SeverityContext {
564 monetary_impact: Some(dec!(50000)),
565 occurrence_count: 2,
566 is_year_end: true,
567 ..Default::default()
568 };
569
570 let scores = calculator.calculate(&anomaly_type, &conf_context, &sev_context);
571
572 assert!(scores.confidence >= 0.0 && scores.confidence <= 1.0);
573 assert!(scores.severity >= 0.0 && scores.severity <= 1.0);
574 assert!(scores.risk_score >= 0.0 && scores.risk_score <= 1.0);
575 assert!(!scores.contributing_factors.is_empty());
576 }
577}