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)]
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 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 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 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, ..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}