1use chrono::NaiveDate;
7use rand::Rng;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use uuid::Uuid;
12
13use datasynth_core::{AcfeFraudCategory, AnomalyDetectionDifficulty, ConcealmentTechnique};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum CollusionRingType {
18 EmployeePair,
21 DepartmentRing,
23 ManagementSubordinate,
25 CrossDepartment,
27
28 EmployeeVendor,
31 EmployeeCustomer,
33 EmployeeContractor,
35
36 VendorRing,
39 CustomerRing,
41}
42
43impl CollusionRingType {
44 pub fn typical_size_range(&self) -> (usize, usize) {
46 match self {
47 CollusionRingType::EmployeePair => (2, 2),
48 CollusionRingType::DepartmentRing => (3, 5),
49 CollusionRingType::ManagementSubordinate => (2, 4),
50 CollusionRingType::CrossDepartment => (3, 6),
51 CollusionRingType::EmployeeVendor => (2, 3),
52 CollusionRingType::EmployeeCustomer => (2, 3),
53 CollusionRingType::EmployeeContractor => (2, 4),
54 CollusionRingType::VendorRing => (2, 4),
55 CollusionRingType::CustomerRing => (2, 3),
56 }
57 }
58
59 pub fn involves_external(&self) -> bool {
61 matches!(
62 self,
63 CollusionRingType::EmployeeVendor
64 | CollusionRingType::EmployeeCustomer
65 | CollusionRingType::EmployeeContractor
66 | CollusionRingType::VendorRing
67 | CollusionRingType::CustomerRing
68 )
69 }
70
71 pub fn detection_difficulty_multiplier(&self) -> f64 {
73 match self {
74 CollusionRingType::EmployeePair => 1.2,
76 CollusionRingType::DepartmentRing => 1.3,
77 CollusionRingType::ManagementSubordinate => 1.5,
78 CollusionRingType::CrossDepartment => 1.4,
79 CollusionRingType::EmployeeVendor => 1.6,
81 CollusionRingType::EmployeeCustomer => 1.5,
82 CollusionRingType::EmployeeContractor => 1.7,
83 CollusionRingType::VendorRing => 1.8,
84 CollusionRingType::CustomerRing => 1.4,
85 }
86 }
87
88 pub fn all_variants() -> &'static [CollusionRingType] {
90 &[
91 CollusionRingType::EmployeePair,
92 CollusionRingType::DepartmentRing,
93 CollusionRingType::ManagementSubordinate,
94 CollusionRingType::CrossDepartment,
95 CollusionRingType::EmployeeVendor,
96 CollusionRingType::EmployeeCustomer,
97 CollusionRingType::EmployeeContractor,
98 CollusionRingType::VendorRing,
99 CollusionRingType::CustomerRing,
100 ]
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106pub enum ConspiratorRole {
107 Initiator,
109 Executor,
111 Approver,
113 Concealer,
115 Lookout,
117 Beneficiary,
119 Informant,
121}
122
123impl ConspiratorRole {
124 pub fn detection_risk_weight(&self) -> f64 {
126 match self {
127 ConspiratorRole::Initiator => 0.25,
128 ConspiratorRole::Executor => 0.35,
129 ConspiratorRole::Approver => 0.30,
130 ConspiratorRole::Concealer => 0.20,
131 ConspiratorRole::Lookout => 0.10,
132 ConspiratorRole::Beneficiary => 0.40,
133 ConspiratorRole::Informant => 0.15,
134 }
135 }
136
137 pub fn is_typically_internal(&self) -> bool {
139 !matches!(self, ConspiratorRole::Beneficiary)
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
145pub enum EntityType {
146 Employee,
148 Manager,
150 Vendor,
152 Customer,
154 Contractor,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Conspirator {
161 pub conspirator_id: Uuid,
163 pub entity_id: String,
165 pub entity_type: EntityType,
167 pub role: ConspiratorRole,
169 pub join_date: NaiveDate,
171 pub loyalty: f64,
173 pub risk_tolerance: f64,
175 pub proceeds_share: f64,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub department: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub position_level: Option<String>,
183 pub successful_actions: u32,
185 pub near_misses: u32,
187}
188
189impl Conspirator {
190 pub fn new(
192 entity_id: impl Into<String>,
193 entity_type: EntityType,
194 role: ConspiratorRole,
195 join_date: NaiveDate,
196 ) -> Self {
197 Self {
198 conspirator_id: Uuid::new_v4(),
199 entity_id: entity_id.into(),
200 entity_type,
201 role,
202 join_date,
203 loyalty: 0.7,
204 risk_tolerance: 0.5,
205 proceeds_share: 0.0,
206 department: None,
207 position_level: None,
208 successful_actions: 0,
209 near_misses: 0,
210 }
211 }
212
213 pub fn with_loyalty(mut self, loyalty: f64) -> Self {
215 self.loyalty = loyalty.clamp(0.0, 1.0);
216 self
217 }
218
219 pub fn with_risk_tolerance(mut self, tolerance: f64) -> Self {
221 self.risk_tolerance = tolerance.clamp(0.0, 1.0);
222 self
223 }
224
225 pub fn with_proceeds_share(mut self, share: f64) -> Self {
227 self.proceeds_share = share.clamp(0.0, 1.0);
228 self
229 }
230
231 pub fn with_department(mut self, department: impl Into<String>) -> Self {
233 self.department = Some(department.into());
234 self
235 }
236
237 pub fn defection_probability(
239 &self,
240 detection_pressure: f64,
241 months_in_scheme: u32,
242 external_pressure: f64,
243 ) -> f64 {
244 let base_rate = 1.0 - self.loyalty;
246
247 let pressure_factor = 1.0 + (detection_pressure * 0.5);
249
250 let fatigue_factor = 1.0 + (months_in_scheme as f64 * 0.02);
252
253 let external_factor = 1.0 + (external_pressure * 0.3);
255
256 let near_miss_factor = 1.0 + (self.near_misses as f64 * 0.15);
258
259 let probability =
260 base_rate * pressure_factor * fatigue_factor * external_factor * near_miss_factor;
261
262 probability.clamp(0.0, 1.0)
263 }
264
265 pub fn record_success(&mut self) {
267 self.successful_actions += 1;
268 self.risk_tolerance = (self.risk_tolerance + 0.02).min(1.0);
270 }
271
272 pub fn record_near_miss(&mut self) {
274 self.near_misses += 1;
275 self.risk_tolerance = (self.risk_tolerance - 0.05).max(0.0);
277 }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
282pub enum RingStatus {
283 Forming,
285 Active,
287 Escalating,
289 Dormant,
291 Dissolving,
293 Detected,
295 Completed,
297}
298
299impl RingStatus {
300 pub fn is_operational(&self) -> bool {
302 matches!(
303 self,
304 RingStatus::Forming | RingStatus::Active | RingStatus::Escalating
305 )
306 }
307
308 pub fn is_terminated(&self) -> bool {
310 matches!(
311 self,
312 RingStatus::Dissolving | RingStatus::Detected | RingStatus::Completed
313 )
314 }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct RingBehavior {
320 pub transaction_interval_days: u32,
322 pub timing_variance: f64,
324 pub avg_transaction_amount: Decimal,
326 pub escalation_factor: f64,
328 pub max_transaction_amount: Decimal,
330 pub preferred_days: Vec<u32>,
332 pub avoid_month_end: bool,
334 pub concealment_techniques: Vec<ConcealmentTechnique>,
336}
337
338impl Default for RingBehavior {
339 fn default() -> Self {
340 Self {
341 transaction_interval_days: 14,
342 timing_variance: 0.3,
343 avg_transaction_amount: Decimal::new(5_000, 0),
344 escalation_factor: 1.05,
345 max_transaction_amount: Decimal::new(50_000, 0),
346 preferred_days: vec![1, 2, 3], avoid_month_end: true,
348 concealment_techniques: vec![
349 ConcealmentTechnique::TransactionSplitting,
350 ConcealmentTechnique::TimingExploitation,
351 ],
352 }
353 }
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct CollusionRingConfig {
359 pub collusion_rate: f64,
361 pub ring_type_weights: HashMap<String, f64>,
363 pub min_duration_months: u32,
365 pub max_duration_months: u32,
367 pub avg_loyalty: f64,
369 pub avg_risk_tolerance: f64,
371}
372
373impl Default for CollusionRingConfig {
374 fn default() -> Self {
375 let mut ring_type_weights = HashMap::new();
376 ring_type_weights.insert("employee_pair".to_string(), 0.25);
377 ring_type_weights.insert("department_ring".to_string(), 0.15);
378 ring_type_weights.insert("management_subordinate".to_string(), 0.15);
379 ring_type_weights.insert("employee_vendor".to_string(), 0.20);
380 ring_type_weights.insert("employee_customer".to_string(), 0.10);
381 ring_type_weights.insert("vendor_ring".to_string(), 0.10);
382 ring_type_weights.insert("customer_ring".to_string(), 0.05);
383
384 Self {
385 collusion_rate: 0.50, ring_type_weights,
387 min_duration_months: 3,
388 max_duration_months: 36,
389 avg_loyalty: 0.70,
390 avg_risk_tolerance: 0.50,
391 }
392 }
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct CollusionRing {
398 pub ring_id: Uuid,
400 pub ring_type: CollusionRingType,
402 pub fraud_category: AcfeFraudCategory,
404 pub members: Vec<Conspirator>,
406 pub formation_date: NaiveDate,
408 pub status: RingStatus,
410 pub total_stolen: Decimal,
412 pub transaction_count: u32,
414 pub detection_risk: f64,
416 pub behavior: RingBehavior,
418 pub active_months: u32,
420 pub transaction_ids: Vec<String>,
422 #[serde(default)]
424 pub metadata: HashMap<String, String>,
425}
426
427impl CollusionRing {
428 pub fn new(
430 ring_type: CollusionRingType,
431 fraud_category: AcfeFraudCategory,
432 formation_date: NaiveDate,
433 ) -> Self {
434 Self {
435 ring_id: Uuid::new_v4(),
436 ring_type,
437 fraud_category,
438 members: Vec::new(),
439 formation_date,
440 status: RingStatus::Forming,
441 total_stolen: Decimal::ZERO,
442 transaction_count: 0,
443 detection_risk: 0.05,
444 behavior: RingBehavior::default(),
445 active_months: 0,
446 transaction_ids: Vec::new(),
447 metadata: HashMap::new(),
448 }
449 }
450
451 pub fn add_member(&mut self, conspirator: Conspirator) {
453 self.members.push(conspirator);
454 self.update_detection_risk();
455 }
456
457 pub fn size(&self) -> usize {
459 self.members.len()
460 }
461
462 pub fn initiators(&self) -> Vec<&Conspirator> {
464 self.members
465 .iter()
466 .filter(|m| m.role == ConspiratorRole::Initiator)
467 .collect()
468 }
469
470 pub fn executors(&self) -> Vec<&Conspirator> {
472 self.members
473 .iter()
474 .filter(|m| m.role == ConspiratorRole::Executor)
475 .collect()
476 }
477
478 pub fn approvers(&self) -> Vec<&Conspirator> {
480 self.members
481 .iter()
482 .filter(|m| m.role == ConspiratorRole::Approver)
483 .collect()
484 }
485
486 fn update_detection_risk(&mut self) {
488 let size_risk = (self.members.len() as f64 * 0.05).min(0.3);
490
491 let external_count = self
493 .members
494 .iter()
495 .filter(|m| !m.role.is_typically_internal())
496 .count();
497 let external_risk = external_count as f64 * 0.03;
498
499 let tx_risk = (self.transaction_count as f64 * 0.005).min(0.2);
501
502 let amount_f64: f64 = self.total_stolen.try_into().unwrap_or(0.0);
504 let amount_risk = ((amount_f64 / 100_000.0).ln().max(0.0) * 0.02).min(0.15);
505
506 let time_risk = (self.active_months as f64 * 0.01).min(0.2);
508
509 let type_multiplier = self.ring_type.detection_difficulty_multiplier();
511
512 self.detection_risk = ((size_risk + external_risk + tx_risk + amount_risk + time_risk)
514 / type_multiplier)
515 .min(0.95);
516 }
517
518 pub fn record_transaction(&mut self, amount: Decimal, transaction_id: impl Into<String>) {
520 self.total_stolen += amount;
521 self.transaction_count += 1;
522 self.transaction_ids.push(transaction_id.into());
523
524 for member in &mut self.members {
526 if matches!(
527 member.role,
528 ConspiratorRole::Executor | ConspiratorRole::Approver | ConspiratorRole::Initiator
529 ) {
530 member.record_success();
531 }
532 }
533
534 self.update_detection_risk();
535 }
536
537 pub fn record_near_miss(&mut self) {
539 for member in &mut self.members {
541 member.record_near_miss();
542 }
543
544 self.detection_risk = (self.detection_risk + 0.1).min(0.95);
546
547 if self.detection_risk > 0.5 {
549 self.status = RingStatus::Dormant;
550 }
551 }
552
553 pub fn advance_month<R: Rng>(&mut self, rng: &mut R) {
555 if !self.status.is_operational() {
556 return;
557 }
558
559 self.active_months += 1;
560
561 if self.check_defection(rng) {
563 self.status = RingStatus::Dissolving;
564 return;
565 }
566
567 if rng.gen::<f64>() < self.detection_risk * 0.1 {
569 self.status = RingStatus::Detected;
570 return;
571 }
572
573 match self.status {
575 RingStatus::Forming if self.active_months >= 2 && self.transaction_count >= 3 => {
576 self.status = RingStatus::Active;
577 }
578 RingStatus::Active if self.active_months >= 6 && self.detection_risk < 0.3 => {
579 if rng.gen::<f64>() < 0.3 {
581 self.status = RingStatus::Escalating;
582 self.behavior.avg_transaction_amount = self
583 .behavior
584 .avg_transaction_amount
585 .saturating_mul(Decimal::from_str_exact("1.5").unwrap_or(Decimal::ONE));
586 }
587 }
588 RingStatus::Dormant if self.active_months % 3 == 0 => {
589 if rng.gen::<f64>() < 0.4 && self.detection_risk < 0.4 {
591 self.status = RingStatus::Active;
592 self.detection_risk *= 0.8; }
594 }
595 _ => {}
596 }
597 }
598
599 fn check_defection<R: Rng>(&self, rng: &mut R) -> bool {
601 for member in &self.members {
602 let defection_prob = member.defection_probability(
603 self.detection_risk,
604 self.active_months,
605 rng.gen::<f64>() * 0.3, );
607
608 if rng.gen::<f64>() < defection_prob {
609 return true;
610 }
611 }
612 false
613 }
614
615 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
617 let concealment_bonus: f64 = self
619 .behavior
620 .concealment_techniques
621 .iter()
622 .map(|c| c.difficulty_bonus())
623 .sum();
624
625 let type_multiplier = self.ring_type.detection_difficulty_multiplier();
627
628 let score = (0.5 + concealment_bonus) * type_multiplier;
630
631 AnomalyDetectionDifficulty::from_score(score.min(1.0))
632 }
633
634 pub fn avg_share_per_member(&self) -> Decimal {
636 if self.members.is_empty() {
637 return Decimal::ZERO;
638 }
639 self.total_stolen / Decimal::from(self.members.len())
640 }
641
642 pub fn description(&self) -> String {
644 format!(
645 "{:?} ring with {} members, {} transactions totaling {}, active {} months",
646 self.ring_type,
647 self.members.len(),
648 self.transaction_count,
649 self.total_stolen,
650 self.active_months
651 )
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use rand::SeedableRng;
659 use rand_chacha::ChaCha8Rng;
660
661 #[test]
662 fn test_collusion_ring_type() {
663 let emp_pair = CollusionRingType::EmployeePair;
664 assert_eq!(emp_pair.typical_size_range(), (2, 2));
665 assert!(!emp_pair.involves_external());
666
667 let emp_vendor = CollusionRingType::EmployeeVendor;
668 assert!(emp_vendor.involves_external());
669 assert!(emp_vendor.detection_difficulty_multiplier() > 1.0);
670 }
671
672 #[test]
673 fn test_conspirator() {
674 let conspirator = Conspirator::new(
675 "EMP001",
676 EntityType::Employee,
677 ConspiratorRole::Executor,
678 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
679 )
680 .with_loyalty(0.8)
681 .with_risk_tolerance(0.6)
682 .with_proceeds_share(0.4)
683 .with_department("Accounting");
684
685 assert_eq!(conspirator.loyalty, 0.8);
686 assert_eq!(conspirator.risk_tolerance, 0.6);
687 assert_eq!(conspirator.department, Some("Accounting".to_string()));
688 }
689
690 #[test]
691 fn test_defection_probability() {
692 let conspirator = Conspirator::new(
693 "EMP001",
694 EntityType::Employee,
695 ConspiratorRole::Executor,
696 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
697 )
698 .with_loyalty(0.9);
699
700 let low_pressure = conspirator.defection_probability(0.1, 1, 0.0);
702 assert!(low_pressure < 0.3);
703
704 let high_pressure = conspirator.defection_probability(0.8, 12, 0.5);
706 assert!(high_pressure > low_pressure);
707 }
708
709 #[test]
710 fn test_collusion_ring() {
711 let mut ring = CollusionRing::new(
712 CollusionRingType::EmployeePair,
713 AcfeFraudCategory::AssetMisappropriation,
714 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
715 );
716
717 ring.add_member(Conspirator::new(
718 "EMP001",
719 EntityType::Employee,
720 ConspiratorRole::Initiator,
721 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
722 ));
723 ring.add_member(Conspirator::new(
724 "EMP002",
725 EntityType::Employee,
726 ConspiratorRole::Approver,
727 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
728 ));
729
730 assert_eq!(ring.size(), 2);
731 assert_eq!(ring.initiators().len(), 1);
732 assert_eq!(ring.approvers().len(), 1);
733 assert_eq!(ring.status, RingStatus::Forming);
734 }
735
736 #[test]
737 fn test_ring_transaction() {
738 let mut ring = CollusionRing::new(
739 CollusionRingType::EmployeeVendor,
740 AcfeFraudCategory::Corruption,
741 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
742 );
743
744 ring.add_member(Conspirator::new(
745 "EMP001",
746 EntityType::Employee,
747 ConspiratorRole::Executor,
748 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
749 ));
750
751 ring.record_transaction(Decimal::new(10_000, 0), "TX001");
752 assert_eq!(ring.total_stolen, Decimal::new(10_000, 0));
753 assert_eq!(ring.transaction_count, 1);
754 assert!(ring.detection_risk >= 0.0);
756 }
757
758 #[test]
759 fn test_ring_advance_month() {
760 let mut rng = ChaCha8Rng::seed_from_u64(42);
761
762 let mut ring = CollusionRing::new(
763 CollusionRingType::DepartmentRing,
764 AcfeFraudCategory::AssetMisappropriation,
765 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
766 );
767
768 for i in 0..3 {
770 ring.add_member(
771 Conspirator::new(
772 format!("EMP{:03}", i),
773 EntityType::Employee,
774 if i == 0 {
775 ConspiratorRole::Initiator
776 } else {
777 ConspiratorRole::Executor
778 },
779 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
780 )
781 .with_loyalty(0.99), );
783 }
784
785 for i in 0..3 {
787 ring.record_transaction(Decimal::new(1_000, 0), format!("TX{:03}", i));
788 }
789
790 for _ in 0..3 {
792 ring.advance_month(&mut rng);
793 }
794
795 assert!(ring.active_months >= 3);
796 assert!(ring.status.is_operational() || ring.status.is_terminated());
798 }
799
800 #[test]
801 fn test_ring_near_miss() {
802 let mut ring = CollusionRing::new(
803 CollusionRingType::EmployeePair,
804 AcfeFraudCategory::AssetMisappropriation,
805 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
806 );
807
808 ring.add_member(Conspirator::new(
809 "EMP001",
810 EntityType::Employee,
811 ConspiratorRole::Executor,
812 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
813 ));
814
815 let initial_risk = ring.detection_risk;
816 ring.record_near_miss();
817
818 assert!(ring.detection_risk > initial_risk);
819 assert_eq!(ring.members[0].near_misses, 1);
820 }
821
822 #[test]
823 fn test_ring_detection_difficulty() {
824 let mut ring = CollusionRing::new(
825 CollusionRingType::EmployeeVendor,
826 AcfeFraudCategory::Corruption,
827 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
828 );
829
830 ring.behavior.concealment_techniques = vec![
831 ConcealmentTechnique::Collusion,
832 ConcealmentTechnique::DocumentManipulation,
833 ConcealmentTechnique::FalseDocumentation,
834 ];
835
836 let difficulty = ring.detection_difficulty();
837 assert!(matches!(
839 difficulty,
840 AnomalyDetectionDifficulty::Hard | AnomalyDetectionDifficulty::Expert
841 ));
842 }
843}