use chrono::{NaiveDate, NaiveDateTime};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AnomalyCausalReason {
RandomRate {
base_rate: f64,
},
TemporalPattern {
pattern_name: String,
},
EntityTargeting {
target_type: String,
target_id: String,
},
ClusterMembership {
cluster_id: String,
},
ScenarioStep {
scenario_type: String,
step_number: u32,
},
DataQualityProfile {
profile: String,
},
MLTrainingBalance {
target_class: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum InjectionStrategy {
AmountManipulation {
original: Decimal,
factor: f64,
},
ThresholdAvoidance {
threshold: Decimal,
adjusted_amount: Decimal,
},
DateShift {
days_shifted: i32,
original_date: NaiveDate,
},
SelfApproval {
user_id: String,
},
SoDViolation {
duty1: String,
duty2: String,
violating_user: String,
},
ExactDuplicate {
original_doc_id: String,
},
NearDuplicate {
original_doc_id: String,
varied_fields: Vec<String>,
},
CircularFlow {
entity_chain: Vec<String>,
},
SplitTransaction {
original_amount: Decimal,
split_count: u32,
split_doc_ids: Vec<String>,
},
RoundNumbering {
original_amount: Decimal,
rounded_amount: Decimal,
},
TimingManipulation {
timing_type: String,
original_time: Option<NaiveDateTime>,
},
AccountMisclassification {
correct_account: String,
incorrect_account: String,
},
MissingField {
field_name: String,
},
Custom {
name: String,
parameters: HashMap<String, String>,
},
}
impl InjectionStrategy {
pub fn description(&self) -> String {
match self {
InjectionStrategy::AmountManipulation { factor, .. } => {
format!("Amount multiplied by {factor:.2}")
}
InjectionStrategy::ThresholdAvoidance { threshold, .. } => {
format!("Amount adjusted to avoid {threshold} threshold")
}
InjectionStrategy::DateShift { days_shifted, .. } => {
if *days_shifted < 0 {
format!("Date backdated by {} days", days_shifted.abs())
} else {
format!("Date forward-dated by {days_shifted} days")
}
}
InjectionStrategy::SelfApproval { user_id } => {
format!("Self-approval by user {user_id}")
}
InjectionStrategy::SoDViolation { duty1, duty2, .. } => {
format!("SoD violation: {duty1} and {duty2}")
}
InjectionStrategy::ExactDuplicate { original_doc_id } => {
format!("Exact duplicate of {original_doc_id}")
}
InjectionStrategy::NearDuplicate {
original_doc_id,
varied_fields,
} => {
format!("Near-duplicate of {original_doc_id} (varied: {varied_fields:?})")
}
InjectionStrategy::CircularFlow { entity_chain } => {
format!("Circular flow through {} entities", entity_chain.len())
}
InjectionStrategy::SplitTransaction { split_count, .. } => {
format!("Split into {split_count} transactions")
}
InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
InjectionStrategy::TimingManipulation { timing_type, .. } => {
format!("Timing manipulation: {timing_type}")
}
InjectionStrategy::AccountMisclassification {
correct_account,
incorrect_account,
} => {
format!("Misclassified from {correct_account} to {incorrect_account}")
}
InjectionStrategy::MissingField { field_name } => {
format!("Missing required field: {field_name}")
}
InjectionStrategy::Custom { name, .. } => format!("Custom: {name}"),
}
}
pub fn strategy_type(&self) -> &'static str {
match self {
InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
InjectionStrategy::DateShift { .. } => "DateShift",
InjectionStrategy::SelfApproval { .. } => "SelfApproval",
InjectionStrategy::SoDViolation { .. } => "SoDViolation",
InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
InjectionStrategy::CircularFlow { .. } => "CircularFlow",
InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
InjectionStrategy::MissingField { .. } => "MissingField",
InjectionStrategy::Custom { .. } => "Custom",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AnomalyType {
Fraud(FraudType),
Error(ErrorType),
ProcessIssue(ProcessIssueType),
Statistical(StatisticalAnomalyType),
Relational(RelationalAnomalyType),
Custom(String),
}
impl AnomalyType {
pub fn category(&self) -> &'static str {
match self {
AnomalyType::Fraud(_) => "Fraud",
AnomalyType::Error(_) => "Error",
AnomalyType::ProcessIssue(_) => "ProcessIssue",
AnomalyType::Statistical(_) => "Statistical",
AnomalyType::Relational(_) => "Relational",
AnomalyType::Custom(_) => "Custom",
}
}
pub fn type_name(&self) -> String {
match self {
AnomalyType::Fraud(t) => format!("{t:?}"),
AnomalyType::Error(t) => format!("{t:?}"),
AnomalyType::ProcessIssue(t) => format!("{t:?}"),
AnomalyType::Statistical(t) => format!("{t:?}"),
AnomalyType::Relational(t) => format!("{t:?}"),
AnomalyType::Custom(s) => s.clone(),
}
}
pub fn severity(&self) -> u8 {
match self {
AnomalyType::Fraud(t) => t.severity(),
AnomalyType::Error(t) => t.severity(),
AnomalyType::ProcessIssue(t) => t.severity(),
AnomalyType::Statistical(t) => t.severity(),
AnomalyType::Relational(t) => t.severity(),
AnomalyType::Custom(_) => 3,
}
}
pub fn is_intentional(&self) -> bool {
matches!(self, AnomalyType::Fraud(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FraudType {
FictitiousEntry,
FictitiousTransaction,
RoundDollarManipulation,
JustBelowThreshold,
RevenueManipulation,
ImproperCapitalization,
ExpenseCapitalization,
ReserveManipulation,
SuspenseAccountAbuse,
SplitTransaction,
TimingAnomaly,
UnauthorizedAccess,
SelfApproval,
ExceededApprovalLimit,
SegregationOfDutiesViolation,
UnauthorizedApproval,
CollusiveApproval,
FictitiousVendor,
DuplicatePayment,
ShellCompanyPayment,
Kickback,
KickbackScheme,
InvoiceManipulation,
AssetMisappropriation,
InventoryTheft,
GhostEmployee,
PrematureRevenue,
UnderstatedLiabilities,
OverstatedAssets,
ChannelStuffing,
ImproperRevenueRecognition,
ImproperPoAllocation,
VariableConsiderationManipulation,
ContractModificationMisstatement,
LeaseClassificationManipulation,
OffBalanceSheetLease,
LeaseLiabilityUnderstatement,
RouAssetMisstatement,
FairValueHierarchyManipulation,
Level3InputManipulation,
ValuationTechniqueManipulation,
DelayedImpairment,
ImpairmentTestAvoidance,
CashFlowProjectionManipulation,
ImproperImpairmentReversal,
BidRigging,
PhantomVendorContract,
SplitContractThreshold,
ConflictOfInterestSourcing,
GhostEmployeePayroll,
PayrollInflation,
DuplicateExpenseReport,
FictitiousExpense,
SplitExpenseToAvoidApproval,
RevenueTimingManipulation,
QuotePriceOverride,
}
impl FraudType {
pub fn severity(&self) -> u8 {
match self {
FraudType::RoundDollarManipulation => 2,
FraudType::JustBelowThreshold => 3,
FraudType::SelfApproval => 3,
FraudType::ExceededApprovalLimit => 3,
FraudType::DuplicatePayment => 3,
FraudType::FictitiousEntry => 4,
FraudType::RevenueManipulation => 5,
FraudType::FictitiousVendor => 5,
FraudType::ShellCompanyPayment => 5,
FraudType::AssetMisappropriation => 5,
FraudType::SegregationOfDutiesViolation => 4,
FraudType::CollusiveApproval => 5,
FraudType::ImproperRevenueRecognition => 5,
FraudType::ImproperPoAllocation => 4,
FraudType::VariableConsiderationManipulation => 4,
FraudType::ContractModificationMisstatement => 3,
FraudType::LeaseClassificationManipulation => 4,
FraudType::OffBalanceSheetLease => 5,
FraudType::LeaseLiabilityUnderstatement => 4,
FraudType::RouAssetMisstatement => 3,
FraudType::FairValueHierarchyManipulation => 4,
FraudType::Level3InputManipulation => 5,
FraudType::ValuationTechniqueManipulation => 4,
FraudType::DelayedImpairment => 4,
FraudType::ImpairmentTestAvoidance => 4,
FraudType::CashFlowProjectionManipulation => 5,
FraudType::ImproperImpairmentReversal => 3,
_ => 4,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ErrorType {
DuplicateEntry,
ReversedAmount,
TransposedDigits,
DecimalError,
MissingField,
InvalidAccount,
WrongPeriod,
BackdatedEntry,
FutureDatedEntry,
CutoffError,
MisclassifiedAccount,
WrongCostCenter,
WrongCompanyCode,
UnbalancedEntry,
RoundingError,
CurrencyError,
TaxCalculationError,
RevenueTimingError,
PoAllocationError,
LeaseClassificationError,
LeaseCalculationError,
FairValueError,
ImpairmentCalculationError,
DiscountRateError,
FrameworkApplicationError,
}
impl ErrorType {
pub fn severity(&self) -> u8 {
match self {
ErrorType::RoundingError => 1,
ErrorType::MissingField => 2,
ErrorType::TransposedDigits => 2,
ErrorType::DecimalError => 3,
ErrorType::DuplicateEntry => 3,
ErrorType::ReversedAmount => 3,
ErrorType::WrongPeriod => 4,
ErrorType::UnbalancedEntry => 5,
ErrorType::CurrencyError => 4,
ErrorType::RevenueTimingError => 4,
ErrorType::PoAllocationError => 3,
ErrorType::LeaseClassificationError => 3,
ErrorType::LeaseCalculationError => 3,
ErrorType::FairValueError => 4,
ErrorType::ImpairmentCalculationError => 4,
ErrorType::DiscountRateError => 3,
ErrorType::FrameworkApplicationError => 4,
_ => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProcessIssueType {
SkippedApproval,
LateApproval,
MissingDocumentation,
IncompleteApprovalChain,
LatePosting,
AfterHoursPosting,
WeekendPosting,
RushedPeriodEnd,
ManualOverride,
UnusualAccess,
SystemBypass,
BatchAnomaly,
VagueDescription,
PostFactoChange,
IncompleteAuditTrail,
MaverickSpend,
ExpiredContractPurchase,
ContractPriceOverride,
SingleBidAward,
QualificationBypass,
ExpiredQuoteConversion,
}
impl ProcessIssueType {
pub fn severity(&self) -> u8 {
match self {
ProcessIssueType::VagueDescription => 1,
ProcessIssueType::LatePosting => 2,
ProcessIssueType::AfterHoursPosting => 2,
ProcessIssueType::WeekendPosting => 2,
ProcessIssueType::SkippedApproval => 4,
ProcessIssueType::ManualOverride => 4,
ProcessIssueType::SystemBypass => 5,
ProcessIssueType::IncompleteAuditTrail => 4,
_ => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StatisticalAnomalyType {
UnusuallyHighAmount,
UnusuallyLowAmount,
BenfordViolation,
ExactDuplicateAmount,
RepeatingAmount,
UnusualFrequency,
TransactionBurst,
UnusualTiming,
TrendBreak,
LevelShift,
SeasonalAnomaly,
StatisticalOutlier,
VarianceChange,
DistributionShift,
SlaBreachPattern,
UnusedContract,
OvertimeAnomaly,
}
impl StatisticalAnomalyType {
pub fn severity(&self) -> u8 {
match self {
StatisticalAnomalyType::UnusualTiming => 1,
StatisticalAnomalyType::UnusualFrequency => 2,
StatisticalAnomalyType::BenfordViolation => 2,
StatisticalAnomalyType::UnusuallyHighAmount => 3,
StatisticalAnomalyType::TrendBreak => 3,
StatisticalAnomalyType::TransactionBurst => 4,
StatisticalAnomalyType::ExactDuplicateAmount => 3,
_ => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RelationalAnomalyType {
CircularTransaction,
UnusualAccountPair,
NewCounterparty,
DormantAccountActivity,
CentralityAnomaly,
IsolatedCluster,
BridgeNodeAnomaly,
CommunityAnomaly,
MissingRelationship,
UnexpectedRelationship,
RelationshipStrengthChange,
UnmatchedIntercompany,
CircularIntercompany,
TransferPricingAnomaly,
}
impl RelationalAnomalyType {
pub fn severity(&self) -> u8 {
match self {
RelationalAnomalyType::NewCounterparty => 1,
RelationalAnomalyType::DormantAccountActivity => 2,
RelationalAnomalyType::UnusualAccountPair => 2,
RelationalAnomalyType::CircularTransaction => 4,
RelationalAnomalyType::CircularIntercompany => 4,
RelationalAnomalyType::TransferPricingAnomaly => 4,
RelationalAnomalyType::UnmatchedIntercompany => 3,
_ => 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabeledAnomaly {
pub anomaly_id: String,
pub anomaly_type: AnomalyType,
pub document_id: String,
pub document_type: String,
pub company_code: String,
pub anomaly_date: NaiveDate,
#[serde(with = "crate::serde_timestamp::naive")]
pub detection_timestamp: NaiveDateTime,
pub confidence: f64,
pub severity: u8,
pub description: String,
pub related_entities: Vec<String>,
pub monetary_impact: Option<Decimal>,
pub metadata: HashMap<String, String>,
pub is_injected: bool,
pub injection_strategy: Option<String>,
pub cluster_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_document_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub causal_reason: Option<AnomalyCausalReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_strategy: Option<InjectionStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_anomaly_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub child_anomaly_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenario_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_seed: Option<u64>,
}
impl LabeledAnomaly {
pub fn new(
anomaly_id: String,
anomaly_type: AnomalyType,
document_id: String,
document_type: String,
company_code: String,
anomaly_date: NaiveDate,
) -> Self {
let severity = anomaly_type.severity();
let description = format!(
"{} - {} in document {}",
anomaly_type.category(),
anomaly_type.type_name(),
document_id
);
Self {
anomaly_id,
anomaly_type,
document_id,
document_type,
company_code,
anomaly_date,
detection_timestamp: chrono::Local::now().naive_local(),
confidence: 1.0,
severity,
description,
related_entities: Vec::new(),
monetary_impact: None,
metadata: HashMap::new(),
is_injected: true,
injection_strategy: None,
cluster_id: None,
original_document_hash: None,
causal_reason: None,
structured_strategy: None,
parent_anomaly_id: None,
child_anomaly_ids: Vec::new(),
scenario_id: None,
run_id: None,
generation_seed: None,
}
}
pub fn with_description(mut self, description: &str) -> Self {
self.description = description.to_string();
self
}
pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
self.monetary_impact = Some(impact);
self
}
pub fn with_related_entity(mut self, entity: &str) -> Self {
self.related_entities.push(entity.to_string());
self
}
pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
self.metadata.insert(key.to_string(), value.to_string());
self
}
pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
self.injection_strategy = Some(strategy.to_string());
self
}
pub fn with_cluster(mut self, cluster_id: &str) -> Self {
self.cluster_id = Some(cluster_id.to_string());
self
}
pub fn with_original_document_hash(mut self, hash: &str) -> Self {
self.original_document_hash = Some(hash.to_string());
self
}
pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
self.causal_reason = Some(reason);
self
}
pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
self.injection_strategy = Some(strategy.strategy_type().to_string());
self.structured_strategy = Some(strategy);
self
}
pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
self.parent_anomaly_id = Some(parent_id.to_string());
self
}
pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
self.child_anomaly_ids.push(child_id.to_string());
self
}
pub fn with_scenario(mut self, scenario_id: &str) -> Self {
self.scenario_id = Some(scenario_id.to_string());
self
}
pub fn with_run_id(mut self, run_id: &str) -> Self {
self.run_id = Some(run_id.to_string());
self
}
pub fn with_generation_seed(mut self, seed: u64) -> Self {
self.generation_seed = Some(seed);
self
}
pub fn with_provenance(
mut self,
run_id: Option<&str>,
seed: Option<u64>,
causal_reason: Option<AnomalyCausalReason>,
) -> Self {
if let Some(id) = run_id {
self.run_id = Some(id.to_string());
}
self.generation_seed = seed;
self.causal_reason = causal_reason;
self
}
pub fn to_features(&self) -> Vec<f64> {
let mut features = Vec::new();
let categories = [
"Fraud",
"Error",
"ProcessIssue",
"Statistical",
"Relational",
"Custom",
];
for cat in &categories {
features.push(if self.anomaly_type.category() == *cat {
1.0
} else {
0.0
});
}
features.push(self.severity as f64 / 5.0);
features.push(self.confidence);
features.push(if self.monetary_impact.is_some() {
1.0
} else {
0.0
});
if let Some(impact) = self.monetary_impact {
let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
features.push((impact_f64.abs() + 1.0).ln());
} else {
features.push(0.0);
}
features.push(if self.anomaly_type.is_intentional() {
1.0
} else {
0.0
});
features.push(self.related_entities.len() as f64);
features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
features.push(if self.parent_anomaly_id.is_some() {
1.0
} else {
0.0
});
features
}
pub fn feature_count() -> usize {
15 }
pub fn feature_names() -> Vec<&'static str> {
vec![
"category_fraud",
"category_error",
"category_process_issue",
"category_statistical",
"category_relational",
"category_custom",
"severity_normalized",
"confidence",
"has_monetary_impact",
"monetary_impact_log",
"is_intentional",
"related_entity_count",
"is_clustered",
"is_scenario_part",
"is_derived",
]
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnomalySummary {
pub total_count: usize,
pub by_category: HashMap<String, usize>,
pub by_type: HashMap<String, usize>,
pub by_severity: HashMap<u8, usize>,
pub by_company: HashMap<String, usize>,
pub total_monetary_impact: Decimal,
pub date_range: Option<(NaiveDate, NaiveDate)>,
pub cluster_count: usize,
}
impl AnomalySummary {
pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
let mut summary = AnomalySummary {
total_count: anomalies.len(),
..Default::default()
};
let mut min_date: Option<NaiveDate> = None;
let mut max_date: Option<NaiveDate> = None;
let mut clusters = std::collections::HashSet::new();
for anomaly in anomalies {
*summary
.by_category
.entry(anomaly.anomaly_type.category().to_string())
.or_insert(0) += 1;
*summary
.by_type
.entry(anomaly.anomaly_type.type_name())
.or_insert(0) += 1;
*summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
*summary
.by_company
.entry(anomaly.company_code.clone())
.or_insert(0) += 1;
if let Some(impact) = anomaly.monetary_impact {
summary.total_monetary_impact += impact;
}
match min_date {
None => min_date = Some(anomaly.anomaly_date),
Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
_ => {}
}
match max_date {
None => max_date = Some(anomaly.anomaly_date),
Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
_ => {}
}
if let Some(cluster_id) = &anomaly.cluster_id {
clusters.insert(cluster_id.clone());
}
}
summary.date_range = min_date.zip(max_date);
summary.cluster_count = clusters.len();
summary
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AnomalyCategory {
FictitiousVendor,
VendorKickback,
RelatedPartyVendor,
DuplicatePayment,
UnauthorizedTransaction,
StructuredTransaction,
CircularFlow,
BehavioralAnomaly,
TimingAnomaly,
JournalAnomaly,
ManualOverride,
MissingApproval,
StatisticalOutlier,
DistributionAnomaly,
Custom(String),
}
impl AnomalyCategory {
pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
match anomaly_type {
AnomalyType::Fraud(fraud_type) => match fraud_type {
FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
AnomalyCategory::FictitiousVendor
}
FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
AnomalyCategory::StructuredTransaction
}
FraudType::SelfApproval
| FraudType::UnauthorizedApproval
| FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
FraudType::TimingAnomaly
| FraudType::RoundDollarManipulation
| FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
_ => AnomalyCategory::BehavioralAnomaly,
},
AnomalyType::Error(error_type) => match error_type {
ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
ErrorType::WrongPeriod
| ErrorType::BackdatedEntry
| ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
_ => AnomalyCategory::JournalAnomaly,
},
AnomalyType::ProcessIssue(process_type) => match process_type {
ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
AnomalyCategory::MissingApproval
}
ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
AnomalyCategory::ManualOverride
}
ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
AnomalyCategory::TimingAnomaly
}
_ => AnomalyCategory::BehavioralAnomaly,
},
AnomalyType::Statistical(stat_type) => match stat_type {
StatisticalAnomalyType::BenfordViolation
| StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
_ => AnomalyCategory::StatisticalOutlier,
},
AnomalyType::Relational(rel_type) => match rel_type {
RelationalAnomalyType::CircularTransaction
| RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
_ => AnomalyCategory::BehavioralAnomaly,
},
AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
}
}
pub fn name(&self) -> &str {
match self {
AnomalyCategory::FictitiousVendor => "fictitious_vendor",
AnomalyCategory::VendorKickback => "vendor_kickback",
AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
AnomalyCategory::DuplicatePayment => "duplicate_payment",
AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
AnomalyCategory::StructuredTransaction => "structured_transaction",
AnomalyCategory::CircularFlow => "circular_flow",
AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
AnomalyCategory::TimingAnomaly => "timing_anomaly",
AnomalyCategory::JournalAnomaly => "journal_anomaly",
AnomalyCategory::ManualOverride => "manual_override",
AnomalyCategory::MissingApproval => "missing_approval",
AnomalyCategory::StatisticalOutlier => "statistical_outlier",
AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
AnomalyCategory::Custom(s) => s.as_str(),
}
}
pub fn ordinal(&self) -> u8 {
match self {
AnomalyCategory::FictitiousVendor => 0,
AnomalyCategory::VendorKickback => 1,
AnomalyCategory::RelatedPartyVendor => 2,
AnomalyCategory::DuplicatePayment => 3,
AnomalyCategory::UnauthorizedTransaction => 4,
AnomalyCategory::StructuredTransaction => 5,
AnomalyCategory::CircularFlow => 6,
AnomalyCategory::BehavioralAnomaly => 7,
AnomalyCategory::TimingAnomaly => 8,
AnomalyCategory::JournalAnomaly => 9,
AnomalyCategory::ManualOverride => 10,
AnomalyCategory::MissingApproval => 11,
AnomalyCategory::StatisticalOutlier => 12,
AnomalyCategory::DistributionAnomaly => 13,
AnomalyCategory::Custom(_) => 14,
}
}
pub fn category_count() -> usize {
15 }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FactorType {
AmountDeviation,
ThresholdProximity,
TimingAnomaly,
EntityRisk,
PatternMatch,
FrequencyDeviation,
RelationshipAnomaly,
ControlBypass,
BenfordViolation,
DuplicateIndicator,
ApprovalChainIssue,
DocumentationGap,
Custom,
}
impl FactorType {
pub fn name(&self) -> &'static str {
match self {
FactorType::AmountDeviation => "amount_deviation",
FactorType::ThresholdProximity => "threshold_proximity",
FactorType::TimingAnomaly => "timing_anomaly",
FactorType::EntityRisk => "entity_risk",
FactorType::PatternMatch => "pattern_match",
FactorType::FrequencyDeviation => "frequency_deviation",
FactorType::RelationshipAnomaly => "relationship_anomaly",
FactorType::ControlBypass => "control_bypass",
FactorType::BenfordViolation => "benford_violation",
FactorType::DuplicateIndicator => "duplicate_indicator",
FactorType::ApprovalChainIssue => "approval_chain_issue",
FactorType::DocumentationGap => "documentation_gap",
FactorType::Custom => "custom",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FactorEvidence {
pub source: String,
pub data: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContributingFactor {
pub factor_type: FactorType,
pub value: f64,
pub threshold: f64,
pub direction_greater: bool,
pub weight: f64,
pub description: String,
pub evidence: Option<FactorEvidence>,
}
impl ContributingFactor {
pub fn new(
factor_type: FactorType,
value: f64,
threshold: f64,
direction_greater: bool,
weight: f64,
description: &str,
) -> Self {
Self {
factor_type,
value,
threshold,
direction_greater,
weight,
description: description.to_string(),
evidence: None,
}
}
pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
self.evidence = Some(FactorEvidence {
source: source.to_string(),
data,
});
self
}
pub fn contribution(&self) -> f64 {
let deviation = if self.direction_greater {
(self.value - self.threshold).max(0.0)
} else {
(self.threshold - self.value).max(0.0)
};
let relative_deviation = if self.threshold.abs() > 0.001 {
deviation / self.threshold.abs()
} else {
deviation
};
(relative_deviation * self.weight).min(1.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnhancedAnomalyLabel {
pub base: LabeledAnomaly,
pub category: AnomalyCategory,
pub enhanced_confidence: f64,
pub enhanced_severity: f64,
pub contributing_factors: Vec<ContributingFactor>,
pub secondary_categories: Vec<AnomalyCategory>,
}
impl EnhancedAnomalyLabel {
pub fn from_base(base: LabeledAnomaly) -> Self {
let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
let enhanced_confidence = base.confidence;
let enhanced_severity = base.severity as f64 / 5.0;
Self {
base,
category,
enhanced_confidence,
enhanced_severity,
contributing_factors: Vec::new(),
secondary_categories: Vec::new(),
}
}
pub fn with_confidence(mut self, confidence: f64) -> Self {
self.enhanced_confidence = confidence.clamp(0.0, 1.0);
self
}
pub fn with_severity(mut self, severity: f64) -> Self {
self.enhanced_severity = severity.clamp(0.0, 1.0);
self
}
pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
self.contributing_factors.push(factor);
self
}
pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
if !self.secondary_categories.contains(&category) && category != self.category {
self.secondary_categories.push(category);
}
self
}
pub fn to_features(&self) -> Vec<f64> {
let mut features = self.base.to_features();
features.push(self.enhanced_confidence);
features.push(self.enhanced_severity);
features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
features.push(self.secondary_categories.len() as f64);
features.push(self.contributing_factors.len() as f64);
let max_weight = self
.contributing_factors
.iter()
.map(|f| f.weight)
.fold(0.0, f64::max);
features.push(max_weight);
let has_control_bypass = self
.contributing_factors
.iter()
.any(|f| f.factor_type == FactorType::ControlBypass);
features.push(if has_control_bypass { 1.0 } else { 0.0 });
let has_amount_deviation = self
.contributing_factors
.iter()
.any(|f| f.factor_type == FactorType::AmountDeviation);
features.push(if has_amount_deviation { 1.0 } else { 0.0 });
let has_timing = self
.contributing_factors
.iter()
.any(|f| f.factor_type == FactorType::TimingAnomaly);
features.push(if has_timing { 1.0 } else { 0.0 });
let has_pattern_match = self
.contributing_factors
.iter()
.any(|f| f.factor_type == FactorType::PatternMatch);
features.push(if has_pattern_match { 1.0 } else { 0.0 });
features
}
pub fn feature_count() -> usize {
25 }
pub fn feature_names() -> Vec<&'static str> {
let mut names = LabeledAnomaly::feature_names();
names.extend(vec![
"enhanced_confidence",
"enhanced_severity",
"category_ordinal",
"secondary_category_count",
"contributing_factor_count",
"max_factor_weight",
"has_control_bypass",
"has_amount_deviation",
"has_timing_factor",
"has_pattern_match",
]);
names
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum SeverityLevel {
Low,
#[default]
Medium,
High,
Critical,
}
impl SeverityLevel {
pub fn numeric(&self) -> u8 {
match self {
SeverityLevel::Low => 1,
SeverityLevel::Medium => 2,
SeverityLevel::High => 3,
SeverityLevel::Critical => 4,
}
}
pub fn from_numeric(value: u8) -> Self {
match value {
1 => SeverityLevel::Low,
2 => SeverityLevel::Medium,
3 => SeverityLevel::High,
_ => SeverityLevel::Critical,
}
}
pub fn from_score(score: f64) -> Self {
match score {
s if s < 0.25 => SeverityLevel::Low,
s if s < 0.50 => SeverityLevel::Medium,
s if s < 0.75 => SeverityLevel::High,
_ => SeverityLevel::Critical,
}
}
pub fn to_score(&self) -> f64 {
match self {
SeverityLevel::Low => 0.125,
SeverityLevel::Medium => 0.375,
SeverityLevel::High => 0.625,
SeverityLevel::Critical => 0.875,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnomalySeverity {
pub level: SeverityLevel,
pub score: f64,
pub financial_impact: Decimal,
pub is_material: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub materiality_threshold: Option<Decimal>,
}
impl AnomalySeverity {
pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
Self {
level,
score: level.to_score(),
financial_impact,
is_material: false,
materiality_threshold: None,
}
}
pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
Self {
level: SeverityLevel::from_score(score),
score: score.clamp(0.0, 1.0),
financial_impact,
is_material: false,
materiality_threshold: None,
}
}
pub fn with_materiality(mut self, threshold: Decimal) -> Self {
self.materiality_threshold = Some(threshold);
self.is_material = self.financial_impact.abs() >= threshold;
self
}
}
impl Default for AnomalySeverity {
fn default() -> Self {
Self {
level: SeverityLevel::Medium,
score: 0.5,
financial_impact: Decimal::ZERO,
is_material: false,
materiality_threshold: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum AnomalyDetectionDifficulty {
Trivial,
Easy,
#[default]
Moderate,
Hard,
Expert,
}
impl AnomalyDetectionDifficulty {
pub fn expected_detection_rate(&self) -> f64 {
match self {
AnomalyDetectionDifficulty::Trivial => 0.99,
AnomalyDetectionDifficulty::Easy => 0.90,
AnomalyDetectionDifficulty::Moderate => 0.70,
AnomalyDetectionDifficulty::Hard => 0.40,
AnomalyDetectionDifficulty::Expert => 0.15,
}
}
pub fn difficulty_score(&self) -> f64 {
match self {
AnomalyDetectionDifficulty::Trivial => 0.05,
AnomalyDetectionDifficulty::Easy => 0.25,
AnomalyDetectionDifficulty::Moderate => 0.50,
AnomalyDetectionDifficulty::Hard => 0.75,
AnomalyDetectionDifficulty::Expert => 0.95,
}
}
pub fn from_score(score: f64) -> Self {
match score {
s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
_ => AnomalyDetectionDifficulty::Expert,
}
}
pub fn name(&self) -> &'static str {
match self {
AnomalyDetectionDifficulty::Trivial => "trivial",
AnomalyDetectionDifficulty::Easy => "easy",
AnomalyDetectionDifficulty::Moderate => "moderate",
AnomalyDetectionDifficulty::Hard => "hard",
AnomalyDetectionDifficulty::Expert => "expert",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum GroundTruthCertainty {
#[default]
Definite,
Probable,
Possible,
}
impl GroundTruthCertainty {
pub fn certainty_score(&self) -> f64 {
match self {
GroundTruthCertainty::Definite => 1.0,
GroundTruthCertainty::Probable => 0.8,
GroundTruthCertainty::Possible => 0.5,
}
}
pub fn name(&self) -> &'static str {
match self {
GroundTruthCertainty::Definite => "definite",
GroundTruthCertainty::Probable => "probable",
GroundTruthCertainty::Possible => "possible",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DetectionMethod {
RuleBased,
Statistical,
MachineLearning,
GraphBased,
ForensicAudit,
Hybrid,
}
impl DetectionMethod {
pub fn name(&self) -> &'static str {
match self {
DetectionMethod::RuleBased => "rule_based",
DetectionMethod::Statistical => "statistical",
DetectionMethod::MachineLearning => "machine_learning",
DetectionMethod::GraphBased => "graph_based",
DetectionMethod::ForensicAudit => "forensic_audit",
DetectionMethod::Hybrid => "hybrid",
}
}
pub fn description(&self) -> &'static str {
match self {
DetectionMethod::RuleBased => "Simple threshold and filter rules",
DetectionMethod::Statistical => "Statistical distribution analysis",
DetectionMethod::MachineLearning => "ML classification models",
DetectionMethod::GraphBased => "Network and relationship analysis",
DetectionMethod::ForensicAudit => "Manual forensic procedures",
DetectionMethod::Hybrid => "Combined multi-method approach",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtendedAnomalyLabel {
pub base: LabeledAnomaly,
pub category: AnomalyCategory,
pub severity: AnomalySeverity,
pub detection_difficulty: AnomalyDetectionDifficulty,
pub recommended_methods: Vec<DetectionMethod>,
pub key_indicators: Vec<String>,
pub ground_truth_certainty: GroundTruthCertainty,
pub contributing_factors: Vec<ContributingFactor>,
pub related_entity_ids: Vec<String>,
pub secondary_categories: Vec<AnomalyCategory>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheme_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheme_stage: Option<u32>,
#[serde(default)]
pub is_near_miss: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub near_miss_explanation: Option<String>,
}
impl ExtendedAnomalyLabel {
pub fn from_base(base: LabeledAnomaly) -> Self {
let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
let severity = AnomalySeverity {
level: SeverityLevel::from_numeric(base.severity),
score: base.severity as f64 / 5.0,
financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
is_material: false,
materiality_threshold: None,
};
Self {
base,
category,
severity,
detection_difficulty: AnomalyDetectionDifficulty::Moderate,
recommended_methods: vec![DetectionMethod::RuleBased],
key_indicators: Vec::new(),
ground_truth_certainty: GroundTruthCertainty::Definite,
contributing_factors: Vec::new(),
related_entity_ids: Vec::new(),
secondary_categories: Vec::new(),
scheme_id: None,
scheme_stage: None,
is_near_miss: false,
near_miss_explanation: None,
}
}
pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
self.severity = severity;
self
}
pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
self.detection_difficulty = difficulty;
self
}
pub fn with_method(mut self, method: DetectionMethod) -> Self {
if !self.recommended_methods.contains(&method) {
self.recommended_methods.push(method);
}
self
}
pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
self.recommended_methods = methods;
self
}
pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
self.key_indicators.push(indicator.into());
self
}
pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
self.ground_truth_certainty = certainty;
self
}
pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
self.contributing_factors.push(factor);
self
}
pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
self.related_entity_ids.push(entity_id.into());
self
}
pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
if category != self.category && !self.secondary_categories.contains(&category) {
self.secondary_categories.push(category);
}
self
}
pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
self.scheme_id = Some(scheme_id.into());
self.scheme_stage = Some(stage);
self
}
pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
self.is_near_miss = true;
self.near_miss_explanation = Some(explanation.into());
self
}
pub fn to_features(&self) -> Vec<f64> {
let mut features = self.base.to_features();
features.push(self.severity.score);
features.push(self.severity.level.to_score());
features.push(if self.severity.is_material { 1.0 } else { 0.0 });
features.push(self.detection_difficulty.difficulty_score());
features.push(self.detection_difficulty.expected_detection_rate());
features.push(self.ground_truth_certainty.certainty_score());
features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
features.push(self.secondary_categories.len() as f64);
features.push(self.contributing_factors.len() as f64);
features.push(self.key_indicators.len() as f64);
features.push(self.recommended_methods.len() as f64);
features.push(self.related_entity_ids.len() as f64);
features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
features.push(self.scheme_stage.unwrap_or(0) as f64);
features.push(if self.is_near_miss { 1.0 } else { 0.0 });
features
}
pub fn feature_count() -> usize {
30 }
pub fn feature_names() -> Vec<&'static str> {
let mut names = LabeledAnomaly::feature_names();
names.extend(vec![
"severity_score",
"severity_level_score",
"is_material",
"difficulty_score",
"expected_detection_rate",
"ground_truth_certainty",
"category_ordinal",
"secondary_category_count",
"contributing_factor_count",
"key_indicator_count",
"recommended_method_count",
"related_entity_count",
"is_part_of_scheme",
"scheme_stage",
"is_near_miss",
]);
names
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SchemeType {
GradualEmbezzlement,
RevenueManipulation,
VendorKickback,
RoundTripping,
GhostEmployee,
ExpenseReimbursement,
InventoryTheft,
Custom,
}
impl SchemeType {
pub fn name(&self) -> &'static str {
match self {
SchemeType::GradualEmbezzlement => "gradual_embezzlement",
SchemeType::RevenueManipulation => "revenue_manipulation",
SchemeType::VendorKickback => "vendor_kickback",
SchemeType::RoundTripping => "round_tripping",
SchemeType::GhostEmployee => "ghost_employee",
SchemeType::ExpenseReimbursement => "expense_reimbursement",
SchemeType::InventoryTheft => "inventory_theft",
SchemeType::Custom => "custom",
}
}
pub fn typical_stages(&self) -> u32 {
match self {
SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum SchemeDetectionStatus {
#[default]
Undetected,
UnderInvestigation,
PartiallyDetected,
FullyDetected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemeTransactionRef {
pub document_id: String,
pub date: chrono::NaiveDate,
pub amount: Decimal,
pub stage: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anomaly_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConcealmentTechnique {
DocumentManipulation,
ApprovalCircumvention,
TimingExploitation,
TransactionSplitting,
AccountMisclassification,
Collusion,
DataAlteration,
FalseDocumentation,
}
impl ConcealmentTechnique {
pub fn difficulty_bonus(&self) -> f64 {
match self {
ConcealmentTechnique::DocumentManipulation => 0.20,
ConcealmentTechnique::ApprovalCircumvention => 0.15,
ConcealmentTechnique::TimingExploitation => 0.10,
ConcealmentTechnique::TransactionSplitting => 0.15,
ConcealmentTechnique::AccountMisclassification => 0.10,
ConcealmentTechnique::Collusion => 0.25,
ConcealmentTechnique::DataAlteration => 0.20,
ConcealmentTechnique::FalseDocumentation => 0.15,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum AcfeFraudCategory {
#[default]
AssetMisappropriation,
Corruption,
FinancialStatementFraud,
}
impl AcfeFraudCategory {
pub fn name(&self) -> &'static str {
match self {
AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
AcfeFraudCategory::Corruption => "corruption",
AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
}
}
pub fn typical_occurrence_rate(&self) -> f64 {
match self {
AcfeFraudCategory::AssetMisappropriation => 0.86,
AcfeFraudCategory::Corruption => 0.33,
AcfeFraudCategory::FinancialStatementFraud => 0.10,
}
}
pub fn typical_median_loss(&self) -> Decimal {
match self {
AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
}
}
pub fn typical_detection_months(&self) -> u32 {
match self {
AcfeFraudCategory::AssetMisappropriation => 12,
AcfeFraudCategory::Corruption => 18,
AcfeFraudCategory::FinancialStatementFraud => 24,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CashFraudScheme {
Larceny,
Skimming,
SalesSkimming,
ReceivablesSkimming,
RefundSchemes,
ShellCompany,
NonAccompliceVendor,
PersonalPurchases,
GhostEmployee,
FalsifiedWages,
CommissionSchemes,
MischaracterizedExpenses,
OverstatedExpenses,
FictitiousExpenses,
ForgedMaker,
ForgedEndorsement,
AlteredPayee,
AuthorizedMaker,
FalseVoids,
FalseRefunds,
}
impl CashFraudScheme {
pub fn category(&self) -> AcfeFraudCategory {
AcfeFraudCategory::AssetMisappropriation
}
pub fn subcategory(&self) -> &'static str {
match self {
CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
CashFraudScheme::SalesSkimming
| CashFraudScheme::ReceivablesSkimming
| CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
CashFraudScheme::ShellCompany
| CashFraudScheme::NonAccompliceVendor
| CashFraudScheme::PersonalPurchases => "billing_schemes",
CashFraudScheme::GhostEmployee
| CashFraudScheme::FalsifiedWages
| CashFraudScheme::CommissionSchemes => "payroll_schemes",
CashFraudScheme::MischaracterizedExpenses
| CashFraudScheme::OverstatedExpenses
| CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
CashFraudScheme::ForgedMaker
| CashFraudScheme::ForgedEndorsement
| CashFraudScheme::AlteredPayee
| CashFraudScheme::AuthorizedMaker => "check_tampering",
CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
}
}
pub fn severity(&self) -> u8 {
match self {
CashFraudScheme::FalseVoids
| CashFraudScheme::FalseRefunds
| CashFraudScheme::MischaracterizedExpenses => 3,
CashFraudScheme::OverstatedExpenses
| CashFraudScheme::Skimming
| CashFraudScheme::Larceny
| CashFraudScheme::PersonalPurchases
| CashFraudScheme::FalsifiedWages => 4,
CashFraudScheme::ShellCompany
| CashFraudScheme::GhostEmployee
| CashFraudScheme::FictitiousExpenses
| CashFraudScheme::ForgedMaker
| CashFraudScheme::AuthorizedMaker => 5,
_ => 4,
}
}
pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
match self {
CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
AnomalyDetectionDifficulty::Easy
}
CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
AnomalyDetectionDifficulty::Moderate
}
CashFraudScheme::Skimming
| CashFraudScheme::ShellCompany
| CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
AnomalyDetectionDifficulty::Expert
}
_ => AnomalyDetectionDifficulty::Moderate,
}
}
pub fn all_variants() -> &'static [CashFraudScheme] {
&[
CashFraudScheme::Larceny,
CashFraudScheme::Skimming,
CashFraudScheme::SalesSkimming,
CashFraudScheme::ReceivablesSkimming,
CashFraudScheme::RefundSchemes,
CashFraudScheme::ShellCompany,
CashFraudScheme::NonAccompliceVendor,
CashFraudScheme::PersonalPurchases,
CashFraudScheme::GhostEmployee,
CashFraudScheme::FalsifiedWages,
CashFraudScheme::CommissionSchemes,
CashFraudScheme::MischaracterizedExpenses,
CashFraudScheme::OverstatedExpenses,
CashFraudScheme::FictitiousExpenses,
CashFraudScheme::ForgedMaker,
CashFraudScheme::ForgedEndorsement,
CashFraudScheme::AlteredPayee,
CashFraudScheme::AuthorizedMaker,
CashFraudScheme::FalseVoids,
CashFraudScheme::FalseRefunds,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetFraudScheme {
InventoryMisuse,
InventoryTheft,
InventoryPurchasingScheme,
InventoryReceivingScheme,
EquipmentMisuse,
EquipmentTheft,
IntellectualPropertyTheft,
TimeTheft,
}
impl AssetFraudScheme {
pub fn category(&self) -> AcfeFraudCategory {
AcfeFraudCategory::AssetMisappropriation
}
pub fn subcategory(&self) -> &'static str {
match self {
AssetFraudScheme::InventoryMisuse
| AssetFraudScheme::InventoryTheft
| AssetFraudScheme::InventoryPurchasingScheme
| AssetFraudScheme::InventoryReceivingScheme => "inventory",
_ => "other_assets",
}
}
pub fn severity(&self) -> u8 {
match self {
AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
AssetFraudScheme::InventoryTheft
| AssetFraudScheme::InventoryPurchasingScheme
| AssetFraudScheme::InventoryReceivingScheme => 4,
AssetFraudScheme::IntellectualPropertyTheft => 5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CorruptionScheme {
PurchasingConflict,
SalesConflict,
OutsideBusinessInterest,
NepotismConflict,
InvoiceKickback,
BidRigging,
CashBribery,
PublicOfficial,
IllegalGratuity,
EconomicExtortion,
}
impl CorruptionScheme {
pub fn category(&self) -> AcfeFraudCategory {
AcfeFraudCategory::Corruption
}
pub fn subcategory(&self) -> &'static str {
match self {
CorruptionScheme::PurchasingConflict
| CorruptionScheme::SalesConflict
| CorruptionScheme::OutsideBusinessInterest
| CorruptionScheme::NepotismConflict => "conflicts_of_interest",
CorruptionScheme::InvoiceKickback
| CorruptionScheme::BidRigging
| CorruptionScheme::CashBribery
| CorruptionScheme::PublicOfficial => "bribery",
CorruptionScheme::IllegalGratuity => "illegal_gratuities",
CorruptionScheme::EconomicExtortion => "economic_extortion",
}
}
pub fn severity(&self) -> u8 {
match self {
CorruptionScheme::NepotismConflict => 3,
CorruptionScheme::PurchasingConflict
| CorruptionScheme::SalesConflict
| CorruptionScheme::OutsideBusinessInterest
| CorruptionScheme::IllegalGratuity => 4,
CorruptionScheme::InvoiceKickback
| CorruptionScheme::BidRigging
| CorruptionScheme::CashBribery
| CorruptionScheme::EconomicExtortion => 5,
CorruptionScheme::PublicOfficial => 5,
}
}
pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
match self {
CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
AnomalyDetectionDifficulty::Moderate
}
CorruptionScheme::PurchasingConflict
| CorruptionScheme::SalesConflict
| CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
CorruptionScheme::InvoiceKickback
| CorruptionScheme::CashBribery
| CorruptionScheme::PublicOfficial
| CorruptionScheme::IllegalGratuity
| CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
}
}
pub fn all_variants() -> &'static [CorruptionScheme] {
&[
CorruptionScheme::PurchasingConflict,
CorruptionScheme::SalesConflict,
CorruptionScheme::OutsideBusinessInterest,
CorruptionScheme::NepotismConflict,
CorruptionScheme::InvoiceKickback,
CorruptionScheme::BidRigging,
CorruptionScheme::CashBribery,
CorruptionScheme::PublicOfficial,
CorruptionScheme::IllegalGratuity,
CorruptionScheme::EconomicExtortion,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FinancialStatementScheme {
PrematureRevenue,
DelayedExpenses,
FictitiousRevenues,
ConcealedLiabilities,
ImproperAssetValuations,
ImproperDisclosures,
ChannelStuffing,
BillAndHold,
ImproperCapitalization,
UnderstatedRevenues,
OverstatedExpenses,
OverstatedLiabilities,
ImproperAssetWritedowns,
}
impl FinancialStatementScheme {
pub fn category(&self) -> AcfeFraudCategory {
AcfeFraudCategory::FinancialStatementFraud
}
pub fn subcategory(&self) -> &'static str {
match self {
FinancialStatementScheme::UnderstatedRevenues
| FinancialStatementScheme::OverstatedExpenses
| FinancialStatementScheme::OverstatedLiabilities
| FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
_ => "overstatement",
}
}
pub fn severity(&self) -> u8 {
5
}
pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
match self {
FinancialStatementScheme::ChannelStuffing
| FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
FinancialStatementScheme::PrematureRevenue
| FinancialStatementScheme::ImproperCapitalization
| FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
FinancialStatementScheme::FictitiousRevenues
| FinancialStatementScheme::ConcealedLiabilities
| FinancialStatementScheme::ImproperAssetValuations
| FinancialStatementScheme::ImproperDisclosures
| FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
_ => AnomalyDetectionDifficulty::Hard,
}
}
pub fn all_variants() -> &'static [FinancialStatementScheme] {
&[
FinancialStatementScheme::PrematureRevenue,
FinancialStatementScheme::DelayedExpenses,
FinancialStatementScheme::FictitiousRevenues,
FinancialStatementScheme::ConcealedLiabilities,
FinancialStatementScheme::ImproperAssetValuations,
FinancialStatementScheme::ImproperDisclosures,
FinancialStatementScheme::ChannelStuffing,
FinancialStatementScheme::BillAndHold,
FinancialStatementScheme::ImproperCapitalization,
FinancialStatementScheme::UnderstatedRevenues,
FinancialStatementScheme::OverstatedExpenses,
FinancialStatementScheme::OverstatedLiabilities,
FinancialStatementScheme::ImproperAssetWritedowns,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AcfeScheme {
Cash(CashFraudScheme),
Asset(AssetFraudScheme),
Corruption(CorruptionScheme),
FinancialStatement(FinancialStatementScheme),
}
impl AcfeScheme {
pub fn category(&self) -> AcfeFraudCategory {
match self {
AcfeScheme::Cash(s) => s.category(),
AcfeScheme::Asset(s) => s.category(),
AcfeScheme::Corruption(s) => s.category(),
AcfeScheme::FinancialStatement(s) => s.category(),
}
}
pub fn severity(&self) -> u8 {
match self {
AcfeScheme::Cash(s) => s.severity(),
AcfeScheme::Asset(s) => s.severity(),
AcfeScheme::Corruption(s) => s.severity(),
AcfeScheme::FinancialStatement(s) => s.severity(),
}
}
pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
match self {
AcfeScheme::Cash(s) => s.detection_difficulty(),
AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
AcfeScheme::Corruption(s) => s.detection_difficulty(),
AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AcfeDetectionMethod {
Tip,
InternalAudit,
ManagementReview,
ExternalAudit,
AccountReconciliation,
DocumentExamination,
ByAccident,
ItControls,
Surveillance,
Confession,
LawEnforcement,
Other,
}
impl AcfeDetectionMethod {
pub fn typical_detection_rate(&self) -> f64 {
match self {
AcfeDetectionMethod::Tip => 0.42,
AcfeDetectionMethod::InternalAudit => 0.16,
AcfeDetectionMethod::ManagementReview => 0.12,
AcfeDetectionMethod::ExternalAudit => 0.04,
AcfeDetectionMethod::AccountReconciliation => 0.05,
AcfeDetectionMethod::DocumentExamination => 0.04,
AcfeDetectionMethod::ByAccident => 0.06,
AcfeDetectionMethod::ItControls => 0.03,
AcfeDetectionMethod::Surveillance => 0.02,
AcfeDetectionMethod::Confession => 0.02,
AcfeDetectionMethod::LawEnforcement => 0.01,
AcfeDetectionMethod::Other => 0.03,
}
}
pub fn all_variants() -> &'static [AcfeDetectionMethod] {
&[
AcfeDetectionMethod::Tip,
AcfeDetectionMethod::InternalAudit,
AcfeDetectionMethod::ManagementReview,
AcfeDetectionMethod::ExternalAudit,
AcfeDetectionMethod::AccountReconciliation,
AcfeDetectionMethod::DocumentExamination,
AcfeDetectionMethod::ByAccident,
AcfeDetectionMethod::ItControls,
AcfeDetectionMethod::Surveillance,
AcfeDetectionMethod::Confession,
AcfeDetectionMethod::LawEnforcement,
AcfeDetectionMethod::Other,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PerpetratorDepartment {
Accounting,
Operations,
Executive,
Sales,
CustomerService,
Purchasing,
It,
HumanResources,
Administrative,
Warehouse,
BoardOfDirectors,
Other,
}
impl PerpetratorDepartment {
pub fn typical_occurrence_rate(&self) -> f64 {
match self {
PerpetratorDepartment::Accounting => 0.21,
PerpetratorDepartment::Operations => 0.17,
PerpetratorDepartment::Executive => 0.12,
PerpetratorDepartment::Sales => 0.11,
PerpetratorDepartment::CustomerService => 0.07,
PerpetratorDepartment::Purchasing => 0.06,
PerpetratorDepartment::It => 0.05,
PerpetratorDepartment::HumanResources => 0.04,
PerpetratorDepartment::Administrative => 0.04,
PerpetratorDepartment::Warehouse => 0.03,
PerpetratorDepartment::BoardOfDirectors => 0.02,
PerpetratorDepartment::Other => 0.08,
}
}
pub fn typical_median_loss(&self) -> Decimal {
match self {
PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
PerpetratorDepartment::It => Decimal::new(100_000, 0),
_ => Decimal::new(80_000, 0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PerpetratorLevel {
Employee,
Manager,
OwnerExecutive,
}
impl PerpetratorLevel {
pub fn typical_occurrence_rate(&self) -> f64 {
match self {
PerpetratorLevel::Employee => 0.42,
PerpetratorLevel::Manager => 0.36,
PerpetratorLevel::OwnerExecutive => 0.22,
}
}
pub fn typical_median_loss(&self) -> Decimal {
match self {
PerpetratorLevel::Employee => Decimal::new(50_000, 0),
PerpetratorLevel::Manager => Decimal::new(125_000, 0),
PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcfeCalibration {
pub median_loss: Decimal,
pub median_duration_months: u32,
pub category_distribution: HashMap<String, f64>,
pub detection_method_distribution: HashMap<String, f64>,
pub department_distribution: HashMap<String, f64>,
pub level_distribution: HashMap<String, f64>,
pub avg_red_flags_per_case: f64,
pub collusion_rate: f64,
}
impl Default for AcfeCalibration {
fn default() -> Self {
let mut category_distribution = HashMap::new();
category_distribution.insert("asset_misappropriation".to_string(), 0.86);
category_distribution.insert("corruption".to_string(), 0.33);
category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
let mut detection_method_distribution = HashMap::new();
for method in AcfeDetectionMethod::all_variants() {
detection_method_distribution.insert(
format!("{method:?}").to_lowercase(),
method.typical_detection_rate(),
);
}
let mut department_distribution = HashMap::new();
department_distribution.insert("accounting".to_string(), 0.21);
department_distribution.insert("operations".to_string(), 0.17);
department_distribution.insert("executive".to_string(), 0.12);
department_distribution.insert("sales".to_string(), 0.11);
department_distribution.insert("customer_service".to_string(), 0.07);
department_distribution.insert("purchasing".to_string(), 0.06);
department_distribution.insert("other".to_string(), 0.26);
let mut level_distribution = HashMap::new();
level_distribution.insert("employee".to_string(), 0.42);
level_distribution.insert("manager".to_string(), 0.36);
level_distribution.insert("owner_executive".to_string(), 0.22);
Self {
median_loss: Decimal::new(117_000, 0),
median_duration_months: 12,
category_distribution,
detection_method_distribution,
department_distribution,
level_distribution,
avg_red_flags_per_case: 2.8,
collusion_rate: 0.50,
}
}
}
impl AcfeCalibration {
pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
Self {
median_loss,
median_duration_months,
..Self::default()
}
}
pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
category.typical_median_loss()
}
pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
category.typical_detection_months()
}
pub fn validate(&self) -> Result<(), String> {
if self.median_loss <= Decimal::ZERO {
return Err("Median loss must be positive".to_string());
}
if self.median_duration_months == 0 {
return Err("Median duration must be at least 1 month".to_string());
}
if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
return Err("Collusion rate must be between 0.0 and 1.0".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FraudTriangle {
pub pressure: PressureType,
pub opportunities: Vec<OpportunityFactor>,
pub rationalization: Rationalization,
}
impl FraudTriangle {
pub fn new(
pressure: PressureType,
opportunities: Vec<OpportunityFactor>,
rationalization: Rationalization,
) -> Self {
Self {
pressure,
opportunities,
rationalization,
}
}
pub fn risk_score(&self) -> f64 {
let pressure_score = self.pressure.risk_weight();
let opportunity_score: f64 = self
.opportunities
.iter()
.map(OpportunityFactor::risk_weight)
.sum::<f64>()
/ self.opportunities.len().max(1) as f64;
let rationalization_score = self.rationalization.risk_weight();
(pressure_score + opportunity_score + rationalization_score) / 3.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PressureType {
PersonalFinancialDifficulties,
FinancialTargets,
MarketExpectations,
CovenantCompliance,
CreditRatingMaintenance,
AcquisitionValuation,
JobSecurity,
StatusMaintenance,
GamblingAddiction,
SubstanceAbuse,
FamilyPressure,
Greed,
}
impl PressureType {
pub fn risk_weight(&self) -> f64 {
match self {
PressureType::PersonalFinancialDifficulties => 0.80,
PressureType::FinancialTargets => 0.75,
PressureType::MarketExpectations => 0.70,
PressureType::CovenantCompliance => 0.85,
PressureType::CreditRatingMaintenance => 0.70,
PressureType::AcquisitionValuation => 0.75,
PressureType::JobSecurity => 0.65,
PressureType::StatusMaintenance => 0.55,
PressureType::GamblingAddiction => 0.90,
PressureType::SubstanceAbuse => 0.85,
PressureType::FamilyPressure => 0.60,
PressureType::Greed => 0.70,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum OpportunityFactor {
WeakInternalControls,
LackOfSegregation,
ManagementOverride,
ComplexTransactions,
RelatedPartyTransactions,
PoorToneAtTop,
InadequateSupervision,
AssetAccess,
PoorRecordKeeping,
LackOfDiscipline,
LackOfIndependentChecks,
}
impl OpportunityFactor {
pub fn risk_weight(&self) -> f64 {
match self {
OpportunityFactor::WeakInternalControls => 0.85,
OpportunityFactor::LackOfSegregation => 0.80,
OpportunityFactor::ManagementOverride => 0.90,
OpportunityFactor::ComplexTransactions => 0.70,
OpportunityFactor::RelatedPartyTransactions => 0.75,
OpportunityFactor::PoorToneAtTop => 0.85,
OpportunityFactor::InadequateSupervision => 0.75,
OpportunityFactor::AssetAccess => 0.70,
OpportunityFactor::PoorRecordKeeping => 0.65,
OpportunityFactor::LackOfDiscipline => 0.60,
OpportunityFactor::LackOfIndependentChecks => 0.75,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rationalization {
TemporaryBorrowing,
EveryoneDoesIt,
ForTheCompanyGood,
Entitlement,
FollowingOrders,
TheyWontMissIt,
NeedItMore,
NotReallyStealing,
Underpaid,
VictimlessCrime,
}
impl Rationalization {
pub fn risk_weight(&self) -> f64 {
match self {
Rationalization::Entitlement => 0.85,
Rationalization::EveryoneDoesIt => 0.80,
Rationalization::NotReallyStealing => 0.80,
Rationalization::TheyWontMissIt => 0.75,
Rationalization::Underpaid => 0.70,
Rationalization::ForTheCompanyGood => 0.65,
Rationalization::NeedItMore => 0.65,
Rationalization::TemporaryBorrowing => 0.60,
Rationalization::FollowingOrders => 0.55,
Rationalization::VictimlessCrime => 0.60,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NearMissPattern {
NearDuplicate {
date_difference_days: u32,
similar_transaction_id: String,
},
ThresholdProximity {
threshold: Decimal,
proximity: f64,
},
UnusualLegitimate {
pattern_type: LegitimatePatternType,
justification: String,
},
CorrectedError {
correction_lag_days: u32,
correction_document_id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LegitimatePatternType {
YearEndBonus,
ContractPrepayment,
SettlementPayment,
InsuranceClaim,
OneTimePayment,
AssetDisposal,
SeasonalInventory,
PromotionalSpending,
}
impl LegitimatePatternType {
pub fn description(&self) -> &'static str {
match self {
LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
LegitimatePatternType::SettlementPayment => "Legal settlement payment",
LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
LegitimatePatternType::OneTimePayment => "One-time vendor payment",
LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FalsePositiveTrigger {
AmountNearThreshold,
UnusualTiming,
SimilarTransaction,
NewCounterparty,
UnusualAccountCombination,
VolumeSpike,
RoundAmount,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NearMissLabel {
pub document_id: String,
pub pattern: NearMissPattern,
pub suspicion_score: f64,
pub false_positive_trigger: FalsePositiveTrigger,
pub explanation: String,
}
impl NearMissLabel {
pub fn new(
document_id: impl Into<String>,
pattern: NearMissPattern,
suspicion_score: f64,
trigger: FalsePositiveTrigger,
explanation: impl Into<String>,
) -> Self {
Self {
document_id: document_id.into(),
pattern,
suspicion_score: suspicion_score.clamp(0.0, 1.0),
false_positive_trigger: trigger,
explanation: explanation.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnomalyRateConfig {
pub total_rate: f64,
pub fraud_rate: f64,
pub error_rate: f64,
pub process_issue_rate: f64,
pub statistical_rate: f64,
pub relational_rate: f64,
}
impl Default for AnomalyRateConfig {
fn default() -> Self {
Self {
total_rate: 0.02, fraud_rate: 0.25, error_rate: 0.35, process_issue_rate: 0.20, statistical_rate: 0.15, relational_rate: 0.05, }
}
}
impl AnomalyRateConfig {
pub fn validate(&self) -> Result<(), String> {
let sum = self.fraud_rate
+ self.error_rate
+ self.process_issue_rate
+ self.statistical_rate
+ self.relational_rate;
if (sum - 1.0).abs() > 0.01 {
return Err(format!("Anomaly category rates must sum to 1.0, got {sum}"));
}
if self.total_rate < 0.0 || self.total_rate > 1.0 {
return Err(format!(
"Total rate must be between 0.0 and 1.0, got {}",
self.total_rate
));
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_anomaly_type_category() {
let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
assert_eq!(fraud.category(), "Fraud");
assert!(fraud.is_intentional());
let error = AnomalyType::Error(ErrorType::DuplicateEntry);
assert_eq!(error.category(), "Error");
assert!(!error.is_intentional());
}
#[test]
fn test_labeled_anomaly() {
let anomaly = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
)
.with_description("User approved their own expense report")
.with_related_entity("USER001");
assert_eq!(anomaly.severity, 3);
assert!(anomaly.is_injected);
assert_eq!(anomaly.related_entities.len(), 1);
}
#[test]
fn test_labeled_anomaly_with_provenance() {
let anomaly = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
)
.with_run_id("run-123")
.with_generation_seed(42)
.with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
.with_structured_strategy(InjectionStrategy::SelfApproval {
user_id: "USER001".to_string(),
})
.with_scenario("scenario-001")
.with_original_document_hash("abc123");
assert_eq!(anomaly.run_id, Some("run-123".to_string()));
assert_eq!(anomaly.generation_seed, Some(42));
assert!(anomaly.causal_reason.is_some());
assert!(anomaly.structured_strategy.is_some());
assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
}
#[test]
fn test_labeled_anomaly_derivation_chain() {
let parent = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::DuplicatePayment),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
let child = LabeledAnomaly::new(
"ANO002".to_string(),
AnomalyType::Error(ErrorType::DuplicateEntry),
"JE002".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
)
.with_parent_anomaly(&parent.anomaly_id);
assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
}
#[test]
fn test_injection_strategy_description() {
let strategy = InjectionStrategy::AmountManipulation {
original: dec!(1000),
factor: 2.5,
};
assert_eq!(strategy.description(), "Amount multiplied by 2.50");
assert_eq!(strategy.strategy_type(), "AmountManipulation");
let strategy = InjectionStrategy::ThresholdAvoidance {
threshold: dec!(10000),
adjusted_amount: dec!(9999),
};
assert_eq!(
strategy.description(),
"Amount adjusted to avoid 10000 threshold"
);
let strategy = InjectionStrategy::DateShift {
days_shifted: -5,
original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
};
assert_eq!(strategy.description(), "Date backdated by 5 days");
let strategy = InjectionStrategy::DateShift {
days_shifted: 3,
original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
};
assert_eq!(strategy.description(), "Date forward-dated by 3 days");
}
#[test]
fn test_causal_reason_variants() {
let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
if let AnomalyCausalReason::RandomRate { base_rate } = reason {
assert!((base_rate - 0.02).abs() < 0.001);
}
let reason = AnomalyCausalReason::TemporalPattern {
pattern_name: "year_end_spike".to_string(),
};
if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
assert_eq!(pattern_name, "year_end_spike");
}
let reason = AnomalyCausalReason::ScenarioStep {
scenario_type: "kickback".to_string(),
step_number: 3,
};
if let AnomalyCausalReason::ScenarioStep {
scenario_type,
step_number,
} = reason
{
assert_eq!(scenario_type, "kickback");
assert_eq!(step_number, 3);
}
}
#[test]
fn test_feature_vector_length() {
let anomaly = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
let features = anomaly.to_features();
assert_eq!(features.len(), LabeledAnomaly::feature_count());
assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
}
#[test]
fn test_feature_vector_with_provenance() {
let anomaly = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
)
.with_scenario("scenario-001")
.with_parent_anomaly("ANO000");
let features = anomaly.to_features();
assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
#[test]
fn test_anomaly_summary() {
let anomalies = vec![
LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
),
LabeledAnomaly::new(
"ANO002".to_string(),
AnomalyType::Error(ErrorType::DuplicateEntry),
"JE002".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
),
];
let summary = AnomalySummary::from_anomalies(&anomalies);
assert_eq!(summary.total_count, 2);
assert_eq!(summary.by_category.get("Fraud"), Some(&1));
assert_eq!(summary.by_category.get("Error"), Some(&1));
}
#[test]
fn test_rate_config_validation() {
let config = AnomalyRateConfig::default();
assert!(config.validate().is_ok());
let bad_config = AnomalyRateConfig {
fraud_rate: 0.5,
error_rate: 0.5,
process_issue_rate: 0.5, ..Default::default()
};
assert!(bad_config.validate().is_err());
}
#[test]
fn test_injection_strategy_serialization() {
let strategy = InjectionStrategy::SoDViolation {
duty1: "CreatePO".to_string(),
duty2: "ApprovePO".to_string(),
violating_user: "USER001".to_string(),
};
let json = serde_json::to_string(&strategy).unwrap();
let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
assert_eq!(strategy, deserialized);
}
#[test]
fn test_labeled_anomaly_serialization_with_provenance() {
let anomaly = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
)
.with_run_id("run-123")
.with_generation_seed(42)
.with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
let json = serde_json::to_string(&anomaly).unwrap();
let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
assert_eq!(anomaly.run_id, deserialized.run_id);
assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
}
#[test]
fn test_anomaly_category_from_anomaly_type() {
let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
assert_eq!(
AnomalyCategory::from_anomaly_type(&fraud_vendor),
AnomalyCategory::FictitiousVendor
);
let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
assert_eq!(
AnomalyCategory::from_anomaly_type(&fraud_kickback),
AnomalyCategory::VendorKickback
);
let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
assert_eq!(
AnomalyCategory::from_anomaly_type(&fraud_structured),
AnomalyCategory::StructuredTransaction
);
let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
assert_eq!(
AnomalyCategory::from_anomaly_type(&error_duplicate),
AnomalyCategory::DuplicatePayment
);
let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
assert_eq!(
AnomalyCategory::from_anomaly_type(&process_skip),
AnomalyCategory::MissingApproval
);
let relational_circular =
AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
assert_eq!(
AnomalyCategory::from_anomaly_type(&relational_circular),
AnomalyCategory::CircularFlow
);
}
#[test]
fn test_anomaly_category_ordinal() {
assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
}
#[test]
fn test_contributing_factor() {
let factor = ContributingFactor::new(
FactorType::AmountDeviation,
15000.0,
10000.0,
true,
0.5,
"Amount exceeds threshold",
);
assert_eq!(factor.factor_type, FactorType::AmountDeviation);
assert_eq!(factor.value, 15000.0);
assert_eq!(factor.threshold, 10000.0);
assert!(factor.direction_greater);
let contribution = factor.contribution();
assert!((contribution - 0.25).abs() < 0.01);
}
#[test]
fn test_contributing_factor_with_evidence() {
let mut data = HashMap::new();
data.insert("expected".to_string(), "10000".to_string());
data.insert("actual".to_string(), "15000".to_string());
let factor = ContributingFactor::new(
FactorType::AmountDeviation,
15000.0,
10000.0,
true,
0.5,
"Amount deviation detected",
)
.with_evidence("transaction_history", data);
assert!(factor.evidence.is_some());
let evidence = factor.evidence.unwrap();
assert_eq!(evidence.source, "transaction_history");
assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
}
#[test]
fn test_enhanced_anomaly_label() {
let base = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::DuplicatePayment),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
let enhanced = EnhancedAnomalyLabel::from_base(base)
.with_confidence(0.85)
.with_severity(0.7)
.with_factor(ContributingFactor::new(
FactorType::DuplicateIndicator,
1.0,
0.5,
true,
0.4,
"Duplicate payment detected",
))
.with_secondary_category(AnomalyCategory::StructuredTransaction);
assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
assert_eq!(enhanced.enhanced_confidence, 0.85);
assert_eq!(enhanced.enhanced_severity, 0.7);
assert_eq!(enhanced.contributing_factors.len(), 1);
assert_eq!(enhanced.secondary_categories.len(), 1);
}
#[test]
fn test_enhanced_anomaly_label_features() {
let base = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
let enhanced = EnhancedAnomalyLabel::from_base(base)
.with_confidence(0.9)
.with_severity(0.8)
.with_factor(ContributingFactor::new(
FactorType::ControlBypass,
1.0,
0.0,
true,
0.5,
"Control bypass detected",
));
let features = enhanced.to_features();
assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
assert_eq!(features.len(), 25);
assert_eq!(features[15], 0.9);
assert_eq!(features[21], 1.0); }
#[test]
fn test_enhanced_anomaly_label_feature_names() {
let names = EnhancedAnomalyLabel::feature_names();
assert_eq!(names.len(), 25);
assert!(names.contains(&"enhanced_confidence"));
assert!(names.contains(&"enhanced_severity"));
assert!(names.contains(&"has_control_bypass"));
}
#[test]
fn test_factor_type_names() {
assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
}
#[test]
fn test_anomaly_category_serialization() {
let category = AnomalyCategory::CircularFlow;
let json = serde_json::to_string(&category).unwrap();
let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
assert_eq!(category, deserialized);
let custom = AnomalyCategory::Custom("custom_type".to_string());
let json = serde_json::to_string(&custom).unwrap();
let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
assert_eq!(custom, deserialized);
}
#[test]
fn test_enhanced_label_secondary_category_dedup() {
let base = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::DuplicatePayment),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
let enhanced = EnhancedAnomalyLabel::from_base(base)
.with_secondary_category(AnomalyCategory::DuplicatePayment)
.with_secondary_category(AnomalyCategory::TimingAnomaly)
.with_secondary_category(AnomalyCategory::TimingAnomaly);
assert_eq!(enhanced.secondary_categories.len(), 1);
assert_eq!(
enhanced.secondary_categories[0],
AnomalyCategory::TimingAnomaly
);
}
#[test]
fn test_revenue_recognition_fraud_types() {
let fraud_types = [
FraudType::ImproperRevenueRecognition,
FraudType::ImproperPoAllocation,
FraudType::VariableConsiderationManipulation,
FraudType::ContractModificationMisstatement,
];
for fraud_type in fraud_types {
let anomaly_type = AnomalyType::Fraud(fraud_type);
assert_eq!(anomaly_type.category(), "Fraud");
assert!(anomaly_type.is_intentional());
assert!(anomaly_type.severity() >= 3);
}
}
#[test]
fn test_lease_accounting_fraud_types() {
let fraud_types = [
FraudType::LeaseClassificationManipulation,
FraudType::OffBalanceSheetLease,
FraudType::LeaseLiabilityUnderstatement,
FraudType::RouAssetMisstatement,
];
for fraud_type in fraud_types {
let anomaly_type = AnomalyType::Fraud(fraud_type);
assert_eq!(anomaly_type.category(), "Fraud");
assert!(anomaly_type.is_intentional());
assert!(anomaly_type.severity() >= 3);
}
assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
}
#[test]
fn test_fair_value_fraud_types() {
let fraud_types = [
FraudType::FairValueHierarchyManipulation,
FraudType::Level3InputManipulation,
FraudType::ValuationTechniqueManipulation,
];
for fraud_type in fraud_types {
let anomaly_type = AnomalyType::Fraud(fraud_type);
assert_eq!(anomaly_type.category(), "Fraud");
assert!(anomaly_type.is_intentional());
assert!(anomaly_type.severity() >= 4);
}
assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
}
#[test]
fn test_impairment_fraud_types() {
let fraud_types = [
FraudType::DelayedImpairment,
FraudType::ImpairmentTestAvoidance,
FraudType::CashFlowProjectionManipulation,
FraudType::ImproperImpairmentReversal,
];
for fraud_type in fraud_types {
let anomaly_type = AnomalyType::Fraud(fraud_type);
assert_eq!(anomaly_type.category(), "Fraud");
assert!(anomaly_type.is_intentional());
assert!(anomaly_type.severity() >= 3);
}
assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
}
#[test]
fn test_standards_error_types() {
let error_types = [
ErrorType::RevenueTimingError,
ErrorType::PoAllocationError,
ErrorType::LeaseClassificationError,
ErrorType::LeaseCalculationError,
ErrorType::FairValueError,
ErrorType::ImpairmentCalculationError,
ErrorType::DiscountRateError,
ErrorType::FrameworkApplicationError,
];
for error_type in error_types {
let anomaly_type = AnomalyType::Error(error_type);
assert_eq!(anomaly_type.category(), "Error");
assert!(!anomaly_type.is_intentional());
assert!(anomaly_type.severity() >= 3);
}
}
#[test]
fn test_framework_application_error() {
let error_type = ErrorType::FrameworkApplicationError;
assert_eq!(error_type.severity(), 4);
let anomaly = LabeledAnomaly::new(
"ERR001".to_string(),
AnomalyType::Error(error_type),
"JE100".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
)
.with_description("LIFO inventory method used under IFRS (not permitted)")
.with_metadata("framework", "IFRS")
.with_metadata("standard_violated", "IAS 2");
assert_eq!(anomaly.anomaly_type.category(), "Error");
assert_eq!(
anomaly.metadata.get("standard_violated"),
Some(&"IAS 2".to_string())
);
}
#[test]
fn test_standards_anomaly_serialization() {
let fraud_types = [
FraudType::ImproperRevenueRecognition,
FraudType::LeaseClassificationManipulation,
FraudType::FairValueHierarchyManipulation,
FraudType::DelayedImpairment,
];
for fraud_type in fraud_types {
let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
let deserialized: FraudType =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(fraud_type, deserialized);
}
let error_types = [
ErrorType::RevenueTimingError,
ErrorType::LeaseCalculationError,
ErrorType::FairValueError,
ErrorType::FrameworkApplicationError,
];
for error_type in error_types {
let json = serde_json::to_string(&error_type).expect("Failed to serialize");
let deserialized: ErrorType =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(error_type, deserialized);
}
}
#[test]
fn test_standards_labeled_anomaly() {
let anomaly = LabeledAnomaly::new(
"STD001".to_string(),
AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
"CONTRACT-2024-001".to_string(),
"Revenue".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
)
.with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
.with_monetary_impact(dec!(500000))
.with_metadata("standard", "ASC 606")
.with_metadata("paragraph", "606-10-25-1")
.with_metadata("contract_id", "C-2024-001")
.with_related_entity("CONTRACT-2024-001")
.with_related_entity("CUSTOMER-500");
assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
assert_eq!(anomaly.related_entities.len(), 2);
assert_eq!(
anomaly.metadata.get("standard"),
Some(&"ASC 606".to_string())
);
}
#[test]
fn test_severity_level() {
assert_eq!(SeverityLevel::Low.numeric(), 1);
assert_eq!(SeverityLevel::Critical.numeric(), 4);
assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
}
#[test]
fn test_anomaly_severity() {
let severity =
AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
assert_eq!(severity.level, SeverityLevel::High);
assert!(severity.is_material);
assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
let low_severity =
AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
assert!(!low_severity.is_material);
}
#[test]
fn test_detection_difficulty() {
assert!(
(AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
);
assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
assert_eq!(
AnomalyDetectionDifficulty::from_score(0.05),
AnomalyDetectionDifficulty::Trivial
);
assert_eq!(
AnomalyDetectionDifficulty::from_score(0.90),
AnomalyDetectionDifficulty::Expert
);
assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
}
#[test]
fn test_ground_truth_certainty() {
assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
}
#[test]
fn test_detection_method() {
assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
}
#[test]
fn test_extended_anomaly_label() {
let base = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::FictitiousVendor),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
)
.with_monetary_impact(dec!(100000));
let extended = ExtendedAnomalyLabel::from_base(base)
.with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
.with_difficulty(AnomalyDetectionDifficulty::Hard)
.with_method(DetectionMethod::GraphBased)
.with_method(DetectionMethod::ForensicAudit)
.with_indicator("New vendor with no history")
.with_indicator("Large first transaction")
.with_certainty(GroundTruthCertainty::Definite)
.with_entity("V001")
.with_secondary_category(AnomalyCategory::BehavioralAnomaly)
.with_scheme("SCHEME001", 2);
assert_eq!(extended.severity.level, SeverityLevel::Critical);
assert_eq!(
extended.detection_difficulty,
AnomalyDetectionDifficulty::Hard
);
assert_eq!(extended.recommended_methods.len(), 3);
assert_eq!(extended.key_indicators.len(), 2);
assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
assert_eq!(extended.scheme_stage, Some(2));
}
#[test]
fn test_extended_anomaly_label_features() {
let base = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Fraud(FraudType::SelfApproval),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
);
let extended =
ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
let features = extended.to_features();
assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
assert_eq!(features.len(), 30);
let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
}
#[test]
fn test_extended_label_near_miss() {
let base = LabeledAnomaly::new(
"ANO001".to_string(),
AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
"JE001".to_string(),
"JE".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
);
let extended = ExtendedAnomalyLabel::from_base(base)
.as_near_miss("Year-end bonus payment, legitimately high");
assert!(extended.is_near_miss);
assert!(extended.near_miss_explanation.is_some());
}
#[test]
fn test_scheme_type() {
assert_eq!(
SchemeType::GradualEmbezzlement.name(),
"gradual_embezzlement"
);
assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
}
#[test]
fn test_concealment_technique() {
assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
assert!(
ConcealmentTechnique::Collusion.difficulty_bonus()
> ConcealmentTechnique::TimingExploitation.difficulty_bonus()
);
}
#[test]
fn test_near_miss_label() {
let near_miss = NearMissLabel::new(
"JE001",
NearMissPattern::ThresholdProximity {
threshold: dec!(10000),
proximity: 0.95,
},
0.7,
FalsePositiveTrigger::AmountNearThreshold,
"Transaction is 95% of threshold but business justified",
);
assert_eq!(near_miss.document_id, "JE001");
assert_eq!(near_miss.suspicion_score, 0.7);
assert_eq!(
near_miss.false_positive_trigger,
FalsePositiveTrigger::AmountNearThreshold
);
}
#[test]
fn test_legitimate_pattern_type() {
assert_eq!(
LegitimatePatternType::YearEndBonus.description(),
"Year-end bonus payment"
);
assert_eq!(
LegitimatePatternType::InsuranceClaim.description(),
"Insurance claim reimbursement"
);
}
#[test]
fn test_severity_detection_difficulty_serialization() {
let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
let json = serde_json::to_string(&severity).expect("Failed to serialize");
let deserialized: AnomalySeverity =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(severity.level, deserialized.level);
let difficulty = AnomalyDetectionDifficulty::Hard;
let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
let deserialized: AnomalyDetectionDifficulty =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(difficulty, deserialized);
}
#[test]
fn test_acfe_fraud_category() {
let asset = AcfeFraudCategory::AssetMisappropriation;
assert_eq!(asset.name(), "asset_misappropriation");
assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
assert_eq!(asset.typical_detection_months(), 12);
let corruption = AcfeFraudCategory::Corruption;
assert_eq!(corruption.name(), "corruption");
assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
assert_eq!(fs_fraud.typical_detection_months(), 24);
}
#[test]
fn test_cash_fraud_scheme() {
let shell = CashFraudScheme::ShellCompany;
assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
assert_eq!(shell.subcategory(), "billing_schemes");
assert_eq!(shell.severity(), 5);
assert_eq!(
shell.detection_difficulty(),
AnomalyDetectionDifficulty::Hard
);
let ghost = CashFraudScheme::GhostEmployee;
assert_eq!(ghost.subcategory(), "payroll_schemes");
assert_eq!(ghost.severity(), 5);
assert_eq!(CashFraudScheme::all_variants().len(), 20);
}
#[test]
fn test_asset_fraud_scheme() {
let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
assert_eq!(
ip_theft.category(),
AcfeFraudCategory::AssetMisappropriation
);
assert_eq!(ip_theft.subcategory(), "other_assets");
assert_eq!(ip_theft.severity(), 5);
let inv_theft = AssetFraudScheme::InventoryTheft;
assert_eq!(inv_theft.subcategory(), "inventory");
assert_eq!(inv_theft.severity(), 4);
}
#[test]
fn test_corruption_scheme() {
let kickback = CorruptionScheme::InvoiceKickback;
assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
assert_eq!(kickback.subcategory(), "bribery");
assert_eq!(kickback.severity(), 5);
assert_eq!(
kickback.detection_difficulty(),
AnomalyDetectionDifficulty::Expert
);
let bid_rigging = CorruptionScheme::BidRigging;
assert_eq!(bid_rigging.subcategory(), "bribery");
assert_eq!(
bid_rigging.detection_difficulty(),
AnomalyDetectionDifficulty::Hard
);
let purchasing = CorruptionScheme::PurchasingConflict;
assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
assert_eq!(CorruptionScheme::all_variants().len(), 10);
}
#[test]
fn test_financial_statement_scheme() {
let fictitious = FinancialStatementScheme::FictitiousRevenues;
assert_eq!(
fictitious.category(),
AcfeFraudCategory::FinancialStatementFraud
);
assert_eq!(fictitious.subcategory(), "overstatement");
assert_eq!(fictitious.severity(), 5);
assert_eq!(
fictitious.detection_difficulty(),
AnomalyDetectionDifficulty::Expert
);
let understated = FinancialStatementScheme::UnderstatedRevenues;
assert_eq!(understated.subcategory(), "understatement");
assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
}
#[test]
fn test_acfe_scheme_unified() {
let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
assert_eq!(
cash_scheme.category(),
AcfeFraudCategory::AssetMisappropriation
);
assert_eq!(cash_scheme.severity(), 5);
let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
assert_eq!(
fs_scheme.category(),
AcfeFraudCategory::FinancialStatementFraud
);
}
#[test]
fn test_acfe_detection_method() {
let tip = AcfeDetectionMethod::Tip;
assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
let internal_audit = AcfeDetectionMethod::InternalAudit;
assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
let external_audit = AcfeDetectionMethod::ExternalAudit;
assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
}
#[test]
fn test_perpetrator_department() {
let accounting = PerpetratorDepartment::Accounting;
assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
let executive = PerpetratorDepartment::Executive;
assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
}
#[test]
fn test_perpetrator_level() {
let employee = PerpetratorLevel::Employee;
assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
let exec = PerpetratorLevel::OwnerExecutive;
assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
}
#[test]
fn test_acfe_calibration() {
let cal = AcfeCalibration::default();
assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
assert_eq!(cal.median_duration_months, 12);
assert!((cal.collusion_rate - 0.50).abs() < 0.01);
assert!(cal.validate().is_ok());
let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
assert_eq!(custom_cal.median_duration_months, 18);
let bad_cal = AcfeCalibration {
collusion_rate: 1.5,
..Default::default()
};
assert!(bad_cal.validate().is_err());
}
#[test]
fn test_fraud_triangle() {
let triangle = FraudTriangle::new(
PressureType::FinancialTargets,
vec![
OpportunityFactor::WeakInternalControls,
OpportunityFactor::ManagementOverride,
],
Rationalization::ForTheCompanyGood,
);
let risk = triangle.risk_score();
assert!((0.0..=1.0).contains(&risk));
assert!(risk > 0.5);
}
#[test]
fn test_pressure_types() {
let financial = PressureType::FinancialTargets;
assert!(financial.risk_weight() > 0.5);
let gambling = PressureType::GamblingAddiction;
assert_eq!(gambling.risk_weight(), 0.90);
}
#[test]
fn test_opportunity_factors() {
let override_factor = OpportunityFactor::ManagementOverride;
assert_eq!(override_factor.risk_weight(), 0.90);
let weak_controls = OpportunityFactor::WeakInternalControls;
assert!(weak_controls.risk_weight() > 0.8);
}
#[test]
fn test_rationalizations() {
let entitlement = Rationalization::Entitlement;
assert!(entitlement.risk_weight() > 0.8);
let borrowing = Rationalization::TemporaryBorrowing;
assert!(borrowing.risk_weight() < entitlement.risk_weight());
}
#[test]
fn test_acfe_scheme_serialization() {
let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
let json = serde_json::to_string(&scheme).expect("Failed to serialize");
let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(scheme, deserialized);
let calibration = AcfeCalibration::default();
let json = serde_json::to_string(&calibration).expect("Failed to serialize");
let deserialized: AcfeCalibration =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(calibration.median_loss, deserialized.median_loss);
}
}