use chrono::{Datelike, NaiveDate, Weekday};
use datasynth_core::utils::weighted_select;
use rand::{Rng, RngExt};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum TemporalPattern {
Uniform,
PeriodEndSpike {
month_end_multiplier: f64,
quarter_end_multiplier: f64,
year_end_multiplier: f64,
},
TimeBased {
after_hours_multiplier: f64,
weekend_multiplier: f64,
},
Seasonal {
month_multipliers: [f64; 12],
},
Custom {
name: String,
},
}
impl Default for TemporalPattern {
fn default() -> Self {
TemporalPattern::PeriodEndSpike {
month_end_multiplier: 2.0,
quarter_end_multiplier: 3.0,
year_end_multiplier: 5.0,
}
}
}
impl TemporalPattern {
pub fn probability_multiplier(&self, date: NaiveDate) -> f64 {
match self {
TemporalPattern::Uniform => 1.0,
TemporalPattern::PeriodEndSpike {
month_end_multiplier,
quarter_end_multiplier,
year_end_multiplier,
} => {
let day = date.day();
let month = date.month();
if month == 12 && day >= 28 {
return *year_end_multiplier;
}
if matches!(month, 3 | 6 | 9 | 12) && day >= 28 {
return *quarter_end_multiplier;
}
if day >= 28 {
return *month_end_multiplier;
}
1.0
}
TemporalPattern::TimeBased {
after_hours_multiplier: _,
weekend_multiplier,
} => {
let weekday = date.weekday();
if weekday == Weekday::Sat || weekday == Weekday::Sun {
return *weekend_multiplier;
}
1.0
}
TemporalPattern::Seasonal { month_multipliers } => {
let month_idx = (date.month() - 1) as usize;
month_multipliers[month_idx]
}
TemporalPattern::Custom { .. } => 1.0,
}
}
pub fn audit_season() -> Self {
TemporalPattern::Seasonal {
month_multipliers: [
2.0, 2.0, 1.5, 1.0, 1.0, 1.2, 1.0, 1.0, 1.2, 1.0, 1.0, 3.0, ],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FraudCategory {
AccountsReceivable,
AccountsPayable,
Payroll,
Expense,
Revenue,
Asset,
General,
}
impl FraudCategory {
pub fn time_window_days(&self) -> (i64, i64) {
match self {
FraudCategory::AccountsReceivable => (30, 45), FraudCategory::AccountsPayable => (14, 30), FraudCategory::Payroll => (28, 35), FraudCategory::Expense => (7, 14), FraudCategory::Revenue => (85, 95), FraudCategory::Asset => (30, 60), FraudCategory::General => (5, 10), }
}
pub fn from_anomaly_type(anomaly_type: &str) -> Self {
let lower = anomaly_type.to_lowercase();
if lower.contains("receivable")
|| lower.contains("ar")
|| lower.contains("invoice")
|| lower.contains("customer")
{
FraudCategory::AccountsReceivable
} else if lower.contains("payable")
|| lower.contains("ap")
|| lower.contains("vendor")
|| lower.contains("payment")
{
FraudCategory::AccountsPayable
} else if lower.contains("payroll")
|| lower.contains("ghost")
|| lower.contains("employee")
|| lower.contains("salary")
{
FraudCategory::Payroll
} else if lower.contains("expense") || lower.contains("reimbursement") {
FraudCategory::Expense
} else if lower.contains("revenue")
|| lower.contains("sales")
|| lower.contains("channel")
|| lower.contains("premature")
{
FraudCategory::Revenue
} else if lower.contains("asset")
|| lower.contains("inventory")
|| lower.contains("fixed")
|| lower.contains("depreciation")
{
FraudCategory::Asset
} else {
FraudCategory::General
}
}
}
#[derive(Debug, Clone)]
pub struct ClusteringConfig {
pub enabled: bool,
pub cluster_start_probability: f64,
pub cluster_continuation_probability: f64,
pub min_cluster_size: usize,
pub max_cluster_size: usize,
pub cluster_time_window_days: i64,
pub use_fraud_specific_windows: bool,
pub preserve_account_relationships: bool,
}
impl Default for ClusteringConfig {
fn default() -> Self {
Self {
enabled: true,
cluster_start_probability: 0.3,
cluster_continuation_probability: 0.7,
min_cluster_size: 2,
max_cluster_size: 10,
cluster_time_window_days: 7,
use_fraud_specific_windows: true,
preserve_account_relationships: true,
}
}
}
#[derive(Debug, Clone)]
pub struct CausalLink {
pub source_entity: String,
pub source_type: String,
pub target_entity: String,
pub target_type: String,
pub relationship: String,
}
impl CausalLink {
pub fn new(
source_entity: impl Into<String>,
source_type: impl Into<String>,
target_entity: impl Into<String>,
target_type: impl Into<String>,
relationship: impl Into<String>,
) -> Self {
Self {
source_entity: source_entity.into(),
source_type: source_type.into(),
target_entity: target_entity.into(),
target_type: target_type.into(),
relationship: relationship.into(),
}
}
}
pub struct ClusterManager {
config: ClusteringConfig,
active_clusters: HashMap<FraudCategory, ActiveCluster>,
next_cluster_id: u64,
cluster_stats: HashMap<String, ClusterStats>,
}
#[derive(Debug, Clone)]
struct ActiveCluster {
cluster_id: String,
size: usize,
start_date: NaiveDate,
#[allow(dead_code)]
category: FraudCategory,
time_window_days: i64,
accounts: Vec<String>,
entities: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ClusterStats {
pub size: usize,
pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub anomaly_types: Vec<String>,
pub fraud_category: Option<FraudCategory>,
pub time_window_days: i64,
pub accounts: Vec<String>,
pub entities: Vec<String>,
pub causal_links: Vec<CausalLink>,
}
impl ClusterManager {
pub fn new(config: ClusteringConfig) -> Self {
Self {
config,
active_clusters: HashMap::new(),
next_cluster_id: 1,
cluster_stats: HashMap::new(),
}
}
pub fn assign_cluster<R: Rng>(
&mut self,
date: NaiveDate,
anomaly_type: &str,
rng: &mut R,
) -> Option<String> {
self.assign_cluster_with_context(date, anomaly_type, None, None, rng)
}
pub fn assign_cluster_with_context<R: Rng>(
&mut self,
date: NaiveDate,
anomaly_type: &str,
account: Option<&str>,
entity: Option<&str>,
rng: &mut R,
) -> Option<String> {
if !self.config.enabled {
return None;
}
let category = FraudCategory::from_anomaly_type(anomaly_type);
let time_window = if self.config.use_fraud_specific_windows {
let (min, max) = category.time_window_days();
rng.random_range(min..=max)
} else {
self.config.cluster_time_window_days
};
if let Some(active) = self.active_clusters.get(&category).cloned() {
let days_elapsed = (date - active.start_date).num_days();
if days_elapsed <= active.time_window_days
&& active.size < self.config.max_cluster_size
&& rng.random::<f64>() < self.config.cluster_continuation_probability
{
let relationship_match = if self.config.preserve_account_relationships {
let account_match =
account.is_none_or(|a| active.accounts.contains(&a.to_string()));
let entity_match =
entity.is_none_or(|e| active.entities.contains(&e.to_string()));
account_match || entity_match
} else {
true
};
if relationship_match {
let cluster_id = active.cluster_id.clone();
if let Some(active_mut) = self.active_clusters.get_mut(&category) {
active_mut.size += 1;
if let Some(acct) = account {
if !active_mut.accounts.contains(&acct.to_string()) {
active_mut.accounts.push(acct.to_string());
}
}
if let Some(ent) = entity {
if !active_mut.entities.contains(&ent.to_string()) {
active_mut.entities.push(ent.to_string());
}
}
}
if let Some(stats) = self.cluster_stats.get_mut(&cluster_id) {
stats.size += 1;
stats.end_date = Some(date);
stats.anomaly_types.push(anomaly_type.to_string());
if let Some(acct) = account {
if !stats.accounts.contains(&acct.to_string()) {
stats.accounts.push(acct.to_string());
}
}
if let Some(ent) = entity {
if !stats.entities.contains(&ent.to_string()) {
stats.entities.push(ent.to_string());
}
}
}
return Some(cluster_id);
}
}
if active.size >= self.config.min_cluster_size {
self.active_clusters.remove(&category);
}
}
if rng.random::<f64>() < self.config.cluster_start_probability {
let cluster_id = format!("CLU{:06}", self.next_cluster_id);
self.next_cluster_id += 1;
let mut accounts = Vec::new();
let mut entities = Vec::new();
if let Some(acct) = account {
accounts.push(acct.to_string());
}
if let Some(ent) = entity {
entities.push(ent.to_string());
}
self.active_clusters.insert(
category,
ActiveCluster {
cluster_id: cluster_id.clone(),
size: 1,
start_date: date,
category,
time_window_days: time_window,
accounts: accounts.clone(),
entities: entities.clone(),
},
);
self.cluster_stats.insert(
cluster_id.clone(),
ClusterStats {
size: 1,
start_date: Some(date),
end_date: Some(date),
anomaly_types: vec![anomaly_type.to_string()],
fraud_category: Some(category),
time_window_days: time_window,
accounts,
entities,
causal_links: Vec::new(),
},
);
return Some(cluster_id);
}
None
}
pub fn add_causal_link(&mut self, cluster_id: &str, link: CausalLink) {
if let Some(stats) = self.cluster_stats.get_mut(cluster_id) {
stats.causal_links.push(link);
}
}
pub fn get_related_account(&self, cluster_id: &str) -> Option<&str> {
self.cluster_stats
.get(cluster_id)
.and_then(|s| s.accounts.first().map(std::string::String::as_str))
}
pub fn get_related_entity(&self, cluster_id: &str) -> Option<&str> {
self.cluster_stats
.get(cluster_id)
.and_then(|s| s.entities.first().map(std::string::String::as_str))
}
pub fn get_cluster_stats(&self, cluster_id: &str) -> Option<&ClusterStats> {
self.cluster_stats.get(cluster_id)
}
pub fn all_cluster_stats(&self) -> &HashMap<String, ClusterStats> {
&self.cluster_stats
}
pub fn cluster_count(&self) -> usize {
self.cluster_stats.len()
}
pub fn clusters_by_category(&self) -> HashMap<FraudCategory, Vec<&ClusterStats>> {
let mut by_category: HashMap<FraudCategory, Vec<&ClusterStats>> = HashMap::new();
for stats in self.cluster_stats.values() {
if let Some(cat) = stats.fraud_category {
by_category.entry(cat).or_default().push(stats);
}
}
by_category
}
}
#[derive(Debug, Clone, Default)]
pub enum EntityTargetingPattern {
#[default]
Random,
VolumeWeighted,
TypeFocused {
type_weights: HashMap<String, f64>,
},
RepeatOffender {
repeat_probability: f64,
},
}
pub struct EntityTargetingManager {
pattern: EntityTargetingPattern,
recent_targets: Vec<String>,
max_recent: usize,
hit_counts: HashMap<String, usize>,
}
impl EntityTargetingManager {
pub fn new(pattern: EntityTargetingPattern) -> Self {
Self {
pattern,
recent_targets: Vec::new(),
max_recent: 20,
hit_counts: HashMap::new(),
}
}
pub fn select_entity<R: Rng>(&mut self, candidates: &[String], rng: &mut R) -> Option<String> {
if candidates.is_empty() {
return None;
}
let selected = match &self.pattern {
EntityTargetingPattern::Random => {
candidates[rng.random_range(0..candidates.len())].clone()
}
EntityTargetingPattern::VolumeWeighted => {
candidates[rng.random_range(0..candidates.len())].clone()
}
EntityTargetingPattern::TypeFocused { type_weights } => {
let weighted: Vec<_> = candidates
.iter()
.filter_map(|c| type_weights.get(c).map(|&w| (c.clone(), w)))
.collect();
if weighted.is_empty() {
candidates[rng.random_range(0..candidates.len())].clone()
} else {
weighted_select(rng, &weighted).clone()
}
}
EntityTargetingPattern::RepeatOffender { repeat_probability } => {
if !self.recent_targets.is_empty() && rng.random::<f64>() < *repeat_probability {
let idx = rng.random_range(0..self.recent_targets.len());
self.recent_targets[idx].clone()
} else {
candidates[rng.random_range(0..candidates.len())].clone()
}
}
};
self.recent_targets.push(selected.clone());
if self.recent_targets.len() > self.max_recent {
self.recent_targets.remove(0);
}
*self.hit_counts.entry(selected.clone()).or_insert(0) += 1;
Some(selected)
}
pub fn hit_count(&self, entity: &str) -> usize {
*self.hit_counts.get(entity).unwrap_or(&0)
}
}
#[derive(Debug, Clone)]
pub struct AnomalyPatternConfig {
pub temporal_pattern: TemporalPattern,
pub clustering: ClusteringConfig,
pub entity_targeting: EntityTargetingPattern,
pub batch_injection: bool,
pub batch_size_range: (usize, usize),
}
impl Default for AnomalyPatternConfig {
fn default() -> Self {
Self {
temporal_pattern: TemporalPattern::default(),
clustering: ClusteringConfig::default(),
entity_targeting: EntityTargetingPattern::default(),
batch_injection: false,
batch_size_range: (2, 5),
}
}
}
pub fn should_inject_anomaly<R: Rng>(
base_rate: f64,
date: NaiveDate,
pattern: &TemporalPattern,
rng: &mut R,
) -> bool {
let multiplier = pattern.probability_multiplier(date);
let adjusted_rate = (base_rate * multiplier).min(1.0);
rng.random::<f64>() < adjusted_rate
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationPattern {
Stable,
Gradual,
Aggressive,
Erratic,
TestThenStrike,
}
impl EscalationPattern {
pub fn escalation_multiplier(&self, prior_fraud_count: usize) -> f64 {
match self {
EscalationPattern::Stable => 1.0,
EscalationPattern::Gradual => {
(1.0 + 0.1 * prior_fraud_count as f64).min(3.0)
}
EscalationPattern::Aggressive => {
(1.0 + 0.25 * prior_fraud_count as f64).min(5.0)
}
EscalationPattern::Erratic => {
let base = 1.0 + 0.15 * prior_fraud_count as f64;
base.min(4.0)
}
EscalationPattern::TestThenStrike => {
if prior_fraud_count < 3 {
0.3 } else if prior_fraud_count == 3 {
5.0 } else {
0.0 }
}
}
}
}
#[derive(Debug, Clone)]
pub struct FraudActor {
pub user_id: String,
pub user_name: String,
pub fraud_history: Vec<FraudIncident>,
pub escalation_pattern: EscalationPattern,
pub preferred_accounts: Vec<String>,
pub preferred_vendors: Vec<String>,
pub total_amount: rust_decimal::Decimal,
pub start_date: Option<NaiveDate>,
pub detection_risk: f64,
pub is_active: bool,
}
#[derive(Debug, Clone)]
pub struct FraudIncident {
pub document_id: String,
pub date: NaiveDate,
pub amount: rust_decimal::Decimal,
pub fraud_type: String,
pub account: Option<String>,
pub entity: Option<String>,
}
impl FraudActor {
pub fn new(
user_id: impl Into<String>,
user_name: impl Into<String>,
escalation_pattern: EscalationPattern,
) -> Self {
Self {
user_id: user_id.into(),
user_name: user_name.into(),
fraud_history: Vec::new(),
escalation_pattern,
preferred_accounts: Vec::new(),
preferred_vendors: Vec::new(),
total_amount: rust_decimal::Decimal::ZERO,
start_date: None,
detection_risk: 0.0,
is_active: true,
}
}
pub fn with_account(mut self, account: impl Into<String>) -> Self {
self.preferred_accounts.push(account.into());
self
}
pub fn with_vendor(mut self, vendor: impl Into<String>) -> Self {
self.preferred_vendors.push(vendor.into());
self
}
pub fn record_fraud(
&mut self,
document_id: impl Into<String>,
date: NaiveDate,
amount: rust_decimal::Decimal,
fraud_type: impl Into<String>,
account: Option<String>,
entity: Option<String>,
) {
let incident = FraudIncident {
document_id: document_id.into(),
date,
amount,
fraud_type: fraud_type.into(),
account: account.clone(),
entity: entity.clone(),
};
self.fraud_history.push(incident);
self.total_amount += amount;
if self.start_date.is_none() {
self.start_date = Some(date);
}
self.update_detection_risk();
if let Some(acct) = account {
if !self.preferred_accounts.contains(&acct) {
self.preferred_accounts.push(acct);
}
}
if let Some(ent) = entity {
if !self.preferred_vendors.contains(&ent) {
self.preferred_vendors.push(ent);
}
}
}
fn update_detection_risk(&mut self) {
let count_factor = (self.fraud_history.len() as f64 * 0.05).min(0.3);
let amount_factor = if self.total_amount > rust_decimal::Decimal::from(100_000) {
0.3
} else if self.total_amount > rust_decimal::Decimal::from(10_000) {
0.2
} else {
0.1
};
let pattern_factor = match self.escalation_pattern {
EscalationPattern::Stable => 0.1,
EscalationPattern::Gradual => 0.15,
EscalationPattern::Erratic => 0.2,
EscalationPattern::Aggressive => 0.25,
EscalationPattern::TestThenStrike => 0.3,
};
self.detection_risk = (count_factor + amount_factor + pattern_factor).min(0.95);
}
pub fn next_escalation_multiplier(&self) -> f64 {
self.escalation_pattern
.escalation_multiplier(self.fraud_history.len())
}
pub fn get_preferred_account<R: Rng>(&self, rng: &mut R) -> Option<&str> {
if self.preferred_accounts.is_empty() {
None
} else {
Some(&self.preferred_accounts[rng.random_range(0..self.preferred_accounts.len())])
}
}
pub fn get_preferred_vendor<R: Rng>(&self, rng: &mut R) -> Option<&str> {
if self.preferred_vendors.is_empty() {
None
} else {
Some(&self.preferred_vendors[rng.random_range(0..self.preferred_vendors.len())])
}
}
}
pub struct FraudActorManager {
actors: Vec<FraudActor>,
user_index: HashMap<String, usize>,
repeat_actor_probability: f64,
max_active_actors: usize,
}
impl FraudActorManager {
pub fn new(repeat_actor_probability: f64, max_active_actors: usize) -> Self {
Self {
actors: Vec::new(),
user_index: HashMap::new(),
repeat_actor_probability,
max_active_actors,
}
}
pub fn add_actor(&mut self, actor: FraudActor) {
let idx = self.actors.len();
self.user_index.insert(actor.user_id.clone(), idx);
self.actors.push(actor);
}
pub fn get_or_create_actor<R: Rng>(
&mut self,
available_users: &[String],
rng: &mut R,
) -> Option<&mut FraudActor> {
if available_users.is_empty() {
return None;
}
let active_actors: Vec<usize> = self
.actors
.iter()
.enumerate()
.filter(|(_, a)| a.is_active)
.map(|(i, _)| i)
.collect();
if !active_actors.is_empty() && rng.random::<f64>() < self.repeat_actor_probability {
let idx = active_actors[rng.random_range(0..active_actors.len())];
return Some(&mut self.actors[idx]);
}
if self.actors.len() < self.max_active_actors {
let user_id = &available_users[rng.random_range(0..available_users.len())];
if let Some(&idx) = self.user_index.get(user_id) {
return Some(&mut self.actors[idx]);
}
let pattern = match rng.random_range(0..5) {
0 => EscalationPattern::Stable,
1 => EscalationPattern::Gradual,
2 => EscalationPattern::Aggressive,
3 => EscalationPattern::Erratic,
_ => EscalationPattern::TestThenStrike,
};
let actor = FraudActor::new(user_id.clone(), format!("Fraudster {user_id}"), pattern);
let idx = self.actors.len();
self.user_index.insert(user_id.clone(), idx);
self.actors.push(actor);
return Some(&mut self.actors[idx]);
}
if !self.actors.is_empty() {
let idx = rng.random_range(0..self.actors.len());
return Some(&mut self.actors[idx]);
}
None
}
pub fn get_actor(&self, user_id: &str) -> Option<&FraudActor> {
self.user_index.get(user_id).map(|&i| &self.actors[i])
}
pub fn get_actor_mut(&mut self, user_id: &str) -> Option<&mut FraudActor> {
if let Some(&idx) = self.user_index.get(user_id) {
Some(&mut self.actors[idx])
} else {
None
}
}
pub fn apply_detection<R: Rng>(&mut self, rng: &mut R) {
for actor in &mut self.actors {
if actor.is_active && rng.random::<f64>() < actor.detection_risk {
actor.is_active = false;
}
}
}
pub fn all_actors(&self) -> &[FraudActor] {
&self.actors
}
pub fn get_statistics(&self) -> FraudActorStatistics {
let total_actors = self.actors.len();
let active_actors = self.actors.iter().filter(|a| a.is_active).count();
let total_incidents: usize = self.actors.iter().map(|a| a.fraud_history.len()).sum();
let total_amount: rust_decimal::Decimal = self.actors.iter().map(|a| a.total_amount).sum();
FraudActorStatistics {
total_actors,
active_actors,
total_incidents,
total_amount,
}
}
}
#[derive(Debug, Clone)]
pub struct FraudActorStatistics {
pub total_actors: usize,
pub active_actors: usize,
pub total_incidents: usize,
pub total_amount: rust_decimal::Decimal,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
#[test]
fn test_temporal_pattern_multiplier() {
let pattern = TemporalPattern::default();
let regular = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
assert_eq!(pattern.probability_multiplier(regular), 1.0);
let month_end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
assert!(pattern.probability_multiplier(month_end) > 1.0);
let year_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
assert!(
pattern.probability_multiplier(year_end) > pattern.probability_multiplier(month_end)
);
}
#[test]
fn test_cluster_manager() {
let mut manager = ClusterManager::new(ClusteringConfig::default());
let mut rng = ChaCha8Rng::seed_from_u64(42);
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let mut clustered = 0;
for i in 0..20 {
let d = date + chrono::Duration::days(i % 7); if manager.assign_cluster(d, "TestType", &mut rng).is_some() {
clustered += 1;
}
}
assert!(clustered > 0);
assert!(manager.cluster_count() > 0);
}
#[test]
fn test_fraud_category_time_windows() {
let ar = FraudCategory::AccountsReceivable;
let general = FraudCategory::General;
let (ar_min, ar_max) = ar.time_window_days();
let (gen_min, gen_max) = general.time_window_days();
assert!(ar_min > gen_min);
assert!(ar_max > gen_max);
}
#[test]
fn test_fraud_category_inference() {
assert_eq!(
FraudCategory::from_anomaly_type("AccountsReceivable"),
FraudCategory::AccountsReceivable
);
assert_eq!(
FraudCategory::from_anomaly_type("VendorPayment"),
FraudCategory::AccountsPayable
);
assert_eq!(
FraudCategory::from_anomaly_type("GhostEmployee"),
FraudCategory::Payroll
);
assert_eq!(
FraudCategory::from_anomaly_type("RandomType"),
FraudCategory::General
);
}
#[test]
fn test_cluster_with_context() {
let mut manager = ClusterManager::new(ClusteringConfig {
cluster_start_probability: 1.0, cluster_continuation_probability: 1.0, ..Default::default()
});
let mut rng = ChaCha8Rng::seed_from_u64(42);
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let cluster1 = manager.assign_cluster_with_context(
date,
"VendorPayment",
Some("200000"),
Some("V001"),
&mut rng,
);
assert!(cluster1.is_some());
let cluster2 = manager.assign_cluster_with_context(
date + chrono::Duration::days(5),
"VendorPayment",
Some("200000"),
Some("V002"),
&mut rng,
);
assert_eq!(cluster1, cluster2);
let stats = manager.get_cluster_stats(&cluster1.unwrap()).unwrap();
assert_eq!(stats.accounts.len(), 1); assert_eq!(stats.entities.len(), 2); }
#[test]
fn test_causal_links() {
let mut manager = ClusterManager::new(ClusteringConfig {
cluster_start_probability: 1.0,
..Default::default()
});
let mut rng = ChaCha8Rng::seed_from_u64(42);
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let cluster_id = manager
.assign_cluster(date, "VendorPayment", &mut rng)
.unwrap();
manager.add_causal_link(
&cluster_id,
CausalLink::new("PAY-001", "Payment", "V001", "Vendor", "references"),
);
manager.add_causal_link(
&cluster_id,
CausalLink::new("V001", "Vendor", "EMP-001", "Employee", "owned_by"),
);
let stats = manager.get_cluster_stats(&cluster_id).unwrap();
assert_eq!(stats.causal_links.len(), 2);
}
#[test]
fn test_should_inject_anomaly() {
let mut rng = ChaCha8Rng::seed_from_u64(42);
let pattern = TemporalPattern::default();
let regular_date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let year_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let mut regular_count = 0;
let mut year_end_count = 0;
for _ in 0..1000 {
if should_inject_anomaly(0.1, regular_date, &pattern, &mut rng) {
regular_count += 1;
}
if should_inject_anomaly(0.1, year_end, &pattern, &mut rng) {
year_end_count += 1;
}
}
assert!(year_end_count > regular_count);
}
#[test]
fn test_escalation_patterns() {
assert_eq!(EscalationPattern::Stable.escalation_multiplier(0), 1.0);
assert_eq!(EscalationPattern::Stable.escalation_multiplier(10), 1.0);
let gradual = EscalationPattern::Gradual;
assert!(gradual.escalation_multiplier(5) > gradual.escalation_multiplier(0));
assert!(gradual.escalation_multiplier(5) <= 3.0);
let aggressive = EscalationPattern::Aggressive;
assert!(aggressive.escalation_multiplier(5) > gradual.escalation_multiplier(5));
let tts = EscalationPattern::TestThenStrike;
assert!(tts.escalation_multiplier(0) < 1.0); assert!(tts.escalation_multiplier(3) > 1.0); assert_eq!(tts.escalation_multiplier(4), 0.0); }
#[test]
fn test_fraud_actor() {
use rust_decimal_macros::dec;
let mut actor = FraudActor::new("USER001", "John Fraudster", EscalationPattern::Gradual)
.with_account("600000")
.with_vendor("V001");
assert_eq!(actor.preferred_accounts.len(), 1);
assert_eq!(actor.preferred_vendors.len(), 1);
assert!(actor.is_active);
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
actor.record_fraud(
"JE-001",
date,
dec!(1000),
"DuplicatePayment",
Some("600000".to_string()),
Some("V002".to_string()),
);
assert_eq!(actor.fraud_history.len(), 1);
assert_eq!(actor.total_amount, dec!(1000));
assert_eq!(actor.start_date, Some(date));
assert!(actor.detection_risk > 0.0);
assert!(actor.preferred_vendors.contains(&"V002".to_string()));
}
#[test]
fn test_fraud_actor_manager() {
let mut rng = ChaCha8Rng::seed_from_u64(42);
let mut manager = FraudActorManager::new(0.7, 5);
let users = vec![
"USER001".to_string(),
"USER002".to_string(),
"USER003".to_string(),
];
let actor = manager.get_or_create_actor(&users, &mut rng);
assert!(actor.is_some());
let actor = actor.unwrap();
let user_id = actor.user_id.clone();
actor.record_fraud(
"JE-001",
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
rust_decimal::Decimal::from(1000),
"FictitiousEntry",
None,
None,
);
let retrieved = manager.get_actor(&user_id);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().fraud_history.len(), 1);
let stats = manager.get_statistics();
assert_eq!(stats.total_actors, 1);
assert_eq!(stats.active_actors, 1);
assert_eq!(stats.total_incidents, 1);
}
#[test]
fn test_fraud_actor_detection() {
use rust_decimal_macros::dec;
let mut rng = ChaCha8Rng::seed_from_u64(42);
let mut manager = FraudActorManager::new(1.0, 10);
let mut actor =
FraudActor::new("USER001", "Heavy Fraudster", EscalationPattern::Aggressive);
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
for i in 0..10 {
actor.record_fraud(
format!("JE-{:03}", i),
date + chrono::Duration::days(i as i64),
dec!(10000),
"FictitiousEntry",
None,
None,
);
}
manager.add_actor(actor);
let actor = manager.get_actor("USER001").unwrap();
assert!(actor.detection_risk > 0.5);
for _ in 0..20 {
manager.apply_detection(&mut rng);
}
let stats = manager.get_statistics();
assert!(stats.active_actors <= stats.total_actors);
}
}