1use chrono::{Datelike, NaiveDate, Weekday};
7use datasynth_core::utils::weighted_select;
8use rand::Rng;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone)]
13pub enum TemporalPattern {
14 Uniform,
16 PeriodEndSpike {
18 month_end_multiplier: f64,
20 quarter_end_multiplier: f64,
22 year_end_multiplier: f64,
24 },
25 TimeBased {
27 after_hours_multiplier: f64,
29 weekend_multiplier: f64,
31 },
32 Seasonal {
34 month_multipliers: [f64; 12],
36 },
37 Custom {
39 name: String,
41 },
42}
43
44impl Default for TemporalPattern {
45 fn default() -> Self {
46 TemporalPattern::PeriodEndSpike {
47 month_end_multiplier: 2.0,
48 quarter_end_multiplier: 3.0,
49 year_end_multiplier: 5.0,
50 }
51 }
52}
53
54impl TemporalPattern {
55 pub fn probability_multiplier(&self, date: NaiveDate) -> f64 {
57 match self {
58 TemporalPattern::Uniform => 1.0,
59 TemporalPattern::PeriodEndSpike {
60 month_end_multiplier,
61 quarter_end_multiplier,
62 year_end_multiplier,
63 } => {
64 let day = date.day();
65 let month = date.month();
66
67 if month == 12 && day >= 28 {
69 return *year_end_multiplier;
70 }
71
72 if matches!(month, 3 | 6 | 9 | 12) && day >= 28 {
74 return *quarter_end_multiplier;
75 }
76
77 if day >= 28 {
79 return *month_end_multiplier;
80 }
81
82 1.0
83 }
84 TemporalPattern::TimeBased {
85 after_hours_multiplier: _,
86 weekend_multiplier,
87 } => {
88 let weekday = date.weekday();
89 if weekday == Weekday::Sat || weekday == Weekday::Sun {
90 return *weekend_multiplier;
91 }
92 1.0
95 }
96 TemporalPattern::Seasonal { month_multipliers } => {
97 let month_idx = (date.month() - 1) as usize;
98 month_multipliers[month_idx]
99 }
100 TemporalPattern::Custom { .. } => 1.0,
101 }
102 }
103
104 pub fn audit_season() -> Self {
106 TemporalPattern::Seasonal {
107 month_multipliers: [
108 2.0, 2.0, 1.5, 1.0, 1.0, 1.2, 1.0, 1.0, 1.2, 1.0, 1.0, 3.0, ],
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119pub enum FraudCategory {
120 AccountsReceivable,
122 AccountsPayable,
124 Payroll,
126 Expense,
128 Revenue,
130 Asset,
132 General,
134}
135
136impl FraudCategory {
137 pub fn time_window_days(&self) -> (i64, i64) {
139 match self {
140 FraudCategory::AccountsReceivable => (30, 45), FraudCategory::AccountsPayable => (14, 30), FraudCategory::Payroll => (28, 35), FraudCategory::Expense => (7, 14), FraudCategory::Revenue => (85, 95), FraudCategory::Asset => (30, 60), FraudCategory::General => (5, 10), }
148 }
149
150 pub fn from_anomaly_type(anomaly_type: &str) -> Self {
152 let lower = anomaly_type.to_lowercase();
153 if lower.contains("receivable")
154 || lower.contains("ar")
155 || lower.contains("invoice")
156 || lower.contains("customer")
157 {
158 FraudCategory::AccountsReceivable
159 } else if lower.contains("payable")
160 || lower.contains("ap")
161 || lower.contains("vendor")
162 || lower.contains("payment")
163 {
164 FraudCategory::AccountsPayable
165 } else if lower.contains("payroll")
166 || lower.contains("ghost")
167 || lower.contains("employee")
168 || lower.contains("salary")
169 {
170 FraudCategory::Payroll
171 } else if lower.contains("expense") || lower.contains("reimbursement") {
172 FraudCategory::Expense
173 } else if lower.contains("revenue")
174 || lower.contains("sales")
175 || lower.contains("channel")
176 || lower.contains("premature")
177 {
178 FraudCategory::Revenue
179 } else if lower.contains("asset")
180 || lower.contains("inventory")
181 || lower.contains("fixed")
182 || lower.contains("depreciation")
183 {
184 FraudCategory::Asset
185 } else {
186 FraudCategory::General
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct ClusteringConfig {
194 pub enabled: bool,
196 pub cluster_start_probability: f64,
198 pub cluster_continuation_probability: f64,
200 pub min_cluster_size: usize,
202 pub max_cluster_size: usize,
204 pub cluster_time_window_days: i64,
206 pub use_fraud_specific_windows: bool,
208 pub preserve_account_relationships: bool,
210}
211
212impl Default for ClusteringConfig {
213 fn default() -> Self {
214 Self {
215 enabled: true,
216 cluster_start_probability: 0.3,
217 cluster_continuation_probability: 0.7,
218 min_cluster_size: 2,
219 max_cluster_size: 10,
220 cluster_time_window_days: 7,
221 use_fraud_specific_windows: true,
222 preserve_account_relationships: true,
223 }
224 }
225}
226
227#[derive(Debug, Clone)]
229pub struct CausalLink {
230 pub source_entity: String,
232 pub source_type: String,
234 pub target_entity: String,
236 pub target_type: String,
238 pub relationship: String,
240}
241
242impl CausalLink {
243 pub fn new(
245 source_entity: impl Into<String>,
246 source_type: impl Into<String>,
247 target_entity: impl Into<String>,
248 target_type: impl Into<String>,
249 relationship: impl Into<String>,
250 ) -> Self {
251 Self {
252 source_entity: source_entity.into(),
253 source_type: source_type.into(),
254 target_entity: target_entity.into(),
255 target_type: target_type.into(),
256 relationship: relationship.into(),
257 }
258 }
259}
260
261pub struct ClusterManager {
263 config: ClusteringConfig,
264 active_clusters: HashMap<FraudCategory, ActiveCluster>,
266 next_cluster_id: u64,
268 cluster_stats: HashMap<String, ClusterStats>,
270}
271
272#[derive(Debug, Clone)]
274struct ActiveCluster {
275 cluster_id: String,
277 size: usize,
279 start_date: NaiveDate,
281 #[allow(dead_code)]
284 category: FraudCategory,
285 time_window_days: i64,
287 accounts: Vec<String>,
289 entities: Vec<String>,
291}
292
293#[derive(Debug, Clone, Default)]
295pub struct ClusterStats {
296 pub size: usize,
298 pub start_date: Option<NaiveDate>,
300 pub end_date: Option<NaiveDate>,
302 pub anomaly_types: Vec<String>,
304 pub fraud_category: Option<FraudCategory>,
306 pub time_window_days: i64,
308 pub accounts: Vec<String>,
310 pub entities: Vec<String>,
312 pub causal_links: Vec<CausalLink>,
314}
315
316impl ClusterManager {
317 pub fn new(config: ClusteringConfig) -> Self {
319 Self {
320 config,
321 active_clusters: HashMap::new(),
322 next_cluster_id: 1,
323 cluster_stats: HashMap::new(),
324 }
325 }
326
327 pub fn assign_cluster<R: Rng>(
329 &mut self,
330 date: NaiveDate,
331 anomaly_type: &str,
332 rng: &mut R,
333 ) -> Option<String> {
334 self.assign_cluster_with_context(date, anomaly_type, None, None, rng)
335 }
336
337 pub fn assign_cluster_with_context<R: Rng>(
339 &mut self,
340 date: NaiveDate,
341 anomaly_type: &str,
342 account: Option<&str>,
343 entity: Option<&str>,
344 rng: &mut R,
345 ) -> Option<String> {
346 if !self.config.enabled {
347 return None;
348 }
349
350 let category = FraudCategory::from_anomaly_type(anomaly_type);
352
353 let time_window = if self.config.use_fraud_specific_windows {
355 let (min, max) = category.time_window_days();
356 rng.gen_range(min..=max)
357 } else {
358 self.config.cluster_time_window_days
359 };
360
361 if let Some(active) = self.active_clusters.get(&category).cloned() {
363 let days_elapsed = (date - active.start_date).num_days();
364
365 if days_elapsed <= active.time_window_days
367 && active.size < self.config.max_cluster_size
368 && rng.gen::<f64>() < self.config.cluster_continuation_probability
369 {
370 let relationship_match = if self.config.preserve_account_relationships {
372 let account_match =
373 account.is_none_or(|a| active.accounts.contains(&a.to_string()));
374 let entity_match =
375 entity.is_none_or(|e| active.entities.contains(&e.to_string()));
376 account_match || entity_match
377 } else {
378 true
379 };
380
381 if relationship_match {
382 let cluster_id = active.cluster_id.clone();
384
385 if let Some(active_mut) = self.active_clusters.get_mut(&category) {
387 active_mut.size += 1;
388 if let Some(acct) = account {
389 if !active_mut.accounts.contains(&acct.to_string()) {
390 active_mut.accounts.push(acct.to_string());
391 }
392 }
393 if let Some(ent) = entity {
394 if !active_mut.entities.contains(&ent.to_string()) {
395 active_mut.entities.push(ent.to_string());
396 }
397 }
398 }
399
400 if let Some(stats) = self.cluster_stats.get_mut(&cluster_id) {
402 stats.size += 1;
403 stats.end_date = Some(date);
404 stats.anomaly_types.push(anomaly_type.to_string());
405 if let Some(acct) = account {
406 if !stats.accounts.contains(&acct.to_string()) {
407 stats.accounts.push(acct.to_string());
408 }
409 }
410 if let Some(ent) = entity {
411 if !stats.entities.contains(&ent.to_string()) {
412 stats.entities.push(ent.to_string());
413 }
414 }
415 }
416
417 return Some(cluster_id);
418 }
419 }
420
421 if active.size >= self.config.min_cluster_size {
423 self.active_clusters.remove(&category);
424 }
425 }
426
427 if rng.gen::<f64>() < self.config.cluster_start_probability {
429 let cluster_id = format!("CLU{:06}", self.next_cluster_id);
430 self.next_cluster_id += 1;
431
432 let mut accounts = Vec::new();
433 let mut entities = Vec::new();
434 if let Some(acct) = account {
435 accounts.push(acct.to_string());
436 }
437 if let Some(ent) = entity {
438 entities.push(ent.to_string());
439 }
440
441 self.active_clusters.insert(
443 category,
444 ActiveCluster {
445 cluster_id: cluster_id.clone(),
446 size: 1,
447 start_date: date,
448 category,
449 time_window_days: time_window,
450 accounts: accounts.clone(),
451 entities: entities.clone(),
452 },
453 );
454
455 self.cluster_stats.insert(
457 cluster_id.clone(),
458 ClusterStats {
459 size: 1,
460 start_date: Some(date),
461 end_date: Some(date),
462 anomaly_types: vec![anomaly_type.to_string()],
463 fraud_category: Some(category),
464 time_window_days: time_window,
465 accounts,
466 entities,
467 causal_links: Vec::new(),
468 },
469 );
470
471 return Some(cluster_id);
472 }
473
474 None
475 }
476
477 pub fn add_causal_link(&mut self, cluster_id: &str, link: CausalLink) {
479 if let Some(stats) = self.cluster_stats.get_mut(cluster_id) {
480 stats.causal_links.push(link);
481 }
482 }
483
484 pub fn get_related_account(&self, cluster_id: &str) -> Option<&str> {
486 self.cluster_stats
487 .get(cluster_id)
488 .and_then(|s| s.accounts.first().map(|a| a.as_str()))
489 }
490
491 pub fn get_related_entity(&self, cluster_id: &str) -> Option<&str> {
493 self.cluster_stats
494 .get(cluster_id)
495 .and_then(|s| s.entities.first().map(|e| e.as_str()))
496 }
497
498 pub fn get_cluster_stats(&self, cluster_id: &str) -> Option<&ClusterStats> {
500 self.cluster_stats.get(cluster_id)
501 }
502
503 pub fn all_cluster_stats(&self) -> &HashMap<String, ClusterStats> {
505 &self.cluster_stats
506 }
507
508 pub fn cluster_count(&self) -> usize {
510 self.cluster_stats.len()
511 }
512
513 pub fn clusters_by_category(&self) -> HashMap<FraudCategory, Vec<&ClusterStats>> {
515 let mut by_category: HashMap<FraudCategory, Vec<&ClusterStats>> = HashMap::new();
516 for stats in self.cluster_stats.values() {
517 if let Some(cat) = stats.fraud_category {
518 by_category.entry(cat).or_default().push(stats);
519 }
520 }
521 by_category
522 }
523}
524
525#[derive(Debug, Clone, Default)]
527pub enum EntityTargetingPattern {
528 #[default]
530 Random,
531 VolumeWeighted,
533 TypeFocused {
535 type_weights: HashMap<String, f64>,
537 },
538 RepeatOffender {
540 repeat_probability: f64,
542 },
543}
544
545pub struct EntityTargetingManager {
547 pattern: EntityTargetingPattern,
548 recent_targets: Vec<String>,
550 max_recent: usize,
552 hit_counts: HashMap<String, usize>,
554}
555
556impl EntityTargetingManager {
557 pub fn new(pattern: EntityTargetingPattern) -> Self {
559 Self {
560 pattern,
561 recent_targets: Vec::new(),
562 max_recent: 20,
563 hit_counts: HashMap::new(),
564 }
565 }
566
567 pub fn select_entity<R: Rng>(&mut self, candidates: &[String], rng: &mut R) -> Option<String> {
569 if candidates.is_empty() {
570 return None;
571 }
572
573 let selected = match &self.pattern {
574 EntityTargetingPattern::Random => {
575 candidates[rng.gen_range(0..candidates.len())].clone()
576 }
577 EntityTargetingPattern::VolumeWeighted => {
578 candidates[rng.gen_range(0..candidates.len())].clone()
581 }
582 EntityTargetingPattern::TypeFocused { type_weights } => {
583 let weighted: Vec<_> = candidates
585 .iter()
586 .filter_map(|c| type_weights.get(c).map(|&w| (c.clone(), w)))
587 .collect();
588
589 if weighted.is_empty() {
590 candidates[rng.gen_range(0..candidates.len())].clone()
591 } else {
592 weighted_select(rng, &weighted).clone()
593 }
594 }
595 EntityTargetingPattern::RepeatOffender { repeat_probability } => {
596 if !self.recent_targets.is_empty() && rng.gen::<f64>() < *repeat_probability {
598 let idx = rng.gen_range(0..self.recent_targets.len());
599 self.recent_targets[idx].clone()
600 } else {
601 candidates[rng.gen_range(0..candidates.len())].clone()
602 }
603 }
604 };
605
606 self.recent_targets.push(selected.clone());
608 if self.recent_targets.len() > self.max_recent {
609 self.recent_targets.remove(0);
610 }
611
612 *self.hit_counts.entry(selected.clone()).or_insert(0) += 1;
613
614 Some(selected)
615 }
616
617 pub fn hit_count(&self, entity: &str) -> usize {
619 *self.hit_counts.get(entity).unwrap_or(&0)
620 }
621}
622
623#[derive(Debug, Clone)]
625pub struct AnomalyPatternConfig {
626 pub temporal_pattern: TemporalPattern,
628 pub clustering: ClusteringConfig,
630 pub entity_targeting: EntityTargetingPattern,
632 pub batch_injection: bool,
634 pub batch_size_range: (usize, usize),
636}
637
638impl Default for AnomalyPatternConfig {
639 fn default() -> Self {
640 Self {
641 temporal_pattern: TemporalPattern::default(),
642 clustering: ClusteringConfig::default(),
643 entity_targeting: EntityTargetingPattern::default(),
644 batch_injection: false,
645 batch_size_range: (2, 5),
646 }
647 }
648}
649
650pub fn should_inject_anomaly<R: Rng>(
652 base_rate: f64,
653 date: NaiveDate,
654 pattern: &TemporalPattern,
655 rng: &mut R,
656) -> bool {
657 let multiplier = pattern.probability_multiplier(date);
658 let adjusted_rate = (base_rate * multiplier).min(1.0);
659 rng.gen::<f64>() < adjusted_rate
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub enum EscalationPattern {
669 Stable,
671 Gradual,
673 Aggressive,
675 Erratic,
677 TestThenStrike,
679}
680
681impl EscalationPattern {
682 pub fn escalation_multiplier(&self, prior_fraud_count: usize) -> f64 {
684 match self {
685 EscalationPattern::Stable => 1.0,
686 EscalationPattern::Gradual => {
687 (1.0 + 0.1 * prior_fraud_count as f64).min(3.0)
689 }
690 EscalationPattern::Aggressive => {
691 (1.0 + 0.25 * prior_fraud_count as f64).min(5.0)
693 }
694 EscalationPattern::Erratic => {
695 let base = 1.0 + 0.15 * prior_fraud_count as f64;
697 base.min(4.0)
698 }
699 EscalationPattern::TestThenStrike => {
700 if prior_fraud_count < 3 {
702 0.3 } else if prior_fraud_count == 3 {
704 5.0 } else {
706 0.0 }
708 }
709 }
710 }
711}
712
713#[derive(Debug, Clone)]
715pub struct FraudActor {
716 pub user_id: String,
718 pub user_name: String,
720 pub fraud_history: Vec<FraudIncident>,
722 pub escalation_pattern: EscalationPattern,
724 pub preferred_accounts: Vec<String>,
726 pub preferred_vendors: Vec<String>,
728 pub total_amount: rust_decimal::Decimal,
730 pub start_date: Option<NaiveDate>,
732 pub detection_risk: f64,
734 pub is_active: bool,
736}
737
738#[derive(Debug, Clone)]
740pub struct FraudIncident {
741 pub document_id: String,
743 pub date: NaiveDate,
745 pub amount: rust_decimal::Decimal,
747 pub fraud_type: String,
749 pub account: Option<String>,
751 pub entity: Option<String>,
753}
754
755impl FraudActor {
756 pub fn new(
758 user_id: impl Into<String>,
759 user_name: impl Into<String>,
760 escalation_pattern: EscalationPattern,
761 ) -> Self {
762 Self {
763 user_id: user_id.into(),
764 user_name: user_name.into(),
765 fraud_history: Vec::new(),
766 escalation_pattern,
767 preferred_accounts: Vec::new(),
768 preferred_vendors: Vec::new(),
769 total_amount: rust_decimal::Decimal::ZERO,
770 start_date: None,
771 detection_risk: 0.0,
772 is_active: true,
773 }
774 }
775
776 pub fn with_account(mut self, account: impl Into<String>) -> Self {
778 self.preferred_accounts.push(account.into());
779 self
780 }
781
782 pub fn with_vendor(mut self, vendor: impl Into<String>) -> Self {
784 self.preferred_vendors.push(vendor.into());
785 self
786 }
787
788 pub fn record_fraud(
790 &mut self,
791 document_id: impl Into<String>,
792 date: NaiveDate,
793 amount: rust_decimal::Decimal,
794 fraud_type: impl Into<String>,
795 account: Option<String>,
796 entity: Option<String>,
797 ) {
798 let incident = FraudIncident {
799 document_id: document_id.into(),
800 date,
801 amount,
802 fraud_type: fraud_type.into(),
803 account: account.clone(),
804 entity: entity.clone(),
805 };
806
807 self.fraud_history.push(incident);
808 self.total_amount += amount;
809
810 if self.start_date.is_none() {
811 self.start_date = Some(date);
812 }
813
814 self.update_detection_risk();
816
817 if let Some(acct) = account {
819 if !self.preferred_accounts.contains(&acct) {
820 self.preferred_accounts.push(acct);
821 }
822 }
823 if let Some(ent) = entity {
824 if !self.preferred_vendors.contains(&ent) {
825 self.preferred_vendors.push(ent);
826 }
827 }
828 }
829
830 fn update_detection_risk(&mut self) {
832 let count_factor = (self.fraud_history.len() as f64 * 0.05).min(0.3);
837 let amount_factor = if self.total_amount > rust_decimal::Decimal::from(100_000) {
838 0.3
839 } else if self.total_amount > rust_decimal::Decimal::from(10_000) {
840 0.2
841 } else {
842 0.1
843 };
844 let pattern_factor = match self.escalation_pattern {
845 EscalationPattern::Stable => 0.1,
846 EscalationPattern::Gradual => 0.15,
847 EscalationPattern::Erratic => 0.2,
848 EscalationPattern::Aggressive => 0.25,
849 EscalationPattern::TestThenStrike => 0.3,
850 };
851
852 self.detection_risk = (count_factor + amount_factor + pattern_factor).min(0.95);
853 }
854
855 pub fn next_escalation_multiplier(&self) -> f64 {
857 self.escalation_pattern
858 .escalation_multiplier(self.fraud_history.len())
859 }
860
861 pub fn get_preferred_account<R: Rng>(&self, rng: &mut R) -> Option<&str> {
863 if self.preferred_accounts.is_empty() {
864 None
865 } else {
866 Some(&self.preferred_accounts[rng.gen_range(0..self.preferred_accounts.len())])
867 }
868 }
869
870 pub fn get_preferred_vendor<R: Rng>(&self, rng: &mut R) -> Option<&str> {
872 if self.preferred_vendors.is_empty() {
873 None
874 } else {
875 Some(&self.preferred_vendors[rng.gen_range(0..self.preferred_vendors.len())])
876 }
877 }
878}
879
880pub struct FraudActorManager {
882 actors: Vec<FraudActor>,
884 user_index: HashMap<String, usize>,
886 repeat_actor_probability: f64,
888 max_active_actors: usize,
890}
891
892impl FraudActorManager {
893 pub fn new(repeat_actor_probability: f64, max_active_actors: usize) -> Self {
895 Self {
896 actors: Vec::new(),
897 user_index: HashMap::new(),
898 repeat_actor_probability,
899 max_active_actors,
900 }
901 }
902
903 pub fn add_actor(&mut self, actor: FraudActor) {
905 let idx = self.actors.len();
906 self.user_index.insert(actor.user_id.clone(), idx);
907 self.actors.push(actor);
908 }
909
910 pub fn get_or_create_actor<R: Rng>(
912 &mut self,
913 available_users: &[String],
914 rng: &mut R,
915 ) -> Option<&mut FraudActor> {
916 if available_users.is_empty() {
917 return None;
918 }
919
920 let active_actors: Vec<usize> = self
922 .actors
923 .iter()
924 .enumerate()
925 .filter(|(_, a)| a.is_active)
926 .map(|(i, _)| i)
927 .collect();
928
929 if !active_actors.is_empty() && rng.gen::<f64>() < self.repeat_actor_probability {
930 let idx = active_actors[rng.gen_range(0..active_actors.len())];
932 return Some(&mut self.actors[idx]);
933 }
934
935 if self.actors.len() < self.max_active_actors {
937 let user_id = &available_users[rng.gen_range(0..available_users.len())];
939
940 if let Some(&idx) = self.user_index.get(user_id) {
942 return Some(&mut self.actors[idx]);
943 }
944
945 let pattern = match rng.gen_range(0..5) {
947 0 => EscalationPattern::Stable,
948 1 => EscalationPattern::Gradual,
949 2 => EscalationPattern::Aggressive,
950 3 => EscalationPattern::Erratic,
951 _ => EscalationPattern::TestThenStrike,
952 };
953
954 let actor = FraudActor::new(user_id.clone(), format!("Fraudster {}", user_id), pattern);
955 let idx = self.actors.len();
956 self.user_index.insert(user_id.clone(), idx);
957 self.actors.push(actor);
958 return Some(&mut self.actors[idx]);
959 }
960
961 if !self.actors.is_empty() {
963 let idx = rng.gen_range(0..self.actors.len());
964 return Some(&mut self.actors[idx]);
965 }
966
967 None
968 }
969
970 pub fn get_actor(&self, user_id: &str) -> Option<&FraudActor> {
972 self.user_index.get(user_id).map(|&i| &self.actors[i])
973 }
974
975 pub fn get_actor_mut(&mut self, user_id: &str) -> Option<&mut FraudActor> {
977 if let Some(&idx) = self.user_index.get(user_id) {
978 Some(&mut self.actors[idx])
979 } else {
980 None
981 }
982 }
983
984 pub fn apply_detection<R: Rng>(&mut self, rng: &mut R) {
986 for actor in &mut self.actors {
987 if actor.is_active && rng.gen::<f64>() < actor.detection_risk {
988 actor.is_active = false;
989 }
990 }
991 }
992
993 pub fn all_actors(&self) -> &[FraudActor] {
995 &self.actors
996 }
997
998 pub fn get_statistics(&self) -> FraudActorStatistics {
1000 let total_actors = self.actors.len();
1001 let active_actors = self.actors.iter().filter(|a| a.is_active).count();
1002 let total_incidents: usize = self.actors.iter().map(|a| a.fraud_history.len()).sum();
1003 let total_amount: rust_decimal::Decimal = self.actors.iter().map(|a| a.total_amount).sum();
1004
1005 FraudActorStatistics {
1006 total_actors,
1007 active_actors,
1008 total_incidents,
1009 total_amount,
1010 }
1011 }
1012}
1013
1014#[derive(Debug, Clone)]
1016pub struct FraudActorStatistics {
1017 pub total_actors: usize,
1019 pub active_actors: usize,
1021 pub total_incidents: usize,
1023 pub total_amount: rust_decimal::Decimal,
1025}
1026
1027#[cfg(test)]
1028#[allow(clippy::unwrap_used)]
1029mod tests {
1030 use super::*;
1031 use rand::SeedableRng;
1032 use rand_chacha::ChaCha8Rng;
1033
1034 #[test]
1035 fn test_temporal_pattern_multiplier() {
1036 let pattern = TemporalPattern::default();
1037
1038 let regular = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1040 assert_eq!(pattern.probability_multiplier(regular), 1.0);
1041
1042 let month_end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
1044 assert!(pattern.probability_multiplier(month_end) > 1.0);
1045
1046 let year_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1048 assert!(
1049 pattern.probability_multiplier(year_end) > pattern.probability_multiplier(month_end)
1050 );
1051 }
1052
1053 #[test]
1054 fn test_cluster_manager() {
1055 let mut manager = ClusterManager::new(ClusteringConfig::default());
1056 let mut rng = ChaCha8Rng::seed_from_u64(42);
1057 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1058
1059 let mut clustered = 0;
1061 for i in 0..20 {
1062 let d = date + chrono::Duration::days(i % 7); if manager.assign_cluster(d, "TestType", &mut rng).is_some() {
1064 clustered += 1;
1065 }
1066 }
1067
1068 assert!(clustered > 0);
1070 assert!(manager.cluster_count() > 0);
1071 }
1072
1073 #[test]
1074 fn test_fraud_category_time_windows() {
1075 let ar = FraudCategory::AccountsReceivable;
1077 let general = FraudCategory::General;
1078
1079 let (ar_min, ar_max) = ar.time_window_days();
1080 let (gen_min, gen_max) = general.time_window_days();
1081
1082 assert!(ar_min > gen_min);
1083 assert!(ar_max > gen_max);
1084 }
1085
1086 #[test]
1087 fn test_fraud_category_inference() {
1088 assert_eq!(
1089 FraudCategory::from_anomaly_type("AccountsReceivable"),
1090 FraudCategory::AccountsReceivable
1091 );
1092 assert_eq!(
1093 FraudCategory::from_anomaly_type("VendorPayment"),
1094 FraudCategory::AccountsPayable
1095 );
1096 assert_eq!(
1097 FraudCategory::from_anomaly_type("GhostEmployee"),
1098 FraudCategory::Payroll
1099 );
1100 assert_eq!(
1101 FraudCategory::from_anomaly_type("RandomType"),
1102 FraudCategory::General
1103 );
1104 }
1105
1106 #[test]
1107 fn test_cluster_with_context() {
1108 let mut manager = ClusterManager::new(ClusteringConfig {
1109 cluster_start_probability: 1.0, cluster_continuation_probability: 1.0, ..Default::default()
1112 });
1113 let mut rng = ChaCha8Rng::seed_from_u64(42);
1114 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1115
1116 let cluster1 = manager.assign_cluster_with_context(
1118 date,
1119 "VendorPayment",
1120 Some("200000"),
1121 Some("V001"),
1122 &mut rng,
1123 );
1124 assert!(cluster1.is_some());
1125
1126 let cluster2 = manager.assign_cluster_with_context(
1128 date + chrono::Duration::days(5),
1129 "VendorPayment",
1130 Some("200000"),
1131 Some("V002"),
1132 &mut rng,
1133 );
1134
1135 assert_eq!(cluster1, cluster2);
1136
1137 let stats = manager.get_cluster_stats(&cluster1.unwrap()).unwrap();
1139 assert_eq!(stats.accounts.len(), 1); assert_eq!(stats.entities.len(), 2); }
1142
1143 #[test]
1144 fn test_causal_links() {
1145 let mut manager = ClusterManager::new(ClusteringConfig {
1146 cluster_start_probability: 1.0,
1147 ..Default::default()
1148 });
1149 let mut rng = ChaCha8Rng::seed_from_u64(42);
1150 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1151
1152 let cluster_id = manager
1153 .assign_cluster(date, "VendorPayment", &mut rng)
1154 .unwrap();
1155
1156 manager.add_causal_link(
1158 &cluster_id,
1159 CausalLink::new("PAY-001", "Payment", "V001", "Vendor", "references"),
1160 );
1161 manager.add_causal_link(
1162 &cluster_id,
1163 CausalLink::new("V001", "Vendor", "EMP-001", "Employee", "owned_by"),
1164 );
1165
1166 let stats = manager.get_cluster_stats(&cluster_id).unwrap();
1167 assert_eq!(stats.causal_links.len(), 2);
1168 }
1169
1170 #[test]
1171 fn test_should_inject_anomaly() {
1172 let mut rng = ChaCha8Rng::seed_from_u64(42);
1173 let pattern = TemporalPattern::default();
1174
1175 let regular_date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1176 let year_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1177
1178 let mut regular_count = 0;
1180 let mut year_end_count = 0;
1181
1182 for _ in 0..1000 {
1183 if should_inject_anomaly(0.1, regular_date, &pattern, &mut rng) {
1184 regular_count += 1;
1185 }
1186 if should_inject_anomaly(0.1, year_end, &pattern, &mut rng) {
1187 year_end_count += 1;
1188 }
1189 }
1190
1191 assert!(year_end_count > regular_count);
1193 }
1194
1195 #[test]
1196 fn test_escalation_patterns() {
1197 assert_eq!(EscalationPattern::Stable.escalation_multiplier(0), 1.0);
1199 assert_eq!(EscalationPattern::Stable.escalation_multiplier(10), 1.0);
1200
1201 let gradual = EscalationPattern::Gradual;
1203 assert!(gradual.escalation_multiplier(5) > gradual.escalation_multiplier(0));
1204 assert!(gradual.escalation_multiplier(5) <= 3.0); let aggressive = EscalationPattern::Aggressive;
1208 assert!(aggressive.escalation_multiplier(5) > gradual.escalation_multiplier(5));
1209
1210 let tts = EscalationPattern::TestThenStrike;
1212 assert!(tts.escalation_multiplier(0) < 1.0); assert!(tts.escalation_multiplier(3) > 1.0); assert_eq!(tts.escalation_multiplier(4), 0.0); }
1216
1217 #[test]
1218 fn test_fraud_actor() {
1219 use rust_decimal_macros::dec;
1220
1221 let mut actor = FraudActor::new("USER001", "John Fraudster", EscalationPattern::Gradual)
1222 .with_account("600000")
1223 .with_vendor("V001");
1224
1225 assert_eq!(actor.preferred_accounts.len(), 1);
1226 assert_eq!(actor.preferred_vendors.len(), 1);
1227 assert!(actor.is_active);
1228
1229 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1231 actor.record_fraud(
1232 "JE-001",
1233 date,
1234 dec!(1000),
1235 "DuplicatePayment",
1236 Some("600000".to_string()),
1237 Some("V002".to_string()),
1238 );
1239
1240 assert_eq!(actor.fraud_history.len(), 1);
1241 assert_eq!(actor.total_amount, dec!(1000));
1242 assert_eq!(actor.start_date, Some(date));
1243 assert!(actor.detection_risk > 0.0);
1244
1245 assert!(actor.preferred_vendors.contains(&"V002".to_string()));
1247 }
1248
1249 #[test]
1250 fn test_fraud_actor_manager() {
1251 let mut rng = ChaCha8Rng::seed_from_u64(42);
1252 let mut manager = FraudActorManager::new(0.7, 5);
1253
1254 let users = vec![
1255 "USER001".to_string(),
1256 "USER002".to_string(),
1257 "USER003".to_string(),
1258 ];
1259
1260 let actor = manager.get_or_create_actor(&users, &mut rng);
1262 assert!(actor.is_some());
1263
1264 let actor = actor.unwrap();
1266 let user_id = actor.user_id.clone();
1267 actor.record_fraud(
1268 "JE-001",
1269 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1270 rust_decimal::Decimal::from(1000),
1271 "FictitiousEntry",
1272 None,
1273 None,
1274 );
1275
1276 let retrieved = manager.get_actor(&user_id);
1278 assert!(retrieved.is_some());
1279 assert_eq!(retrieved.unwrap().fraud_history.len(), 1);
1280
1281 let stats = manager.get_statistics();
1283 assert_eq!(stats.total_actors, 1);
1284 assert_eq!(stats.active_actors, 1);
1285 assert_eq!(stats.total_incidents, 1);
1286 }
1287
1288 #[test]
1289 fn test_fraud_actor_detection() {
1290 use rust_decimal_macros::dec;
1291
1292 let mut rng = ChaCha8Rng::seed_from_u64(42);
1293 let mut manager = FraudActorManager::new(1.0, 10);
1294
1295 let mut actor =
1297 FraudActor::new("USER001", "Heavy Fraudster", EscalationPattern::Aggressive);
1298 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1299
1300 for i in 0..10 {
1302 actor.record_fraud(
1303 format!("JE-{:03}", i),
1304 date + chrono::Duration::days(i as i64),
1305 dec!(10000),
1306 "FictitiousEntry",
1307 None,
1308 None,
1309 );
1310 }
1311
1312 manager.add_actor(actor);
1313
1314 let actor = manager.get_actor("USER001").unwrap();
1316 assert!(actor.detection_risk > 0.5);
1317
1318 for _ in 0..20 {
1320 manager.apply_detection(&mut rng);
1321 }
1322
1323 let stats = manager.get_statistics();
1325 assert!(stats.active_actors <= stats.total_actors);
1327 }
1328}