use chrono::NaiveDate;
use rand::Rng;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use datasynth_core::AcfeFraudCategory;
use super::network::{CollusionRing, CollusionRingType, Conspirator, ConspiratorRole, EntityType};
pub struct CollusionRingGenerator {
rng: ChaCha8Rng,
}
impl CollusionRingGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: ChaCha8Rng::seed_from_u64(seed),
}
}
pub fn generate(
&mut self,
employee_ids: &[String],
vendor_ids: &[String],
start_date: NaiveDate,
months: u32,
) -> Vec<CollusionRing> {
if employee_ids.len() < 2 {
return Vec::new();
}
let max_rings = 3.min(employee_ids.len() / 2);
let ring_count = self.rng.random_range(1..=max_rings);
let mut rings = Vec::with_capacity(ring_count);
let mut employee_cursor = 0usize;
let mut vendor_cursor = 0usize;
for _ in 0..ring_count {
let ring_type = self.pick_ring_type(
employee_ids.len() - employee_cursor,
vendor_ids.len() - vendor_cursor,
);
let (min_size, max_size) = ring_type.typical_size_range();
let fraud_category = self.pick_fraud_category();
let mut ring = CollusionRing::new(ring_type, fraud_category, start_date);
let available_employees = employee_ids.len() - employee_cursor;
let _available_vendors = vendor_ids.len() - vendor_cursor;
let size = if max_size <= min_size {
min_size
} else {
self.rng.random_range(min_size..=max_size)
};
let mut added = 0usize;
if employee_cursor < employee_ids.len() {
let c = self.make_conspirator(
&employee_ids[employee_cursor],
EntityType::Employee,
ConspiratorRole::Initiator,
start_date,
);
ring.add_member(c);
employee_cursor += 1;
added += 1;
}
if ring_type.involves_external() && vendor_cursor < vendor_ids.len() && added < size {
let c = self.make_conspirator(
&vendor_ids[vendor_cursor],
EntityType::Vendor,
ConspiratorRole::Beneficiary,
start_date,
);
ring.add_member(c);
vendor_cursor += 1;
added += 1;
}
let remaining_roles = [
ConspiratorRole::Executor,
ConspiratorRole::Approver,
ConspiratorRole::Concealer,
ConspiratorRole::Lookout,
];
let mut role_idx = 0;
while added < size && employee_cursor < employee_ids.len() && available_employees > 0 {
let role = remaining_roles[role_idx % remaining_roles.len()];
let c = self.make_conspirator(
&employee_ids[employee_cursor],
EntityType::Employee,
role,
start_date,
);
ring.add_member(c);
employee_cursor += 1;
added += 1;
role_idx += 1;
}
for _ in 0..months {
ring.advance_month(&mut self.rng);
}
rings.push(ring);
if employee_cursor >= employee_ids.len() {
break;
}
if employee_ids.len() - employee_cursor < 2 {
break;
}
}
rings
}
fn pick_ring_type(
&mut self,
remaining_employees: usize,
remaining_vendors: usize,
) -> CollusionRingType {
let mut candidates: Vec<CollusionRingType> = Vec::new();
if remaining_employees >= 2 {
candidates.push(CollusionRingType::EmployeePair);
}
if remaining_employees >= 3 {
candidates.push(CollusionRingType::DepartmentRing);
candidates.push(CollusionRingType::CrossDepartment);
}
if remaining_employees >= 2 {
candidates.push(CollusionRingType::ManagementSubordinate);
}
if remaining_employees >= 1 && remaining_vendors >= 1 {
candidates.push(CollusionRingType::EmployeeVendor);
}
if candidates.is_empty() {
return CollusionRingType::EmployeePair;
}
let idx = self.rng.random_range(0..candidates.len());
candidates[idx]
}
fn pick_fraud_category(&mut self) -> AcfeFraudCategory {
let roll: f64 = self.rng.random();
if roll < 0.50 {
AcfeFraudCategory::AssetMisappropriation
} else if roll < 0.80 {
AcfeFraudCategory::Corruption
} else {
AcfeFraudCategory::FinancialStatementFraud
}
}
fn make_conspirator(
&mut self,
entity_id: &str,
entity_type: EntityType,
role: ConspiratorRole,
join_date: NaiveDate,
) -> Conspirator {
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)
.with_loyalty(loyalty)
.with_risk_tolerance(risk_tolerance)
.with_proceeds_share(proceeds_share)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_generate_empty_when_insufficient_employees() {
let mut gen = CollusionRingGenerator::new(42);
let employees = vec!["EMP001".to_string()];
let vendors = vec!["V001".to_string()];
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let rings = gen.generate(&employees, &vendors, start, 6);
assert!(rings.is_empty(), "Need at least 2 employees");
}
#[test]
fn test_generate_creates_rings() {
let mut gen = CollusionRingGenerator::new(42);
let employees: Vec<String> = (1..=10).map(|i| format!("EMP{:03}", i)).collect();
let vendors: Vec<String> = (1..=5).map(|i| format!("V{:03}", i)).collect();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let rings = gen.generate(&employees, &vendors, start, 12);
assert!(!rings.is_empty(), "Should generate at least one ring");
assert!(rings.len() <= 3, "Should generate at most 3 rings");
for ring in &rings {
assert!(ring.size() >= 2, "Each ring should have at least 2 members");
assert!(
ring.active_months > 0 || ring.status.is_terminated(),
"Ring should have been advanced or terminated"
);
}
}
#[test]
fn test_generate_deterministic() {
let employees: Vec<String> = (1..=6).map(|i| format!("EMP{:03}", i)).collect();
let vendors: Vec<String> = (1..=3).map(|i| format!("V{:03}", i)).collect();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let rings_a = CollusionRingGenerator::new(99).generate(&employees, &vendors, start, 6);
let rings_b = CollusionRingGenerator::new(99).generate(&employees, &vendors, start, 6);
assert_eq!(rings_a.len(), rings_b.len());
for (a, b) in rings_a.iter().zip(rings_b.iter()) {
assert_eq!(a.ring_type, b.ring_type);
assert_eq!(a.size(), b.size());
assert_eq!(a.active_months, b.active_months);
assert_eq!(a.status, b.status);
}
}
}