datasynth_generators/fraud/collusion/
generator.rs1use 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
15pub struct CollusionRingGenerator {
17 rng: ChaCha8Rng,
18}
19
20impl CollusionRingGenerator {
21 pub fn new(seed: u64) -> Self {
23 Self {
24 rng: ChaCha8Rng::seed_from_u64(seed),
25 }
26 }
27
28 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 if employee_ids.len() < 2 {
43 return Vec::new();
44 }
45
46 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 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 let fraud_category = self.pick_fraud_category();
65
66 let mut ring = CollusionRing::new(ring_type, fraud_category, start_date);
67
68 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 let mut added = 0usize;
80
81 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 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 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 for _ in 0..months {
132 ring.advance_month(&mut self.rng);
133 }
134
135 rings.push(ring);
136
137 if employee_cursor >= employee_ids.len() {
139 break;
140 }
141 if employee_ids.len() - employee_cursor < 2 {
143 break;
144 }
145 }
146
147 rings
148 }
149
150 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 return CollusionRingType::EmployeePair;
179 }
180
181 let idx = self.rng.random_range(0..candidates.len());
182 candidates[idx]
183 }
184
185 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 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; let risk_tolerance = 0.3 + self.rng.random::<f64>() * 0.5; let proceeds_share = 0.1 + self.rng.random::<f64>() * 0.4; 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}