Skip to main content

datasynth_generators/fraud/collusion/
network.rs

1//! Collusion ring network modeling.
2//!
3//! Models fraud networks with multiple conspirators, coordinated schemes,
4//! trust dynamics, and realistic behavioral patterns.
5
6use 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/// Type of collusion ring based on participant composition.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum CollusionRingType {
20    // ========== Internal Collusion ==========
21    /// Two employees colluding (e.g., approver + processor).
22    EmployeePair,
23    /// Small departmental ring (3-5 employees).
24    DepartmentRing,
25    /// Manager with one or more subordinates.
26    ManagementSubordinate,
27    /// Multiple employees across departments.
28    CrossDepartment,
29
30    // ========== Internal-External Collusion ==========
31    /// Purchasing employee with vendor contact.
32    EmployeeVendor,
33    /// Sales rep with customer contact.
34    EmployeeCustomer,
35    /// Project manager with external contractor.
36    EmployeeContractor,
37
38    // ========== External Rings ==========
39    /// Multiple vendors colluding for bid rigging.
40    VendorRing,
41    /// Multiple customers for return fraud schemes.
42    CustomerRing,
43}
44
45impl CollusionRingType {
46    /// Returns the typical size range for this ring type.
47    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    /// Returns whether this ring type involves external parties.
62    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    /// Returns the detection difficulty multiplier for this ring type.
74    pub fn detection_difficulty_multiplier(&self) -> f64 {
75        match self {
76            // Internal collusion easier to detect through behavioral analysis
77            CollusionRingType::EmployeePair => 1.2,
78            CollusionRingType::DepartmentRing => 1.3,
79            CollusionRingType::ManagementSubordinate => 1.5,
80            CollusionRingType::CrossDepartment => 1.4,
81            // External collusion harder due to limited visibility
82            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    /// Returns all variants for iteration.
91    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/// Role of a conspirator within the fraud ring.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
108pub enum ConspiratorRole {
109    /// Conceives the scheme and recruits others.
110    Initiator,
111    /// Performs the actual transactions.
112    Executor,
113    /// Provides approvals or authorization overrides.
114    Approver,
115    /// Hides evidence and manipulates records.
116    Concealer,
117    /// Monitors for detection and warns others.
118    Lookout,
119    /// External recipient of proceeds (vendor, customer).
120    Beneficiary,
121    /// Provides inside information without direct participation.
122    Informant,
123}
124
125impl ConspiratorRole {
126    /// Returns the risk weight (likelihood of detection through this role).
127    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    /// Returns whether this role is typically internal to the organization.
140    pub fn is_typically_internal(&self) -> bool {
141        !matches!(self, ConspiratorRole::Beneficiary)
142    }
143}
144
145/// Type of entity participating in the ring.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147pub enum EntityType {
148    /// Employee of the organization.
149    Employee,
150    /// Manager or executive.
151    Manager,
152    /// External vendor.
153    Vendor,
154    /// External customer.
155    Customer,
156    /// External contractor.
157    Contractor,
158}
159
160/// A conspirator within a collusion ring.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Conspirator {
163    /// Unique identifier for this conspirator.
164    pub conspirator_id: Uuid,
165    /// Reference to the entity (employee ID, vendor ID, etc.).
166    pub entity_id: String,
167    /// Type of entity.
168    pub entity_type: EntityType,
169    /// Role within the ring.
170    pub role: ConspiratorRole,
171    /// Date joined the ring.
172    pub join_date: NaiveDate,
173    /// Loyalty score (0.0-1.0): probability of not defecting under pressure.
174    pub loyalty: f64,
175    /// Risk tolerance (0.0-1.0): willingness to escalate scheme.
176    pub risk_tolerance: f64,
177    /// Share of proceeds (0.0-1.0).
178    pub proceeds_share: f64,
179    /// Department (for employees).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub department: Option<String>,
182    /// Position level (for employees).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub position_level: Option<String>,
185    /// Times the conspirator has been involved in successful transactions.
186    pub successful_actions: u32,
187    /// Times the conspirator has had close calls.
188    pub near_misses: u32,
189}
190
191impl Conspirator {
192    /// Creates a new conspirator.
193    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    /// Sets the loyalty score.
217    pub fn with_loyalty(mut self, loyalty: f64) -> Self {
218        self.loyalty = loyalty.clamp(0.0, 1.0);
219        self
220    }
221
222    /// Sets the risk tolerance.
223    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    /// Sets the proceeds share.
229    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    /// Sets the department.
235    pub fn with_department(mut self, department: impl Into<String>) -> Self {
236        self.department = Some(department.into());
237        self
238    }
239
240    /// Calculates defection probability based on current conditions.
241    pub fn defection_probability(
242        &self,
243        detection_pressure: f64,
244        months_in_scheme: u32,
245        external_pressure: f64,
246    ) -> f64 {
247        // Base defection rate inversely proportional to loyalty
248        let base_rate = 1.0 - self.loyalty;
249
250        // Modify by detection pressure
251        let pressure_factor = 1.0 + (detection_pressure * 0.5);
252
253        // Modify by time in scheme (fatigue)
254        let fatigue_factor = 1.0 + (months_in_scheme as f64 * 0.02);
255
256        // External pressure (personal issues, fear)
257        let external_factor = 1.0 + (external_pressure * 0.3);
258
259        // Near misses increase defection likelihood
260        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    /// Records a successful action.
269    pub fn record_success(&mut self) {
270        self.successful_actions += 1;
271        // Success can increase risk tolerance
272        self.risk_tolerance = (self.risk_tolerance + 0.02).min(1.0);
273    }
274
275    /// Records a near miss.
276    pub fn record_near_miss(&mut self) {
277        self.near_misses += 1;
278        // Near misses decrease risk tolerance
279        self.risk_tolerance = (self.risk_tolerance - 0.05).max(0.0);
280    }
281}
282
283/// Status of a collusion ring.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
285pub enum RingStatus {
286    /// Trust-building phase, small test transactions.
287    Forming,
288    /// Actively executing scheme.
289    Active,
290    /// Increasing amounts and frequency.
291    Escalating,
292    /// Temporarily paused due to fear or external events.
293    Dormant,
294    /// Breaking apart (member leaving or distrust).
295    Dissolving,
296    /// Caught by detection mechanisms.
297    Detected,
298    /// Successfully completed without detection.
299    Completed,
300}
301
302impl RingStatus {
303    /// Returns whether the ring is currently operational.
304    pub fn is_operational(&self) -> bool {
305        matches!(
306            self,
307            RingStatus::Forming | RingStatus::Active | RingStatus::Escalating
308        )
309    }
310
311    /// Returns whether the ring has ended.
312    pub fn is_terminated(&self) -> bool {
313        matches!(
314            self,
315            RingStatus::Dissolving | RingStatus::Detected | RingStatus::Completed
316        )
317    }
318}
319
320/// Behavioral parameters for ring simulation.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct RingBehavior {
323    /// Average days between transactions.
324    pub transaction_interval_days: u32,
325    /// Variance in transaction timing (0.0-1.0).
326    pub timing_variance: f64,
327    /// Average transaction amount.
328    pub avg_transaction_amount: Decimal,
329    /// Escalation factor per successful month.
330    pub escalation_factor: f64,
331    /// Maximum amount per transaction.
332    pub max_transaction_amount: Decimal,
333    /// Preferred days of week (0=Mon, 6=Sun).
334    pub preferred_days: Vec<u32>,
335    /// Whether to avoid month-end periods.
336    pub avoid_month_end: bool,
337    /// Concealment techniques used.
338    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], // Tue-Thu
350            avoid_month_end: true,
351            concealment_techniques: vec![
352                ConcealmentTechnique::TransactionSplitting,
353                ConcealmentTechnique::TimingExploitation,
354            ],
355        }
356    }
357}
358
359/// Configuration for collusion ring generation.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct CollusionRingConfig {
362    /// Probability of collusion in fraud schemes.
363    pub collusion_rate: f64,
364    /// Distribution of ring types.
365    pub ring_type_weights: HashMap<String, f64>,
366    /// Minimum ring duration in months.
367    pub min_duration_months: u32,
368    /// Maximum ring duration in months.
369    pub max_duration_months: u32,
370    /// Average loyalty score for new conspirators.
371    pub avg_loyalty: f64,
372    /// Average risk tolerance for new conspirators.
373    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, // ACFE reports ~50% of fraud involves collusion
389            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/// A collusion ring modeling multiple conspirators in a coordinated scheme.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct CollusionRing {
401    /// Unique ring identifier.
402    pub ring_id: Uuid,
403    /// Type of collusion ring.
404    pub ring_type: CollusionRingType,
405    /// ACFE fraud category.
406    pub fraud_category: AcfeFraudCategory,
407    /// Members of the ring.
408    pub members: Vec<Conspirator>,
409    /// Date the ring was formed.
410    pub formation_date: NaiveDate,
411    /// Current status.
412    pub status: RingStatus,
413    /// Total amount stolen by the ring.
414    pub total_stolen: Decimal,
415    /// Number of successful transactions.
416    pub transaction_count: u32,
417    /// Current detection risk (0.0-1.0).
418    pub detection_risk: f64,
419    /// Behavioral parameters.
420    pub behavior: RingBehavior,
421    /// Months the ring has been active.
422    pub active_months: u32,
423    /// Transaction IDs associated with this ring.
424    pub transaction_ids: Vec<String>,
425    /// Metadata for tracking.
426    #[serde(default)]
427    pub metadata: HashMap<String, String>,
428}
429
430impl CollusionRing {
431    /// Creates a new collusion ring.
432    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    /// Adds a conspirator to the ring.
456    pub fn add_member(&mut self, conspirator: Conspirator) {
457        self.members.push(conspirator);
458        self.update_detection_risk();
459    }
460
461    /// Returns the size of the ring.
462    pub fn size(&self) -> usize {
463        self.members.len()
464    }
465
466    /// Returns the initiator(s) of the ring.
467    pub fn initiators(&self) -> Vec<&Conspirator> {
468        self.members
469            .iter()
470            .filter(|m| m.role == ConspiratorRole::Initiator)
471            .collect()
472    }
473
474    /// Returns the executor(s) of the ring.
475    pub fn executors(&self) -> Vec<&Conspirator> {
476        self.members
477            .iter()
478            .filter(|m| m.role == ConspiratorRole::Executor)
479            .collect()
480    }
481
482    /// Returns the approver(s) of the ring.
483    pub fn approvers(&self) -> Vec<&Conspirator> {
484        self.members
485            .iter()
486            .filter(|m| m.role == ConspiratorRole::Approver)
487            .collect()
488    }
489
490    /// Updates detection risk based on current state.
491    fn update_detection_risk(&mut self) {
492        // Base risk from ring size
493        let size_risk = (self.members.len() as f64 * 0.05).min(0.3);
494
495        // Risk from external members
496        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        // Risk from transaction count
504        let tx_risk = (self.transaction_count as f64 * 0.005).min(0.2);
505
506        // Risk from total amount
507        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        // Risk from time active
511        let time_risk = (self.active_months as f64 * 0.01).min(0.2);
512
513        // Ring type multiplier
514        let type_multiplier = self.ring_type.detection_difficulty_multiplier();
515
516        // Calculate total risk (capped at 0.95)
517        self.detection_risk = ((size_risk + external_risk + tx_risk + amount_risk + time_risk)
518            / type_multiplier)
519            .min(0.95);
520    }
521
522    /// Records a successful transaction.
523    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        // Update member success counts
529        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    /// Records a near miss event.
542    pub fn record_near_miss(&mut self) {
543        // All members become more cautious
544        for member in &mut self.members {
545            member.record_near_miss();
546        }
547
548        // Increase detection risk
549        self.detection_risk = (self.detection_risk + 0.1).min(0.95);
550
551        // Consider going dormant
552        if self.detection_risk > 0.5 {
553            self.status = RingStatus::Dormant;
554        }
555    }
556
557    /// Advances the ring by one month.
558    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        // Check for defection
566        if self.check_defection(rng) {
567            self.status = RingStatus::Dissolving;
568            return;
569        }
570
571        // Check for detection
572        if rng.random::<f64>() < self.detection_risk * 0.1 {
573            self.status = RingStatus::Detected;
574            return;
575        }
576
577        // Status progression
578        match self.status {
579            RingStatus::Forming if self.active_months >= 2 && self.transaction_count >= 3 => {
580                self.status = RingStatus::Active;
581            }
582            // Consider escalation if successful.
583            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            // Chance to reactivate. Risk is reduced during dormancy.
595            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        // Simulate transactions for operational rings
607        // (Forming needs transactions too, since Forming→Active requires transaction_count >= 3)
608        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                // Variance: ±30% around avg_transaction_amount
615                let variance = 0.7 + rng.random::<f64>() * 0.6; // 0.7 to 1.3
616                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            // Apply escalation factor for escalating rings
625            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    /// Checks if any member defects.
635    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, // Random external pressure
641            );
642
643            if rng.random::<f64>() < defection_prob {
644                return true;
645            }
646        }
647        false
648    }
649
650    /// Returns the detection difficulty for this ring.
651    pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
652        // Base difficulty from concealment techniques
653        let concealment_bonus: f64 = self
654            .behavior
655            .concealment_techniques
656            .iter()
657            .map(datasynth_core::ConcealmentTechnique::difficulty_bonus)
658            .sum();
659
660        // Ring type multiplier
661        let type_multiplier = self.ring_type.detection_difficulty_multiplier();
662
663        // Combined score
664        let score = (0.5 + concealment_bonus) * type_multiplier;
665
666        AnomalyDetectionDifficulty::from_score(score.min(1.0))
667    }
668
669    /// Calculates the average share per member.
670    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    /// Returns descriptive summary of the ring.
678    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        // Low pressure = low defection
737        let low_pressure = conspirator.defection_probability(0.1, 1, 0.0);
738        assert!(low_pressure < 0.3);
739
740        // High pressure = higher defection
741        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        // Detection risk should have been recalculated after transaction
791        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        // Add members with high loyalty to prevent defection
805        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), // Very loyal to avoid random defection
818            );
819        }
820
821        // Record some transactions first
822        for i in 0..3 {
823            ring.record_transaction(Decimal::new(1_000, 0), format!("TX{:03}", i));
824        }
825
826        // Advance to activate
827        for _ in 0..3 {
828            ring.advance_month(&mut rng);
829        }
830
831        assert!(ring.active_months >= 3);
832        // Status might have changed
833        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        // With multiple concealment techniques and external involvement, should be hard+
874        assert!(matches!(
875            difficulty,
876            AnomalyDetectionDifficulty::Hard | AnomalyDetectionDifficulty::Expert
877        ));
878    }
879}