1use chrono::NaiveDate;
7use rand::{Rng, RngExt};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use uuid::Uuid;
12
13use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
14
15use datasynth_core::{AcfeFraudCategory, AnomalyDetectionDifficulty, ConcealmentTechnique};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum CollusionRingType {
20 EmployeePair,
23 DepartmentRing,
25 ManagementSubordinate,
27 CrossDepartment,
29
30 EmployeeVendor,
33 EmployeeCustomer,
35 EmployeeContractor,
37
38 VendorRing,
41 CustomerRing,
43}
44
45impl CollusionRingType {
46 pub fn typical_size_range(&self) -> (usize, usize) {
48 match self {
49 CollusionRingType::EmployeePair => (2, 2),
50 CollusionRingType::DepartmentRing => (3, 5),
51 CollusionRingType::ManagementSubordinate => (2, 4),
52 CollusionRingType::CrossDepartment => (3, 6),
53 CollusionRingType::EmployeeVendor => (2, 3),
54 CollusionRingType::EmployeeCustomer => (2, 3),
55 CollusionRingType::EmployeeContractor => (2, 4),
56 CollusionRingType::VendorRing => (2, 4),
57 CollusionRingType::CustomerRing => (2, 3),
58 }
59 }
60
61 pub fn involves_external(&self) -> bool {
63 matches!(
64 self,
65 CollusionRingType::EmployeeVendor
66 | CollusionRingType::EmployeeCustomer
67 | CollusionRingType::EmployeeContractor
68 | CollusionRingType::VendorRing
69 | CollusionRingType::CustomerRing
70 )
71 }
72
73 pub fn detection_difficulty_multiplier(&self) -> f64 {
75 match self {
76 CollusionRingType::EmployeePair => 1.2,
78 CollusionRingType::DepartmentRing => 1.3,
79 CollusionRingType::ManagementSubordinate => 1.5,
80 CollusionRingType::CrossDepartment => 1.4,
81 CollusionRingType::EmployeeVendor => 1.6,
83 CollusionRingType::EmployeeCustomer => 1.5,
84 CollusionRingType::EmployeeContractor => 1.7,
85 CollusionRingType::VendorRing => 1.8,
86 CollusionRingType::CustomerRing => 1.4,
87 }
88 }
89
90 pub fn all_variants() -> &'static [CollusionRingType] {
92 &[
93 CollusionRingType::EmployeePair,
94 CollusionRingType::DepartmentRing,
95 CollusionRingType::ManagementSubordinate,
96 CollusionRingType::CrossDepartment,
97 CollusionRingType::EmployeeVendor,
98 CollusionRingType::EmployeeCustomer,
99 CollusionRingType::EmployeeContractor,
100 CollusionRingType::VendorRing,
101 CollusionRingType::CustomerRing,
102 ]
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
108pub enum ConspiratorRole {
109 Initiator,
111 Executor,
113 Approver,
115 Concealer,
117 Lookout,
119 Beneficiary,
121 Informant,
123}
124
125impl ConspiratorRole {
126 pub fn detection_risk_weight(&self) -> f64 {
128 match self {
129 ConspiratorRole::Initiator => 0.25,
130 ConspiratorRole::Executor => 0.35,
131 ConspiratorRole::Approver => 0.30,
132 ConspiratorRole::Concealer => 0.20,
133 ConspiratorRole::Lookout => 0.10,
134 ConspiratorRole::Beneficiary => 0.40,
135 ConspiratorRole::Informant => 0.15,
136 }
137 }
138
139 pub fn is_typically_internal(&self) -> bool {
141 !matches!(self, ConspiratorRole::Beneficiary)
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147pub enum EntityType {
148 Employee,
150 Manager,
152 Vendor,
154 Customer,
156 Contractor,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Conspirator {
163 pub conspirator_id: Uuid,
165 pub entity_id: String,
167 pub entity_type: EntityType,
169 pub role: ConspiratorRole,
171 pub join_date: NaiveDate,
173 pub loyalty: f64,
175 pub risk_tolerance: f64,
177 pub proceeds_share: f64,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub department: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub position_level: Option<String>,
185 pub successful_actions: u32,
187 pub near_misses: u32,
189}
190
191impl Conspirator {
192 pub fn new(
194 entity_id: impl Into<String>,
195 entity_type: EntityType,
196 role: ConspiratorRole,
197 join_date: NaiveDate,
198 ) -> Self {
199 let uuid_factory = DeterministicUuidFactory::new(0, GeneratorType::Anomaly);
200 Self {
201 conspirator_id: uuid_factory.next(),
202 entity_id: entity_id.into(),
203 entity_type,
204 role,
205 join_date,
206 loyalty: 0.7,
207 risk_tolerance: 0.5,
208 proceeds_share: 0.0,
209 department: None,
210 position_level: None,
211 successful_actions: 0,
212 near_misses: 0,
213 }
214 }
215
216 pub fn with_loyalty(mut self, loyalty: f64) -> Self {
218 self.loyalty = loyalty.clamp(0.0, 1.0);
219 self
220 }
221
222 pub fn with_risk_tolerance(mut self, tolerance: f64) -> Self {
224 self.risk_tolerance = tolerance.clamp(0.0, 1.0);
225 self
226 }
227
228 pub fn with_proceeds_share(mut self, share: f64) -> Self {
230 self.proceeds_share = share.clamp(0.0, 1.0);
231 self
232 }
233
234 pub fn with_department(mut self, department: impl Into<String>) -> Self {
236 self.department = Some(department.into());
237 self
238 }
239
240 pub fn defection_probability(
242 &self,
243 detection_pressure: f64,
244 months_in_scheme: u32,
245 external_pressure: f64,
246 ) -> f64 {
247 let base_rate = 1.0 - self.loyalty;
249
250 let pressure_factor = 1.0 + (detection_pressure * 0.5);
252
253 let fatigue_factor = 1.0 + (months_in_scheme as f64 * 0.02);
255
256 let external_factor = 1.0 + (external_pressure * 0.3);
258
259 let near_miss_factor = 1.0 + (self.near_misses as f64 * 0.15);
261
262 let probability =
263 base_rate * pressure_factor * fatigue_factor * external_factor * near_miss_factor;
264
265 probability.clamp(0.0, 1.0)
266 }
267
268 pub fn record_success(&mut self) {
270 self.successful_actions += 1;
271 self.risk_tolerance = (self.risk_tolerance + 0.02).min(1.0);
273 }
274
275 pub fn record_near_miss(&mut self) {
277 self.near_misses += 1;
278 self.risk_tolerance = (self.risk_tolerance - 0.05).max(0.0);
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
285pub enum RingStatus {
286 Forming,
288 Active,
290 Escalating,
292 Dormant,
294 Dissolving,
296 Detected,
298 Completed,
300}
301
302impl RingStatus {
303 pub fn is_operational(&self) -> bool {
305 matches!(
306 self,
307 RingStatus::Forming | RingStatus::Active | RingStatus::Escalating
308 )
309 }
310
311 pub fn is_terminated(&self) -> bool {
313 matches!(
314 self,
315 RingStatus::Dissolving | RingStatus::Detected | RingStatus::Completed
316 )
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct RingBehavior {
323 pub transaction_interval_days: u32,
325 pub timing_variance: f64,
327 pub avg_transaction_amount: Decimal,
329 pub escalation_factor: f64,
331 pub max_transaction_amount: Decimal,
333 pub preferred_days: Vec<u32>,
335 pub avoid_month_end: bool,
337 pub concealment_techniques: Vec<ConcealmentTechnique>,
339}
340
341impl Default for RingBehavior {
342 fn default() -> Self {
343 Self {
344 transaction_interval_days: 14,
345 timing_variance: 0.3,
346 avg_transaction_amount: Decimal::new(5_000, 0),
347 escalation_factor: 1.05,
348 max_transaction_amount: Decimal::new(50_000, 0),
349 preferred_days: vec![1, 2, 3], avoid_month_end: true,
351 concealment_techniques: vec![
352 ConcealmentTechnique::TransactionSplitting,
353 ConcealmentTechnique::TimingExploitation,
354 ],
355 }
356 }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct CollusionRingConfig {
362 pub collusion_rate: f64,
364 pub ring_type_weights: HashMap<String, f64>,
366 pub min_duration_months: u32,
368 pub max_duration_months: u32,
370 pub avg_loyalty: f64,
372 pub avg_risk_tolerance: f64,
374}
375
376impl Default for CollusionRingConfig {
377 fn default() -> Self {
378 let mut ring_type_weights = HashMap::new();
379 ring_type_weights.insert("employee_pair".to_string(), 0.25);
380 ring_type_weights.insert("department_ring".to_string(), 0.15);
381 ring_type_weights.insert("management_subordinate".to_string(), 0.15);
382 ring_type_weights.insert("employee_vendor".to_string(), 0.20);
383 ring_type_weights.insert("employee_customer".to_string(), 0.10);
384 ring_type_weights.insert("vendor_ring".to_string(), 0.10);
385 ring_type_weights.insert("customer_ring".to_string(), 0.05);
386
387 Self {
388 collusion_rate: 0.50, ring_type_weights,
390 min_duration_months: 3,
391 max_duration_months: 36,
392 avg_loyalty: 0.70,
393 avg_risk_tolerance: 0.50,
394 }
395 }
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct CollusionRing {
401 pub ring_id: Uuid,
403 pub ring_type: CollusionRingType,
405 pub fraud_category: AcfeFraudCategory,
407 pub members: Vec<Conspirator>,
409 pub formation_date: NaiveDate,
411 pub status: RingStatus,
413 pub total_stolen: Decimal,
415 pub transaction_count: u32,
417 pub detection_risk: f64,
419 pub behavior: RingBehavior,
421 pub active_months: u32,
423 pub transaction_ids: Vec<String>,
425 #[serde(default)]
427 pub metadata: HashMap<String, String>,
428}
429
430impl CollusionRing {
431 pub fn new(
433 ring_type: CollusionRingType,
434 fraud_category: AcfeFraudCategory,
435 formation_date: NaiveDate,
436 ) -> Self {
437 let uuid_factory = DeterministicUuidFactory::new(0, GeneratorType::Anomaly);
438 Self {
439 ring_id: uuid_factory.next(),
440 ring_type,
441 fraud_category,
442 members: Vec::new(),
443 formation_date,
444 status: RingStatus::Forming,
445 total_stolen: Decimal::ZERO,
446 transaction_count: 0,
447 detection_risk: 0.05,
448 behavior: RingBehavior::default(),
449 active_months: 0,
450 transaction_ids: Vec::new(),
451 metadata: HashMap::new(),
452 }
453 }
454
455 pub fn add_member(&mut self, conspirator: Conspirator) {
457 self.members.push(conspirator);
458 self.update_detection_risk();
459 }
460
461 pub fn size(&self) -> usize {
463 self.members.len()
464 }
465
466 pub fn initiators(&self) -> Vec<&Conspirator> {
468 self.members
469 .iter()
470 .filter(|m| m.role == ConspiratorRole::Initiator)
471 .collect()
472 }
473
474 pub fn executors(&self) -> Vec<&Conspirator> {
476 self.members
477 .iter()
478 .filter(|m| m.role == ConspiratorRole::Executor)
479 .collect()
480 }
481
482 pub fn approvers(&self) -> Vec<&Conspirator> {
484 self.members
485 .iter()
486 .filter(|m| m.role == ConspiratorRole::Approver)
487 .collect()
488 }
489
490 fn update_detection_risk(&mut self) {
492 let size_risk = (self.members.len() as f64 * 0.05).min(0.3);
494
495 let external_count = self
497 .members
498 .iter()
499 .filter(|m| !m.role.is_typically_internal())
500 .count();
501 let external_risk = external_count as f64 * 0.03;
502
503 let tx_risk = (self.transaction_count as f64 * 0.005).min(0.2);
505
506 let amount_f64: f64 = self.total_stolen.try_into().unwrap_or(0.0);
508 let amount_risk = ((amount_f64 / 100_000.0).ln().max(0.0) * 0.02).min(0.15);
509
510 let time_risk = (self.active_months as f64 * 0.01).min(0.2);
512
513 let type_multiplier = self.ring_type.detection_difficulty_multiplier();
515
516 self.detection_risk = ((size_risk + external_risk + tx_risk + amount_risk + time_risk)
518 / type_multiplier)
519 .min(0.95);
520 }
521
522 pub fn record_transaction(&mut self, amount: Decimal, transaction_id: impl Into<String>) {
524 self.total_stolen += amount;
525 self.transaction_count += 1;
526 self.transaction_ids.push(transaction_id.into());
527
528 for member in &mut self.members {
530 if matches!(
531 member.role,
532 ConspiratorRole::Executor | ConspiratorRole::Approver | ConspiratorRole::Initiator
533 ) {
534 member.record_success();
535 }
536 }
537
538 self.update_detection_risk();
539 }
540
541 pub fn record_near_miss(&mut self) {
543 for member in &mut self.members {
545 member.record_near_miss();
546 }
547
548 self.detection_risk = (self.detection_risk + 0.1).min(0.95);
550
551 if self.detection_risk > 0.5 {
553 self.status = RingStatus::Dormant;
554 }
555 }
556
557 pub fn advance_month<R: Rng>(&mut self, rng: &mut R) {
559 if !self.status.is_operational() {
560 return;
561 }
562
563 self.active_months += 1;
564
565 if self.check_defection(rng) {
567 self.status = RingStatus::Dissolving;
568 return;
569 }
570
571 if rng.random::<f64>() < self.detection_risk * 0.1 {
573 self.status = RingStatus::Detected;
574 return;
575 }
576
577 match self.status {
579 RingStatus::Forming if self.active_months >= 2 && self.transaction_count >= 3 => {
580 self.status = RingStatus::Active;
581 }
582 RingStatus::Active
584 if self.active_months >= 6
585 && self.detection_risk < 0.3
586 && rng.random::<f64>() < 0.3 =>
587 {
588 self.status = RingStatus::Escalating;
589 self.behavior.avg_transaction_amount = self
590 .behavior
591 .avg_transaction_amount
592 .saturating_mul(Decimal::from_str_exact("1.5").unwrap_or(Decimal::ONE));
593 }
594 RingStatus::Dormant
596 if self.active_months.is_multiple_of(3)
597 && rng.random::<f64>() < 0.4
598 && self.detection_risk < 0.4 =>
599 {
600 self.status = RingStatus::Active;
601 self.detection_risk *= 0.8;
602 }
603 _ => {}
604 }
605
606 if matches!(
609 self.status,
610 RingStatus::Forming | RingStatus::Active | RingStatus::Escalating
611 ) {
612 let txns_per_month = (30 / self.behavior.transaction_interval_days.max(1)).max(1);
613 for _ in 0..txns_per_month {
614 let variance = 0.7 + rng.random::<f64>() * 0.6; let amount = self
617 .behavior
618 .avg_transaction_amount
619 .saturating_mul(Decimal::try_from(variance).unwrap_or(Decimal::ONE));
620 let amount = amount.min(self.behavior.max_transaction_amount);
621 let tx_id = format!("TX-{}-{:04}", self.ring_id, self.transaction_count + 1);
622 self.record_transaction(amount, tx_id);
623 }
624 if self.status == RingStatus::Escalating {
626 self.behavior.avg_transaction_amount =
627 self.behavior.avg_transaction_amount.saturating_mul(
628 Decimal::try_from(self.behavior.escalation_factor).unwrap_or(Decimal::ONE),
629 );
630 }
631 }
632 }
633
634 fn check_defection<R: Rng>(&self, rng: &mut R) -> bool {
636 for member in &self.members {
637 let defection_prob = member.defection_probability(
638 self.detection_risk,
639 self.active_months,
640 rng.random::<f64>() * 0.3, );
642
643 if rng.random::<f64>() < defection_prob {
644 return true;
645 }
646 }
647 false
648 }
649
650 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
652 let concealment_bonus: f64 = self
654 .behavior
655 .concealment_techniques
656 .iter()
657 .map(datasynth_core::ConcealmentTechnique::difficulty_bonus)
658 .sum();
659
660 let type_multiplier = self.ring_type.detection_difficulty_multiplier();
662
663 let score = (0.5 + concealment_bonus) * type_multiplier;
665
666 AnomalyDetectionDifficulty::from_score(score.min(1.0))
667 }
668
669 pub fn avg_share_per_member(&self) -> Decimal {
671 if self.members.is_empty() {
672 return Decimal::ZERO;
673 }
674 self.total_stolen / Decimal::from(self.members.len())
675 }
676
677 pub fn description(&self) -> String {
679 format!(
680 "{:?} ring with {} members, {} transactions totaling {}, active {} months",
681 self.ring_type,
682 self.members.len(),
683 self.transaction_count,
684 self.total_stolen,
685 self.active_months
686 )
687 }
688}
689
690#[cfg(test)]
691#[allow(clippy::unwrap_used)]
692mod tests {
693 use super::*;
694 use rand::SeedableRng;
695 use rand_chacha::ChaCha8Rng;
696
697 #[test]
698 fn test_collusion_ring_type() {
699 let emp_pair = CollusionRingType::EmployeePair;
700 assert_eq!(emp_pair.typical_size_range(), (2, 2));
701 assert!(!emp_pair.involves_external());
702
703 let emp_vendor = CollusionRingType::EmployeeVendor;
704 assert!(emp_vendor.involves_external());
705 assert!(emp_vendor.detection_difficulty_multiplier() > 1.0);
706 }
707
708 #[test]
709 fn test_conspirator() {
710 let conspirator = Conspirator::new(
711 "EMP001",
712 EntityType::Employee,
713 ConspiratorRole::Executor,
714 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
715 )
716 .with_loyalty(0.8)
717 .with_risk_tolerance(0.6)
718 .with_proceeds_share(0.4)
719 .with_department("Accounting");
720
721 assert_eq!(conspirator.loyalty, 0.8);
722 assert_eq!(conspirator.risk_tolerance, 0.6);
723 assert_eq!(conspirator.department, Some("Accounting".to_string()));
724 }
725
726 #[test]
727 fn test_defection_probability() {
728 let conspirator = Conspirator::new(
729 "EMP001",
730 EntityType::Employee,
731 ConspiratorRole::Executor,
732 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
733 )
734 .with_loyalty(0.9);
735
736 let low_pressure = conspirator.defection_probability(0.1, 1, 0.0);
738 assert!(low_pressure < 0.3);
739
740 let high_pressure = conspirator.defection_probability(0.8, 12, 0.5);
742 assert!(high_pressure > low_pressure);
743 }
744
745 #[test]
746 fn test_collusion_ring() {
747 let mut ring = CollusionRing::new(
748 CollusionRingType::EmployeePair,
749 AcfeFraudCategory::AssetMisappropriation,
750 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
751 );
752
753 ring.add_member(Conspirator::new(
754 "EMP001",
755 EntityType::Employee,
756 ConspiratorRole::Initiator,
757 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
758 ));
759 ring.add_member(Conspirator::new(
760 "EMP002",
761 EntityType::Employee,
762 ConspiratorRole::Approver,
763 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
764 ));
765
766 assert_eq!(ring.size(), 2);
767 assert_eq!(ring.initiators().len(), 1);
768 assert_eq!(ring.approvers().len(), 1);
769 assert_eq!(ring.status, RingStatus::Forming);
770 }
771
772 #[test]
773 fn test_ring_transaction() {
774 let mut ring = CollusionRing::new(
775 CollusionRingType::EmployeeVendor,
776 AcfeFraudCategory::Corruption,
777 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
778 );
779
780 ring.add_member(Conspirator::new(
781 "EMP001",
782 EntityType::Employee,
783 ConspiratorRole::Executor,
784 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
785 ));
786
787 ring.record_transaction(Decimal::new(10_000, 0), "TX001");
788 assert_eq!(ring.total_stolen, Decimal::new(10_000, 0));
789 assert_eq!(ring.transaction_count, 1);
790 assert!(ring.detection_risk >= 0.0);
792 }
793
794 #[test]
795 fn test_ring_advance_month() {
796 let mut rng = ChaCha8Rng::seed_from_u64(42);
797
798 let mut ring = CollusionRing::new(
799 CollusionRingType::DepartmentRing,
800 AcfeFraudCategory::AssetMisappropriation,
801 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
802 );
803
804 for i in 0..3 {
806 ring.add_member(
807 Conspirator::new(
808 format!("EMP{:03}", i),
809 EntityType::Employee,
810 if i == 0 {
811 ConspiratorRole::Initiator
812 } else {
813 ConspiratorRole::Executor
814 },
815 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
816 )
817 .with_loyalty(0.99), );
819 }
820
821 for i in 0..3 {
823 ring.record_transaction(Decimal::new(1_000, 0), format!("TX{:03}", i));
824 }
825
826 for _ in 0..3 {
828 ring.advance_month(&mut rng);
829 }
830
831 assert!(ring.active_months >= 3);
832 assert!(ring.status.is_operational() || ring.status.is_terminated());
834 }
835
836 #[test]
837 fn test_ring_near_miss() {
838 let mut ring = CollusionRing::new(
839 CollusionRingType::EmployeePair,
840 AcfeFraudCategory::AssetMisappropriation,
841 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
842 );
843
844 ring.add_member(Conspirator::new(
845 "EMP001",
846 EntityType::Employee,
847 ConspiratorRole::Executor,
848 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
849 ));
850
851 let initial_risk = ring.detection_risk;
852 ring.record_near_miss();
853
854 assert!(ring.detection_risk > initial_risk);
855 assert_eq!(ring.members[0].near_misses, 1);
856 }
857
858 #[test]
859 fn test_ring_detection_difficulty() {
860 let mut ring = CollusionRing::new(
861 CollusionRingType::EmployeeVendor,
862 AcfeFraudCategory::Corruption,
863 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
864 );
865
866 ring.behavior.concealment_techniques = vec![
867 ConcealmentTechnique::Collusion,
868 ConcealmentTechnique::DocumentManipulation,
869 ConcealmentTechnique::FalseDocumentation,
870 ];
871
872 let difficulty = ring.detection_difficulty();
873 assert!(matches!(
875 difficulty,
876 AnomalyDetectionDifficulty::Hard | AnomalyDetectionDifficulty::Expert
877 ));
878 }
879}