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)]
285 category: FraudCategory,
286 time_window_days: i64,
288 accounts: Vec<String>,
290 entities: Vec<String>,
292}
293
294#[derive(Debug, Clone, Default)]
296pub struct ClusterStats {
297 pub size: usize,
299 pub start_date: Option<NaiveDate>,
301 pub end_date: Option<NaiveDate>,
303 pub anomaly_types: Vec<String>,
305 pub fraud_category: Option<FraudCategory>,
307 pub time_window_days: i64,
309 pub accounts: Vec<String>,
311 pub entities: Vec<String>,
313 pub causal_links: Vec<CausalLink>,
315}
316
317impl ClusterManager {
318 pub fn new(config: ClusteringConfig) -> Self {
320 Self {
321 config,
322 active_clusters: HashMap::new(),
323 next_cluster_id: 1,
324 cluster_stats: HashMap::new(),
325 }
326 }
327
328 pub fn assign_cluster<R: Rng>(
330 &mut self,
331 date: NaiveDate,
332 anomaly_type: &str,
333 rng: &mut R,
334 ) -> Option<String> {
335 self.assign_cluster_with_context(date, anomaly_type, None, None, rng)
336 }
337
338 pub fn assign_cluster_with_context<R: Rng>(
340 &mut self,
341 date: NaiveDate,
342 anomaly_type: &str,
343 account: Option<&str>,
344 entity: Option<&str>,
345 rng: &mut R,
346 ) -> Option<String> {
347 if !self.config.enabled {
348 return None;
349 }
350
351 let category = FraudCategory::from_anomaly_type(anomaly_type);
353
354 let time_window = if self.config.use_fraud_specific_windows {
356 let (min, max) = category.time_window_days();
357 rng.random_range(min..=max)
358 } else {
359 self.config.cluster_time_window_days
360 };
361
362 if let Some(active) = self.active_clusters.get(&category).cloned() {
364 let days_elapsed = (date - active.start_date).num_days();
365
366 if days_elapsed <= active.time_window_days
368 && active.size < self.config.max_cluster_size
369 && rng.random::<f64>() < self.config.cluster_continuation_probability
370 {
371 let relationship_match = if self.config.preserve_account_relationships {
373 let account_match =
374 account.is_none_or(|a| active.accounts.contains(&a.to_string()));
375 let entity_match =
376 entity.is_none_or(|e| active.entities.contains(&e.to_string()));
377 account_match || entity_match
378 } else {
379 true
380 };
381
382 if relationship_match {
383 let cluster_id = active.cluster_id.clone();
385
386 if let Some(active_mut) = self.active_clusters.get_mut(&category) {
388 active_mut.size += 1;
389 if let Some(acct) = account {
390 if !active_mut.accounts.contains(&acct.to_string()) {
391 active_mut.accounts.push(acct.to_string());
392 }
393 }
394 if let Some(ent) = entity {
395 if !active_mut.entities.contains(&ent.to_string()) {
396 active_mut.entities.push(ent.to_string());
397 }
398 }
399 }
400
401 if let Some(stats) = self.cluster_stats.get_mut(&cluster_id) {
403 stats.size += 1;
404 stats.end_date = Some(date);
405 stats.anomaly_types.push(anomaly_type.to_string());
406 if let Some(acct) = account {
407 if !stats.accounts.contains(&acct.to_string()) {
408 stats.accounts.push(acct.to_string());
409 }
410 }
411 if let Some(ent) = entity {
412 if !stats.entities.contains(&ent.to_string()) {
413 stats.entities.push(ent.to_string());
414 }
415 }
416 }
417
418 return Some(cluster_id);
419 }
420 }
421
422 if active.size >= self.config.min_cluster_size {
424 self.active_clusters.remove(&category);
425 }
426 }
427
428 if rng.random::<f64>() < self.config.cluster_start_probability {
430 let cluster_id = format!("CLU{:06}", self.next_cluster_id);
431 self.next_cluster_id += 1;
432
433 let mut accounts = Vec::new();
434 let mut entities = Vec::new();
435 if let Some(acct) = account {
436 accounts.push(acct.to_string());
437 }
438 if let Some(ent) = entity {
439 entities.push(ent.to_string());
440 }
441
442 self.active_clusters.insert(
444 category,
445 ActiveCluster {
446 cluster_id: cluster_id.clone(),
447 size: 1,
448 start_date: date,
449 category,
450 time_window_days: time_window,
451 accounts: accounts.clone(),
452 entities: entities.clone(),
453 },
454 );
455
456 self.cluster_stats.insert(
458 cluster_id.clone(),
459 ClusterStats {
460 size: 1,
461 start_date: Some(date),
462 end_date: Some(date),
463 anomaly_types: vec![anomaly_type.to_string()],
464 fraud_category: Some(category),
465 time_window_days: time_window,
466 accounts,
467 entities,
468 causal_links: Vec::new(),
469 },
470 );
471
472 return Some(cluster_id);
473 }
474
475 None
476 }
477
478 pub fn add_causal_link(&mut self, cluster_id: &str, link: CausalLink) {
480 if let Some(stats) = self.cluster_stats.get_mut(cluster_id) {
481 stats.causal_links.push(link);
482 }
483 }
484
485 pub fn get_related_account(&self, cluster_id: &str) -> Option<&str> {
487 self.cluster_stats
488 .get(cluster_id)
489 .and_then(|s| s.accounts.first().map(std::string::String::as_str))
490 }
491
492 pub fn get_related_entity(&self, cluster_id: &str) -> Option<&str> {
494 self.cluster_stats
495 .get(cluster_id)
496 .and_then(|s| s.entities.first().map(std::string::String::as_str))
497 }
498
499 pub fn get_cluster_stats(&self, cluster_id: &str) -> Option<&ClusterStats> {
501 self.cluster_stats.get(cluster_id)
502 }
503
504 pub fn all_cluster_stats(&self) -> &HashMap<String, ClusterStats> {
506 &self.cluster_stats
507 }
508
509 pub fn cluster_count(&self) -> usize {
511 self.cluster_stats.len()
512 }
513
514 pub fn clusters_by_category(&self) -> HashMap<FraudCategory, Vec<&ClusterStats>> {
516 let mut by_category: HashMap<FraudCategory, Vec<&ClusterStats>> = HashMap::new();
517 for stats in self.cluster_stats.values() {
518 if let Some(cat) = stats.fraud_category {
519 by_category.entry(cat).or_default().push(stats);
520 }
521 }
522 by_category
523 }
524}
525
526#[derive(Debug, Clone, Default)]
528pub enum EntityTargetingPattern {
529 #[default]
531 Random,
532 VolumeWeighted,
534 TypeFocused {
536 type_weights: HashMap<String, f64>,
538 },
539 RepeatOffender {
541 repeat_probability: f64,
543 },
544}
545
546pub struct EntityTargetingManager {
548 pattern: EntityTargetingPattern,
549 recent_targets: Vec<String>,
551 max_recent: usize,
553 hit_counts: HashMap<String, usize>,
555}
556
557impl EntityTargetingManager {
558 pub fn new(pattern: EntityTargetingPattern) -> Self {
560 Self {
561 pattern,
562 recent_targets: Vec::new(),
563 max_recent: 20,
564 hit_counts: HashMap::new(),
565 }
566 }
567
568 pub fn select_entity<R: Rng>(&mut self, candidates: &[String], rng: &mut R) -> Option<String> {
570 if candidates.is_empty() {
571 return None;
572 }
573
574 let selected = match &self.pattern {
575 EntityTargetingPattern::Random => {
576 candidates[rng.random_range(0..candidates.len())].clone()
577 }
578 EntityTargetingPattern::VolumeWeighted => {
579 candidates[rng.random_range(0..candidates.len())].clone()
582 }
583 EntityTargetingPattern::TypeFocused { type_weights } => {
584 let weighted: Vec<_> = candidates
586 .iter()
587 .filter_map(|c| type_weights.get(c).map(|&w| (c.clone(), w)))
588 .collect();
589
590 if weighted.is_empty() {
591 candidates[rng.random_range(0..candidates.len())].clone()
592 } else {
593 weighted_select(rng, &weighted).clone()
594 }
595 }
596 EntityTargetingPattern::RepeatOffender { repeat_probability } => {
597 if !self.recent_targets.is_empty() && rng.random::<f64>() < *repeat_probability {
599 let idx = rng.random_range(0..self.recent_targets.len());
600 self.recent_targets[idx].clone()
601 } else {
602 candidates[rng.random_range(0..candidates.len())].clone()
603 }
604 }
605 };
606
607 self.recent_targets.push(selected.clone());
609 if self.recent_targets.len() > self.max_recent {
610 self.recent_targets.remove(0);
611 }
612
613 *self.hit_counts.entry(selected.clone()).or_insert(0) += 1;
614
615 Some(selected)
616 }
617
618 pub fn hit_count(&self, entity: &str) -> usize {
620 *self.hit_counts.get(entity).unwrap_or(&0)
621 }
622}
623
624#[derive(Debug, Clone)]
626pub struct AnomalyPatternConfig {
627 pub temporal_pattern: TemporalPattern,
629 pub clustering: ClusteringConfig,
631 pub entity_targeting: EntityTargetingPattern,
633 pub batch_injection: bool,
635 pub batch_size_range: (usize, usize),
637}
638
639impl Default for AnomalyPatternConfig {
640 fn default() -> Self {
641 Self {
642 temporal_pattern: TemporalPattern::default(),
643 clustering: ClusteringConfig::default(),
644 entity_targeting: EntityTargetingPattern::default(),
645 batch_injection: false,
646 batch_size_range: (2, 5),
647 }
648 }
649}
650
651pub fn should_inject_anomaly<R: Rng>(
653 base_rate: f64,
654 date: NaiveDate,
655 pattern: &TemporalPattern,
656 rng: &mut R,
657) -> bool {
658 let multiplier = pattern.probability_multiplier(date);
659 let adjusted_rate = (base_rate * multiplier).min(1.0);
660 rng.random::<f64>() < adjusted_rate
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
669pub enum EscalationPattern {
670 Stable,
672 Gradual,
674 Aggressive,
676 Erratic,
678 TestThenStrike,
680}
681
682impl EscalationPattern {
683 pub fn escalation_multiplier(&self, prior_fraud_count: usize) -> f64 {
685 match self {
686 EscalationPattern::Stable => 1.0,
687 EscalationPattern::Gradual => {
688 (1.0 + 0.1 * prior_fraud_count as f64).min(3.0)
690 }
691 EscalationPattern::Aggressive => {
692 (1.0 + 0.25 * prior_fraud_count as f64).min(5.0)
694 }
695 EscalationPattern::Erratic => {
696 let base = 1.0 + 0.15 * prior_fraud_count as f64;
698 base.min(4.0)
699 }
700 EscalationPattern::TestThenStrike => {
701 if prior_fraud_count < 3 {
703 0.3 } else if prior_fraud_count == 3 {
705 5.0 } else {
707 0.0 }
709 }
710 }
711 }
712}
713
714#[derive(Debug, Clone)]
716pub struct FraudActor {
717 pub user_id: String,
719 pub user_name: String,
721 pub fraud_history: Vec<FraudIncident>,
723 pub escalation_pattern: EscalationPattern,
725 pub preferred_accounts: Vec<String>,
727 pub preferred_vendors: Vec<String>,
729 pub total_amount: rust_decimal::Decimal,
731 pub start_date: Option<NaiveDate>,
733 pub detection_risk: f64,
735 pub is_active: bool,
737}
738
739#[derive(Debug, Clone)]
741pub struct FraudIncident {
742 pub document_id: String,
744 pub date: NaiveDate,
746 pub amount: rust_decimal::Decimal,
748 pub fraud_type: String,
750 pub account: Option<String>,
752 pub entity: Option<String>,
754}
755
756impl FraudActor {
757 pub fn new(
759 user_id: impl Into<String>,
760 user_name: impl Into<String>,
761 escalation_pattern: EscalationPattern,
762 ) -> Self {
763 Self {
764 user_id: user_id.into(),
765 user_name: user_name.into(),
766 fraud_history: Vec::new(),
767 escalation_pattern,
768 preferred_accounts: Vec::new(),
769 preferred_vendors: Vec::new(),
770 total_amount: rust_decimal::Decimal::ZERO,
771 start_date: None,
772 detection_risk: 0.0,
773 is_active: true,
774 }
775 }
776
777 pub fn with_account(mut self, account: impl Into<String>) -> Self {
779 self.preferred_accounts.push(account.into());
780 self
781 }
782
783 pub fn with_vendor(mut self, vendor: impl Into<String>) -> Self {
785 self.preferred_vendors.push(vendor.into());
786 self
787 }
788
789 pub fn record_fraud(
791 &mut self,
792 document_id: impl Into<String>,
793 date: NaiveDate,
794 amount: rust_decimal::Decimal,
795 fraud_type: impl Into<String>,
796 account: Option<String>,
797 entity: Option<String>,
798 ) {
799 let incident = FraudIncident {
800 document_id: document_id.into(),
801 date,
802 amount,
803 fraud_type: fraud_type.into(),
804 account: account.clone(),
805 entity: entity.clone(),
806 };
807
808 self.fraud_history.push(incident);
809 self.total_amount += amount;
810
811 if self.start_date.is_none() {
812 self.start_date = Some(date);
813 }
814
815 self.update_detection_risk();
817
818 if let Some(acct) = account {
820 if !self.preferred_accounts.contains(&acct) {
821 self.preferred_accounts.push(acct);
822 }
823 }
824 if let Some(ent) = entity {
825 if !self.preferred_vendors.contains(&ent) {
826 self.preferred_vendors.push(ent);
827 }
828 }
829 }
830
831 fn update_detection_risk(&mut self) {
833 let count_factor = (self.fraud_history.len() as f64 * 0.05).min(0.3);
838 let amount_factor = if self.total_amount > rust_decimal::Decimal::from(100_000) {
839 0.3
840 } else if self.total_amount > rust_decimal::Decimal::from(10_000) {
841 0.2
842 } else {
843 0.1
844 };
845 let pattern_factor = match self.escalation_pattern {
846 EscalationPattern::Stable => 0.1,
847 EscalationPattern::Gradual => 0.15,
848 EscalationPattern::Erratic => 0.2,
849 EscalationPattern::Aggressive => 0.25,
850 EscalationPattern::TestThenStrike => 0.3,
851 };
852
853 self.detection_risk = (count_factor + amount_factor + pattern_factor).min(0.95);
854 }
855
856 pub fn next_escalation_multiplier(&self) -> f64 {
858 self.escalation_pattern
859 .escalation_multiplier(self.fraud_history.len())
860 }
861
862 pub fn get_preferred_account<R: Rng>(&self, rng: &mut R) -> Option<&str> {
864 if self.preferred_accounts.is_empty() {
865 None
866 } else {
867 Some(&self.preferred_accounts[rng.random_range(0..self.preferred_accounts.len())])
868 }
869 }
870
871 pub fn get_preferred_vendor<R: Rng>(&self, rng: &mut R) -> Option<&str> {
873 if self.preferred_vendors.is_empty() {
874 None
875 } else {
876 Some(&self.preferred_vendors[rng.random_range(0..self.preferred_vendors.len())])
877 }
878 }
879}
880
881pub struct FraudActorManager {
883 actors: Vec<FraudActor>,
885 user_index: HashMap<String, usize>,
887 repeat_actor_probability: f64,
889 max_active_actors: usize,
891}
892
893impl FraudActorManager {
894 pub fn new(repeat_actor_probability: f64, max_active_actors: usize) -> Self {
896 Self {
897 actors: Vec::new(),
898 user_index: HashMap::new(),
899 repeat_actor_probability,
900 max_active_actors,
901 }
902 }
903
904 pub fn add_actor(&mut self, actor: FraudActor) {
906 let idx = self.actors.len();
907 self.user_index.insert(actor.user_id.clone(), idx);
908 self.actors.push(actor);
909 }
910
911 pub fn get_or_create_actor<R: Rng>(
913 &mut self,
914 available_users: &[String],
915 rng: &mut R,
916 ) -> Option<&mut FraudActor> {
917 if available_users.is_empty() {
918 return None;
919 }
920
921 let active_actors: Vec<usize> = self
923 .actors
924 .iter()
925 .enumerate()
926 .filter(|(_, a)| a.is_active)
927 .map(|(i, _)| i)
928 .collect();
929
930 if !active_actors.is_empty() && rng.random::<f64>() < self.repeat_actor_probability {
931 let idx = active_actors[rng.random_range(0..active_actors.len())];
933 return Some(&mut self.actors[idx]);
934 }
935
936 if self.actors.len() < self.max_active_actors {
938 let user_id = &available_users[rng.random_range(0..available_users.len())];
940
941 if let Some(&idx) = self.user_index.get(user_id) {
943 return Some(&mut self.actors[idx]);
944 }
945
946 let pattern = match rng.random_range(0..5) {
948 0 => EscalationPattern::Stable,
949 1 => EscalationPattern::Gradual,
950 2 => EscalationPattern::Aggressive,
951 3 => EscalationPattern::Erratic,
952 _ => EscalationPattern::TestThenStrike,
953 };
954
955 let actor = FraudActor::new(user_id.clone(), format!("Fraudster {user_id}"), pattern);
956 let idx = self.actors.len();
957 self.user_index.insert(user_id.clone(), idx);
958 self.actors.push(actor);
959 return Some(&mut self.actors[idx]);
960 }
961
962 if !self.actors.is_empty() {
964 let idx = rng.random_range(0..self.actors.len());
965 return Some(&mut self.actors[idx]);
966 }
967
968 None
969 }
970
971 pub fn get_actor(&self, user_id: &str) -> Option<&FraudActor> {
973 self.user_index.get(user_id).map(|&i| &self.actors[i])
974 }
975
976 pub fn get_actor_mut(&mut self, user_id: &str) -> Option<&mut FraudActor> {
978 if let Some(&idx) = self.user_index.get(user_id) {
979 Some(&mut self.actors[idx])
980 } else {
981 None
982 }
983 }
984
985 pub fn apply_detection<R: Rng>(&mut self, rng: &mut R) {
987 for actor in &mut self.actors {
988 if actor.is_active && rng.random::<f64>() < actor.detection_risk {
989 actor.is_active = false;
990 }
991 }
992 }
993
994 pub fn all_actors(&self) -> &[FraudActor] {
996 &self.actors
997 }
998
999 pub fn get_statistics(&self) -> FraudActorStatistics {
1001 let total_actors = self.actors.len();
1002 let active_actors = self.actors.iter().filter(|a| a.is_active).count();
1003 let total_incidents: usize = self.actors.iter().map(|a| a.fraud_history.len()).sum();
1004 let total_amount: rust_decimal::Decimal = self.actors.iter().map(|a| a.total_amount).sum();
1005
1006 FraudActorStatistics {
1007 total_actors,
1008 active_actors,
1009 total_incidents,
1010 total_amount,
1011 }
1012 }
1013}
1014
1015#[derive(Debug, Clone)]
1017pub struct FraudActorStatistics {
1018 pub total_actors: usize,
1020 pub active_actors: usize,
1022 pub total_incidents: usize,
1024 pub total_amount: rust_decimal::Decimal,
1026}
1027
1028#[cfg(test)]
1029#[allow(clippy::unwrap_used)]
1030mod tests {
1031 use super::*;
1032 use rand::SeedableRng;
1033 use rand_chacha::ChaCha8Rng;
1034
1035 #[test]
1036 fn test_temporal_pattern_multiplier() {
1037 let pattern = TemporalPattern::default();
1038
1039 let regular = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1041 assert_eq!(pattern.probability_multiplier(regular), 1.0);
1042
1043 let month_end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
1045 assert!(pattern.probability_multiplier(month_end) > 1.0);
1046
1047 let year_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1049 assert!(
1050 pattern.probability_multiplier(year_end) > pattern.probability_multiplier(month_end)
1051 );
1052 }
1053
1054 #[test]
1055 fn test_cluster_manager() {
1056 let mut manager = ClusterManager::new(ClusteringConfig::default());
1057 let mut rng = ChaCha8Rng::seed_from_u64(42);
1058 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1059
1060 let mut clustered = 0;
1062 for i in 0..20 {
1063 let d = date + chrono::Duration::days(i % 7); if manager.assign_cluster(d, "TestType", &mut rng).is_some() {
1065 clustered += 1;
1066 }
1067 }
1068
1069 assert!(clustered > 0);
1071 assert!(manager.cluster_count() > 0);
1072 }
1073
1074 #[test]
1075 fn test_fraud_category_time_windows() {
1076 let ar = FraudCategory::AccountsReceivable;
1078 let general = FraudCategory::General;
1079
1080 let (ar_min, ar_max) = ar.time_window_days();
1081 let (gen_min, gen_max) = general.time_window_days();
1082
1083 assert!(ar_min > gen_min);
1084 assert!(ar_max > gen_max);
1085 }
1086
1087 #[test]
1088 fn test_fraud_category_inference() {
1089 assert_eq!(
1090 FraudCategory::from_anomaly_type("AccountsReceivable"),
1091 FraudCategory::AccountsReceivable
1092 );
1093 assert_eq!(
1094 FraudCategory::from_anomaly_type("VendorPayment"),
1095 FraudCategory::AccountsPayable
1096 );
1097 assert_eq!(
1098 FraudCategory::from_anomaly_type("GhostEmployee"),
1099 FraudCategory::Payroll
1100 );
1101 assert_eq!(
1102 FraudCategory::from_anomaly_type("RandomType"),
1103 FraudCategory::General
1104 );
1105 }
1106
1107 #[test]
1108 fn test_cluster_with_context() {
1109 let mut manager = ClusterManager::new(ClusteringConfig {
1110 cluster_start_probability: 1.0, cluster_continuation_probability: 1.0, ..Default::default()
1113 });
1114 let mut rng = ChaCha8Rng::seed_from_u64(42);
1115 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1116
1117 let cluster1 = manager.assign_cluster_with_context(
1119 date,
1120 "VendorPayment",
1121 Some("200000"),
1122 Some("V001"),
1123 &mut rng,
1124 );
1125 assert!(cluster1.is_some());
1126
1127 let cluster2 = manager.assign_cluster_with_context(
1129 date + chrono::Duration::days(5),
1130 "VendorPayment",
1131 Some("200000"),
1132 Some("V002"),
1133 &mut rng,
1134 );
1135
1136 assert_eq!(cluster1, cluster2);
1137
1138 let stats = manager.get_cluster_stats(&cluster1.unwrap()).unwrap();
1140 assert_eq!(stats.accounts.len(), 1); assert_eq!(stats.entities.len(), 2); }
1143
1144 #[test]
1145 fn test_causal_links() {
1146 let mut manager = ClusterManager::new(ClusteringConfig {
1147 cluster_start_probability: 1.0,
1148 ..Default::default()
1149 });
1150 let mut rng = ChaCha8Rng::seed_from_u64(42);
1151 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1152
1153 let cluster_id = manager
1154 .assign_cluster(date, "VendorPayment", &mut rng)
1155 .unwrap();
1156
1157 manager.add_causal_link(
1159 &cluster_id,
1160 CausalLink::new("PAY-001", "Payment", "V001", "Vendor", "references"),
1161 );
1162 manager.add_causal_link(
1163 &cluster_id,
1164 CausalLink::new("V001", "Vendor", "EMP-001", "Employee", "owned_by"),
1165 );
1166
1167 let stats = manager.get_cluster_stats(&cluster_id).unwrap();
1168 assert_eq!(stats.causal_links.len(), 2);
1169 }
1170
1171 #[test]
1172 fn test_should_inject_anomaly() {
1173 let mut rng = ChaCha8Rng::seed_from_u64(42);
1174 let pattern = TemporalPattern::default();
1175
1176 let regular_date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1177 let year_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1178
1179 let mut regular_count = 0;
1181 let mut year_end_count = 0;
1182
1183 for _ in 0..1000 {
1184 if should_inject_anomaly(0.1, regular_date, &pattern, &mut rng) {
1185 regular_count += 1;
1186 }
1187 if should_inject_anomaly(0.1, year_end, &pattern, &mut rng) {
1188 year_end_count += 1;
1189 }
1190 }
1191
1192 assert!(year_end_count > regular_count);
1194 }
1195
1196 #[test]
1197 fn test_escalation_patterns() {
1198 assert_eq!(EscalationPattern::Stable.escalation_multiplier(0), 1.0);
1200 assert_eq!(EscalationPattern::Stable.escalation_multiplier(10), 1.0);
1201
1202 let gradual = EscalationPattern::Gradual;
1204 assert!(gradual.escalation_multiplier(5) > gradual.escalation_multiplier(0));
1205 assert!(gradual.escalation_multiplier(5) <= 3.0); let aggressive = EscalationPattern::Aggressive;
1209 assert!(aggressive.escalation_multiplier(5) > gradual.escalation_multiplier(5));
1210
1211 let tts = EscalationPattern::TestThenStrike;
1213 assert!(tts.escalation_multiplier(0) < 1.0); assert!(tts.escalation_multiplier(3) > 1.0); assert_eq!(tts.escalation_multiplier(4), 0.0); }
1217
1218 #[test]
1219 fn test_fraud_actor() {
1220 use rust_decimal_macros::dec;
1221
1222 let mut actor = FraudActor::new("USER001", "John Fraudster", EscalationPattern::Gradual)
1223 .with_account("600000")
1224 .with_vendor("V001");
1225
1226 assert_eq!(actor.preferred_accounts.len(), 1);
1227 assert_eq!(actor.preferred_vendors.len(), 1);
1228 assert!(actor.is_active);
1229
1230 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1232 actor.record_fraud(
1233 "JE-001",
1234 date,
1235 dec!(1000),
1236 "DuplicatePayment",
1237 Some("600000".to_string()),
1238 Some("V002".to_string()),
1239 );
1240
1241 assert_eq!(actor.fraud_history.len(), 1);
1242 assert_eq!(actor.total_amount, dec!(1000));
1243 assert_eq!(actor.start_date, Some(date));
1244 assert!(actor.detection_risk > 0.0);
1245
1246 assert!(actor.preferred_vendors.contains(&"V002".to_string()));
1248 }
1249
1250 #[test]
1251 fn test_fraud_actor_manager() {
1252 let mut rng = ChaCha8Rng::seed_from_u64(42);
1253 let mut manager = FraudActorManager::new(0.7, 5);
1254
1255 let users = vec![
1256 "USER001".to_string(),
1257 "USER002".to_string(),
1258 "USER003".to_string(),
1259 ];
1260
1261 let actor = manager.get_or_create_actor(&users, &mut rng);
1263 assert!(actor.is_some());
1264
1265 let actor = actor.unwrap();
1267 let user_id = actor.user_id.clone();
1268 actor.record_fraud(
1269 "JE-001",
1270 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1271 rust_decimal::Decimal::from(1000),
1272 "FictitiousEntry",
1273 None,
1274 None,
1275 );
1276
1277 let retrieved = manager.get_actor(&user_id);
1279 assert!(retrieved.is_some());
1280 assert_eq!(retrieved.unwrap().fraud_history.len(), 1);
1281
1282 let stats = manager.get_statistics();
1284 assert_eq!(stats.total_actors, 1);
1285 assert_eq!(stats.active_actors, 1);
1286 assert_eq!(stats.total_incidents, 1);
1287 }
1288
1289 #[test]
1290 fn test_fraud_actor_detection() {
1291 use rust_decimal_macros::dec;
1292
1293 let mut rng = ChaCha8Rng::seed_from_u64(42);
1294 let mut manager = FraudActorManager::new(1.0, 10);
1295
1296 let mut actor =
1298 FraudActor::new("USER001", "Heavy Fraudster", EscalationPattern::Aggressive);
1299 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
1300
1301 for i in 0..10 {
1303 actor.record_fraud(
1304 format!("JE-{:03}", i),
1305 date + chrono::Duration::days(i as i64),
1306 dec!(10000),
1307 "FictitiousEntry",
1308 None,
1309 None,
1310 );
1311 }
1312
1313 manager.add_actor(actor);
1314
1315 let actor = manager.get_actor("USER001").unwrap();
1317 assert!(actor.detection_risk > 0.5);
1318
1319 for _ in 0..20 {
1321 manager.apply_detection(&mut rng);
1322 }
1323
1324 let stats = manager.get_statistics();
1326 assert!(stats.active_actors <= stats.total_actors);
1328 }
1329}