1use chrono::{NaiveDate, NaiveDateTime};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum AnomalyCausalReason {
20 RandomRate {
22 base_rate: f64,
24 },
25 TemporalPattern {
27 pattern_name: String,
29 },
30 EntityTargeting {
32 target_type: String,
34 target_id: String,
36 },
37 ClusterMembership {
39 cluster_id: String,
41 },
42 ScenarioStep {
44 scenario_type: String,
46 step_number: u32,
48 },
49 DataQualityProfile {
51 profile: String,
53 },
54 MLTrainingBalance {
56 target_class: String,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub enum InjectionStrategy {
67 AmountManipulation {
69 original: Decimal,
71 factor: f64,
73 },
74 ThresholdAvoidance {
76 threshold: Decimal,
78 adjusted_amount: Decimal,
80 },
81 DateShift {
83 days_shifted: i32,
85 original_date: NaiveDate,
87 },
88 SelfApproval {
90 user_id: String,
92 },
93 SoDViolation {
95 duty1: String,
97 duty2: String,
99 violating_user: String,
101 },
102 ExactDuplicate {
104 original_doc_id: String,
106 },
107 NearDuplicate {
109 original_doc_id: String,
111 varied_fields: Vec<String>,
113 },
114 CircularFlow {
116 entity_chain: Vec<String>,
118 },
119 SplitTransaction {
121 original_amount: Decimal,
123 split_count: u32,
125 split_doc_ids: Vec<String>,
127 },
128 RoundNumbering {
130 original_amount: Decimal,
132 rounded_amount: Decimal,
134 },
135 TimingManipulation {
137 timing_type: String,
139 original_time: Option<NaiveDateTime>,
141 },
142 AccountMisclassification {
144 correct_account: String,
146 incorrect_account: String,
148 },
149 MissingField {
151 field_name: String,
153 },
154 Custom {
156 name: String,
158 parameters: HashMap<String, String>,
160 },
161}
162
163impl InjectionStrategy {
164 pub fn description(&self) -> String {
166 match self {
167 InjectionStrategy::AmountManipulation { factor, .. } => {
168 format!("Amount multiplied by {:.2}", factor)
169 }
170 InjectionStrategy::ThresholdAvoidance { threshold, .. } => {
171 format!("Amount adjusted to avoid {} threshold", threshold)
172 }
173 InjectionStrategy::DateShift { days_shifted, .. } => {
174 if *days_shifted < 0 {
175 format!("Date backdated by {} days", days_shifted.abs())
176 } else {
177 format!("Date forward-dated by {} days", days_shifted)
178 }
179 }
180 InjectionStrategy::SelfApproval { user_id } => {
181 format!("Self-approval by user {}", user_id)
182 }
183 InjectionStrategy::SoDViolation { duty1, duty2, .. } => {
184 format!("SoD violation: {} and {}", duty1, duty2)
185 }
186 InjectionStrategy::ExactDuplicate { original_doc_id } => {
187 format!("Exact duplicate of {}", original_doc_id)
188 }
189 InjectionStrategy::NearDuplicate {
190 original_doc_id,
191 varied_fields,
192 } => {
193 format!(
194 "Near-duplicate of {} (varied: {:?})",
195 original_doc_id, varied_fields
196 )
197 }
198 InjectionStrategy::CircularFlow { entity_chain } => {
199 format!("Circular flow through {} entities", entity_chain.len())
200 }
201 InjectionStrategy::SplitTransaction { split_count, .. } => {
202 format!("Split into {} transactions", split_count)
203 }
204 InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
205 InjectionStrategy::TimingManipulation { timing_type, .. } => {
206 format!("Timing manipulation: {}", timing_type)
207 }
208 InjectionStrategy::AccountMisclassification {
209 correct_account,
210 incorrect_account,
211 } => {
212 format!(
213 "Misclassified from {} to {}",
214 correct_account, incorrect_account
215 )
216 }
217 InjectionStrategy::MissingField { field_name } => {
218 format!("Missing required field: {}", field_name)
219 }
220 InjectionStrategy::Custom { name, .. } => format!("Custom: {}", name),
221 }
222 }
223
224 pub fn strategy_type(&self) -> &'static str {
226 match self {
227 InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
228 InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
229 InjectionStrategy::DateShift { .. } => "DateShift",
230 InjectionStrategy::SelfApproval { .. } => "SelfApproval",
231 InjectionStrategy::SoDViolation { .. } => "SoDViolation",
232 InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
233 InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
234 InjectionStrategy::CircularFlow { .. } => "CircularFlow",
235 InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
236 InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
237 InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
238 InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
239 InjectionStrategy::MissingField { .. } => "MissingField",
240 InjectionStrategy::Custom { .. } => "Custom",
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
247pub enum AnomalyType {
248 Fraud(FraudType),
250 Error(ErrorType),
252 ProcessIssue(ProcessIssueType),
254 Statistical(StatisticalAnomalyType),
256 Relational(RelationalAnomalyType),
258 Custom(String),
260}
261
262impl AnomalyType {
263 pub fn category(&self) -> &'static str {
265 match self {
266 AnomalyType::Fraud(_) => "Fraud",
267 AnomalyType::Error(_) => "Error",
268 AnomalyType::ProcessIssue(_) => "ProcessIssue",
269 AnomalyType::Statistical(_) => "Statistical",
270 AnomalyType::Relational(_) => "Relational",
271 AnomalyType::Custom(_) => "Custom",
272 }
273 }
274
275 pub fn type_name(&self) -> String {
277 match self {
278 AnomalyType::Fraud(t) => format!("{:?}", t),
279 AnomalyType::Error(t) => format!("{:?}", t),
280 AnomalyType::ProcessIssue(t) => format!("{:?}", t),
281 AnomalyType::Statistical(t) => format!("{:?}", t),
282 AnomalyType::Relational(t) => format!("{:?}", t),
283 AnomalyType::Custom(s) => s.clone(),
284 }
285 }
286
287 pub fn severity(&self) -> u8 {
289 match self {
290 AnomalyType::Fraud(t) => t.severity(),
291 AnomalyType::Error(t) => t.severity(),
292 AnomalyType::ProcessIssue(t) => t.severity(),
293 AnomalyType::Statistical(t) => t.severity(),
294 AnomalyType::Relational(t) => t.severity(),
295 AnomalyType::Custom(_) => 3,
296 }
297 }
298
299 pub fn is_intentional(&self) -> bool {
301 matches!(self, AnomalyType::Fraud(_))
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
307pub enum FraudType {
308 FictitiousEntry,
311 FictitiousTransaction,
313 RoundDollarManipulation,
315 JustBelowThreshold,
317 RevenueManipulation,
319 ImproperCapitalization,
321 ExpenseCapitalization,
323 ReserveManipulation,
325 SuspenseAccountAbuse,
327 SplitTransaction,
329 TimingAnomaly,
331 UnauthorizedAccess,
333
334 SelfApproval,
337 ExceededApprovalLimit,
339 SegregationOfDutiesViolation,
341 UnauthorizedApproval,
343 CollusiveApproval,
345
346 FictitiousVendor,
349 DuplicatePayment,
351 ShellCompanyPayment,
353 Kickback,
355 KickbackScheme,
357 InvoiceManipulation,
359
360 AssetMisappropriation,
363 InventoryTheft,
365 GhostEmployee,
367
368 PrematureRevenue,
371 UnderstatedLiabilities,
373 OverstatedAssets,
375 ChannelStuffing,
377
378 ImproperRevenueRecognition,
381 ImproperPoAllocation,
383 VariableConsiderationManipulation,
385 ContractModificationMisstatement,
387
388 LeaseClassificationManipulation,
391 OffBalanceSheetLease,
393 LeaseLiabilityUnderstatement,
395 RouAssetMisstatement,
397
398 FairValueHierarchyManipulation,
401 Level3InputManipulation,
403 ValuationTechniqueManipulation,
405
406 DelayedImpairment,
409 ImpairmentTestAvoidance,
411 CashFlowProjectionManipulation,
413 ImproperImpairmentReversal,
415
416 BidRigging,
419 PhantomVendorContract,
421 SplitContractThreshold,
423 ConflictOfInterestSourcing,
425
426 GhostEmployeePayroll,
429 PayrollInflation,
431 DuplicateExpenseReport,
433 FictitiousExpense,
435 SplitExpenseToAvoidApproval,
437
438 RevenueTimingManipulation,
441 QuotePriceOverride,
443}
444
445impl FraudType {
446 pub fn severity(&self) -> u8 {
448 match self {
449 FraudType::RoundDollarManipulation => 2,
450 FraudType::JustBelowThreshold => 3,
451 FraudType::SelfApproval => 3,
452 FraudType::ExceededApprovalLimit => 3,
453 FraudType::DuplicatePayment => 3,
454 FraudType::FictitiousEntry => 4,
455 FraudType::RevenueManipulation => 5,
456 FraudType::FictitiousVendor => 5,
457 FraudType::ShellCompanyPayment => 5,
458 FraudType::AssetMisappropriation => 5,
459 FraudType::SegregationOfDutiesViolation => 4,
460 FraudType::CollusiveApproval => 5,
461 FraudType::ImproperRevenueRecognition => 5,
463 FraudType::ImproperPoAllocation => 4,
464 FraudType::VariableConsiderationManipulation => 4,
465 FraudType::ContractModificationMisstatement => 3,
466 FraudType::LeaseClassificationManipulation => 4,
468 FraudType::OffBalanceSheetLease => 5,
469 FraudType::LeaseLiabilityUnderstatement => 4,
470 FraudType::RouAssetMisstatement => 3,
471 FraudType::FairValueHierarchyManipulation => 4,
473 FraudType::Level3InputManipulation => 5,
474 FraudType::ValuationTechniqueManipulation => 4,
475 FraudType::DelayedImpairment => 4,
477 FraudType::ImpairmentTestAvoidance => 4,
478 FraudType::CashFlowProjectionManipulation => 5,
479 FraudType::ImproperImpairmentReversal => 3,
480 _ => 4,
481 }
482 }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
487pub enum ErrorType {
488 DuplicateEntry,
491 ReversedAmount,
493 TransposedDigits,
495 DecimalError,
497 MissingField,
499 InvalidAccount,
501
502 WrongPeriod,
505 BackdatedEntry,
507 FutureDatedEntry,
509 CutoffError,
511
512 MisclassifiedAccount,
515 WrongCostCenter,
517 WrongCompanyCode,
519
520 UnbalancedEntry,
523 RoundingError,
525 CurrencyError,
527 TaxCalculationError,
529
530 RevenueTimingError,
533 PoAllocationError,
535 LeaseClassificationError,
537 LeaseCalculationError,
539 FairValueError,
541 ImpairmentCalculationError,
543 DiscountRateError,
545 FrameworkApplicationError,
547}
548
549impl ErrorType {
550 pub fn severity(&self) -> u8 {
552 match self {
553 ErrorType::RoundingError => 1,
554 ErrorType::MissingField => 2,
555 ErrorType::TransposedDigits => 2,
556 ErrorType::DecimalError => 3,
557 ErrorType::DuplicateEntry => 3,
558 ErrorType::ReversedAmount => 3,
559 ErrorType::WrongPeriod => 4,
560 ErrorType::UnbalancedEntry => 5,
561 ErrorType::CurrencyError => 4,
562 ErrorType::RevenueTimingError => 4,
564 ErrorType::PoAllocationError => 3,
565 ErrorType::LeaseClassificationError => 3,
566 ErrorType::LeaseCalculationError => 3,
567 ErrorType::FairValueError => 4,
568 ErrorType::ImpairmentCalculationError => 4,
569 ErrorType::DiscountRateError => 3,
570 ErrorType::FrameworkApplicationError => 4,
571 _ => 3,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
578pub enum ProcessIssueType {
579 SkippedApproval,
582 LateApproval,
584 MissingDocumentation,
586 IncompleteApprovalChain,
588
589 LatePosting,
592 AfterHoursPosting,
594 WeekendPosting,
596 RushedPeriodEnd,
598
599 ManualOverride,
602 UnusualAccess,
604 SystemBypass,
606 BatchAnomaly,
608
609 VagueDescription,
612 PostFactoChange,
614 IncompleteAuditTrail,
616
617 MaverickSpend,
620 ExpiredContractPurchase,
622 ContractPriceOverride,
624 SingleBidAward,
626 QualificationBypass,
628
629 ExpiredQuoteConversion,
632}
633
634impl ProcessIssueType {
635 pub fn severity(&self) -> u8 {
637 match self {
638 ProcessIssueType::VagueDescription => 1,
639 ProcessIssueType::LatePosting => 2,
640 ProcessIssueType::AfterHoursPosting => 2,
641 ProcessIssueType::WeekendPosting => 2,
642 ProcessIssueType::SkippedApproval => 4,
643 ProcessIssueType::ManualOverride => 4,
644 ProcessIssueType::SystemBypass => 5,
645 ProcessIssueType::IncompleteAuditTrail => 4,
646 _ => 3,
647 }
648 }
649}
650
651#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
653pub enum StatisticalAnomalyType {
654 UnusuallyHighAmount,
657 UnusuallyLowAmount,
659 BenfordViolation,
661 ExactDuplicateAmount,
663 RepeatingAmount,
665
666 UnusualFrequency,
669 TransactionBurst,
671 UnusualTiming,
673
674 TrendBreak,
677 LevelShift,
679 SeasonalAnomaly,
681
682 StatisticalOutlier,
685 VarianceChange,
687 DistributionShift,
689
690 SlaBreachPattern,
693 UnusedContract,
695
696 OvertimeAnomaly,
699}
700
701impl StatisticalAnomalyType {
702 pub fn severity(&self) -> u8 {
704 match self {
705 StatisticalAnomalyType::UnusualTiming => 1,
706 StatisticalAnomalyType::UnusualFrequency => 2,
707 StatisticalAnomalyType::BenfordViolation => 2,
708 StatisticalAnomalyType::UnusuallyHighAmount => 3,
709 StatisticalAnomalyType::TrendBreak => 3,
710 StatisticalAnomalyType::TransactionBurst => 4,
711 StatisticalAnomalyType::ExactDuplicateAmount => 3,
712 _ => 3,
713 }
714 }
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
719pub enum RelationalAnomalyType {
720 CircularTransaction,
723 UnusualAccountPair,
725 NewCounterparty,
727 DormantAccountActivity,
729
730 CentralityAnomaly,
733 IsolatedCluster,
735 BridgeNodeAnomaly,
737 CommunityAnomaly,
739
740 MissingRelationship,
743 UnexpectedRelationship,
745 RelationshipStrengthChange,
747
748 UnmatchedIntercompany,
751 CircularIntercompany,
753 TransferPricingAnomaly,
755}
756
757impl RelationalAnomalyType {
758 pub fn severity(&self) -> u8 {
760 match self {
761 RelationalAnomalyType::NewCounterparty => 1,
762 RelationalAnomalyType::DormantAccountActivity => 2,
763 RelationalAnomalyType::UnusualAccountPair => 2,
764 RelationalAnomalyType::CircularTransaction => 4,
765 RelationalAnomalyType::CircularIntercompany => 4,
766 RelationalAnomalyType::TransferPricingAnomaly => 4,
767 RelationalAnomalyType::UnmatchedIntercompany => 3,
768 _ => 3,
769 }
770 }
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct LabeledAnomaly {
776 pub anomaly_id: String,
778 pub anomaly_type: AnomalyType,
780 pub document_id: String,
782 pub document_type: String,
784 pub company_code: String,
786 pub anomaly_date: NaiveDate,
788 pub detection_timestamp: NaiveDateTime,
790 pub confidence: f64,
792 pub severity: u8,
794 pub description: String,
796 pub related_entities: Vec<String>,
798 pub monetary_impact: Option<Decimal>,
800 pub metadata: HashMap<String, String>,
802 pub is_injected: bool,
804 pub injection_strategy: Option<String>,
806 pub cluster_id: Option<String>,
808
809 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub original_document_hash: Option<String>,
816
817 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub causal_reason: Option<AnomalyCausalReason>,
821
822 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub structured_strategy: Option<InjectionStrategy>,
826
827 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub parent_anomaly_id: Option<String>,
831
832 #[serde(default, skip_serializing_if = "Vec::is_empty")]
834 pub child_anomaly_ids: Vec<String>,
835
836 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub scenario_id: Option<String>,
839
840 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub run_id: Option<String>,
844
845 #[serde(default, skip_serializing_if = "Option::is_none")]
848 pub generation_seed: Option<u64>,
849}
850
851impl LabeledAnomaly {
852 pub fn new(
854 anomaly_id: String,
855 anomaly_type: AnomalyType,
856 document_id: String,
857 document_type: String,
858 company_code: String,
859 anomaly_date: NaiveDate,
860 ) -> Self {
861 let severity = anomaly_type.severity();
862 let description = format!(
863 "{} - {} in document {}",
864 anomaly_type.category(),
865 anomaly_type.type_name(),
866 document_id
867 );
868
869 Self {
870 anomaly_id,
871 anomaly_type,
872 document_id,
873 document_type,
874 company_code,
875 anomaly_date,
876 detection_timestamp: chrono::Local::now().naive_local(),
877 confidence: 1.0,
878 severity,
879 description,
880 related_entities: Vec::new(),
881 monetary_impact: None,
882 metadata: HashMap::new(),
883 is_injected: true,
884 injection_strategy: None,
885 cluster_id: None,
886 original_document_hash: None,
888 causal_reason: None,
889 structured_strategy: None,
890 parent_anomaly_id: None,
891 child_anomaly_ids: Vec::new(),
892 scenario_id: None,
893 run_id: None,
894 generation_seed: None,
895 }
896 }
897
898 pub fn with_description(mut self, description: &str) -> Self {
900 self.description = description.to_string();
901 self
902 }
903
904 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
906 self.monetary_impact = Some(impact);
907 self
908 }
909
910 pub fn with_related_entity(mut self, entity: &str) -> Self {
912 self.related_entities.push(entity.to_string());
913 self
914 }
915
916 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
918 self.metadata.insert(key.to_string(), value.to_string());
919 self
920 }
921
922 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
924 self.injection_strategy = Some(strategy.to_string());
925 self
926 }
927
928 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
930 self.cluster_id = Some(cluster_id.to_string());
931 self
932 }
933
934 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
940 self.original_document_hash = Some(hash.to_string());
941 self
942 }
943
944 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
946 self.causal_reason = Some(reason);
947 self
948 }
949
950 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
952 self.injection_strategy = Some(strategy.strategy_type().to_string());
954 self.structured_strategy = Some(strategy);
955 self
956 }
957
958 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
960 self.parent_anomaly_id = Some(parent_id.to_string());
961 self
962 }
963
964 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
966 self.child_anomaly_ids.push(child_id.to_string());
967 self
968 }
969
970 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
972 self.scenario_id = Some(scenario_id.to_string());
973 self
974 }
975
976 pub fn with_run_id(mut self, run_id: &str) -> Self {
978 self.run_id = Some(run_id.to_string());
979 self
980 }
981
982 pub fn with_generation_seed(mut self, seed: u64) -> Self {
984 self.generation_seed = Some(seed);
985 self
986 }
987
988 pub fn with_provenance(
990 mut self,
991 run_id: Option<&str>,
992 seed: Option<u64>,
993 causal_reason: Option<AnomalyCausalReason>,
994 ) -> Self {
995 if let Some(id) = run_id {
996 self.run_id = Some(id.to_string());
997 }
998 self.generation_seed = seed;
999 self.causal_reason = causal_reason;
1000 self
1001 }
1002
1003 pub fn to_features(&self) -> Vec<f64> {
1017 let mut features = Vec::new();
1018
1019 let categories = [
1021 "Fraud",
1022 "Error",
1023 "ProcessIssue",
1024 "Statistical",
1025 "Relational",
1026 "Custom",
1027 ];
1028 for cat in &categories {
1029 features.push(if self.anomaly_type.category() == *cat {
1030 1.0
1031 } else {
1032 0.0
1033 });
1034 }
1035
1036 features.push(self.severity as f64 / 5.0);
1038
1039 features.push(self.confidence);
1041
1042 features.push(if self.monetary_impact.is_some() {
1044 1.0
1045 } else {
1046 0.0
1047 });
1048
1049 if let Some(impact) = self.monetary_impact {
1051 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
1052 features.push((impact_f64.abs() + 1.0).ln());
1053 } else {
1054 features.push(0.0);
1055 }
1056
1057 features.push(if self.anomaly_type.is_intentional() {
1059 1.0
1060 } else {
1061 0.0
1062 });
1063
1064 features.push(self.related_entities.len() as f64);
1066
1067 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1069
1070 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1073
1074 features.push(if self.parent_anomaly_id.is_some() {
1076 1.0
1077 } else {
1078 0.0
1079 });
1080
1081 features
1082 }
1083
1084 pub fn feature_count() -> usize {
1086 15 }
1088
1089 pub fn feature_names() -> Vec<&'static str> {
1091 vec![
1092 "category_fraud",
1093 "category_error",
1094 "category_process_issue",
1095 "category_statistical",
1096 "category_relational",
1097 "category_custom",
1098 "severity_normalized",
1099 "confidence",
1100 "has_monetary_impact",
1101 "monetary_impact_log",
1102 "is_intentional",
1103 "related_entity_count",
1104 "is_clustered",
1105 "is_scenario_part",
1106 "is_derived",
1107 ]
1108 }
1109}
1110
1111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1113pub struct AnomalySummary {
1114 pub total_count: usize,
1116 pub by_category: HashMap<String, usize>,
1118 pub by_type: HashMap<String, usize>,
1120 pub by_severity: HashMap<u8, usize>,
1122 pub by_company: HashMap<String, usize>,
1124 pub total_monetary_impact: Decimal,
1126 pub date_range: Option<(NaiveDate, NaiveDate)>,
1128 pub cluster_count: usize,
1130}
1131
1132impl AnomalySummary {
1133 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1135 let mut summary = AnomalySummary {
1136 total_count: anomalies.len(),
1137 ..Default::default()
1138 };
1139
1140 let mut min_date: Option<NaiveDate> = None;
1141 let mut max_date: Option<NaiveDate> = None;
1142 let mut clusters = std::collections::HashSet::new();
1143
1144 for anomaly in anomalies {
1145 *summary
1147 .by_category
1148 .entry(anomaly.anomaly_type.category().to_string())
1149 .or_insert(0) += 1;
1150
1151 *summary
1153 .by_type
1154 .entry(anomaly.anomaly_type.type_name())
1155 .or_insert(0) += 1;
1156
1157 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1159
1160 *summary
1162 .by_company
1163 .entry(anomaly.company_code.clone())
1164 .or_insert(0) += 1;
1165
1166 if let Some(impact) = anomaly.monetary_impact {
1168 summary.total_monetary_impact += impact;
1169 }
1170
1171 match min_date {
1173 None => min_date = Some(anomaly.anomaly_date),
1174 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1175 _ => {}
1176 }
1177 match max_date {
1178 None => max_date = Some(anomaly.anomaly_date),
1179 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1180 _ => {}
1181 }
1182
1183 if let Some(cluster_id) = &anomaly.cluster_id {
1185 clusters.insert(cluster_id.clone());
1186 }
1187 }
1188
1189 summary.date_range = min_date.zip(max_date);
1190 summary.cluster_count = clusters.len();
1191
1192 summary
1193 }
1194}
1195
1196#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1205pub enum AnomalyCategory {
1206 FictitiousVendor,
1209 VendorKickback,
1211 RelatedPartyVendor,
1213
1214 DuplicatePayment,
1217 UnauthorizedTransaction,
1219 StructuredTransaction,
1221
1222 CircularFlow,
1225 BehavioralAnomaly,
1227 TimingAnomaly,
1229
1230 JournalAnomaly,
1233 ManualOverride,
1235 MissingApproval,
1237
1238 StatisticalOutlier,
1241 DistributionAnomaly,
1243
1244 Custom(String),
1247}
1248
1249impl AnomalyCategory {
1250 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1252 match anomaly_type {
1253 AnomalyType::Fraud(fraud_type) => match fraud_type {
1254 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1255 AnomalyCategory::FictitiousVendor
1256 }
1257 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1258 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1259 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1260 AnomalyCategory::StructuredTransaction
1261 }
1262 FraudType::SelfApproval
1263 | FraudType::UnauthorizedApproval
1264 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1265 FraudType::TimingAnomaly
1266 | FraudType::RoundDollarManipulation
1267 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1268 _ => AnomalyCategory::BehavioralAnomaly,
1269 },
1270 AnomalyType::Error(error_type) => match error_type {
1271 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1272 ErrorType::WrongPeriod
1273 | ErrorType::BackdatedEntry
1274 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1275 _ => AnomalyCategory::JournalAnomaly,
1276 },
1277 AnomalyType::ProcessIssue(process_type) => match process_type {
1278 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1279 AnomalyCategory::MissingApproval
1280 }
1281 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1282 AnomalyCategory::ManualOverride
1283 }
1284 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1285 AnomalyCategory::TimingAnomaly
1286 }
1287 _ => AnomalyCategory::BehavioralAnomaly,
1288 },
1289 AnomalyType::Statistical(stat_type) => match stat_type {
1290 StatisticalAnomalyType::BenfordViolation
1291 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1292 _ => AnomalyCategory::StatisticalOutlier,
1293 },
1294 AnomalyType::Relational(rel_type) => match rel_type {
1295 RelationalAnomalyType::CircularTransaction
1296 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1297 _ => AnomalyCategory::BehavioralAnomaly,
1298 },
1299 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1300 }
1301 }
1302
1303 pub fn name(&self) -> &str {
1305 match self {
1306 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1307 AnomalyCategory::VendorKickback => "vendor_kickback",
1308 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1309 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1310 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1311 AnomalyCategory::StructuredTransaction => "structured_transaction",
1312 AnomalyCategory::CircularFlow => "circular_flow",
1313 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1314 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1315 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1316 AnomalyCategory::ManualOverride => "manual_override",
1317 AnomalyCategory::MissingApproval => "missing_approval",
1318 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1319 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1320 AnomalyCategory::Custom(s) => s.as_str(),
1321 }
1322 }
1323
1324 pub fn ordinal(&self) -> u8 {
1326 match self {
1327 AnomalyCategory::FictitiousVendor => 0,
1328 AnomalyCategory::VendorKickback => 1,
1329 AnomalyCategory::RelatedPartyVendor => 2,
1330 AnomalyCategory::DuplicatePayment => 3,
1331 AnomalyCategory::UnauthorizedTransaction => 4,
1332 AnomalyCategory::StructuredTransaction => 5,
1333 AnomalyCategory::CircularFlow => 6,
1334 AnomalyCategory::BehavioralAnomaly => 7,
1335 AnomalyCategory::TimingAnomaly => 8,
1336 AnomalyCategory::JournalAnomaly => 9,
1337 AnomalyCategory::ManualOverride => 10,
1338 AnomalyCategory::MissingApproval => 11,
1339 AnomalyCategory::StatisticalOutlier => 12,
1340 AnomalyCategory::DistributionAnomaly => 13,
1341 AnomalyCategory::Custom(_) => 14,
1342 }
1343 }
1344
1345 pub fn category_count() -> usize {
1347 15 }
1349}
1350
1351#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1353pub enum FactorType {
1354 AmountDeviation,
1356 ThresholdProximity,
1358 TimingAnomaly,
1360 EntityRisk,
1362 PatternMatch,
1364 FrequencyDeviation,
1366 RelationshipAnomaly,
1368 ControlBypass,
1370 BenfordViolation,
1372 DuplicateIndicator,
1374 ApprovalChainIssue,
1376 DocumentationGap,
1378 Custom,
1380}
1381
1382impl FactorType {
1383 pub fn name(&self) -> &'static str {
1385 match self {
1386 FactorType::AmountDeviation => "amount_deviation",
1387 FactorType::ThresholdProximity => "threshold_proximity",
1388 FactorType::TimingAnomaly => "timing_anomaly",
1389 FactorType::EntityRisk => "entity_risk",
1390 FactorType::PatternMatch => "pattern_match",
1391 FactorType::FrequencyDeviation => "frequency_deviation",
1392 FactorType::RelationshipAnomaly => "relationship_anomaly",
1393 FactorType::ControlBypass => "control_bypass",
1394 FactorType::BenfordViolation => "benford_violation",
1395 FactorType::DuplicateIndicator => "duplicate_indicator",
1396 FactorType::ApprovalChainIssue => "approval_chain_issue",
1397 FactorType::DocumentationGap => "documentation_gap",
1398 FactorType::Custom => "custom",
1399 }
1400 }
1401}
1402
1403#[derive(Debug, Clone, Serialize, Deserialize)]
1405pub struct FactorEvidence {
1406 pub source: String,
1408 pub data: HashMap<String, String>,
1410}
1411
1412#[derive(Debug, Clone, Serialize, Deserialize)]
1414pub struct ContributingFactor {
1415 pub factor_type: FactorType,
1417 pub value: f64,
1419 pub threshold: f64,
1421 pub direction_greater: bool,
1423 pub weight: f64,
1425 pub description: String,
1427 pub evidence: Option<FactorEvidence>,
1429}
1430
1431impl ContributingFactor {
1432 pub fn new(
1434 factor_type: FactorType,
1435 value: f64,
1436 threshold: f64,
1437 direction_greater: bool,
1438 weight: f64,
1439 description: &str,
1440 ) -> Self {
1441 Self {
1442 factor_type,
1443 value,
1444 threshold,
1445 direction_greater,
1446 weight,
1447 description: description.to_string(),
1448 evidence: None,
1449 }
1450 }
1451
1452 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1454 self.evidence = Some(FactorEvidence {
1455 source: source.to_string(),
1456 data,
1457 });
1458 self
1459 }
1460
1461 pub fn contribution(&self) -> f64 {
1463 let deviation = if self.direction_greater {
1464 (self.value - self.threshold).max(0.0)
1465 } else {
1466 (self.threshold - self.value).max(0.0)
1467 };
1468
1469 let relative_deviation = if self.threshold.abs() > 0.001 {
1471 deviation / self.threshold.abs()
1472 } else {
1473 deviation
1474 };
1475
1476 (relative_deviation * self.weight).min(1.0)
1478 }
1479}
1480
1481#[derive(Debug, Clone, Serialize, Deserialize)]
1483pub struct EnhancedAnomalyLabel {
1484 pub base: LabeledAnomaly,
1486 pub category: AnomalyCategory,
1488 pub enhanced_confidence: f64,
1490 pub enhanced_severity: f64,
1492 pub contributing_factors: Vec<ContributingFactor>,
1494 pub secondary_categories: Vec<AnomalyCategory>,
1496}
1497
1498impl EnhancedAnomalyLabel {
1499 pub fn from_base(base: LabeledAnomaly) -> Self {
1501 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1502 let enhanced_confidence = base.confidence;
1503 let enhanced_severity = base.severity as f64 / 5.0;
1504
1505 Self {
1506 base,
1507 category,
1508 enhanced_confidence,
1509 enhanced_severity,
1510 contributing_factors: Vec::new(),
1511 secondary_categories: Vec::new(),
1512 }
1513 }
1514
1515 pub fn with_confidence(mut self, confidence: f64) -> Self {
1517 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1518 self
1519 }
1520
1521 pub fn with_severity(mut self, severity: f64) -> Self {
1523 self.enhanced_severity = severity.clamp(0.0, 1.0);
1524 self
1525 }
1526
1527 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1529 self.contributing_factors.push(factor);
1530 self
1531 }
1532
1533 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1535 if !self.secondary_categories.contains(&category) && category != self.category {
1536 self.secondary_categories.push(category);
1537 }
1538 self
1539 }
1540
1541 pub fn to_features(&self) -> Vec<f64> {
1545 let mut features = self.base.to_features();
1546
1547 features.push(self.enhanced_confidence);
1549 features.push(self.enhanced_severity);
1550 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1551 features.push(self.secondary_categories.len() as f64);
1552 features.push(self.contributing_factors.len() as f64);
1553
1554 let max_weight = self
1556 .contributing_factors
1557 .iter()
1558 .map(|f| f.weight)
1559 .fold(0.0, f64::max);
1560 features.push(max_weight);
1561
1562 let has_control_bypass = self
1564 .contributing_factors
1565 .iter()
1566 .any(|f| f.factor_type == FactorType::ControlBypass);
1567 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1568
1569 let has_amount_deviation = self
1570 .contributing_factors
1571 .iter()
1572 .any(|f| f.factor_type == FactorType::AmountDeviation);
1573 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1574
1575 let has_timing = self
1576 .contributing_factors
1577 .iter()
1578 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1579 features.push(if has_timing { 1.0 } else { 0.0 });
1580
1581 let has_pattern_match = self
1582 .contributing_factors
1583 .iter()
1584 .any(|f| f.factor_type == FactorType::PatternMatch);
1585 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1586
1587 features
1588 }
1589
1590 pub fn feature_count() -> usize {
1592 25 }
1594
1595 pub fn feature_names() -> Vec<&'static str> {
1597 let mut names = LabeledAnomaly::feature_names();
1598 names.extend(vec![
1599 "enhanced_confidence",
1600 "enhanced_severity",
1601 "category_ordinal",
1602 "secondary_category_count",
1603 "contributing_factor_count",
1604 "max_factor_weight",
1605 "has_control_bypass",
1606 "has_amount_deviation",
1607 "has_timing_factor",
1608 "has_pattern_match",
1609 ]);
1610 names
1611 }
1612}
1613
1614#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1620pub enum SeverityLevel {
1621 Low,
1623 #[default]
1625 Medium,
1626 High,
1628 Critical,
1630}
1631
1632impl SeverityLevel {
1633 pub fn numeric(&self) -> u8 {
1635 match self {
1636 SeverityLevel::Low => 1,
1637 SeverityLevel::Medium => 2,
1638 SeverityLevel::High => 3,
1639 SeverityLevel::Critical => 4,
1640 }
1641 }
1642
1643 pub fn from_numeric(value: u8) -> Self {
1645 match value {
1646 1 => SeverityLevel::Low,
1647 2 => SeverityLevel::Medium,
1648 3 => SeverityLevel::High,
1649 _ => SeverityLevel::Critical,
1650 }
1651 }
1652
1653 pub fn from_score(score: f64) -> Self {
1655 match score {
1656 s if s < 0.25 => SeverityLevel::Low,
1657 s if s < 0.50 => SeverityLevel::Medium,
1658 s if s < 0.75 => SeverityLevel::High,
1659 _ => SeverityLevel::Critical,
1660 }
1661 }
1662
1663 pub fn to_score(&self) -> f64 {
1665 match self {
1666 SeverityLevel::Low => 0.125,
1667 SeverityLevel::Medium => 0.375,
1668 SeverityLevel::High => 0.625,
1669 SeverityLevel::Critical => 0.875,
1670 }
1671 }
1672}
1673
1674#[derive(Debug, Clone, Serialize, Deserialize)]
1676pub struct AnomalySeverity {
1677 pub level: SeverityLevel,
1679 pub score: f64,
1681 pub financial_impact: Decimal,
1683 pub is_material: bool,
1685 #[serde(default, skip_serializing_if = "Option::is_none")]
1687 pub materiality_threshold: Option<Decimal>,
1688}
1689
1690impl AnomalySeverity {
1691 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1693 Self {
1694 level,
1695 score: level.to_score(),
1696 financial_impact,
1697 is_material: false,
1698 materiality_threshold: None,
1699 }
1700 }
1701
1702 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1704 Self {
1705 level: SeverityLevel::from_score(score),
1706 score: score.clamp(0.0, 1.0),
1707 financial_impact,
1708 is_material: false,
1709 materiality_threshold: None,
1710 }
1711 }
1712
1713 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1715 self.materiality_threshold = Some(threshold);
1716 self.is_material = self.financial_impact.abs() >= threshold;
1717 self
1718 }
1719}
1720
1721impl Default for AnomalySeverity {
1722 fn default() -> Self {
1723 Self {
1724 level: SeverityLevel::Medium,
1725 score: 0.5,
1726 financial_impact: Decimal::ZERO,
1727 is_material: false,
1728 materiality_threshold: None,
1729 }
1730 }
1731}
1732
1733#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1741pub enum AnomalyDetectionDifficulty {
1742 Trivial,
1744 Easy,
1746 #[default]
1748 Moderate,
1749 Hard,
1751 Expert,
1753}
1754
1755impl AnomalyDetectionDifficulty {
1756 pub fn expected_detection_rate(&self) -> f64 {
1758 match self {
1759 AnomalyDetectionDifficulty::Trivial => 0.99,
1760 AnomalyDetectionDifficulty::Easy => 0.90,
1761 AnomalyDetectionDifficulty::Moderate => 0.70,
1762 AnomalyDetectionDifficulty::Hard => 0.40,
1763 AnomalyDetectionDifficulty::Expert => 0.15,
1764 }
1765 }
1766
1767 pub fn difficulty_score(&self) -> f64 {
1769 match self {
1770 AnomalyDetectionDifficulty::Trivial => 0.05,
1771 AnomalyDetectionDifficulty::Easy => 0.25,
1772 AnomalyDetectionDifficulty::Moderate => 0.50,
1773 AnomalyDetectionDifficulty::Hard => 0.75,
1774 AnomalyDetectionDifficulty::Expert => 0.95,
1775 }
1776 }
1777
1778 pub fn from_score(score: f64) -> Self {
1780 match score {
1781 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1782 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1783 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1784 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1785 _ => AnomalyDetectionDifficulty::Expert,
1786 }
1787 }
1788
1789 pub fn name(&self) -> &'static str {
1791 match self {
1792 AnomalyDetectionDifficulty::Trivial => "trivial",
1793 AnomalyDetectionDifficulty::Easy => "easy",
1794 AnomalyDetectionDifficulty::Moderate => "moderate",
1795 AnomalyDetectionDifficulty::Hard => "hard",
1796 AnomalyDetectionDifficulty::Expert => "expert",
1797 }
1798 }
1799}
1800
1801#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1805pub enum GroundTruthCertainty {
1806 #[default]
1808 Definite,
1809 Probable,
1811 Possible,
1813}
1814
1815impl GroundTruthCertainty {
1816 pub fn certainty_score(&self) -> f64 {
1818 match self {
1819 GroundTruthCertainty::Definite => 1.0,
1820 GroundTruthCertainty::Probable => 0.8,
1821 GroundTruthCertainty::Possible => 0.5,
1822 }
1823 }
1824
1825 pub fn name(&self) -> &'static str {
1827 match self {
1828 GroundTruthCertainty::Definite => "definite",
1829 GroundTruthCertainty::Probable => "probable",
1830 GroundTruthCertainty::Possible => "possible",
1831 }
1832 }
1833}
1834
1835#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1839pub enum DetectionMethod {
1840 RuleBased,
1842 Statistical,
1844 MachineLearning,
1846 GraphBased,
1848 ForensicAudit,
1850 Hybrid,
1852}
1853
1854impl DetectionMethod {
1855 pub fn name(&self) -> &'static str {
1857 match self {
1858 DetectionMethod::RuleBased => "rule_based",
1859 DetectionMethod::Statistical => "statistical",
1860 DetectionMethod::MachineLearning => "machine_learning",
1861 DetectionMethod::GraphBased => "graph_based",
1862 DetectionMethod::ForensicAudit => "forensic_audit",
1863 DetectionMethod::Hybrid => "hybrid",
1864 }
1865 }
1866
1867 pub fn description(&self) -> &'static str {
1869 match self {
1870 DetectionMethod::RuleBased => "Simple threshold and filter rules",
1871 DetectionMethod::Statistical => "Statistical distribution analysis",
1872 DetectionMethod::MachineLearning => "ML classification models",
1873 DetectionMethod::GraphBased => "Network and relationship analysis",
1874 DetectionMethod::ForensicAudit => "Manual forensic procedures",
1875 DetectionMethod::Hybrid => "Combined multi-method approach",
1876 }
1877 }
1878}
1879
1880#[derive(Debug, Clone, Serialize, Deserialize)]
1885pub struct ExtendedAnomalyLabel {
1886 pub base: LabeledAnomaly,
1888 pub category: AnomalyCategory,
1890 pub severity: AnomalySeverity,
1892 pub detection_difficulty: AnomalyDetectionDifficulty,
1894 pub recommended_methods: Vec<DetectionMethod>,
1896 pub key_indicators: Vec<String>,
1898 pub ground_truth_certainty: GroundTruthCertainty,
1900 pub contributing_factors: Vec<ContributingFactor>,
1902 pub related_entity_ids: Vec<String>,
1904 pub secondary_categories: Vec<AnomalyCategory>,
1906 #[serde(default, skip_serializing_if = "Option::is_none")]
1908 pub scheme_id: Option<String>,
1909 #[serde(default, skip_serializing_if = "Option::is_none")]
1911 pub scheme_stage: Option<u32>,
1912 #[serde(default)]
1914 pub is_near_miss: bool,
1915 #[serde(default, skip_serializing_if = "Option::is_none")]
1917 pub near_miss_explanation: Option<String>,
1918}
1919
1920impl ExtendedAnomalyLabel {
1921 pub fn from_base(base: LabeledAnomaly) -> Self {
1923 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1924 let severity = AnomalySeverity {
1925 level: SeverityLevel::from_numeric(base.severity),
1926 score: base.severity as f64 / 5.0,
1927 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
1928 is_material: false,
1929 materiality_threshold: None,
1930 };
1931
1932 Self {
1933 base,
1934 category,
1935 severity,
1936 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
1937 recommended_methods: vec![DetectionMethod::RuleBased],
1938 key_indicators: Vec::new(),
1939 ground_truth_certainty: GroundTruthCertainty::Definite,
1940 contributing_factors: Vec::new(),
1941 related_entity_ids: Vec::new(),
1942 secondary_categories: Vec::new(),
1943 scheme_id: None,
1944 scheme_stage: None,
1945 is_near_miss: false,
1946 near_miss_explanation: None,
1947 }
1948 }
1949
1950 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
1952 self.severity = severity;
1953 self
1954 }
1955
1956 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
1958 self.detection_difficulty = difficulty;
1959 self
1960 }
1961
1962 pub fn with_method(mut self, method: DetectionMethod) -> Self {
1964 if !self.recommended_methods.contains(&method) {
1965 self.recommended_methods.push(method);
1966 }
1967 self
1968 }
1969
1970 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
1972 self.recommended_methods = methods;
1973 self
1974 }
1975
1976 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
1978 self.key_indicators.push(indicator.into());
1979 self
1980 }
1981
1982 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
1984 self.ground_truth_certainty = certainty;
1985 self
1986 }
1987
1988 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1990 self.contributing_factors.push(factor);
1991 self
1992 }
1993
1994 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
1996 self.related_entity_ids.push(entity_id.into());
1997 self
1998 }
1999
2000 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
2002 if category != self.category && !self.secondary_categories.contains(&category) {
2003 self.secondary_categories.push(category);
2004 }
2005 self
2006 }
2007
2008 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
2010 self.scheme_id = Some(scheme_id.into());
2011 self.scheme_stage = Some(stage);
2012 self
2013 }
2014
2015 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
2017 self.is_near_miss = true;
2018 self.near_miss_explanation = Some(explanation.into());
2019 self
2020 }
2021
2022 pub fn to_features(&self) -> Vec<f64> {
2026 let mut features = self.base.to_features();
2027
2028 features.push(self.severity.score);
2030 features.push(self.severity.level.to_score());
2031 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
2032 features.push(self.detection_difficulty.difficulty_score());
2033 features.push(self.detection_difficulty.expected_detection_rate());
2034 features.push(self.ground_truth_certainty.certainty_score());
2035 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
2036 features.push(self.secondary_categories.len() as f64);
2037 features.push(self.contributing_factors.len() as f64);
2038 features.push(self.key_indicators.len() as f64);
2039 features.push(self.recommended_methods.len() as f64);
2040 features.push(self.related_entity_ids.len() as f64);
2041 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
2042 features.push(self.scheme_stage.unwrap_or(0) as f64);
2043 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
2044
2045 features
2046 }
2047
2048 pub fn feature_count() -> usize {
2050 30 }
2052
2053 pub fn feature_names() -> Vec<&'static str> {
2055 let mut names = LabeledAnomaly::feature_names();
2056 names.extend(vec![
2057 "severity_score",
2058 "severity_level_score",
2059 "is_material",
2060 "difficulty_score",
2061 "expected_detection_rate",
2062 "ground_truth_certainty",
2063 "category_ordinal",
2064 "secondary_category_count",
2065 "contributing_factor_count",
2066 "key_indicator_count",
2067 "recommended_method_count",
2068 "related_entity_count",
2069 "is_part_of_scheme",
2070 "scheme_stage",
2071 "is_near_miss",
2072 ]);
2073 names
2074 }
2075}
2076
2077#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2083pub enum SchemeType {
2084 GradualEmbezzlement,
2086 RevenueManipulation,
2088 VendorKickback,
2090 RoundTripping,
2092 GhostEmployee,
2094 ExpenseReimbursement,
2096 InventoryTheft,
2098 Custom,
2100}
2101
2102impl SchemeType {
2103 pub fn name(&self) -> &'static str {
2105 match self {
2106 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2107 SchemeType::RevenueManipulation => "revenue_manipulation",
2108 SchemeType::VendorKickback => "vendor_kickback",
2109 SchemeType::RoundTripping => "round_tripping",
2110 SchemeType::GhostEmployee => "ghost_employee",
2111 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2112 SchemeType::InventoryTheft => "inventory_theft",
2113 SchemeType::Custom => "custom",
2114 }
2115 }
2116
2117 pub fn typical_stages(&self) -> u32 {
2119 match self {
2120 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2128 }
2129 }
2130}
2131
2132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2134pub enum SchemeDetectionStatus {
2135 #[default]
2137 Undetected,
2138 UnderInvestigation,
2140 PartiallyDetected,
2142 FullyDetected,
2144}
2145
2146#[derive(Debug, Clone, Serialize, Deserialize)]
2148pub struct SchemeTransactionRef {
2149 pub document_id: String,
2151 pub date: chrono::NaiveDate,
2153 pub amount: Decimal,
2155 pub stage: u32,
2157 #[serde(default, skip_serializing_if = "Option::is_none")]
2159 pub anomaly_id: Option<String>,
2160}
2161
2162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2164pub enum ConcealmentTechnique {
2165 DocumentManipulation,
2167 ApprovalCircumvention,
2169 TimingExploitation,
2171 TransactionSplitting,
2173 AccountMisclassification,
2175 Collusion,
2177 DataAlteration,
2179 FalseDocumentation,
2181}
2182
2183impl ConcealmentTechnique {
2184 pub fn difficulty_bonus(&self) -> f64 {
2186 match self {
2187 ConcealmentTechnique::DocumentManipulation => 0.20,
2188 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2189 ConcealmentTechnique::TimingExploitation => 0.10,
2190 ConcealmentTechnique::TransactionSplitting => 0.15,
2191 ConcealmentTechnique::AccountMisclassification => 0.10,
2192 ConcealmentTechnique::Collusion => 0.25,
2193 ConcealmentTechnique::DataAlteration => 0.20,
2194 ConcealmentTechnique::FalseDocumentation => 0.15,
2195 }
2196 }
2197}
2198
2199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2216pub enum AcfeFraudCategory {
2217 #[default]
2220 AssetMisappropriation,
2221 Corruption,
2224 FinancialStatementFraud,
2227}
2228
2229impl AcfeFraudCategory {
2230 pub fn name(&self) -> &'static str {
2232 match self {
2233 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2234 AcfeFraudCategory::Corruption => "corruption",
2235 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2236 }
2237 }
2238
2239 pub fn typical_occurrence_rate(&self) -> f64 {
2241 match self {
2242 AcfeFraudCategory::AssetMisappropriation => 0.86,
2243 AcfeFraudCategory::Corruption => 0.33,
2244 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2245 }
2246 }
2247
2248 pub fn typical_median_loss(&self) -> Decimal {
2250 match self {
2251 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2252 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2253 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2254 }
2255 }
2256
2257 pub fn typical_detection_months(&self) -> u32 {
2259 match self {
2260 AcfeFraudCategory::AssetMisappropriation => 12,
2261 AcfeFraudCategory::Corruption => 18,
2262 AcfeFraudCategory::FinancialStatementFraud => 24,
2263 }
2264 }
2265}
2266
2267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2274pub enum CashFraudScheme {
2275 Larceny,
2278 Skimming,
2280
2281 SalesSkimming,
2284 ReceivablesSkimming,
2286 RefundSchemes,
2288
2289 ShellCompany,
2292 NonAccompliceVendor,
2294 PersonalPurchases,
2296
2297 GhostEmployee,
2300 FalsifiedWages,
2302 CommissionSchemes,
2304
2305 MischaracterizedExpenses,
2308 OverstatedExpenses,
2310 FictitiousExpenses,
2312
2313 ForgedMaker,
2316 ForgedEndorsement,
2318 AlteredPayee,
2320 AuthorizedMaker,
2322
2323 FalseVoids,
2326 FalseRefunds,
2328}
2329
2330impl CashFraudScheme {
2331 pub fn category(&self) -> AcfeFraudCategory {
2333 AcfeFraudCategory::AssetMisappropriation
2334 }
2335
2336 pub fn subcategory(&self) -> &'static str {
2338 match self {
2339 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2340 CashFraudScheme::SalesSkimming
2341 | CashFraudScheme::ReceivablesSkimming
2342 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2343 CashFraudScheme::ShellCompany
2344 | CashFraudScheme::NonAccompliceVendor
2345 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2346 CashFraudScheme::GhostEmployee
2347 | CashFraudScheme::FalsifiedWages
2348 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2349 CashFraudScheme::MischaracterizedExpenses
2350 | CashFraudScheme::OverstatedExpenses
2351 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2352 CashFraudScheme::ForgedMaker
2353 | CashFraudScheme::ForgedEndorsement
2354 | CashFraudScheme::AlteredPayee
2355 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2356 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2357 }
2358 }
2359
2360 pub fn severity(&self) -> u8 {
2362 match self {
2363 CashFraudScheme::FalseVoids
2365 | CashFraudScheme::FalseRefunds
2366 | CashFraudScheme::MischaracterizedExpenses => 3,
2367 CashFraudScheme::OverstatedExpenses
2369 | CashFraudScheme::Skimming
2370 | CashFraudScheme::Larceny
2371 | CashFraudScheme::PersonalPurchases
2372 | CashFraudScheme::FalsifiedWages => 4,
2373 CashFraudScheme::ShellCompany
2375 | CashFraudScheme::GhostEmployee
2376 | CashFraudScheme::FictitiousExpenses
2377 | CashFraudScheme::ForgedMaker
2378 | CashFraudScheme::AuthorizedMaker => 5,
2379 _ => 4,
2380 }
2381 }
2382
2383 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2385 match self {
2386 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2388 AnomalyDetectionDifficulty::Easy
2389 }
2390 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2392 AnomalyDetectionDifficulty::Moderate
2393 }
2394 CashFraudScheme::Skimming
2396 | CashFraudScheme::ShellCompany
2397 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2398 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2400 AnomalyDetectionDifficulty::Expert
2401 }
2402 _ => AnomalyDetectionDifficulty::Moderate,
2403 }
2404 }
2405
2406 pub fn all_variants() -> &'static [CashFraudScheme] {
2408 &[
2409 CashFraudScheme::Larceny,
2410 CashFraudScheme::Skimming,
2411 CashFraudScheme::SalesSkimming,
2412 CashFraudScheme::ReceivablesSkimming,
2413 CashFraudScheme::RefundSchemes,
2414 CashFraudScheme::ShellCompany,
2415 CashFraudScheme::NonAccompliceVendor,
2416 CashFraudScheme::PersonalPurchases,
2417 CashFraudScheme::GhostEmployee,
2418 CashFraudScheme::FalsifiedWages,
2419 CashFraudScheme::CommissionSchemes,
2420 CashFraudScheme::MischaracterizedExpenses,
2421 CashFraudScheme::OverstatedExpenses,
2422 CashFraudScheme::FictitiousExpenses,
2423 CashFraudScheme::ForgedMaker,
2424 CashFraudScheme::ForgedEndorsement,
2425 CashFraudScheme::AlteredPayee,
2426 CashFraudScheme::AuthorizedMaker,
2427 CashFraudScheme::FalseVoids,
2428 CashFraudScheme::FalseRefunds,
2429 ]
2430 }
2431}
2432
2433#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2435pub enum AssetFraudScheme {
2436 InventoryMisuse,
2439 InventoryTheft,
2441 InventoryPurchasingScheme,
2443 InventoryReceivingScheme,
2445
2446 EquipmentMisuse,
2449 EquipmentTheft,
2451 IntellectualPropertyTheft,
2453 TimeTheft,
2455}
2456
2457impl AssetFraudScheme {
2458 pub fn category(&self) -> AcfeFraudCategory {
2460 AcfeFraudCategory::AssetMisappropriation
2461 }
2462
2463 pub fn subcategory(&self) -> &'static str {
2465 match self {
2466 AssetFraudScheme::InventoryMisuse
2467 | AssetFraudScheme::InventoryTheft
2468 | AssetFraudScheme::InventoryPurchasingScheme
2469 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2470 _ => "other_assets",
2471 }
2472 }
2473
2474 pub fn severity(&self) -> u8 {
2476 match self {
2477 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2478 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2479 AssetFraudScheme::InventoryTheft
2480 | AssetFraudScheme::InventoryPurchasingScheme
2481 | AssetFraudScheme::InventoryReceivingScheme => 4,
2482 AssetFraudScheme::IntellectualPropertyTheft => 5,
2483 }
2484 }
2485}
2486
2487#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2492pub enum CorruptionScheme {
2493 PurchasingConflict,
2496 SalesConflict,
2498 OutsideBusinessInterest,
2500 NepotismConflict,
2502
2503 InvoiceKickback,
2506 BidRigging,
2508 CashBribery,
2510 PublicOfficial,
2512
2513 IllegalGratuity,
2516
2517 EconomicExtortion,
2520}
2521
2522impl CorruptionScheme {
2523 pub fn category(&self) -> AcfeFraudCategory {
2525 AcfeFraudCategory::Corruption
2526 }
2527
2528 pub fn subcategory(&self) -> &'static str {
2530 match self {
2531 CorruptionScheme::PurchasingConflict
2532 | CorruptionScheme::SalesConflict
2533 | CorruptionScheme::OutsideBusinessInterest
2534 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2535 CorruptionScheme::InvoiceKickback
2536 | CorruptionScheme::BidRigging
2537 | CorruptionScheme::CashBribery
2538 | CorruptionScheme::PublicOfficial => "bribery",
2539 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2540 CorruptionScheme::EconomicExtortion => "economic_extortion",
2541 }
2542 }
2543
2544 pub fn severity(&self) -> u8 {
2546 match self {
2547 CorruptionScheme::NepotismConflict => 3,
2549 CorruptionScheme::PurchasingConflict
2551 | CorruptionScheme::SalesConflict
2552 | CorruptionScheme::OutsideBusinessInterest
2553 | CorruptionScheme::IllegalGratuity => 4,
2554 CorruptionScheme::InvoiceKickback
2556 | CorruptionScheme::BidRigging
2557 | CorruptionScheme::CashBribery
2558 | CorruptionScheme::EconomicExtortion => 5,
2559 CorruptionScheme::PublicOfficial => 5,
2561 }
2562 }
2563
2564 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2566 match self {
2567 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2569 AnomalyDetectionDifficulty::Moderate
2570 }
2571 CorruptionScheme::PurchasingConflict
2573 | CorruptionScheme::SalesConflict
2574 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2575 CorruptionScheme::InvoiceKickback
2577 | CorruptionScheme::CashBribery
2578 | CorruptionScheme::PublicOfficial
2579 | CorruptionScheme::IllegalGratuity
2580 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2581 }
2582 }
2583
2584 pub fn all_variants() -> &'static [CorruptionScheme] {
2586 &[
2587 CorruptionScheme::PurchasingConflict,
2588 CorruptionScheme::SalesConflict,
2589 CorruptionScheme::OutsideBusinessInterest,
2590 CorruptionScheme::NepotismConflict,
2591 CorruptionScheme::InvoiceKickback,
2592 CorruptionScheme::BidRigging,
2593 CorruptionScheme::CashBribery,
2594 CorruptionScheme::PublicOfficial,
2595 CorruptionScheme::IllegalGratuity,
2596 CorruptionScheme::EconomicExtortion,
2597 ]
2598 }
2599}
2600
2601#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2606pub enum FinancialStatementScheme {
2607 PrematureRevenue,
2610 DelayedExpenses,
2612 FictitiousRevenues,
2614 ConcealedLiabilities,
2616 ImproperAssetValuations,
2618 ImproperDisclosures,
2620 ChannelStuffing,
2622 BillAndHold,
2624 ImproperCapitalization,
2626
2627 UnderstatedRevenues,
2630 OverstatedExpenses,
2632 OverstatedLiabilities,
2634 ImproperAssetWritedowns,
2636}
2637
2638impl FinancialStatementScheme {
2639 pub fn category(&self) -> AcfeFraudCategory {
2641 AcfeFraudCategory::FinancialStatementFraud
2642 }
2643
2644 pub fn subcategory(&self) -> &'static str {
2646 match self {
2647 FinancialStatementScheme::UnderstatedRevenues
2648 | FinancialStatementScheme::OverstatedExpenses
2649 | FinancialStatementScheme::OverstatedLiabilities
2650 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2651 _ => "overstatement",
2652 }
2653 }
2654
2655 pub fn severity(&self) -> u8 {
2657 5
2659 }
2660
2661 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2663 match self {
2664 FinancialStatementScheme::ChannelStuffing
2666 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2667 FinancialStatementScheme::PrematureRevenue
2669 | FinancialStatementScheme::ImproperCapitalization
2670 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2671 FinancialStatementScheme::FictitiousRevenues
2673 | FinancialStatementScheme::ConcealedLiabilities
2674 | FinancialStatementScheme::ImproperAssetValuations
2675 | FinancialStatementScheme::ImproperDisclosures
2676 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2677 _ => AnomalyDetectionDifficulty::Hard,
2678 }
2679 }
2680
2681 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2683 &[
2684 FinancialStatementScheme::PrematureRevenue,
2685 FinancialStatementScheme::DelayedExpenses,
2686 FinancialStatementScheme::FictitiousRevenues,
2687 FinancialStatementScheme::ConcealedLiabilities,
2688 FinancialStatementScheme::ImproperAssetValuations,
2689 FinancialStatementScheme::ImproperDisclosures,
2690 FinancialStatementScheme::ChannelStuffing,
2691 FinancialStatementScheme::BillAndHold,
2692 FinancialStatementScheme::ImproperCapitalization,
2693 FinancialStatementScheme::UnderstatedRevenues,
2694 FinancialStatementScheme::OverstatedExpenses,
2695 FinancialStatementScheme::OverstatedLiabilities,
2696 FinancialStatementScheme::ImproperAssetWritedowns,
2697 ]
2698 }
2699}
2700
2701#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2703pub enum AcfeScheme {
2704 Cash(CashFraudScheme),
2706 Asset(AssetFraudScheme),
2708 Corruption(CorruptionScheme),
2710 FinancialStatement(FinancialStatementScheme),
2712}
2713
2714impl AcfeScheme {
2715 pub fn category(&self) -> AcfeFraudCategory {
2717 match self {
2718 AcfeScheme::Cash(s) => s.category(),
2719 AcfeScheme::Asset(s) => s.category(),
2720 AcfeScheme::Corruption(s) => s.category(),
2721 AcfeScheme::FinancialStatement(s) => s.category(),
2722 }
2723 }
2724
2725 pub fn severity(&self) -> u8 {
2727 match self {
2728 AcfeScheme::Cash(s) => s.severity(),
2729 AcfeScheme::Asset(s) => s.severity(),
2730 AcfeScheme::Corruption(s) => s.severity(),
2731 AcfeScheme::FinancialStatement(s) => s.severity(),
2732 }
2733 }
2734
2735 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2737 match self {
2738 AcfeScheme::Cash(s) => s.detection_difficulty(),
2739 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2740 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2741 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2742 }
2743 }
2744}
2745
2746#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2748pub enum AcfeDetectionMethod {
2749 Tip,
2751 InternalAudit,
2753 ManagementReview,
2755 ExternalAudit,
2757 AccountReconciliation,
2759 DocumentExamination,
2761 ByAccident,
2763 ItControls,
2765 Surveillance,
2767 Confession,
2769 LawEnforcement,
2771 Other,
2773}
2774
2775impl AcfeDetectionMethod {
2776 pub fn typical_detection_rate(&self) -> f64 {
2778 match self {
2779 AcfeDetectionMethod::Tip => 0.42,
2780 AcfeDetectionMethod::InternalAudit => 0.16,
2781 AcfeDetectionMethod::ManagementReview => 0.12,
2782 AcfeDetectionMethod::ExternalAudit => 0.04,
2783 AcfeDetectionMethod::AccountReconciliation => 0.05,
2784 AcfeDetectionMethod::DocumentExamination => 0.04,
2785 AcfeDetectionMethod::ByAccident => 0.06,
2786 AcfeDetectionMethod::ItControls => 0.03,
2787 AcfeDetectionMethod::Surveillance => 0.02,
2788 AcfeDetectionMethod::Confession => 0.02,
2789 AcfeDetectionMethod::LawEnforcement => 0.01,
2790 AcfeDetectionMethod::Other => 0.03,
2791 }
2792 }
2793
2794 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2796 &[
2797 AcfeDetectionMethod::Tip,
2798 AcfeDetectionMethod::InternalAudit,
2799 AcfeDetectionMethod::ManagementReview,
2800 AcfeDetectionMethod::ExternalAudit,
2801 AcfeDetectionMethod::AccountReconciliation,
2802 AcfeDetectionMethod::DocumentExamination,
2803 AcfeDetectionMethod::ByAccident,
2804 AcfeDetectionMethod::ItControls,
2805 AcfeDetectionMethod::Surveillance,
2806 AcfeDetectionMethod::Confession,
2807 AcfeDetectionMethod::LawEnforcement,
2808 AcfeDetectionMethod::Other,
2809 ]
2810 }
2811}
2812
2813#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2815pub enum PerpetratorDepartment {
2816 Accounting,
2818 Operations,
2820 Executive,
2822 Sales,
2824 CustomerService,
2826 Purchasing,
2828 It,
2830 HumanResources,
2832 Administrative,
2834 Warehouse,
2836 BoardOfDirectors,
2838 Other,
2840}
2841
2842impl PerpetratorDepartment {
2843 pub fn typical_occurrence_rate(&self) -> f64 {
2845 match self {
2846 PerpetratorDepartment::Accounting => 0.21,
2847 PerpetratorDepartment::Operations => 0.17,
2848 PerpetratorDepartment::Executive => 0.12,
2849 PerpetratorDepartment::Sales => 0.11,
2850 PerpetratorDepartment::CustomerService => 0.07,
2851 PerpetratorDepartment::Purchasing => 0.06,
2852 PerpetratorDepartment::It => 0.05,
2853 PerpetratorDepartment::HumanResources => 0.04,
2854 PerpetratorDepartment::Administrative => 0.04,
2855 PerpetratorDepartment::Warehouse => 0.03,
2856 PerpetratorDepartment::BoardOfDirectors => 0.02,
2857 PerpetratorDepartment::Other => 0.08,
2858 }
2859 }
2860
2861 pub fn typical_median_loss(&self) -> Decimal {
2863 match self {
2864 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
2865 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
2866 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
2867 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
2868 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
2869 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
2870 PerpetratorDepartment::It => Decimal::new(100_000, 0),
2871 _ => Decimal::new(80_000, 0),
2872 }
2873 }
2874}
2875
2876#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2878pub enum PerpetratorLevel {
2879 Employee,
2881 Manager,
2883 OwnerExecutive,
2885}
2886
2887impl PerpetratorLevel {
2888 pub fn typical_occurrence_rate(&self) -> f64 {
2890 match self {
2891 PerpetratorLevel::Employee => 0.42,
2892 PerpetratorLevel::Manager => 0.36,
2893 PerpetratorLevel::OwnerExecutive => 0.22,
2894 }
2895 }
2896
2897 pub fn typical_median_loss(&self) -> Decimal {
2899 match self {
2900 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
2901 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
2902 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
2903 }
2904 }
2905}
2906
2907#[derive(Debug, Clone, Serialize, Deserialize)]
2912pub struct AcfeCalibration {
2913 pub median_loss: Decimal,
2915 pub median_duration_months: u32,
2917 pub category_distribution: HashMap<String, f64>,
2919 pub detection_method_distribution: HashMap<String, f64>,
2921 pub department_distribution: HashMap<String, f64>,
2923 pub level_distribution: HashMap<String, f64>,
2925 pub avg_red_flags_per_case: f64,
2927 pub collusion_rate: f64,
2929}
2930
2931impl Default for AcfeCalibration {
2932 fn default() -> Self {
2933 let mut category_distribution = HashMap::new();
2934 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
2935 category_distribution.insert("corruption".to_string(), 0.33);
2936 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
2937
2938 let mut detection_method_distribution = HashMap::new();
2939 for method in AcfeDetectionMethod::all_variants() {
2940 detection_method_distribution.insert(
2941 format!("{:?}", method).to_lowercase(),
2942 method.typical_detection_rate(),
2943 );
2944 }
2945
2946 let mut department_distribution = HashMap::new();
2947 department_distribution.insert("accounting".to_string(), 0.21);
2948 department_distribution.insert("operations".to_string(), 0.17);
2949 department_distribution.insert("executive".to_string(), 0.12);
2950 department_distribution.insert("sales".to_string(), 0.11);
2951 department_distribution.insert("customer_service".to_string(), 0.07);
2952 department_distribution.insert("purchasing".to_string(), 0.06);
2953 department_distribution.insert("other".to_string(), 0.26);
2954
2955 let mut level_distribution = HashMap::new();
2956 level_distribution.insert("employee".to_string(), 0.42);
2957 level_distribution.insert("manager".to_string(), 0.36);
2958 level_distribution.insert("owner_executive".to_string(), 0.22);
2959
2960 Self {
2961 median_loss: Decimal::new(117_000, 0),
2962 median_duration_months: 12,
2963 category_distribution,
2964 detection_method_distribution,
2965 department_distribution,
2966 level_distribution,
2967 avg_red_flags_per_case: 2.8,
2968 collusion_rate: 0.50,
2969 }
2970 }
2971}
2972
2973impl AcfeCalibration {
2974 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
2976 Self {
2977 median_loss,
2978 median_duration_months,
2979 ..Self::default()
2980 }
2981 }
2982
2983 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
2985 category.typical_median_loss()
2986 }
2987
2988 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
2990 category.typical_detection_months()
2991 }
2992
2993 pub fn validate(&self) -> Result<(), String> {
2995 if self.median_loss <= Decimal::ZERO {
2996 return Err("Median loss must be positive".to_string());
2997 }
2998 if self.median_duration_months == 0 {
2999 return Err("Median duration must be at least 1 month".to_string());
3000 }
3001 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
3002 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
3003 }
3004 Ok(())
3005 }
3006}
3007
3008#[derive(Debug, Clone, Serialize, Deserialize)]
3013pub struct FraudTriangle {
3014 pub pressure: PressureType,
3016 pub opportunities: Vec<OpportunityFactor>,
3018 pub rationalization: Rationalization,
3020}
3021
3022impl FraudTriangle {
3023 pub fn new(
3025 pressure: PressureType,
3026 opportunities: Vec<OpportunityFactor>,
3027 rationalization: Rationalization,
3028 ) -> Self {
3029 Self {
3030 pressure,
3031 opportunities,
3032 rationalization,
3033 }
3034 }
3035
3036 pub fn risk_score(&self) -> f64 {
3038 let pressure_score = self.pressure.risk_weight();
3039 let opportunity_score: f64 = self
3040 .opportunities
3041 .iter()
3042 .map(|o| o.risk_weight())
3043 .sum::<f64>()
3044 / self.opportunities.len().max(1) as f64;
3045 let rationalization_score = self.rationalization.risk_weight();
3046
3047 (pressure_score + opportunity_score + rationalization_score) / 3.0
3048 }
3049}
3050
3051#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3053pub enum PressureType {
3054 PersonalFinancialDifficulties,
3057 FinancialTargets,
3059 MarketExpectations,
3061 CovenantCompliance,
3063 CreditRatingMaintenance,
3065 AcquisitionValuation,
3067
3068 JobSecurity,
3071 StatusMaintenance,
3073 GamblingAddiction,
3075 SubstanceAbuse,
3077 FamilyPressure,
3079 Greed,
3081}
3082
3083impl PressureType {
3084 pub fn risk_weight(&self) -> f64 {
3086 match self {
3087 PressureType::PersonalFinancialDifficulties => 0.80,
3088 PressureType::FinancialTargets => 0.75,
3089 PressureType::MarketExpectations => 0.70,
3090 PressureType::CovenantCompliance => 0.85,
3091 PressureType::CreditRatingMaintenance => 0.70,
3092 PressureType::AcquisitionValuation => 0.75,
3093 PressureType::JobSecurity => 0.65,
3094 PressureType::StatusMaintenance => 0.55,
3095 PressureType::GamblingAddiction => 0.90,
3096 PressureType::SubstanceAbuse => 0.85,
3097 PressureType::FamilyPressure => 0.60,
3098 PressureType::Greed => 0.70,
3099 }
3100 }
3101}
3102
3103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3105pub enum OpportunityFactor {
3106 WeakInternalControls,
3108 LackOfSegregation,
3110 ManagementOverride,
3112 ComplexTransactions,
3114 RelatedPartyTransactions,
3116 PoorToneAtTop,
3118 InadequateSupervision,
3120 AssetAccess,
3122 PoorRecordKeeping,
3124 LackOfDiscipline,
3126 LackOfIndependentChecks,
3128}
3129
3130impl OpportunityFactor {
3131 pub fn risk_weight(&self) -> f64 {
3133 match self {
3134 OpportunityFactor::WeakInternalControls => 0.85,
3135 OpportunityFactor::LackOfSegregation => 0.80,
3136 OpportunityFactor::ManagementOverride => 0.90,
3137 OpportunityFactor::ComplexTransactions => 0.70,
3138 OpportunityFactor::RelatedPartyTransactions => 0.75,
3139 OpportunityFactor::PoorToneAtTop => 0.85,
3140 OpportunityFactor::InadequateSupervision => 0.75,
3141 OpportunityFactor::AssetAccess => 0.70,
3142 OpportunityFactor::PoorRecordKeeping => 0.65,
3143 OpportunityFactor::LackOfDiscipline => 0.60,
3144 OpportunityFactor::LackOfIndependentChecks => 0.75,
3145 }
3146 }
3147}
3148
3149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3151pub enum Rationalization {
3152 TemporaryBorrowing,
3154 EveryoneDoesIt,
3156 ForTheCompanyGood,
3158 Entitlement,
3160 FollowingOrders,
3162 TheyWontMissIt,
3164 NeedItMore,
3166 NotReallyStealing,
3168 Underpaid,
3170 VictimlessCrime,
3172}
3173
3174impl Rationalization {
3175 pub fn risk_weight(&self) -> f64 {
3177 match self {
3178 Rationalization::Entitlement => 0.85,
3180 Rationalization::EveryoneDoesIt => 0.80,
3181 Rationalization::NotReallyStealing => 0.80,
3182 Rationalization::TheyWontMissIt => 0.75,
3183 Rationalization::Underpaid => 0.70,
3185 Rationalization::ForTheCompanyGood => 0.65,
3186 Rationalization::NeedItMore => 0.65,
3187 Rationalization::TemporaryBorrowing => 0.60,
3189 Rationalization::FollowingOrders => 0.55,
3190 Rationalization::VictimlessCrime => 0.60,
3191 }
3192 }
3193}
3194
3195#[derive(Debug, Clone, Serialize, Deserialize)]
3201pub enum NearMissPattern {
3202 NearDuplicate {
3204 date_difference_days: u32,
3206 similar_transaction_id: String,
3208 },
3209 ThresholdProximity {
3211 threshold: Decimal,
3213 proximity: f64,
3215 },
3216 UnusualLegitimate {
3218 pattern_type: LegitimatePatternType,
3220 justification: String,
3222 },
3223 CorrectedError {
3225 correction_lag_days: u32,
3227 correction_document_id: String,
3229 },
3230}
3231
3232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3234pub enum LegitimatePatternType {
3235 YearEndBonus,
3237 ContractPrepayment,
3239 SettlementPayment,
3241 InsuranceClaim,
3243 OneTimePayment,
3245 AssetDisposal,
3247 SeasonalInventory,
3249 PromotionalSpending,
3251}
3252
3253impl LegitimatePatternType {
3254 pub fn description(&self) -> &'static str {
3256 match self {
3257 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3258 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3259 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3260 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3261 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3262 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3263 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3264 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3265 }
3266 }
3267}
3268
3269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3271pub enum FalsePositiveTrigger {
3272 AmountNearThreshold,
3274 UnusualTiming,
3276 SimilarTransaction,
3278 NewCounterparty,
3280 UnusualAccountCombination,
3282 VolumeSpike,
3284 RoundAmount,
3286}
3287
3288#[derive(Debug, Clone, Serialize, Deserialize)]
3290pub struct NearMissLabel {
3291 pub document_id: String,
3293 pub pattern: NearMissPattern,
3295 pub suspicion_score: f64,
3297 pub false_positive_trigger: FalsePositiveTrigger,
3299 pub explanation: String,
3301}
3302
3303impl NearMissLabel {
3304 pub fn new(
3306 document_id: impl Into<String>,
3307 pattern: NearMissPattern,
3308 suspicion_score: f64,
3309 trigger: FalsePositiveTrigger,
3310 explanation: impl Into<String>,
3311 ) -> Self {
3312 Self {
3313 document_id: document_id.into(),
3314 pattern,
3315 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3316 false_positive_trigger: trigger,
3317 explanation: explanation.into(),
3318 }
3319 }
3320}
3321
3322#[derive(Debug, Clone, Serialize, Deserialize)]
3324pub struct AnomalyRateConfig {
3325 pub total_rate: f64,
3327 pub fraud_rate: f64,
3329 pub error_rate: f64,
3331 pub process_issue_rate: f64,
3333 pub statistical_rate: f64,
3335 pub relational_rate: f64,
3337}
3338
3339impl Default for AnomalyRateConfig {
3340 fn default() -> Self {
3341 Self {
3342 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, }
3349 }
3350}
3351
3352impl AnomalyRateConfig {
3353 pub fn validate(&self) -> Result<(), String> {
3355 let sum = self.fraud_rate
3356 + self.error_rate
3357 + self.process_issue_rate
3358 + self.statistical_rate
3359 + self.relational_rate;
3360
3361 if (sum - 1.0).abs() > 0.01 {
3362 return Err(format!(
3363 "Anomaly category rates must sum to 1.0, got {}",
3364 sum
3365 ));
3366 }
3367
3368 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3369 return Err(format!(
3370 "Total rate must be between 0.0 and 1.0, got {}",
3371 self.total_rate
3372 ));
3373 }
3374
3375 Ok(())
3376 }
3377}
3378
3379#[cfg(test)]
3380#[allow(clippy::unwrap_used)]
3381mod tests {
3382 use super::*;
3383 use rust_decimal_macros::dec;
3384
3385 #[test]
3386 fn test_anomaly_type_category() {
3387 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3388 assert_eq!(fraud.category(), "Fraud");
3389 assert!(fraud.is_intentional());
3390
3391 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3392 assert_eq!(error.category(), "Error");
3393 assert!(!error.is_intentional());
3394 }
3395
3396 #[test]
3397 fn test_labeled_anomaly() {
3398 let anomaly = LabeledAnomaly::new(
3399 "ANO001".to_string(),
3400 AnomalyType::Fraud(FraudType::SelfApproval),
3401 "JE001".to_string(),
3402 "JE".to_string(),
3403 "1000".to_string(),
3404 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3405 )
3406 .with_description("User approved their own expense report")
3407 .with_related_entity("USER001");
3408
3409 assert_eq!(anomaly.severity, 3);
3410 assert!(anomaly.is_injected);
3411 assert_eq!(anomaly.related_entities.len(), 1);
3412 }
3413
3414 #[test]
3415 fn test_labeled_anomaly_with_provenance() {
3416 let anomaly = LabeledAnomaly::new(
3417 "ANO001".to_string(),
3418 AnomalyType::Fraud(FraudType::SelfApproval),
3419 "JE001".to_string(),
3420 "JE".to_string(),
3421 "1000".to_string(),
3422 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3423 )
3424 .with_run_id("run-123")
3425 .with_generation_seed(42)
3426 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3427 .with_structured_strategy(InjectionStrategy::SelfApproval {
3428 user_id: "USER001".to_string(),
3429 })
3430 .with_scenario("scenario-001")
3431 .with_original_document_hash("abc123");
3432
3433 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3434 assert_eq!(anomaly.generation_seed, Some(42));
3435 assert!(anomaly.causal_reason.is_some());
3436 assert!(anomaly.structured_strategy.is_some());
3437 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3438 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3439
3440 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3442 }
3443
3444 #[test]
3445 fn test_labeled_anomaly_derivation_chain() {
3446 let parent = LabeledAnomaly::new(
3447 "ANO001".to_string(),
3448 AnomalyType::Fraud(FraudType::DuplicatePayment),
3449 "JE001".to_string(),
3450 "JE".to_string(),
3451 "1000".to_string(),
3452 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3453 );
3454
3455 let child = LabeledAnomaly::new(
3456 "ANO002".to_string(),
3457 AnomalyType::Error(ErrorType::DuplicateEntry),
3458 "JE002".to_string(),
3459 "JE".to_string(),
3460 "1000".to_string(),
3461 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3462 )
3463 .with_parent_anomaly(&parent.anomaly_id);
3464
3465 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3466 }
3467
3468 #[test]
3469 fn test_injection_strategy_description() {
3470 let strategy = InjectionStrategy::AmountManipulation {
3471 original: dec!(1000),
3472 factor: 2.5,
3473 };
3474 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3475 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3476
3477 let strategy = InjectionStrategy::ThresholdAvoidance {
3478 threshold: dec!(10000),
3479 adjusted_amount: dec!(9999),
3480 };
3481 assert_eq!(
3482 strategy.description(),
3483 "Amount adjusted to avoid 10000 threshold"
3484 );
3485
3486 let strategy = InjectionStrategy::DateShift {
3487 days_shifted: -5,
3488 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3489 };
3490 assert_eq!(strategy.description(), "Date backdated by 5 days");
3491
3492 let strategy = InjectionStrategy::DateShift {
3493 days_shifted: 3,
3494 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3495 };
3496 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3497 }
3498
3499 #[test]
3500 fn test_causal_reason_variants() {
3501 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3502 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3503 assert!((base_rate - 0.02).abs() < 0.001);
3504 }
3505
3506 let reason = AnomalyCausalReason::TemporalPattern {
3507 pattern_name: "year_end_spike".to_string(),
3508 };
3509 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3510 assert_eq!(pattern_name, "year_end_spike");
3511 }
3512
3513 let reason = AnomalyCausalReason::ScenarioStep {
3514 scenario_type: "kickback".to_string(),
3515 step_number: 3,
3516 };
3517 if let AnomalyCausalReason::ScenarioStep {
3518 scenario_type,
3519 step_number,
3520 } = reason
3521 {
3522 assert_eq!(scenario_type, "kickback");
3523 assert_eq!(step_number, 3);
3524 }
3525 }
3526
3527 #[test]
3528 fn test_feature_vector_length() {
3529 let anomaly = LabeledAnomaly::new(
3530 "ANO001".to_string(),
3531 AnomalyType::Fraud(FraudType::SelfApproval),
3532 "JE001".to_string(),
3533 "JE".to_string(),
3534 "1000".to_string(),
3535 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3536 );
3537
3538 let features = anomaly.to_features();
3539 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3540 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3541 }
3542
3543 #[test]
3544 fn test_feature_vector_with_provenance() {
3545 let anomaly = LabeledAnomaly::new(
3546 "ANO001".to_string(),
3547 AnomalyType::Fraud(FraudType::SelfApproval),
3548 "JE001".to_string(),
3549 "JE".to_string(),
3550 "1000".to_string(),
3551 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3552 )
3553 .with_scenario("scenario-001")
3554 .with_parent_anomaly("ANO000");
3555
3556 let features = anomaly.to_features();
3557
3558 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3562
3563 #[test]
3564 fn test_anomaly_summary() {
3565 let anomalies = vec![
3566 LabeledAnomaly::new(
3567 "ANO001".to_string(),
3568 AnomalyType::Fraud(FraudType::SelfApproval),
3569 "JE001".to_string(),
3570 "JE".to_string(),
3571 "1000".to_string(),
3572 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3573 ),
3574 LabeledAnomaly::new(
3575 "ANO002".to_string(),
3576 AnomalyType::Error(ErrorType::DuplicateEntry),
3577 "JE002".to_string(),
3578 "JE".to_string(),
3579 "1000".to_string(),
3580 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3581 ),
3582 ];
3583
3584 let summary = AnomalySummary::from_anomalies(&anomalies);
3585
3586 assert_eq!(summary.total_count, 2);
3587 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3588 assert_eq!(summary.by_category.get("Error"), Some(&1));
3589 }
3590
3591 #[test]
3592 fn test_rate_config_validation() {
3593 let config = AnomalyRateConfig::default();
3594 assert!(config.validate().is_ok());
3595
3596 let bad_config = AnomalyRateConfig {
3597 fraud_rate: 0.5,
3598 error_rate: 0.5,
3599 process_issue_rate: 0.5, ..Default::default()
3601 };
3602 assert!(bad_config.validate().is_err());
3603 }
3604
3605 #[test]
3606 fn test_injection_strategy_serialization() {
3607 let strategy = InjectionStrategy::SoDViolation {
3608 duty1: "CreatePO".to_string(),
3609 duty2: "ApprovePO".to_string(),
3610 violating_user: "USER001".to_string(),
3611 };
3612
3613 let json = serde_json::to_string(&strategy).unwrap();
3614 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3615
3616 assert_eq!(strategy, deserialized);
3617 }
3618
3619 #[test]
3620 fn test_labeled_anomaly_serialization_with_provenance() {
3621 let anomaly = LabeledAnomaly::new(
3622 "ANO001".to_string(),
3623 AnomalyType::Fraud(FraudType::SelfApproval),
3624 "JE001".to_string(),
3625 "JE".to_string(),
3626 "1000".to_string(),
3627 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3628 )
3629 .with_run_id("run-123")
3630 .with_generation_seed(42)
3631 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3632
3633 let json = serde_json::to_string(&anomaly).unwrap();
3634 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3635
3636 assert_eq!(anomaly.run_id, deserialized.run_id);
3637 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3638 }
3639
3640 #[test]
3645 fn test_anomaly_category_from_anomaly_type() {
3646 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3648 assert_eq!(
3649 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3650 AnomalyCategory::FictitiousVendor
3651 );
3652
3653 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3654 assert_eq!(
3655 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3656 AnomalyCategory::VendorKickback
3657 );
3658
3659 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3660 assert_eq!(
3661 AnomalyCategory::from_anomaly_type(&fraud_structured),
3662 AnomalyCategory::StructuredTransaction
3663 );
3664
3665 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3667 assert_eq!(
3668 AnomalyCategory::from_anomaly_type(&error_duplicate),
3669 AnomalyCategory::DuplicatePayment
3670 );
3671
3672 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3674 assert_eq!(
3675 AnomalyCategory::from_anomaly_type(&process_skip),
3676 AnomalyCategory::MissingApproval
3677 );
3678
3679 let relational_circular =
3681 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3682 assert_eq!(
3683 AnomalyCategory::from_anomaly_type(&relational_circular),
3684 AnomalyCategory::CircularFlow
3685 );
3686 }
3687
3688 #[test]
3689 fn test_anomaly_category_ordinal() {
3690 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3691 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3692 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3693 }
3694
3695 #[test]
3696 fn test_contributing_factor() {
3697 let factor = ContributingFactor::new(
3698 FactorType::AmountDeviation,
3699 15000.0,
3700 10000.0,
3701 true,
3702 0.5,
3703 "Amount exceeds threshold",
3704 );
3705
3706 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3707 assert_eq!(factor.value, 15000.0);
3708 assert_eq!(factor.threshold, 10000.0);
3709 assert!(factor.direction_greater);
3710
3711 let contribution = factor.contribution();
3713 assert!((contribution - 0.25).abs() < 0.01);
3714 }
3715
3716 #[test]
3717 fn test_contributing_factor_with_evidence() {
3718 let mut data = HashMap::new();
3719 data.insert("expected".to_string(), "10000".to_string());
3720 data.insert("actual".to_string(), "15000".to_string());
3721
3722 let factor = ContributingFactor::new(
3723 FactorType::AmountDeviation,
3724 15000.0,
3725 10000.0,
3726 true,
3727 0.5,
3728 "Amount deviation detected",
3729 )
3730 .with_evidence("transaction_history", data);
3731
3732 assert!(factor.evidence.is_some());
3733 let evidence = factor.evidence.unwrap();
3734 assert_eq!(evidence.source, "transaction_history");
3735 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3736 }
3737
3738 #[test]
3739 fn test_enhanced_anomaly_label() {
3740 let base = LabeledAnomaly::new(
3741 "ANO001".to_string(),
3742 AnomalyType::Fraud(FraudType::DuplicatePayment),
3743 "JE001".to_string(),
3744 "JE".to_string(),
3745 "1000".to_string(),
3746 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3747 );
3748
3749 let enhanced = EnhancedAnomalyLabel::from_base(base)
3750 .with_confidence(0.85)
3751 .with_severity(0.7)
3752 .with_factor(ContributingFactor::new(
3753 FactorType::DuplicateIndicator,
3754 1.0,
3755 0.5,
3756 true,
3757 0.4,
3758 "Duplicate payment detected",
3759 ))
3760 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3761
3762 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3763 assert_eq!(enhanced.enhanced_confidence, 0.85);
3764 assert_eq!(enhanced.enhanced_severity, 0.7);
3765 assert_eq!(enhanced.contributing_factors.len(), 1);
3766 assert_eq!(enhanced.secondary_categories.len(), 1);
3767 }
3768
3769 #[test]
3770 fn test_enhanced_anomaly_label_features() {
3771 let base = LabeledAnomaly::new(
3772 "ANO001".to_string(),
3773 AnomalyType::Fraud(FraudType::SelfApproval),
3774 "JE001".to_string(),
3775 "JE".to_string(),
3776 "1000".to_string(),
3777 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3778 );
3779
3780 let enhanced = EnhancedAnomalyLabel::from_base(base)
3781 .with_confidence(0.9)
3782 .with_severity(0.8)
3783 .with_factor(ContributingFactor::new(
3784 FactorType::ControlBypass,
3785 1.0,
3786 0.0,
3787 true,
3788 0.5,
3789 "Control bypass detected",
3790 ));
3791
3792 let features = enhanced.to_features();
3793
3794 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3796 assert_eq!(features.len(), 25);
3797
3798 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3804
3805 #[test]
3806 fn test_enhanced_anomaly_label_feature_names() {
3807 let names = EnhancedAnomalyLabel::feature_names();
3808 assert_eq!(names.len(), 25);
3809 assert!(names.contains(&"enhanced_confidence"));
3810 assert!(names.contains(&"enhanced_severity"));
3811 assert!(names.contains(&"has_control_bypass"));
3812 }
3813
3814 #[test]
3815 fn test_factor_type_names() {
3816 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3817 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3818 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3819 }
3820
3821 #[test]
3822 fn test_anomaly_category_serialization() {
3823 let category = AnomalyCategory::CircularFlow;
3824 let json = serde_json::to_string(&category).unwrap();
3825 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3826 assert_eq!(category, deserialized);
3827
3828 let custom = AnomalyCategory::Custom("custom_type".to_string());
3829 let json = serde_json::to_string(&custom).unwrap();
3830 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3831 assert_eq!(custom, deserialized);
3832 }
3833
3834 #[test]
3835 fn test_enhanced_label_secondary_category_dedup() {
3836 let base = LabeledAnomaly::new(
3837 "ANO001".to_string(),
3838 AnomalyType::Fraud(FraudType::DuplicatePayment),
3839 "JE001".to_string(),
3840 "JE".to_string(),
3841 "1000".to_string(),
3842 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3843 );
3844
3845 let enhanced = EnhancedAnomalyLabel::from_base(base)
3846 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3848 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3850 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3852
3853 assert_eq!(enhanced.secondary_categories.len(), 1);
3855 assert_eq!(
3856 enhanced.secondary_categories[0],
3857 AnomalyCategory::TimingAnomaly
3858 );
3859 }
3860
3861 #[test]
3866 fn test_revenue_recognition_fraud_types() {
3867 let fraud_types = [
3869 FraudType::ImproperRevenueRecognition,
3870 FraudType::ImproperPoAllocation,
3871 FraudType::VariableConsiderationManipulation,
3872 FraudType::ContractModificationMisstatement,
3873 ];
3874
3875 for fraud_type in fraud_types {
3876 let anomaly_type = AnomalyType::Fraud(fraud_type);
3877 assert_eq!(anomaly_type.category(), "Fraud");
3878 assert!(anomaly_type.is_intentional());
3879 assert!(anomaly_type.severity() >= 3);
3880 }
3881 }
3882
3883 #[test]
3884 fn test_lease_accounting_fraud_types() {
3885 let fraud_types = [
3887 FraudType::LeaseClassificationManipulation,
3888 FraudType::OffBalanceSheetLease,
3889 FraudType::LeaseLiabilityUnderstatement,
3890 FraudType::RouAssetMisstatement,
3891 ];
3892
3893 for fraud_type in fraud_types {
3894 let anomaly_type = AnomalyType::Fraud(fraud_type);
3895 assert_eq!(anomaly_type.category(), "Fraud");
3896 assert!(anomaly_type.is_intentional());
3897 assert!(anomaly_type.severity() >= 3);
3898 }
3899
3900 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3902 }
3903
3904 #[test]
3905 fn test_fair_value_fraud_types() {
3906 let fraud_types = [
3908 FraudType::FairValueHierarchyManipulation,
3909 FraudType::Level3InputManipulation,
3910 FraudType::ValuationTechniqueManipulation,
3911 ];
3912
3913 for fraud_type in fraud_types {
3914 let anomaly_type = AnomalyType::Fraud(fraud_type);
3915 assert_eq!(anomaly_type.category(), "Fraud");
3916 assert!(anomaly_type.is_intentional());
3917 assert!(anomaly_type.severity() >= 4);
3918 }
3919
3920 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3922 }
3923
3924 #[test]
3925 fn test_impairment_fraud_types() {
3926 let fraud_types = [
3928 FraudType::DelayedImpairment,
3929 FraudType::ImpairmentTestAvoidance,
3930 FraudType::CashFlowProjectionManipulation,
3931 FraudType::ImproperImpairmentReversal,
3932 ];
3933
3934 for fraud_type in fraud_types {
3935 let anomaly_type = AnomalyType::Fraud(fraud_type);
3936 assert_eq!(anomaly_type.category(), "Fraud");
3937 assert!(anomaly_type.is_intentional());
3938 assert!(anomaly_type.severity() >= 3);
3939 }
3940
3941 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3943 }
3944
3945 #[test]
3950 fn test_standards_error_types() {
3951 let error_types = [
3953 ErrorType::RevenueTimingError,
3954 ErrorType::PoAllocationError,
3955 ErrorType::LeaseClassificationError,
3956 ErrorType::LeaseCalculationError,
3957 ErrorType::FairValueError,
3958 ErrorType::ImpairmentCalculationError,
3959 ErrorType::DiscountRateError,
3960 ErrorType::FrameworkApplicationError,
3961 ];
3962
3963 for error_type in error_types {
3964 let anomaly_type = AnomalyType::Error(error_type);
3965 assert_eq!(anomaly_type.category(), "Error");
3966 assert!(!anomaly_type.is_intentional());
3967 assert!(anomaly_type.severity() >= 3);
3968 }
3969 }
3970
3971 #[test]
3972 fn test_framework_application_error() {
3973 let error_type = ErrorType::FrameworkApplicationError;
3975 assert_eq!(error_type.severity(), 4);
3976
3977 let anomaly = LabeledAnomaly::new(
3978 "ERR001".to_string(),
3979 AnomalyType::Error(error_type),
3980 "JE100".to_string(),
3981 "JE".to_string(),
3982 "1000".to_string(),
3983 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3984 )
3985 .with_description("LIFO inventory method used under IFRS (not permitted)")
3986 .with_metadata("framework", "IFRS")
3987 .with_metadata("standard_violated", "IAS 2");
3988
3989 assert_eq!(anomaly.anomaly_type.category(), "Error");
3990 assert_eq!(
3991 anomaly.metadata.get("standard_violated"),
3992 Some(&"IAS 2".to_string())
3993 );
3994 }
3995
3996 #[test]
3997 fn test_standards_anomaly_serialization() {
3998 let fraud_types = [
4000 FraudType::ImproperRevenueRecognition,
4001 FraudType::LeaseClassificationManipulation,
4002 FraudType::FairValueHierarchyManipulation,
4003 FraudType::DelayedImpairment,
4004 ];
4005
4006 for fraud_type in fraud_types {
4007 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
4008 let deserialized: FraudType =
4009 serde_json::from_str(&json).expect("Failed to deserialize");
4010 assert_eq!(fraud_type, deserialized);
4011 }
4012
4013 let error_types = [
4015 ErrorType::RevenueTimingError,
4016 ErrorType::LeaseCalculationError,
4017 ErrorType::FairValueError,
4018 ErrorType::FrameworkApplicationError,
4019 ];
4020
4021 for error_type in error_types {
4022 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
4023 let deserialized: ErrorType =
4024 serde_json::from_str(&json).expect("Failed to deserialize");
4025 assert_eq!(error_type, deserialized);
4026 }
4027 }
4028
4029 #[test]
4030 fn test_standards_labeled_anomaly() {
4031 let anomaly = LabeledAnomaly::new(
4033 "STD001".to_string(),
4034 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
4035 "CONTRACT-2024-001".to_string(),
4036 "Revenue".to_string(),
4037 "1000".to_string(),
4038 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
4039 )
4040 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
4041 .with_monetary_impact(dec!(500000))
4042 .with_metadata("standard", "ASC 606")
4043 .with_metadata("paragraph", "606-10-25-1")
4044 .with_metadata("contract_id", "C-2024-001")
4045 .with_related_entity("CONTRACT-2024-001")
4046 .with_related_entity("CUSTOMER-500");
4047
4048 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
4050 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
4051 assert_eq!(anomaly.related_entities.len(), 2);
4052 assert_eq!(
4053 anomaly.metadata.get("standard"),
4054 Some(&"ASC 606".to_string())
4055 );
4056 }
4057
4058 #[test]
4063 fn test_severity_level() {
4064 assert_eq!(SeverityLevel::Low.numeric(), 1);
4065 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4066
4067 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4068 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4069
4070 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4071 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4072
4073 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4074 }
4075
4076 #[test]
4077 fn test_anomaly_severity() {
4078 let severity =
4079 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4080
4081 assert_eq!(severity.level, SeverityLevel::High);
4082 assert!(severity.is_material);
4083 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4084
4085 let low_severity =
4087 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4088 assert!(!low_severity.is_material);
4089 }
4090
4091 #[test]
4092 fn test_detection_difficulty() {
4093 assert!(
4094 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4095 );
4096 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4097
4098 assert_eq!(
4099 AnomalyDetectionDifficulty::from_score(0.05),
4100 AnomalyDetectionDifficulty::Trivial
4101 );
4102 assert_eq!(
4103 AnomalyDetectionDifficulty::from_score(0.90),
4104 AnomalyDetectionDifficulty::Expert
4105 );
4106
4107 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4108 }
4109
4110 #[test]
4111 fn test_ground_truth_certainty() {
4112 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4113 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4114 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4115 }
4116
4117 #[test]
4118 fn test_detection_method() {
4119 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4120 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4121 }
4122
4123 #[test]
4124 fn test_extended_anomaly_label() {
4125 let base = LabeledAnomaly::new(
4126 "ANO001".to_string(),
4127 AnomalyType::Fraud(FraudType::FictitiousVendor),
4128 "JE001".to_string(),
4129 "JE".to_string(),
4130 "1000".to_string(),
4131 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4132 )
4133 .with_monetary_impact(dec!(100000));
4134
4135 let extended = ExtendedAnomalyLabel::from_base(base)
4136 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4137 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4138 .with_method(DetectionMethod::GraphBased)
4139 .with_method(DetectionMethod::ForensicAudit)
4140 .with_indicator("New vendor with no history")
4141 .with_indicator("Large first transaction")
4142 .with_certainty(GroundTruthCertainty::Definite)
4143 .with_entity("V001")
4144 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4145 .with_scheme("SCHEME001", 2);
4146
4147 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4148 assert_eq!(
4149 extended.detection_difficulty,
4150 AnomalyDetectionDifficulty::Hard
4151 );
4152 assert_eq!(extended.recommended_methods.len(), 3);
4154 assert_eq!(extended.key_indicators.len(), 2);
4155 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4156 assert_eq!(extended.scheme_stage, Some(2));
4157 }
4158
4159 #[test]
4160 fn test_extended_anomaly_label_features() {
4161 let base = LabeledAnomaly::new(
4162 "ANO001".to_string(),
4163 AnomalyType::Fraud(FraudType::SelfApproval),
4164 "JE001".to_string(),
4165 "JE".to_string(),
4166 "1000".to_string(),
4167 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4168 );
4169
4170 let extended =
4171 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4172
4173 let features = extended.to_features();
4174 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4175 assert_eq!(features.len(), 30);
4176
4177 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4180 }
4181
4182 #[test]
4183 fn test_extended_label_near_miss() {
4184 let base = LabeledAnomaly::new(
4185 "ANO001".to_string(),
4186 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4187 "JE001".to_string(),
4188 "JE".to_string(),
4189 "1000".to_string(),
4190 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4191 );
4192
4193 let extended = ExtendedAnomalyLabel::from_base(base)
4194 .as_near_miss("Year-end bonus payment, legitimately high");
4195
4196 assert!(extended.is_near_miss);
4197 assert!(extended.near_miss_explanation.is_some());
4198 }
4199
4200 #[test]
4201 fn test_scheme_type() {
4202 assert_eq!(
4203 SchemeType::GradualEmbezzlement.name(),
4204 "gradual_embezzlement"
4205 );
4206 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4207 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4208 }
4209
4210 #[test]
4211 fn test_concealment_technique() {
4212 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4213 assert!(
4214 ConcealmentTechnique::Collusion.difficulty_bonus()
4215 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4216 );
4217 }
4218
4219 #[test]
4220 fn test_near_miss_label() {
4221 let near_miss = NearMissLabel::new(
4222 "JE001",
4223 NearMissPattern::ThresholdProximity {
4224 threshold: dec!(10000),
4225 proximity: 0.95,
4226 },
4227 0.7,
4228 FalsePositiveTrigger::AmountNearThreshold,
4229 "Transaction is 95% of threshold but business justified",
4230 );
4231
4232 assert_eq!(near_miss.document_id, "JE001");
4233 assert_eq!(near_miss.suspicion_score, 0.7);
4234 assert_eq!(
4235 near_miss.false_positive_trigger,
4236 FalsePositiveTrigger::AmountNearThreshold
4237 );
4238 }
4239
4240 #[test]
4241 fn test_legitimate_pattern_type() {
4242 assert_eq!(
4243 LegitimatePatternType::YearEndBonus.description(),
4244 "Year-end bonus payment"
4245 );
4246 assert_eq!(
4247 LegitimatePatternType::InsuranceClaim.description(),
4248 "Insurance claim reimbursement"
4249 );
4250 }
4251
4252 #[test]
4253 fn test_severity_detection_difficulty_serialization() {
4254 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4255 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4256 let deserialized: AnomalySeverity =
4257 serde_json::from_str(&json).expect("Failed to deserialize");
4258 assert_eq!(severity.level, deserialized.level);
4259
4260 let difficulty = AnomalyDetectionDifficulty::Hard;
4261 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4262 let deserialized: AnomalyDetectionDifficulty =
4263 serde_json::from_str(&json).expect("Failed to deserialize");
4264 assert_eq!(difficulty, deserialized);
4265 }
4266
4267 #[test]
4272 fn test_acfe_fraud_category() {
4273 let asset = AcfeFraudCategory::AssetMisappropriation;
4274 assert_eq!(asset.name(), "asset_misappropriation");
4275 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4276 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4277 assert_eq!(asset.typical_detection_months(), 12);
4278
4279 let corruption = AcfeFraudCategory::Corruption;
4280 assert_eq!(corruption.name(), "corruption");
4281 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4282
4283 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4284 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4285 assert_eq!(fs_fraud.typical_detection_months(), 24);
4286 }
4287
4288 #[test]
4289 fn test_cash_fraud_scheme() {
4290 let shell = CashFraudScheme::ShellCompany;
4291 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4292 assert_eq!(shell.subcategory(), "billing_schemes");
4293 assert_eq!(shell.severity(), 5);
4294 assert_eq!(
4295 shell.detection_difficulty(),
4296 AnomalyDetectionDifficulty::Hard
4297 );
4298
4299 let ghost = CashFraudScheme::GhostEmployee;
4300 assert_eq!(ghost.subcategory(), "payroll_schemes");
4301 assert_eq!(ghost.severity(), 5);
4302
4303 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4305 }
4306
4307 #[test]
4308 fn test_asset_fraud_scheme() {
4309 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4310 assert_eq!(
4311 ip_theft.category(),
4312 AcfeFraudCategory::AssetMisappropriation
4313 );
4314 assert_eq!(ip_theft.subcategory(), "other_assets");
4315 assert_eq!(ip_theft.severity(), 5);
4316
4317 let inv_theft = AssetFraudScheme::InventoryTheft;
4318 assert_eq!(inv_theft.subcategory(), "inventory");
4319 assert_eq!(inv_theft.severity(), 4);
4320 }
4321
4322 #[test]
4323 fn test_corruption_scheme() {
4324 let kickback = CorruptionScheme::InvoiceKickback;
4325 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4326 assert_eq!(kickback.subcategory(), "bribery");
4327 assert_eq!(kickback.severity(), 5);
4328 assert_eq!(
4329 kickback.detection_difficulty(),
4330 AnomalyDetectionDifficulty::Expert
4331 );
4332
4333 let bid_rigging = CorruptionScheme::BidRigging;
4334 assert_eq!(bid_rigging.subcategory(), "bribery");
4335 assert_eq!(
4336 bid_rigging.detection_difficulty(),
4337 AnomalyDetectionDifficulty::Hard
4338 );
4339
4340 let purchasing = CorruptionScheme::PurchasingConflict;
4341 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4342
4343 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4345 }
4346
4347 #[test]
4348 fn test_financial_statement_scheme() {
4349 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4350 assert_eq!(
4351 fictitious.category(),
4352 AcfeFraudCategory::FinancialStatementFraud
4353 );
4354 assert_eq!(fictitious.subcategory(), "overstatement");
4355 assert_eq!(fictitious.severity(), 5);
4356 assert_eq!(
4357 fictitious.detection_difficulty(),
4358 AnomalyDetectionDifficulty::Expert
4359 );
4360
4361 let understated = FinancialStatementScheme::UnderstatedRevenues;
4362 assert_eq!(understated.subcategory(), "understatement");
4363
4364 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4366 }
4367
4368 #[test]
4369 fn test_acfe_scheme_unified() {
4370 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4371 assert_eq!(
4372 cash_scheme.category(),
4373 AcfeFraudCategory::AssetMisappropriation
4374 );
4375 assert_eq!(cash_scheme.severity(), 5);
4376
4377 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4378 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4379
4380 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4381 assert_eq!(
4382 fs_scheme.category(),
4383 AcfeFraudCategory::FinancialStatementFraud
4384 );
4385 }
4386
4387 #[test]
4388 fn test_acfe_detection_method() {
4389 let tip = AcfeDetectionMethod::Tip;
4390 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4391
4392 let internal_audit = AcfeDetectionMethod::InternalAudit;
4393 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4394
4395 let external_audit = AcfeDetectionMethod::ExternalAudit;
4396 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4397
4398 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4400 }
4401
4402 #[test]
4403 fn test_perpetrator_department() {
4404 let accounting = PerpetratorDepartment::Accounting;
4405 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4406 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4407
4408 let executive = PerpetratorDepartment::Executive;
4409 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4410 }
4411
4412 #[test]
4413 fn test_perpetrator_level() {
4414 let employee = PerpetratorLevel::Employee;
4415 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4416 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4417
4418 let exec = PerpetratorLevel::OwnerExecutive;
4419 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4420 }
4421
4422 #[test]
4423 fn test_acfe_calibration() {
4424 let cal = AcfeCalibration::default();
4425 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4426 assert_eq!(cal.median_duration_months, 12);
4427 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4428 assert!(cal.validate().is_ok());
4429
4430 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4432 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4433 assert_eq!(custom_cal.median_duration_months, 18);
4434
4435 let bad_cal = AcfeCalibration {
4437 collusion_rate: 1.5,
4438 ..Default::default()
4439 };
4440 assert!(bad_cal.validate().is_err());
4441 }
4442
4443 #[test]
4444 fn test_fraud_triangle() {
4445 let triangle = FraudTriangle::new(
4446 PressureType::FinancialTargets,
4447 vec![
4448 OpportunityFactor::WeakInternalControls,
4449 OpportunityFactor::ManagementOverride,
4450 ],
4451 Rationalization::ForTheCompanyGood,
4452 );
4453
4454 let risk = triangle.risk_score();
4456 assert!((0.0..=1.0).contains(&risk));
4457 assert!(risk > 0.5);
4459 }
4460
4461 #[test]
4462 fn test_pressure_types() {
4463 let financial = PressureType::FinancialTargets;
4464 assert!(financial.risk_weight() > 0.5);
4465
4466 let gambling = PressureType::GamblingAddiction;
4467 assert_eq!(gambling.risk_weight(), 0.90);
4468 }
4469
4470 #[test]
4471 fn test_opportunity_factors() {
4472 let override_factor = OpportunityFactor::ManagementOverride;
4473 assert_eq!(override_factor.risk_weight(), 0.90);
4474
4475 let weak_controls = OpportunityFactor::WeakInternalControls;
4476 assert!(weak_controls.risk_weight() > 0.8);
4477 }
4478
4479 #[test]
4480 fn test_rationalizations() {
4481 let entitlement = Rationalization::Entitlement;
4482 assert!(entitlement.risk_weight() > 0.8);
4483
4484 let borrowing = Rationalization::TemporaryBorrowing;
4485 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4486 }
4487
4488 #[test]
4489 fn test_acfe_scheme_serialization() {
4490 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4491 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4492 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4493 assert_eq!(scheme, deserialized);
4494
4495 let calibration = AcfeCalibration::default();
4496 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4497 let deserialized: AcfeCalibration =
4498 serde_json::from_str(&json).expect("Failed to deserialize");
4499 assert_eq!(calibration.median_loss, deserialized.median_loss);
4500 }
4501}