Skip to main content

datasynth_generators/fraud/collusion/
generator.rs

1//! Bulk collusion ring generator.
2//!
3//! Creates realistic collusion rings from employee and vendor pools,
4//! then simulates their lifecycle across a configurable number of months.
5
6use chrono::NaiveDate;
7use rand::Rng;
8use rand::SeedableRng;
9use rand_chacha::ChaCha8Rng;
10
11use datasynth_core::AcfeFraudCategory;
12
13use super::network::{CollusionRing, CollusionRingType, Conspirator, ConspiratorRole, EntityType};
14
15/// Generates collusion rings from available employee and vendor pools.
16pub struct CollusionRingGenerator {
17    rng: ChaCha8Rng,
18}
19
20impl CollusionRingGenerator {
21    /// Create a new generator with the given seed.
22    pub fn new(seed: u64) -> Self {
23        Self {
24            rng: ChaCha8Rng::seed_from_u64(seed),
25        }
26    }
27
28    /// Generate collusion rings and advance them over the simulation period.
29    ///
30    /// Creates 1-3 rings (resources permitting) from the supplied employee and
31    /// vendor ID pools, picks ring types appropriate to the available entities,
32    /// populates each ring with `Conspirator` members, and then advances every
33    /// ring month-by-month.
34    pub fn generate(
35        &mut self,
36        employee_ids: &[String],
37        vendor_ids: &[String],
38        start_date: NaiveDate,
39        months: u32,
40    ) -> Vec<CollusionRing> {
41        // Need at least 2 employees to form any ring.
42        if employee_ids.len() < 2 {
43            return Vec::new();
44        }
45
46        // Decide how many rings to create (1-3).
47        let max_rings = 3.min(employee_ids.len() / 2);
48        let ring_count = self.rng.random_range(1..=max_rings);
49
50        let mut rings = Vec::with_capacity(ring_count);
51        let mut employee_cursor = 0usize;
52        let mut vendor_cursor = 0usize;
53
54        for _ in 0..ring_count {
55            // Pick a ring type that we can actually populate.
56            let ring_type = self.pick_ring_type(
57                employee_ids.len() - employee_cursor,
58                vendor_ids.len() - vendor_cursor,
59            );
60
61            let (min_size, max_size) = ring_type.typical_size_range();
62
63            // Pick a fraud category weighted by ACFE frequencies.
64            let fraud_category = self.pick_fraud_category();
65
66            let mut ring = CollusionRing::new(ring_type, fraud_category, start_date);
67
68            // Determine ring size within bounds.
69            let available_employees = employee_ids.len() - employee_cursor;
70            let _available_vendors = vendor_ids.len() - vendor_cursor;
71
72            let size = if max_size <= min_size {
73                min_size
74            } else {
75                self.rng.random_range(min_size..=max_size)
76            };
77
78            // Populate the ring with conspirators.
79            let mut added = 0usize;
80
81            // The first member is always the Initiator (an employee).
82            if employee_cursor < employee_ids.len() {
83                let c = self.make_conspirator(
84                    &employee_ids[employee_cursor],
85                    EntityType::Employee,
86                    ConspiratorRole::Initiator,
87                    start_date,
88                );
89                ring.add_member(c);
90                employee_cursor += 1;
91                added += 1;
92            }
93
94            // For external ring types, add at least one external member.
95            if ring_type.involves_external() && vendor_cursor < vendor_ids.len() && added < size {
96                let c = self.make_conspirator(
97                    &vendor_ids[vendor_cursor],
98                    EntityType::Vendor,
99                    ConspiratorRole::Beneficiary,
100                    start_date,
101                );
102                ring.add_member(c);
103                vendor_cursor += 1;
104                added += 1;
105            }
106
107            // Fill remaining slots from employee pool with varied roles.
108            let remaining_roles = [
109                ConspiratorRole::Executor,
110                ConspiratorRole::Approver,
111                ConspiratorRole::Concealer,
112                ConspiratorRole::Lookout,
113            ];
114            let mut role_idx = 0;
115
116            while added < size && employee_cursor < employee_ids.len() && available_employees > 0 {
117                let role = remaining_roles[role_idx % remaining_roles.len()];
118                let c = self.make_conspirator(
119                    &employee_ids[employee_cursor],
120                    EntityType::Employee,
121                    role,
122                    start_date,
123                );
124                ring.add_member(c);
125                employee_cursor += 1;
126                added += 1;
127                role_idx += 1;
128            }
129
130            // Advance the ring month-by-month.
131            for _ in 0..months {
132                ring.advance_month(&mut self.rng);
133            }
134
135            rings.push(ring);
136
137            // If we've exhausted all employees, stop creating rings.
138            if employee_cursor >= employee_ids.len() {
139                break;
140            }
141            // Need at least 2 employees remaining for the next ring.
142            if employee_ids.len() - employee_cursor < 2 {
143                break;
144            }
145        }
146
147        rings
148    }
149
150    // ----------------------------------------------------------------
151    // Helpers
152    // ----------------------------------------------------------------
153
154    /// Pick a ring type that can be populated with the remaining entity pools.
155    fn pick_ring_type(
156        &mut self,
157        remaining_employees: usize,
158        remaining_vendors: usize,
159    ) -> CollusionRingType {
160        let mut candidates: Vec<CollusionRingType> = Vec::new();
161
162        if remaining_employees >= 2 {
163            candidates.push(CollusionRingType::EmployeePair);
164        }
165        if remaining_employees >= 3 {
166            candidates.push(CollusionRingType::DepartmentRing);
167            candidates.push(CollusionRingType::CrossDepartment);
168        }
169        if remaining_employees >= 2 {
170            candidates.push(CollusionRingType::ManagementSubordinate);
171        }
172        if remaining_employees >= 1 && remaining_vendors >= 1 {
173            candidates.push(CollusionRingType::EmployeeVendor);
174        }
175
176        if candidates.is_empty() {
177            // Fallback: should not happen because the caller checks min 2 employees.
178            return CollusionRingType::EmployeePair;
179        }
180
181        let idx = self.rng.random_range(0..candidates.len());
182        candidates[idx]
183    }
184
185    /// Pick a fraud category using ACFE-weighted probabilities.
186    fn pick_fraud_category(&mut self) -> AcfeFraudCategory {
187        let roll: f64 = self.rng.random();
188        if roll < 0.50 {
189            AcfeFraudCategory::AssetMisappropriation
190        } else if roll < 0.80 {
191            AcfeFraudCategory::Corruption
192        } else {
193            AcfeFraudCategory::FinancialStatementFraud
194        }
195    }
196
197    /// Create a single `Conspirator` with randomised loyalty / risk tolerance.
198    fn make_conspirator(
199        &mut self,
200        entity_id: &str,
201        entity_type: EntityType,
202        role: ConspiratorRole,
203        join_date: NaiveDate,
204    ) -> Conspirator {
205        let loyalty = 0.5 + self.rng.random::<f64>() * 0.4; // 0.50 – 0.90
206        let risk_tolerance = 0.3 + self.rng.random::<f64>() * 0.5; // 0.30 – 0.80
207        let proceeds_share = 0.1 + self.rng.random::<f64>() * 0.4; // 0.10 – 0.50
208
209        Conspirator::new(entity_id, entity_type, role, join_date)
210            .with_loyalty(loyalty)
211            .with_risk_tolerance(risk_tolerance)
212            .with_proceeds_share(proceeds_share)
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::unwrap_used)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_generate_empty_when_insufficient_employees() {
223        let mut gen = CollusionRingGenerator::new(42);
224        let employees = vec!["EMP001".to_string()];
225        let vendors = vec!["V001".to_string()];
226        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
227
228        let rings = gen.generate(&employees, &vendors, start, 6);
229        assert!(rings.is_empty(), "Need at least 2 employees");
230    }
231
232    #[test]
233    fn test_generate_creates_rings() {
234        let mut gen = CollusionRingGenerator::new(42);
235        let employees: Vec<String> = (1..=10).map(|i| format!("EMP{:03}", i)).collect();
236        let vendors: Vec<String> = (1..=5).map(|i| format!("V{:03}", i)).collect();
237        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
238
239        let rings = gen.generate(&employees, &vendors, start, 12);
240
241        assert!(!rings.is_empty(), "Should generate at least one ring");
242        assert!(rings.len() <= 3, "Should generate at most 3 rings");
243
244        for ring in &rings {
245            assert!(ring.size() >= 2, "Each ring should have at least 2 members");
246            assert!(
247                ring.active_months > 0 || ring.status.is_terminated(),
248                "Ring should have been advanced or terminated"
249            );
250        }
251    }
252
253    #[test]
254    fn test_generate_deterministic() {
255        let employees: Vec<String> = (1..=6).map(|i| format!("EMP{:03}", i)).collect();
256        let vendors: Vec<String> = (1..=3).map(|i| format!("V{:03}", i)).collect();
257        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
258
259        let rings_a = CollusionRingGenerator::new(99).generate(&employees, &vendors, start, 6);
260        let rings_b = CollusionRingGenerator::new(99).generate(&employees, &vendors, start, 6);
261
262        assert_eq!(rings_a.len(), rings_b.len());
263        for (a, b) in rings_a.iter().zip(rings_b.iter()) {
264            assert_eq!(a.ring_type, b.ring_type);
265            assert_eq!(a.size(), b.size());
266            assert_eq!(a.active_months, b.active_months);
267            assert_eq!(a.status, b.status);
268        }
269    }
270}