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;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use uuid::Uuid;
12
13use datasynth_core::{AcfeFraudCategory, AnomalyDetectionDifficulty, ConcealmentTechnique};
14
15/// Type of collusion ring based on participant composition.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum CollusionRingType {
18    // ========== Internal Collusion ==========
19    /// Two employees colluding (e.g., approver + processor).
20    EmployeePair,
21    /// Small departmental ring (3-5 employees).
22    DepartmentRing,
23    /// Manager with one or more subordinates.
24    ManagementSubordinate,
25    /// Multiple employees across departments.
26    CrossDepartment,
27
28    // ========== Internal-External Collusion ==========
29    /// Purchasing employee with vendor contact.
30    EmployeeVendor,
31    /// Sales rep with customer contact.
32    EmployeeCustomer,
33    /// Project manager with external contractor.
34    EmployeeContractor,
35
36    // ========== External Rings ==========
37    /// Multiple vendors colluding for bid rigging.
38    VendorRing,
39    /// Multiple customers for return fraud schemes.
40    CustomerRing,
41}
42
43impl CollusionRingType {
44    /// Returns the typical size range for this ring type.
45    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    /// Returns whether this ring type involves external parties.
60    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    /// Returns the detection difficulty multiplier for this ring type.
72    pub fn detection_difficulty_multiplier(&self) -> f64 {
73        match self {
74            // Internal collusion easier to detect through behavioral analysis
75            CollusionRingType::EmployeePair => 1.2,
76            CollusionRingType::DepartmentRing => 1.3,
77            CollusionRingType::ManagementSubordinate => 1.5,
78            CollusionRingType::CrossDepartment => 1.4,
79            // External collusion harder due to limited visibility
80            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    /// Returns all variants for iteration.
89    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/// Role of a conspirator within the fraud ring.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106pub enum ConspiratorRole {
107    /// Conceives the scheme and recruits others.
108    Initiator,
109    /// Performs the actual transactions.
110    Executor,
111    /// Provides approvals or authorization overrides.
112    Approver,
113    /// Hides evidence and manipulates records.
114    Concealer,
115    /// Monitors for detection and warns others.
116    Lookout,
117    /// External recipient of proceeds (vendor, customer).
118    Beneficiary,
119    /// Provides inside information without direct participation.
120    Informant,
121}
122
123impl ConspiratorRole {
124    /// Returns the risk weight (likelihood of detection through this role).
125    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    /// Returns whether this role is typically internal to the organization.
138    pub fn is_typically_internal(&self) -> bool {
139        !matches!(self, ConspiratorRole::Beneficiary)
140    }
141}
142
143/// Type of entity participating in the ring.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
145pub enum EntityType {
146    /// Employee of the organization.
147    Employee,
148    /// Manager or executive.
149    Manager,
150    /// External vendor.
151    Vendor,
152    /// External customer.
153    Customer,
154    /// External contractor.
155    Contractor,
156}
157
158/// A conspirator within a collusion ring.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Conspirator {
161    /// Unique identifier for this conspirator.
162    pub conspirator_id: Uuid,
163    /// Reference to the entity (employee ID, vendor ID, etc.).
164    pub entity_id: String,
165    /// Type of entity.
166    pub entity_type: EntityType,
167    /// Role within the ring.
168    pub role: ConspiratorRole,
169    /// Date joined the ring.
170    pub join_date: NaiveDate,
171    /// Loyalty score (0.0-1.0): probability of not defecting under pressure.
172    pub loyalty: f64,
173    /// Risk tolerance (0.0-1.0): willingness to escalate scheme.
174    pub risk_tolerance: f64,
175    /// Share of proceeds (0.0-1.0).
176    pub proceeds_share: f64,
177    /// Department (for employees).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub department: Option<String>,
180    /// Position level (for employees).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub position_level: Option<String>,
183    /// Times the conspirator has been involved in successful transactions.
184    pub successful_actions: u32,
185    /// Times the conspirator has had close calls.
186    pub near_misses: u32,
187}
188
189impl Conspirator {
190    /// Creates a new conspirator.
191    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    /// Sets the loyalty score.
214    pub fn with_loyalty(mut self, loyalty: f64) -> Self {
215        self.loyalty = loyalty.clamp(0.0, 1.0);
216        self
217    }
218
219    /// Sets the risk tolerance.
220    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    /// Sets the proceeds share.
226    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    /// Sets the department.
232    pub fn with_department(mut self, department: impl Into<String>) -> Self {
233        self.department = Some(department.into());
234        self
235    }
236
237    /// Calculates defection probability based on current conditions.
238    pub fn defection_probability(
239        &self,
240        detection_pressure: f64,
241        months_in_scheme: u32,
242        external_pressure: f64,
243    ) -> f64 {
244        // Base defection rate inversely proportional to loyalty
245        let base_rate = 1.0 - self.loyalty;
246
247        // Modify by detection pressure
248        let pressure_factor = 1.0 + (detection_pressure * 0.5);
249
250        // Modify by time in scheme (fatigue)
251        let fatigue_factor = 1.0 + (months_in_scheme as f64 * 0.02);
252
253        // External pressure (personal issues, fear)
254        let external_factor = 1.0 + (external_pressure * 0.3);
255
256        // Near misses increase defection likelihood
257        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    /// Records a successful action.
266    pub fn record_success(&mut self) {
267        self.successful_actions += 1;
268        // Success can increase risk tolerance
269        self.risk_tolerance = (self.risk_tolerance + 0.02).min(1.0);
270    }
271
272    /// Records a near miss.
273    pub fn record_near_miss(&mut self) {
274        self.near_misses += 1;
275        // Near misses decrease risk tolerance
276        self.risk_tolerance = (self.risk_tolerance - 0.05).max(0.0);
277    }
278}
279
280/// Status of a collusion ring.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
282pub enum RingStatus {
283    /// Trust-building phase, small test transactions.
284    Forming,
285    /// Actively executing scheme.
286    Active,
287    /// Increasing amounts and frequency.
288    Escalating,
289    /// Temporarily paused due to fear or external events.
290    Dormant,
291    /// Breaking apart (member leaving or distrust).
292    Dissolving,
293    /// Caught by detection mechanisms.
294    Detected,
295    /// Successfully completed without detection.
296    Completed,
297}
298
299impl RingStatus {
300    /// Returns whether the ring is currently operational.
301    pub fn is_operational(&self) -> bool {
302        matches!(
303            self,
304            RingStatus::Forming | RingStatus::Active | RingStatus::Escalating
305        )
306    }
307
308    /// Returns whether the ring has ended.
309    pub fn is_terminated(&self) -> bool {
310        matches!(
311            self,
312            RingStatus::Dissolving | RingStatus::Detected | RingStatus::Completed
313        )
314    }
315}
316
317/// Behavioral parameters for ring simulation.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct RingBehavior {
320    /// Average days between transactions.
321    pub transaction_interval_days: u32,
322    /// Variance in transaction timing (0.0-1.0).
323    pub timing_variance: f64,
324    /// Average transaction amount.
325    pub avg_transaction_amount: Decimal,
326    /// Escalation factor per successful month.
327    pub escalation_factor: f64,
328    /// Maximum amount per transaction.
329    pub max_transaction_amount: Decimal,
330    /// Preferred days of week (0=Mon, 6=Sun).
331    pub preferred_days: Vec<u32>,
332    /// Whether to avoid month-end periods.
333    pub avoid_month_end: bool,
334    /// Concealment techniques used.
335    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], // Tue-Thu
347            avoid_month_end: true,
348            concealment_techniques: vec![
349                ConcealmentTechnique::TransactionSplitting,
350                ConcealmentTechnique::TimingExploitation,
351            ],
352        }
353    }
354}
355
356/// Configuration for collusion ring generation.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct CollusionRingConfig {
359    /// Probability of collusion in fraud schemes.
360    pub collusion_rate: f64,
361    /// Distribution of ring types.
362    pub ring_type_weights: HashMap<String, f64>,
363    /// Minimum ring duration in months.
364    pub min_duration_months: u32,
365    /// Maximum ring duration in months.
366    pub max_duration_months: u32,
367    /// Average loyalty score for new conspirators.
368    pub avg_loyalty: f64,
369    /// Average risk tolerance for new conspirators.
370    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, // ACFE reports ~50% of fraud involves collusion
386            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/// A collusion ring modeling multiple conspirators in a coordinated scheme.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct CollusionRing {
398    /// Unique ring identifier.
399    pub ring_id: Uuid,
400    /// Type of collusion ring.
401    pub ring_type: CollusionRingType,
402    /// ACFE fraud category.
403    pub fraud_category: AcfeFraudCategory,
404    /// Members of the ring.
405    pub members: Vec<Conspirator>,
406    /// Date the ring was formed.
407    pub formation_date: NaiveDate,
408    /// Current status.
409    pub status: RingStatus,
410    /// Total amount stolen by the ring.
411    pub total_stolen: Decimal,
412    /// Number of successful transactions.
413    pub transaction_count: u32,
414    /// Current detection risk (0.0-1.0).
415    pub detection_risk: f64,
416    /// Behavioral parameters.
417    pub behavior: RingBehavior,
418    /// Months the ring has been active.
419    pub active_months: u32,
420    /// Transaction IDs associated with this ring.
421    pub transaction_ids: Vec<String>,
422    /// Metadata for tracking.
423    #[serde(default)]
424    pub metadata: HashMap<String, String>,
425}
426
427impl CollusionRing {
428    /// Creates a new collusion ring.
429    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    /// Adds a conspirator to the ring.
452    pub fn add_member(&mut self, conspirator: Conspirator) {
453        self.members.push(conspirator);
454        self.update_detection_risk();
455    }
456
457    /// Returns the size of the ring.
458    pub fn size(&self) -> usize {
459        self.members.len()
460    }
461
462    /// Returns the initiator(s) of the ring.
463    pub fn initiators(&self) -> Vec<&Conspirator> {
464        self.members
465            .iter()
466            .filter(|m| m.role == ConspiratorRole::Initiator)
467            .collect()
468    }
469
470    /// Returns the executor(s) of the ring.
471    pub fn executors(&self) -> Vec<&Conspirator> {
472        self.members
473            .iter()
474            .filter(|m| m.role == ConspiratorRole::Executor)
475            .collect()
476    }
477
478    /// Returns the approver(s) of the ring.
479    pub fn approvers(&self) -> Vec<&Conspirator> {
480        self.members
481            .iter()
482            .filter(|m| m.role == ConspiratorRole::Approver)
483            .collect()
484    }
485
486    /// Updates detection risk based on current state.
487    fn update_detection_risk(&mut self) {
488        // Base risk from ring size
489        let size_risk = (self.members.len() as f64 * 0.05).min(0.3);
490
491        // Risk from external members
492        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        // Risk from transaction count
500        let tx_risk = (self.transaction_count as f64 * 0.005).min(0.2);
501
502        // Risk from total amount
503        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        // Risk from time active
507        let time_risk = (self.active_months as f64 * 0.01).min(0.2);
508
509        // Ring type multiplier
510        let type_multiplier = self.ring_type.detection_difficulty_multiplier();
511
512        // Calculate total risk (capped at 0.95)
513        self.detection_risk = ((size_risk + external_risk + tx_risk + amount_risk + time_risk)
514            / type_multiplier)
515            .min(0.95);
516    }
517
518    /// Records a successful transaction.
519    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        // Update member success counts
525        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    /// Records a near miss event.
538    pub fn record_near_miss(&mut self) {
539        // All members become more cautious
540        for member in &mut self.members {
541            member.record_near_miss();
542        }
543
544        // Increase detection risk
545        self.detection_risk = (self.detection_risk + 0.1).min(0.95);
546
547        // Consider going dormant
548        if self.detection_risk > 0.5 {
549            self.status = RingStatus::Dormant;
550        }
551    }
552
553    /// Advances the ring by one month.
554    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        // Check for defection
562        if self.check_defection(rng) {
563            self.status = RingStatus::Dissolving;
564            return;
565        }
566
567        // Check for detection
568        if rng.gen::<f64>() < self.detection_risk * 0.1 {
569            self.status = RingStatus::Detected;
570            return;
571        }
572
573        // Status progression
574        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                // Consider escalation if successful
580                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                // Chance to reactivate
590                if rng.gen::<f64>() < 0.4 && self.detection_risk < 0.4 {
591                    self.status = RingStatus::Active;
592                    self.detection_risk *= 0.8; // Risk reduced during dormancy
593                }
594            }
595            _ => {}
596        }
597    }
598
599    /// Checks if any member defects.
600    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, // Random external pressure
606            );
607
608            if rng.gen::<f64>() < defection_prob {
609                return true;
610            }
611        }
612        false
613    }
614
615    /// Returns the detection difficulty for this ring.
616    pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
617        // Base difficulty from concealment techniques
618        let concealment_bonus: f64 = self
619            .behavior
620            .concealment_techniques
621            .iter()
622            .map(|c| c.difficulty_bonus())
623            .sum();
624
625        // Ring type multiplier
626        let type_multiplier = self.ring_type.detection_difficulty_multiplier();
627
628        // Combined score
629        let score = (0.5 + concealment_bonus) * type_multiplier;
630
631        AnomalyDetectionDifficulty::from_score(score.min(1.0))
632    }
633
634    /// Calculates the average share per member.
635    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    /// Returns descriptive summary of the ring.
643    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        // Low pressure = low defection
701        let low_pressure = conspirator.defection_probability(0.1, 1, 0.0);
702        assert!(low_pressure < 0.3);
703
704        // High pressure = higher defection
705        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        // Detection risk should have been recalculated after transaction
755        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        // Add members with high loyalty to prevent defection
769        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), // Very loyal to avoid random defection
782            );
783        }
784
785        // Record some transactions first
786        for i in 0..3 {
787            ring.record_transaction(Decimal::new(1_000, 0), format!("TX{:03}", i));
788        }
789
790        // Advance to activate
791        for _ in 0..3 {
792            ring.advance_month(&mut rng);
793        }
794
795        assert!(ring.active_months >= 3);
796        // Status might have changed
797        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        // With multiple concealment techniques and external involvement, should be hard+
838        assert!(matches!(
839            difficulty,
840            AnomalyDetectionDifficulty::Hard | AnomalyDetectionDifficulty::Expert
841        ));
842    }
843}