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 {factor:.2}")
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_shifted} days")
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: {duty1} and {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!("Near-duplicate of {original_doc_id} (varied: {varied_fields:?})")
194 }
195 InjectionStrategy::CircularFlow { entity_chain } => {
196 format!("Circular flow through {} entities", entity_chain.len())
197 }
198 InjectionStrategy::SplitTransaction { split_count, .. } => {
199 format!("Split into {split_count} transactions")
200 }
201 InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
202 InjectionStrategy::TimingManipulation { timing_type, .. } => {
203 format!("Timing manipulation: {timing_type}")
204 }
205 InjectionStrategy::AccountMisclassification {
206 correct_account,
207 incorrect_account,
208 } => {
209 format!("Misclassified from {correct_account} to {incorrect_account}")
210 }
211 InjectionStrategy::MissingField { field_name } => {
212 format!("Missing required field: {field_name}")
213 }
214 InjectionStrategy::Custom { name, .. } => format!("Custom: {name}"),
215 }
216 }
217
218 pub fn strategy_type(&self) -> &'static str {
220 match self {
221 InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
222 InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
223 InjectionStrategy::DateShift { .. } => "DateShift",
224 InjectionStrategy::SelfApproval { .. } => "SelfApproval",
225 InjectionStrategy::SoDViolation { .. } => "SoDViolation",
226 InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
227 InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
228 InjectionStrategy::CircularFlow { .. } => "CircularFlow",
229 InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
230 InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
231 InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
232 InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
233 InjectionStrategy::MissingField { .. } => "MissingField",
234 InjectionStrategy::Custom { .. } => "Custom",
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
241pub enum AnomalyType {
242 Fraud(FraudType),
244 Error(ErrorType),
246 ProcessIssue(ProcessIssueType),
248 Statistical(StatisticalAnomalyType),
250 Relational(RelationalAnomalyType),
252 Custom(String),
254}
255
256impl AnomalyType {
257 pub fn category(&self) -> &'static str {
259 match self {
260 AnomalyType::Fraud(_) => "Fraud",
261 AnomalyType::Error(_) => "Error",
262 AnomalyType::ProcessIssue(_) => "ProcessIssue",
263 AnomalyType::Statistical(_) => "Statistical",
264 AnomalyType::Relational(_) => "Relational",
265 AnomalyType::Custom(_) => "Custom",
266 }
267 }
268
269 pub fn type_name(&self) -> String {
271 match self {
272 AnomalyType::Fraud(t) => format!("{t:?}"),
273 AnomalyType::Error(t) => format!("{t:?}"),
274 AnomalyType::ProcessIssue(t) => format!("{t:?}"),
275 AnomalyType::Statistical(t) => format!("{t:?}"),
276 AnomalyType::Relational(t) => format!("{t:?}"),
277 AnomalyType::Custom(s) => s.clone(),
278 }
279 }
280
281 pub fn severity(&self) -> u8 {
283 match self {
284 AnomalyType::Fraud(t) => t.severity(),
285 AnomalyType::Error(t) => t.severity(),
286 AnomalyType::ProcessIssue(t) => t.severity(),
287 AnomalyType::Statistical(t) => t.severity(),
288 AnomalyType::Relational(t) => t.severity(),
289 AnomalyType::Custom(_) => 3,
290 }
291 }
292
293 pub fn is_intentional(&self) -> bool {
295 matches!(self, AnomalyType::Fraud(_))
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
301pub enum FraudType {
302 FictitiousEntry,
305 FictitiousTransaction,
307 RoundDollarManipulation,
309 JustBelowThreshold,
311 RevenueManipulation,
313 ImproperCapitalization,
315 ExpenseCapitalization,
317 ReserveManipulation,
319 SuspenseAccountAbuse,
321 SplitTransaction,
323 TimingAnomaly,
325 UnauthorizedAccess,
327
328 SelfApproval,
331 ExceededApprovalLimit,
333 SegregationOfDutiesViolation,
335 UnauthorizedApproval,
337 CollusiveApproval,
339
340 FictitiousVendor,
343 DuplicatePayment,
345 ShellCompanyPayment,
347 Kickback,
349 KickbackScheme,
351 UnauthorizedDiscount,
353 RoundTripping,
356 InvoiceManipulation,
358
359 AssetMisappropriation,
362 InventoryTheft,
364 GhostEmployee,
366
367 PrematureRevenue,
370 UnderstatedLiabilities,
372 OverstatedAssets,
374 ChannelStuffing,
376
377 ImproperRevenueRecognition,
380 ImproperPoAllocation,
382 VariableConsiderationManipulation,
384 ContractModificationMisstatement,
386
387 LeaseClassificationManipulation,
390 OffBalanceSheetLease,
392 LeaseLiabilityUnderstatement,
394 RouAssetMisstatement,
396
397 FairValueHierarchyManipulation,
400 Level3InputManipulation,
402 ValuationTechniqueManipulation,
404
405 DelayedImpairment,
408 ImpairmentTestAvoidance,
410 CashFlowProjectionManipulation,
412 ImproperImpairmentReversal,
414
415 BidRigging,
418 PhantomVendorContract,
420 SplitContractThreshold,
422 ConflictOfInterestSourcing,
424
425 GhostEmployeePayroll,
428 PayrollInflation,
430 DuplicateExpenseReport,
432 FictitiousExpense,
434 SplitExpenseToAvoidApproval,
436
437 RevenueTimingManipulation,
440 QuotePriceOverride,
442}
443
444impl FraudType {
445 pub fn severity(&self) -> u8 {
447 match self {
448 FraudType::RoundDollarManipulation => 2,
449 FraudType::JustBelowThreshold => 3,
450 FraudType::SelfApproval => 3,
451 FraudType::ExceededApprovalLimit => 3,
452 FraudType::DuplicatePayment => 3,
453 FraudType::FictitiousEntry => 4,
454 FraudType::RevenueManipulation => 5,
455 FraudType::FictitiousVendor => 5,
456 FraudType::ShellCompanyPayment => 5,
457 FraudType::AssetMisappropriation => 5,
458 FraudType::SegregationOfDutiesViolation => 4,
459 FraudType::CollusiveApproval => 5,
460 FraudType::ImproperRevenueRecognition => 5,
462 FraudType::ImproperPoAllocation => 4,
463 FraudType::VariableConsiderationManipulation => 4,
464 FraudType::ContractModificationMisstatement => 3,
465 FraudType::LeaseClassificationManipulation => 4,
467 FraudType::OffBalanceSheetLease => 5,
468 FraudType::LeaseLiabilityUnderstatement => 4,
469 FraudType::RouAssetMisstatement => 3,
470 FraudType::FairValueHierarchyManipulation => 4,
472 FraudType::Level3InputManipulation => 5,
473 FraudType::ValuationTechniqueManipulation => 4,
474 FraudType::DelayedImpairment => 4,
476 FraudType::ImpairmentTestAvoidance => 4,
477 FraudType::CashFlowProjectionManipulation => 5,
478 FraudType::ImproperImpairmentReversal => 3,
479 _ => 4,
480 }
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
486pub enum ErrorType {
487 DuplicateEntry,
490 ReversedAmount,
492 TransposedDigits,
494 DecimalError,
496 MissingField,
498 InvalidAccount,
500
501 WrongPeriod,
504 BackdatedEntry,
506 FutureDatedEntry,
508 CutoffError,
510
511 MisclassifiedAccount,
514 WrongCostCenter,
516 WrongCompanyCode,
518
519 UnbalancedEntry,
522 RoundingError,
524 CurrencyError,
526 TaxCalculationError,
528
529 RevenueTimingError,
532 PoAllocationError,
534 LeaseClassificationError,
536 LeaseCalculationError,
538 FairValueError,
540 ImpairmentCalculationError,
542 DiscountRateError,
544 FrameworkApplicationError,
546}
547
548impl ErrorType {
549 pub fn severity(&self) -> u8 {
551 match self {
552 ErrorType::RoundingError => 1,
553 ErrorType::MissingField => 2,
554 ErrorType::TransposedDigits => 2,
555 ErrorType::DecimalError => 3,
556 ErrorType::DuplicateEntry => 3,
557 ErrorType::ReversedAmount => 3,
558 ErrorType::WrongPeriod => 4,
559 ErrorType::UnbalancedEntry => 5,
560 ErrorType::CurrencyError => 4,
561 ErrorType::RevenueTimingError => 4,
563 ErrorType::PoAllocationError => 3,
564 ErrorType::LeaseClassificationError => 3,
565 ErrorType::LeaseCalculationError => 3,
566 ErrorType::FairValueError => 4,
567 ErrorType::ImpairmentCalculationError => 4,
568 ErrorType::DiscountRateError => 3,
569 ErrorType::FrameworkApplicationError => 4,
570 _ => 3,
571 }
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
577pub enum ProcessIssueType {
578 SkippedApproval,
581 LateApproval,
583 MissingDocumentation,
585 IncompleteApprovalChain,
587
588 LatePosting,
591 AfterHoursPosting,
593 WeekendPosting,
595 RushedPeriodEnd,
597 PostClosePosting,
601
602 ManualOverride,
605 UnusualAccess,
607 SystemBypass,
609 BatchAnomaly,
611
612 VagueDescription,
615 PostFactoChange,
617 IncompleteAuditTrail,
619
620 MaverickSpend,
623 ExpiredContractPurchase,
625 ContractPriceOverride,
627 SingleBidAward,
629 QualificationBypass,
631
632 ExpiredQuoteConversion,
635}
636
637impl ProcessIssueType {
638 pub fn severity(&self) -> u8 {
640 match self {
641 ProcessIssueType::VagueDescription => 1,
642 ProcessIssueType::LatePosting => 2,
643 ProcessIssueType::AfterHoursPosting => 2,
644 ProcessIssueType::WeekendPosting => 2,
645 ProcessIssueType::PostClosePosting => 4,
646 ProcessIssueType::SkippedApproval => 4,
647 ProcessIssueType::ManualOverride => 4,
648 ProcessIssueType::SystemBypass => 5,
649 ProcessIssueType::IncompleteAuditTrail => 4,
650 _ => 3,
651 }
652 }
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
657pub enum StatisticalAnomalyType {
658 UnusuallyHighAmount,
661 UnusuallyLowAmount,
663 BenfordViolation,
665 ExactDuplicateAmount,
667 RepeatingAmount,
669
670 UnusualFrequency,
673 TransactionBurst,
675 UnusualTiming,
677
678 TrendBreak,
681 LevelShift,
683 SeasonalAnomaly,
685
686 StatisticalOutlier,
689 VarianceChange,
691 DistributionShift,
693
694 SlaBreachPattern,
697 UnusedContract,
699
700 OvertimeAnomaly,
703}
704
705impl StatisticalAnomalyType {
706 pub fn severity(&self) -> u8 {
708 match self {
709 StatisticalAnomalyType::UnusualTiming => 1,
710 StatisticalAnomalyType::UnusualFrequency => 2,
711 StatisticalAnomalyType::BenfordViolation => 2,
712 StatisticalAnomalyType::UnusuallyHighAmount => 3,
713 StatisticalAnomalyType::TrendBreak => 3,
714 StatisticalAnomalyType::TransactionBurst => 4,
715 StatisticalAnomalyType::ExactDuplicateAmount => 3,
716 _ => 3,
717 }
718 }
719}
720
721#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
723pub enum RelationalAnomalyType {
724 CircularTransaction,
727 UnusualAccountPair,
729 NewCounterparty,
731 DormantAccountActivity,
733
734 CentralityAnomaly,
737 IsolatedCluster,
739 BridgeNodeAnomaly,
741 CommunityAnomaly,
743
744 MissingRelationship,
747 UnexpectedRelationship,
749 RelationshipStrengthChange,
751
752 UnmatchedIntercompany,
755 CircularIntercompany,
757 TransferPricingAnomaly,
759}
760
761impl RelationalAnomalyType {
762 pub fn severity(&self) -> u8 {
764 match self {
765 RelationalAnomalyType::NewCounterparty => 1,
766 RelationalAnomalyType::DormantAccountActivity => 2,
767 RelationalAnomalyType::UnusualAccountPair => 2,
768 RelationalAnomalyType::CircularTransaction => 4,
769 RelationalAnomalyType::CircularIntercompany => 4,
770 RelationalAnomalyType::TransferPricingAnomaly => 4,
771 RelationalAnomalyType::UnmatchedIntercompany => 3,
772 _ => 3,
773 }
774 }
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize)]
779pub struct LabeledAnomaly {
780 pub anomaly_id: String,
782 pub anomaly_type: AnomalyType,
784 pub document_id: String,
786 pub document_type: String,
788 pub company_code: String,
790 pub anomaly_date: NaiveDate,
792 #[serde(with = "crate::serde_timestamp::naive")]
794 pub detection_timestamp: NaiveDateTime,
795 pub confidence: f64,
797 pub severity: u8,
799 pub description: String,
801 pub related_entities: Vec<String>,
803 pub monetary_impact: Option<Decimal>,
805 pub metadata: HashMap<String, String>,
807 pub is_injected: bool,
809 pub injection_strategy: Option<String>,
811 pub cluster_id: Option<String>,
813
814 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub original_document_hash: Option<String>,
821
822 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub causal_reason: Option<AnomalyCausalReason>,
826
827 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub structured_strategy: Option<InjectionStrategy>,
831
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub parent_anomaly_id: Option<String>,
836
837 #[serde(default, skip_serializing_if = "Vec::is_empty")]
839 pub child_anomaly_ids: Vec<String>,
840
841 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub scenario_id: Option<String>,
844
845 #[serde(default, skip_serializing_if = "Option::is_none")]
848 pub run_id: Option<String>,
849
850 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub generation_seed: Option<u64>,
854}
855
856impl LabeledAnomaly {
857 pub fn new(
859 anomaly_id: String,
860 anomaly_type: AnomalyType,
861 document_id: String,
862 document_type: String,
863 company_code: String,
864 anomaly_date: NaiveDate,
865 ) -> Self {
866 let severity = anomaly_type.severity();
867 let description = format!(
868 "{} - {} in document {}",
869 anomaly_type.category(),
870 anomaly_type.type_name(),
871 document_id
872 );
873
874 Self {
875 anomaly_id,
876 anomaly_type,
877 document_id,
878 document_type,
879 company_code,
880 anomaly_date,
881 detection_timestamp: chrono::Local::now().naive_local(),
882 confidence: 1.0,
883 severity,
884 description,
885 related_entities: Vec::new(),
886 monetary_impact: None,
887 metadata: HashMap::new(),
888 is_injected: true,
889 injection_strategy: None,
890 cluster_id: None,
891 original_document_hash: None,
893 causal_reason: None,
894 structured_strategy: None,
895 parent_anomaly_id: None,
896 child_anomaly_ids: Vec::new(),
897 scenario_id: None,
898 run_id: None,
899 generation_seed: None,
900 }
901 }
902
903 pub fn with_description(mut self, description: &str) -> Self {
905 self.description = description.to_string();
906 self
907 }
908
909 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
911 self.monetary_impact = Some(impact);
912 self
913 }
914
915 pub fn with_related_entity(mut self, entity: &str) -> Self {
917 self.related_entities.push(entity.to_string());
918 self
919 }
920
921 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
923 self.metadata.insert(key.to_string(), value.to_string());
924 self
925 }
926
927 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
929 self.injection_strategy = Some(strategy.to_string());
930 self
931 }
932
933 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
935 self.cluster_id = Some(cluster_id.to_string());
936 self
937 }
938
939 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
945 self.original_document_hash = Some(hash.to_string());
946 self
947 }
948
949 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
951 self.causal_reason = Some(reason);
952 self
953 }
954
955 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
957 self.injection_strategy = Some(strategy.strategy_type().to_string());
959 self.structured_strategy = Some(strategy);
960 self
961 }
962
963 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
965 self.parent_anomaly_id = Some(parent_id.to_string());
966 self
967 }
968
969 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
971 self.child_anomaly_ids.push(child_id.to_string());
972 self
973 }
974
975 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
977 self.scenario_id = Some(scenario_id.to_string());
978 self
979 }
980
981 pub fn with_run_id(mut self, run_id: &str) -> Self {
983 self.run_id = Some(run_id.to_string());
984 self
985 }
986
987 pub fn with_generation_seed(mut self, seed: u64) -> Self {
989 self.generation_seed = Some(seed);
990 self
991 }
992
993 pub fn with_provenance(
995 mut self,
996 run_id: Option<&str>,
997 seed: Option<u64>,
998 causal_reason: Option<AnomalyCausalReason>,
999 ) -> Self {
1000 if let Some(id) = run_id {
1001 self.run_id = Some(id.to_string());
1002 }
1003 self.generation_seed = seed;
1004 self.causal_reason = causal_reason;
1005 self
1006 }
1007
1008 pub fn to_features(&self) -> Vec<f64> {
1022 let mut features = Vec::new();
1023
1024 let categories = [
1026 "Fraud",
1027 "Error",
1028 "ProcessIssue",
1029 "Statistical",
1030 "Relational",
1031 "Custom",
1032 ];
1033 for cat in &categories {
1034 features.push(if self.anomaly_type.category() == *cat {
1035 1.0
1036 } else {
1037 0.0
1038 });
1039 }
1040
1041 features.push(self.severity as f64 / 5.0);
1043
1044 features.push(self.confidence);
1046
1047 features.push(if self.monetary_impact.is_some() {
1049 1.0
1050 } else {
1051 0.0
1052 });
1053
1054 if let Some(impact) = self.monetary_impact {
1056 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
1057 features.push((impact_f64.abs() + 1.0).ln());
1058 } else {
1059 features.push(0.0);
1060 }
1061
1062 features.push(if self.anomaly_type.is_intentional() {
1064 1.0
1065 } else {
1066 0.0
1067 });
1068
1069 features.push(self.related_entities.len() as f64);
1071
1072 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1074
1075 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1078
1079 features.push(if self.parent_anomaly_id.is_some() {
1081 1.0
1082 } else {
1083 0.0
1084 });
1085
1086 features
1087 }
1088
1089 pub fn feature_count() -> usize {
1091 15 }
1093
1094 pub fn feature_names() -> Vec<&'static str> {
1096 vec![
1097 "category_fraud",
1098 "category_error",
1099 "category_process_issue",
1100 "category_statistical",
1101 "category_relational",
1102 "category_custom",
1103 "severity_normalized",
1104 "confidence",
1105 "has_monetary_impact",
1106 "monetary_impact_log",
1107 "is_intentional",
1108 "related_entity_count",
1109 "is_clustered",
1110 "is_scenario_part",
1111 "is_derived",
1112 ]
1113 }
1114}
1115
1116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1118pub struct AnomalySummary {
1119 pub total_count: usize,
1121 pub by_category: HashMap<String, usize>,
1123 pub by_type: HashMap<String, usize>,
1125 pub by_severity: HashMap<u8, usize>,
1127 pub by_company: HashMap<String, usize>,
1129 pub total_monetary_impact: Decimal,
1131 pub date_range: Option<(NaiveDate, NaiveDate)>,
1133 pub cluster_count: usize,
1135}
1136
1137impl AnomalySummary {
1138 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1140 let mut summary = AnomalySummary {
1141 total_count: anomalies.len(),
1142 ..Default::default()
1143 };
1144
1145 let mut min_date: Option<NaiveDate> = None;
1146 let mut max_date: Option<NaiveDate> = None;
1147 let mut clusters = std::collections::HashSet::new();
1148
1149 for anomaly in anomalies {
1150 *summary
1152 .by_category
1153 .entry(anomaly.anomaly_type.category().to_string())
1154 .or_insert(0) += 1;
1155
1156 *summary
1158 .by_type
1159 .entry(anomaly.anomaly_type.type_name())
1160 .or_insert(0) += 1;
1161
1162 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1164
1165 *summary
1167 .by_company
1168 .entry(anomaly.company_code.clone())
1169 .or_insert(0) += 1;
1170
1171 if let Some(impact) = anomaly.monetary_impact {
1173 summary.total_monetary_impact += impact;
1174 }
1175
1176 match min_date {
1178 None => min_date = Some(anomaly.anomaly_date),
1179 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1180 _ => {}
1181 }
1182 match max_date {
1183 None => max_date = Some(anomaly.anomaly_date),
1184 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1185 _ => {}
1186 }
1187
1188 if let Some(cluster_id) = &anomaly.cluster_id {
1190 clusters.insert(cluster_id.clone());
1191 }
1192 }
1193
1194 summary.date_range = min_date.zip(max_date);
1195 summary.cluster_count = clusters.len();
1196
1197 summary
1198 }
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1210pub enum AnomalyCategory {
1211 FictitiousVendor,
1214 VendorKickback,
1216 RelatedPartyVendor,
1218
1219 DuplicatePayment,
1222 UnauthorizedTransaction,
1224 StructuredTransaction,
1226
1227 CircularFlow,
1230 BehavioralAnomaly,
1232 TimingAnomaly,
1234
1235 JournalAnomaly,
1238 ManualOverride,
1240 MissingApproval,
1242
1243 StatisticalOutlier,
1246 DistributionAnomaly,
1248
1249 Custom(String),
1252}
1253
1254impl AnomalyCategory {
1255 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1257 match anomaly_type {
1258 AnomalyType::Fraud(fraud_type) => match fraud_type {
1259 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1260 AnomalyCategory::FictitiousVendor
1261 }
1262 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1263 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1264 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1265 AnomalyCategory::StructuredTransaction
1266 }
1267 FraudType::SelfApproval
1268 | FraudType::UnauthorizedApproval
1269 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1270 FraudType::TimingAnomaly
1271 | FraudType::RoundDollarManipulation
1272 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1273 _ => AnomalyCategory::BehavioralAnomaly,
1274 },
1275 AnomalyType::Error(error_type) => match error_type {
1276 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1277 ErrorType::WrongPeriod
1278 | ErrorType::BackdatedEntry
1279 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1280 _ => AnomalyCategory::JournalAnomaly,
1281 },
1282 AnomalyType::ProcessIssue(process_type) => match process_type {
1283 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1284 AnomalyCategory::MissingApproval
1285 }
1286 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1287 AnomalyCategory::ManualOverride
1288 }
1289 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1290 AnomalyCategory::TimingAnomaly
1291 }
1292 _ => AnomalyCategory::BehavioralAnomaly,
1293 },
1294 AnomalyType::Statistical(stat_type) => match stat_type {
1295 StatisticalAnomalyType::BenfordViolation
1296 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1297 _ => AnomalyCategory::StatisticalOutlier,
1298 },
1299 AnomalyType::Relational(rel_type) => match rel_type {
1300 RelationalAnomalyType::CircularTransaction
1301 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1302 _ => AnomalyCategory::BehavioralAnomaly,
1303 },
1304 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1305 }
1306 }
1307
1308 pub fn name(&self) -> &str {
1310 match self {
1311 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1312 AnomalyCategory::VendorKickback => "vendor_kickback",
1313 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1314 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1315 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1316 AnomalyCategory::StructuredTransaction => "structured_transaction",
1317 AnomalyCategory::CircularFlow => "circular_flow",
1318 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1319 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1320 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1321 AnomalyCategory::ManualOverride => "manual_override",
1322 AnomalyCategory::MissingApproval => "missing_approval",
1323 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1324 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1325 AnomalyCategory::Custom(s) => s.as_str(),
1326 }
1327 }
1328
1329 pub fn ordinal(&self) -> u8 {
1331 match self {
1332 AnomalyCategory::FictitiousVendor => 0,
1333 AnomalyCategory::VendorKickback => 1,
1334 AnomalyCategory::RelatedPartyVendor => 2,
1335 AnomalyCategory::DuplicatePayment => 3,
1336 AnomalyCategory::UnauthorizedTransaction => 4,
1337 AnomalyCategory::StructuredTransaction => 5,
1338 AnomalyCategory::CircularFlow => 6,
1339 AnomalyCategory::BehavioralAnomaly => 7,
1340 AnomalyCategory::TimingAnomaly => 8,
1341 AnomalyCategory::JournalAnomaly => 9,
1342 AnomalyCategory::ManualOverride => 10,
1343 AnomalyCategory::MissingApproval => 11,
1344 AnomalyCategory::StatisticalOutlier => 12,
1345 AnomalyCategory::DistributionAnomaly => 13,
1346 AnomalyCategory::Custom(_) => 14,
1347 }
1348 }
1349
1350 pub fn category_count() -> usize {
1352 15 }
1354}
1355
1356#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1358pub enum FactorType {
1359 AmountDeviation,
1361 ThresholdProximity,
1363 TimingAnomaly,
1365 EntityRisk,
1367 PatternMatch,
1369 FrequencyDeviation,
1371 RelationshipAnomaly,
1373 ControlBypass,
1375 BenfordViolation,
1377 DuplicateIndicator,
1379 ApprovalChainIssue,
1381 DocumentationGap,
1383 Custom,
1385}
1386
1387impl FactorType {
1388 pub fn name(&self) -> &'static str {
1390 match self {
1391 FactorType::AmountDeviation => "amount_deviation",
1392 FactorType::ThresholdProximity => "threshold_proximity",
1393 FactorType::TimingAnomaly => "timing_anomaly",
1394 FactorType::EntityRisk => "entity_risk",
1395 FactorType::PatternMatch => "pattern_match",
1396 FactorType::FrequencyDeviation => "frequency_deviation",
1397 FactorType::RelationshipAnomaly => "relationship_anomaly",
1398 FactorType::ControlBypass => "control_bypass",
1399 FactorType::BenfordViolation => "benford_violation",
1400 FactorType::DuplicateIndicator => "duplicate_indicator",
1401 FactorType::ApprovalChainIssue => "approval_chain_issue",
1402 FactorType::DocumentationGap => "documentation_gap",
1403 FactorType::Custom => "custom",
1404 }
1405 }
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1410pub struct FactorEvidence {
1411 pub source: String,
1413 pub data: HashMap<String, String>,
1415}
1416
1417#[derive(Debug, Clone, Serialize, Deserialize)]
1419pub struct ContributingFactor {
1420 pub factor_type: FactorType,
1422 pub value: f64,
1424 pub threshold: f64,
1426 pub direction_greater: bool,
1428 pub weight: f64,
1430 pub description: String,
1432 pub evidence: Option<FactorEvidence>,
1434}
1435
1436impl ContributingFactor {
1437 pub fn new(
1439 factor_type: FactorType,
1440 value: f64,
1441 threshold: f64,
1442 direction_greater: bool,
1443 weight: f64,
1444 description: &str,
1445 ) -> Self {
1446 Self {
1447 factor_type,
1448 value,
1449 threshold,
1450 direction_greater,
1451 weight,
1452 description: description.to_string(),
1453 evidence: None,
1454 }
1455 }
1456
1457 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1459 self.evidence = Some(FactorEvidence {
1460 source: source.to_string(),
1461 data,
1462 });
1463 self
1464 }
1465
1466 pub fn contribution(&self) -> f64 {
1468 let deviation = if self.direction_greater {
1469 (self.value - self.threshold).max(0.0)
1470 } else {
1471 (self.threshold - self.value).max(0.0)
1472 };
1473
1474 let relative_deviation = if self.threshold.abs() > 0.001 {
1476 deviation / self.threshold.abs()
1477 } else {
1478 deviation
1479 };
1480
1481 (relative_deviation * self.weight).min(1.0)
1483 }
1484}
1485
1486#[derive(Debug, Clone, Serialize, Deserialize)]
1488pub struct EnhancedAnomalyLabel {
1489 pub base: LabeledAnomaly,
1491 pub category: AnomalyCategory,
1493 pub enhanced_confidence: f64,
1495 pub enhanced_severity: f64,
1497 pub contributing_factors: Vec<ContributingFactor>,
1499 pub secondary_categories: Vec<AnomalyCategory>,
1501}
1502
1503impl EnhancedAnomalyLabel {
1504 pub fn from_base(base: LabeledAnomaly) -> Self {
1506 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1507 let enhanced_confidence = base.confidence;
1508 let enhanced_severity = base.severity as f64 / 5.0;
1509
1510 Self {
1511 base,
1512 category,
1513 enhanced_confidence,
1514 enhanced_severity,
1515 contributing_factors: Vec::new(),
1516 secondary_categories: Vec::new(),
1517 }
1518 }
1519
1520 pub fn with_confidence(mut self, confidence: f64) -> Self {
1522 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1523 self
1524 }
1525
1526 pub fn with_severity(mut self, severity: f64) -> Self {
1528 self.enhanced_severity = severity.clamp(0.0, 1.0);
1529 self
1530 }
1531
1532 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1534 self.contributing_factors.push(factor);
1535 self
1536 }
1537
1538 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1540 if !self.secondary_categories.contains(&category) && category != self.category {
1541 self.secondary_categories.push(category);
1542 }
1543 self
1544 }
1545
1546 pub fn to_features(&self) -> Vec<f64> {
1550 let mut features = self.base.to_features();
1551
1552 features.push(self.enhanced_confidence);
1554 features.push(self.enhanced_severity);
1555 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1556 features.push(self.secondary_categories.len() as f64);
1557 features.push(self.contributing_factors.len() as f64);
1558
1559 let max_weight = self
1561 .contributing_factors
1562 .iter()
1563 .map(|f| f.weight)
1564 .fold(0.0, f64::max);
1565 features.push(max_weight);
1566
1567 let has_control_bypass = self
1569 .contributing_factors
1570 .iter()
1571 .any(|f| f.factor_type == FactorType::ControlBypass);
1572 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1573
1574 let has_amount_deviation = self
1575 .contributing_factors
1576 .iter()
1577 .any(|f| f.factor_type == FactorType::AmountDeviation);
1578 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1579
1580 let has_timing = self
1581 .contributing_factors
1582 .iter()
1583 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1584 features.push(if has_timing { 1.0 } else { 0.0 });
1585
1586 let has_pattern_match = self
1587 .contributing_factors
1588 .iter()
1589 .any(|f| f.factor_type == FactorType::PatternMatch);
1590 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1591
1592 features
1593 }
1594
1595 pub fn feature_count() -> usize {
1597 25 }
1599
1600 pub fn feature_names() -> Vec<&'static str> {
1602 let mut names = LabeledAnomaly::feature_names();
1603 names.extend(vec![
1604 "enhanced_confidence",
1605 "enhanced_severity",
1606 "category_ordinal",
1607 "secondary_category_count",
1608 "contributing_factor_count",
1609 "max_factor_weight",
1610 "has_control_bypass",
1611 "has_amount_deviation",
1612 "has_timing_factor",
1613 "has_pattern_match",
1614 ]);
1615 names
1616 }
1617}
1618
1619#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1625pub enum SeverityLevel {
1626 Low,
1628 #[default]
1630 Medium,
1631 High,
1633 Critical,
1635}
1636
1637impl SeverityLevel {
1638 pub fn numeric(&self) -> u8 {
1640 match self {
1641 SeverityLevel::Low => 1,
1642 SeverityLevel::Medium => 2,
1643 SeverityLevel::High => 3,
1644 SeverityLevel::Critical => 4,
1645 }
1646 }
1647
1648 pub fn from_numeric(value: u8) -> Self {
1650 match value {
1651 1 => SeverityLevel::Low,
1652 2 => SeverityLevel::Medium,
1653 3 => SeverityLevel::High,
1654 _ => SeverityLevel::Critical,
1655 }
1656 }
1657
1658 pub fn from_score(score: f64) -> Self {
1660 match score {
1661 s if s < 0.25 => SeverityLevel::Low,
1662 s if s < 0.50 => SeverityLevel::Medium,
1663 s if s < 0.75 => SeverityLevel::High,
1664 _ => SeverityLevel::Critical,
1665 }
1666 }
1667
1668 pub fn to_score(&self) -> f64 {
1670 match self {
1671 SeverityLevel::Low => 0.125,
1672 SeverityLevel::Medium => 0.375,
1673 SeverityLevel::High => 0.625,
1674 SeverityLevel::Critical => 0.875,
1675 }
1676 }
1677}
1678
1679#[derive(Debug, Clone, Serialize, Deserialize)]
1681pub struct AnomalySeverity {
1682 pub level: SeverityLevel,
1684 pub score: f64,
1686 pub financial_impact: Decimal,
1688 pub is_material: bool,
1690 #[serde(default, skip_serializing_if = "Option::is_none")]
1692 pub materiality_threshold: Option<Decimal>,
1693}
1694
1695impl AnomalySeverity {
1696 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1698 Self {
1699 level,
1700 score: level.to_score(),
1701 financial_impact,
1702 is_material: false,
1703 materiality_threshold: None,
1704 }
1705 }
1706
1707 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1709 Self {
1710 level: SeverityLevel::from_score(score),
1711 score: score.clamp(0.0, 1.0),
1712 financial_impact,
1713 is_material: false,
1714 materiality_threshold: None,
1715 }
1716 }
1717
1718 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1720 self.materiality_threshold = Some(threshold);
1721 self.is_material = self.financial_impact.abs() >= threshold;
1722 self
1723 }
1724}
1725
1726impl Default for AnomalySeverity {
1727 fn default() -> Self {
1728 Self {
1729 level: SeverityLevel::Medium,
1730 score: 0.5,
1731 financial_impact: Decimal::ZERO,
1732 is_material: false,
1733 materiality_threshold: None,
1734 }
1735 }
1736}
1737
1738#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1746pub enum AnomalyDetectionDifficulty {
1747 Trivial,
1749 Easy,
1751 #[default]
1753 Moderate,
1754 Hard,
1756 Expert,
1758}
1759
1760impl AnomalyDetectionDifficulty {
1761 pub fn expected_detection_rate(&self) -> f64 {
1763 match self {
1764 AnomalyDetectionDifficulty::Trivial => 0.99,
1765 AnomalyDetectionDifficulty::Easy => 0.90,
1766 AnomalyDetectionDifficulty::Moderate => 0.70,
1767 AnomalyDetectionDifficulty::Hard => 0.40,
1768 AnomalyDetectionDifficulty::Expert => 0.15,
1769 }
1770 }
1771
1772 pub fn difficulty_score(&self) -> f64 {
1774 match self {
1775 AnomalyDetectionDifficulty::Trivial => 0.05,
1776 AnomalyDetectionDifficulty::Easy => 0.25,
1777 AnomalyDetectionDifficulty::Moderate => 0.50,
1778 AnomalyDetectionDifficulty::Hard => 0.75,
1779 AnomalyDetectionDifficulty::Expert => 0.95,
1780 }
1781 }
1782
1783 pub fn from_score(score: f64) -> Self {
1785 match score {
1786 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1787 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1788 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1789 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1790 _ => AnomalyDetectionDifficulty::Expert,
1791 }
1792 }
1793
1794 pub fn name(&self) -> &'static str {
1796 match self {
1797 AnomalyDetectionDifficulty::Trivial => "trivial",
1798 AnomalyDetectionDifficulty::Easy => "easy",
1799 AnomalyDetectionDifficulty::Moderate => "moderate",
1800 AnomalyDetectionDifficulty::Hard => "hard",
1801 AnomalyDetectionDifficulty::Expert => "expert",
1802 }
1803 }
1804}
1805
1806#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1810pub enum GroundTruthCertainty {
1811 #[default]
1813 Definite,
1814 Probable,
1816 Possible,
1818}
1819
1820impl GroundTruthCertainty {
1821 pub fn certainty_score(&self) -> f64 {
1823 match self {
1824 GroundTruthCertainty::Definite => 1.0,
1825 GroundTruthCertainty::Probable => 0.8,
1826 GroundTruthCertainty::Possible => 0.5,
1827 }
1828 }
1829
1830 pub fn name(&self) -> &'static str {
1832 match self {
1833 GroundTruthCertainty::Definite => "definite",
1834 GroundTruthCertainty::Probable => "probable",
1835 GroundTruthCertainty::Possible => "possible",
1836 }
1837 }
1838}
1839
1840#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1844pub enum DetectionMethod {
1845 RuleBased,
1847 Statistical,
1849 MachineLearning,
1851 GraphBased,
1853 ForensicAudit,
1855 Hybrid,
1857}
1858
1859impl DetectionMethod {
1860 pub fn name(&self) -> &'static str {
1862 match self {
1863 DetectionMethod::RuleBased => "rule_based",
1864 DetectionMethod::Statistical => "statistical",
1865 DetectionMethod::MachineLearning => "machine_learning",
1866 DetectionMethod::GraphBased => "graph_based",
1867 DetectionMethod::ForensicAudit => "forensic_audit",
1868 DetectionMethod::Hybrid => "hybrid",
1869 }
1870 }
1871
1872 pub fn description(&self) -> &'static str {
1874 match self {
1875 DetectionMethod::RuleBased => "Simple threshold and filter rules",
1876 DetectionMethod::Statistical => "Statistical distribution analysis",
1877 DetectionMethod::MachineLearning => "ML classification models",
1878 DetectionMethod::GraphBased => "Network and relationship analysis",
1879 DetectionMethod::ForensicAudit => "Manual forensic procedures",
1880 DetectionMethod::Hybrid => "Combined multi-method approach",
1881 }
1882 }
1883}
1884
1885#[derive(Debug, Clone, Serialize, Deserialize)]
1890pub struct ExtendedAnomalyLabel {
1891 pub base: LabeledAnomaly,
1893 pub category: AnomalyCategory,
1895 pub severity: AnomalySeverity,
1897 pub detection_difficulty: AnomalyDetectionDifficulty,
1899 pub recommended_methods: Vec<DetectionMethod>,
1901 pub key_indicators: Vec<String>,
1903 pub ground_truth_certainty: GroundTruthCertainty,
1905 pub contributing_factors: Vec<ContributingFactor>,
1907 pub related_entity_ids: Vec<String>,
1909 pub secondary_categories: Vec<AnomalyCategory>,
1911 #[serde(default, skip_serializing_if = "Option::is_none")]
1913 pub scheme_id: Option<String>,
1914 #[serde(default, skip_serializing_if = "Option::is_none")]
1916 pub scheme_stage: Option<u32>,
1917 #[serde(default)]
1919 pub is_near_miss: bool,
1920 #[serde(default, skip_serializing_if = "Option::is_none")]
1922 pub near_miss_explanation: Option<String>,
1923}
1924
1925impl ExtendedAnomalyLabel {
1926 pub fn from_base(base: LabeledAnomaly) -> Self {
1928 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1929 let severity = AnomalySeverity {
1930 level: SeverityLevel::from_numeric(base.severity),
1931 score: base.severity as f64 / 5.0,
1932 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
1933 is_material: false,
1934 materiality_threshold: None,
1935 };
1936
1937 Self {
1938 base,
1939 category,
1940 severity,
1941 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
1942 recommended_methods: vec![DetectionMethod::RuleBased],
1943 key_indicators: Vec::new(),
1944 ground_truth_certainty: GroundTruthCertainty::Definite,
1945 contributing_factors: Vec::new(),
1946 related_entity_ids: Vec::new(),
1947 secondary_categories: Vec::new(),
1948 scheme_id: None,
1949 scheme_stage: None,
1950 is_near_miss: false,
1951 near_miss_explanation: None,
1952 }
1953 }
1954
1955 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
1957 self.severity = severity;
1958 self
1959 }
1960
1961 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
1963 self.detection_difficulty = difficulty;
1964 self
1965 }
1966
1967 pub fn with_method(mut self, method: DetectionMethod) -> Self {
1969 if !self.recommended_methods.contains(&method) {
1970 self.recommended_methods.push(method);
1971 }
1972 self
1973 }
1974
1975 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
1977 self.recommended_methods = methods;
1978 self
1979 }
1980
1981 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
1983 self.key_indicators.push(indicator.into());
1984 self
1985 }
1986
1987 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
1989 self.ground_truth_certainty = certainty;
1990 self
1991 }
1992
1993 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1995 self.contributing_factors.push(factor);
1996 self
1997 }
1998
1999 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
2001 self.related_entity_ids.push(entity_id.into());
2002 self
2003 }
2004
2005 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
2007 if category != self.category && !self.secondary_categories.contains(&category) {
2008 self.secondary_categories.push(category);
2009 }
2010 self
2011 }
2012
2013 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
2015 self.scheme_id = Some(scheme_id.into());
2016 self.scheme_stage = Some(stage);
2017 self
2018 }
2019
2020 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
2022 self.is_near_miss = true;
2023 self.near_miss_explanation = Some(explanation.into());
2024 self
2025 }
2026
2027 pub fn to_features(&self) -> Vec<f64> {
2031 let mut features = self.base.to_features();
2032
2033 features.push(self.severity.score);
2035 features.push(self.severity.level.to_score());
2036 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
2037 features.push(self.detection_difficulty.difficulty_score());
2038 features.push(self.detection_difficulty.expected_detection_rate());
2039 features.push(self.ground_truth_certainty.certainty_score());
2040 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
2041 features.push(self.secondary_categories.len() as f64);
2042 features.push(self.contributing_factors.len() as f64);
2043 features.push(self.key_indicators.len() as f64);
2044 features.push(self.recommended_methods.len() as f64);
2045 features.push(self.related_entity_ids.len() as f64);
2046 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
2047 features.push(self.scheme_stage.unwrap_or(0) as f64);
2048 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
2049
2050 features
2051 }
2052
2053 pub fn feature_count() -> usize {
2055 30 }
2057
2058 pub fn feature_names() -> Vec<&'static str> {
2060 let mut names = LabeledAnomaly::feature_names();
2061 names.extend(vec![
2062 "severity_score",
2063 "severity_level_score",
2064 "is_material",
2065 "difficulty_score",
2066 "expected_detection_rate",
2067 "ground_truth_certainty",
2068 "category_ordinal",
2069 "secondary_category_count",
2070 "contributing_factor_count",
2071 "key_indicator_count",
2072 "recommended_method_count",
2073 "related_entity_count",
2074 "is_part_of_scheme",
2075 "scheme_stage",
2076 "is_near_miss",
2077 ]);
2078 names
2079 }
2080}
2081
2082#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2088pub enum SchemeType {
2089 GradualEmbezzlement,
2091 RevenueManipulation,
2093 VendorKickback,
2095 RoundTripping,
2097 GhostEmployee,
2099 ExpenseReimbursement,
2101 InventoryTheft,
2103 Custom,
2105}
2106
2107impl SchemeType {
2108 pub fn name(&self) -> &'static str {
2110 match self {
2111 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2112 SchemeType::RevenueManipulation => "revenue_manipulation",
2113 SchemeType::VendorKickback => "vendor_kickback",
2114 SchemeType::RoundTripping => "round_tripping",
2115 SchemeType::GhostEmployee => "ghost_employee",
2116 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2117 SchemeType::InventoryTheft => "inventory_theft",
2118 SchemeType::Custom => "custom",
2119 }
2120 }
2121
2122 pub fn typical_stages(&self) -> u32 {
2124 match self {
2125 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2133 }
2134 }
2135}
2136
2137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2139pub enum SchemeDetectionStatus {
2140 #[default]
2142 Undetected,
2143 UnderInvestigation,
2145 PartiallyDetected,
2147 FullyDetected,
2149}
2150
2151#[derive(Debug, Clone, Serialize, Deserialize)]
2153pub struct SchemeTransactionRef {
2154 pub document_id: String,
2156 pub date: chrono::NaiveDate,
2158 pub amount: Decimal,
2160 pub stage: u32,
2162 #[serde(default, skip_serializing_if = "Option::is_none")]
2164 pub anomaly_id: Option<String>,
2165}
2166
2167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2169pub enum ConcealmentTechnique {
2170 DocumentManipulation,
2172 ApprovalCircumvention,
2174 TimingExploitation,
2176 TransactionSplitting,
2178 AccountMisclassification,
2180 Collusion,
2182 DataAlteration,
2184 FalseDocumentation,
2186}
2187
2188impl ConcealmentTechnique {
2189 pub fn difficulty_bonus(&self) -> f64 {
2191 match self {
2192 ConcealmentTechnique::DocumentManipulation => 0.20,
2193 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2194 ConcealmentTechnique::TimingExploitation => 0.10,
2195 ConcealmentTechnique::TransactionSplitting => 0.15,
2196 ConcealmentTechnique::AccountMisclassification => 0.10,
2197 ConcealmentTechnique::Collusion => 0.25,
2198 ConcealmentTechnique::DataAlteration => 0.20,
2199 ConcealmentTechnique::FalseDocumentation => 0.15,
2200 }
2201 }
2202}
2203
2204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2221pub enum AcfeFraudCategory {
2222 #[default]
2225 AssetMisappropriation,
2226 Corruption,
2229 FinancialStatementFraud,
2232}
2233
2234impl AcfeFraudCategory {
2235 pub fn name(&self) -> &'static str {
2237 match self {
2238 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2239 AcfeFraudCategory::Corruption => "corruption",
2240 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2241 }
2242 }
2243
2244 pub fn typical_occurrence_rate(&self) -> f64 {
2246 match self {
2247 AcfeFraudCategory::AssetMisappropriation => 0.86,
2248 AcfeFraudCategory::Corruption => 0.33,
2249 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2250 }
2251 }
2252
2253 pub fn typical_median_loss(&self) -> Decimal {
2255 match self {
2256 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2257 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2258 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2259 }
2260 }
2261
2262 pub fn typical_detection_months(&self) -> u32 {
2264 match self {
2265 AcfeFraudCategory::AssetMisappropriation => 12,
2266 AcfeFraudCategory::Corruption => 18,
2267 AcfeFraudCategory::FinancialStatementFraud => 24,
2268 }
2269 }
2270}
2271
2272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2279pub enum CashFraudScheme {
2280 Larceny,
2283 Skimming,
2285
2286 SalesSkimming,
2289 ReceivablesSkimming,
2291 RefundSchemes,
2293
2294 ShellCompany,
2297 NonAccompliceVendor,
2299 PersonalPurchases,
2301
2302 GhostEmployee,
2305 FalsifiedWages,
2307 CommissionSchemes,
2309
2310 MischaracterizedExpenses,
2313 OverstatedExpenses,
2315 FictitiousExpenses,
2317
2318 ForgedMaker,
2321 ForgedEndorsement,
2323 AlteredPayee,
2325 AuthorizedMaker,
2327
2328 FalseVoids,
2331 FalseRefunds,
2333}
2334
2335impl CashFraudScheme {
2336 pub fn category(&self) -> AcfeFraudCategory {
2338 AcfeFraudCategory::AssetMisappropriation
2339 }
2340
2341 pub fn subcategory(&self) -> &'static str {
2343 match self {
2344 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2345 CashFraudScheme::SalesSkimming
2346 | CashFraudScheme::ReceivablesSkimming
2347 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2348 CashFraudScheme::ShellCompany
2349 | CashFraudScheme::NonAccompliceVendor
2350 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2351 CashFraudScheme::GhostEmployee
2352 | CashFraudScheme::FalsifiedWages
2353 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2354 CashFraudScheme::MischaracterizedExpenses
2355 | CashFraudScheme::OverstatedExpenses
2356 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2357 CashFraudScheme::ForgedMaker
2358 | CashFraudScheme::ForgedEndorsement
2359 | CashFraudScheme::AlteredPayee
2360 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2361 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2362 }
2363 }
2364
2365 pub fn severity(&self) -> u8 {
2367 match self {
2368 CashFraudScheme::FalseVoids
2370 | CashFraudScheme::FalseRefunds
2371 | CashFraudScheme::MischaracterizedExpenses => 3,
2372 CashFraudScheme::OverstatedExpenses
2374 | CashFraudScheme::Skimming
2375 | CashFraudScheme::Larceny
2376 | CashFraudScheme::PersonalPurchases
2377 | CashFraudScheme::FalsifiedWages => 4,
2378 CashFraudScheme::ShellCompany
2380 | CashFraudScheme::GhostEmployee
2381 | CashFraudScheme::FictitiousExpenses
2382 | CashFraudScheme::ForgedMaker
2383 | CashFraudScheme::AuthorizedMaker => 5,
2384 _ => 4,
2385 }
2386 }
2387
2388 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2390 match self {
2391 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2393 AnomalyDetectionDifficulty::Easy
2394 }
2395 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2397 AnomalyDetectionDifficulty::Moderate
2398 }
2399 CashFraudScheme::Skimming
2401 | CashFraudScheme::ShellCompany
2402 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2403 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2405 AnomalyDetectionDifficulty::Expert
2406 }
2407 _ => AnomalyDetectionDifficulty::Moderate,
2408 }
2409 }
2410
2411 pub fn all_variants() -> &'static [CashFraudScheme] {
2413 &[
2414 CashFraudScheme::Larceny,
2415 CashFraudScheme::Skimming,
2416 CashFraudScheme::SalesSkimming,
2417 CashFraudScheme::ReceivablesSkimming,
2418 CashFraudScheme::RefundSchemes,
2419 CashFraudScheme::ShellCompany,
2420 CashFraudScheme::NonAccompliceVendor,
2421 CashFraudScheme::PersonalPurchases,
2422 CashFraudScheme::GhostEmployee,
2423 CashFraudScheme::FalsifiedWages,
2424 CashFraudScheme::CommissionSchemes,
2425 CashFraudScheme::MischaracterizedExpenses,
2426 CashFraudScheme::OverstatedExpenses,
2427 CashFraudScheme::FictitiousExpenses,
2428 CashFraudScheme::ForgedMaker,
2429 CashFraudScheme::ForgedEndorsement,
2430 CashFraudScheme::AlteredPayee,
2431 CashFraudScheme::AuthorizedMaker,
2432 CashFraudScheme::FalseVoids,
2433 CashFraudScheme::FalseRefunds,
2434 ]
2435 }
2436}
2437
2438#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2440pub enum AssetFraudScheme {
2441 InventoryMisuse,
2444 InventoryTheft,
2446 InventoryPurchasingScheme,
2448 InventoryReceivingScheme,
2450
2451 EquipmentMisuse,
2454 EquipmentTheft,
2456 IntellectualPropertyTheft,
2458 TimeTheft,
2460}
2461
2462impl AssetFraudScheme {
2463 pub fn category(&self) -> AcfeFraudCategory {
2465 AcfeFraudCategory::AssetMisappropriation
2466 }
2467
2468 pub fn subcategory(&self) -> &'static str {
2470 match self {
2471 AssetFraudScheme::InventoryMisuse
2472 | AssetFraudScheme::InventoryTheft
2473 | AssetFraudScheme::InventoryPurchasingScheme
2474 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2475 _ => "other_assets",
2476 }
2477 }
2478
2479 pub fn severity(&self) -> u8 {
2481 match self {
2482 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2483 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2484 AssetFraudScheme::InventoryTheft
2485 | AssetFraudScheme::InventoryPurchasingScheme
2486 | AssetFraudScheme::InventoryReceivingScheme => 4,
2487 AssetFraudScheme::IntellectualPropertyTheft => 5,
2488 }
2489 }
2490}
2491
2492#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2497pub enum CorruptionScheme {
2498 PurchasingConflict,
2501 SalesConflict,
2503 OutsideBusinessInterest,
2505 NepotismConflict,
2507
2508 InvoiceKickback,
2511 BidRigging,
2513 CashBribery,
2515 PublicOfficial,
2517
2518 IllegalGratuity,
2521
2522 EconomicExtortion,
2525}
2526
2527impl CorruptionScheme {
2528 pub fn category(&self) -> AcfeFraudCategory {
2530 AcfeFraudCategory::Corruption
2531 }
2532
2533 pub fn subcategory(&self) -> &'static str {
2535 match self {
2536 CorruptionScheme::PurchasingConflict
2537 | CorruptionScheme::SalesConflict
2538 | CorruptionScheme::OutsideBusinessInterest
2539 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2540 CorruptionScheme::InvoiceKickback
2541 | CorruptionScheme::BidRigging
2542 | CorruptionScheme::CashBribery
2543 | CorruptionScheme::PublicOfficial => "bribery",
2544 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2545 CorruptionScheme::EconomicExtortion => "economic_extortion",
2546 }
2547 }
2548
2549 pub fn severity(&self) -> u8 {
2551 match self {
2552 CorruptionScheme::NepotismConflict => 3,
2554 CorruptionScheme::PurchasingConflict
2556 | CorruptionScheme::SalesConflict
2557 | CorruptionScheme::OutsideBusinessInterest
2558 | CorruptionScheme::IllegalGratuity => 4,
2559 CorruptionScheme::InvoiceKickback
2561 | CorruptionScheme::BidRigging
2562 | CorruptionScheme::CashBribery
2563 | CorruptionScheme::EconomicExtortion => 5,
2564 CorruptionScheme::PublicOfficial => 5,
2566 }
2567 }
2568
2569 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2571 match self {
2572 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2574 AnomalyDetectionDifficulty::Moderate
2575 }
2576 CorruptionScheme::PurchasingConflict
2578 | CorruptionScheme::SalesConflict
2579 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2580 CorruptionScheme::InvoiceKickback
2582 | CorruptionScheme::CashBribery
2583 | CorruptionScheme::PublicOfficial
2584 | CorruptionScheme::IllegalGratuity
2585 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2586 }
2587 }
2588
2589 pub fn all_variants() -> &'static [CorruptionScheme] {
2591 &[
2592 CorruptionScheme::PurchasingConflict,
2593 CorruptionScheme::SalesConflict,
2594 CorruptionScheme::OutsideBusinessInterest,
2595 CorruptionScheme::NepotismConflict,
2596 CorruptionScheme::InvoiceKickback,
2597 CorruptionScheme::BidRigging,
2598 CorruptionScheme::CashBribery,
2599 CorruptionScheme::PublicOfficial,
2600 CorruptionScheme::IllegalGratuity,
2601 CorruptionScheme::EconomicExtortion,
2602 ]
2603 }
2604}
2605
2606#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2611pub enum FinancialStatementScheme {
2612 PrematureRevenue,
2615 DelayedExpenses,
2617 FictitiousRevenues,
2619 ConcealedLiabilities,
2621 ImproperAssetValuations,
2623 ImproperDisclosures,
2625 ChannelStuffing,
2627 BillAndHold,
2629 ImproperCapitalization,
2631
2632 UnderstatedRevenues,
2635 OverstatedExpenses,
2637 OverstatedLiabilities,
2639 ImproperAssetWritedowns,
2641}
2642
2643impl FinancialStatementScheme {
2644 pub fn category(&self) -> AcfeFraudCategory {
2646 AcfeFraudCategory::FinancialStatementFraud
2647 }
2648
2649 pub fn subcategory(&self) -> &'static str {
2651 match self {
2652 FinancialStatementScheme::UnderstatedRevenues
2653 | FinancialStatementScheme::OverstatedExpenses
2654 | FinancialStatementScheme::OverstatedLiabilities
2655 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2656 _ => "overstatement",
2657 }
2658 }
2659
2660 pub fn severity(&self) -> u8 {
2662 5
2664 }
2665
2666 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2668 match self {
2669 FinancialStatementScheme::ChannelStuffing
2671 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2672 FinancialStatementScheme::PrematureRevenue
2674 | FinancialStatementScheme::ImproperCapitalization
2675 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2676 FinancialStatementScheme::FictitiousRevenues
2678 | FinancialStatementScheme::ConcealedLiabilities
2679 | FinancialStatementScheme::ImproperAssetValuations
2680 | FinancialStatementScheme::ImproperDisclosures
2681 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2682 _ => AnomalyDetectionDifficulty::Hard,
2683 }
2684 }
2685
2686 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2688 &[
2689 FinancialStatementScheme::PrematureRevenue,
2690 FinancialStatementScheme::DelayedExpenses,
2691 FinancialStatementScheme::FictitiousRevenues,
2692 FinancialStatementScheme::ConcealedLiabilities,
2693 FinancialStatementScheme::ImproperAssetValuations,
2694 FinancialStatementScheme::ImproperDisclosures,
2695 FinancialStatementScheme::ChannelStuffing,
2696 FinancialStatementScheme::BillAndHold,
2697 FinancialStatementScheme::ImproperCapitalization,
2698 FinancialStatementScheme::UnderstatedRevenues,
2699 FinancialStatementScheme::OverstatedExpenses,
2700 FinancialStatementScheme::OverstatedLiabilities,
2701 FinancialStatementScheme::ImproperAssetWritedowns,
2702 ]
2703 }
2704}
2705
2706#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2708pub enum AcfeScheme {
2709 Cash(CashFraudScheme),
2711 Asset(AssetFraudScheme),
2713 Corruption(CorruptionScheme),
2715 FinancialStatement(FinancialStatementScheme),
2717}
2718
2719impl AcfeScheme {
2720 pub fn category(&self) -> AcfeFraudCategory {
2722 match self {
2723 AcfeScheme::Cash(s) => s.category(),
2724 AcfeScheme::Asset(s) => s.category(),
2725 AcfeScheme::Corruption(s) => s.category(),
2726 AcfeScheme::FinancialStatement(s) => s.category(),
2727 }
2728 }
2729
2730 pub fn severity(&self) -> u8 {
2732 match self {
2733 AcfeScheme::Cash(s) => s.severity(),
2734 AcfeScheme::Asset(s) => s.severity(),
2735 AcfeScheme::Corruption(s) => s.severity(),
2736 AcfeScheme::FinancialStatement(s) => s.severity(),
2737 }
2738 }
2739
2740 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2742 match self {
2743 AcfeScheme::Cash(s) => s.detection_difficulty(),
2744 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2745 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2746 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2747 }
2748 }
2749}
2750
2751#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2753pub enum AcfeDetectionMethod {
2754 Tip,
2756 InternalAudit,
2758 ManagementReview,
2760 ExternalAudit,
2762 AccountReconciliation,
2764 DocumentExamination,
2766 ByAccident,
2768 ItControls,
2770 Surveillance,
2772 Confession,
2774 LawEnforcement,
2776 Other,
2778}
2779
2780impl AcfeDetectionMethod {
2781 pub fn typical_detection_rate(&self) -> f64 {
2783 match self {
2784 AcfeDetectionMethod::Tip => 0.42,
2785 AcfeDetectionMethod::InternalAudit => 0.16,
2786 AcfeDetectionMethod::ManagementReview => 0.12,
2787 AcfeDetectionMethod::ExternalAudit => 0.04,
2788 AcfeDetectionMethod::AccountReconciliation => 0.05,
2789 AcfeDetectionMethod::DocumentExamination => 0.04,
2790 AcfeDetectionMethod::ByAccident => 0.06,
2791 AcfeDetectionMethod::ItControls => 0.03,
2792 AcfeDetectionMethod::Surveillance => 0.02,
2793 AcfeDetectionMethod::Confession => 0.02,
2794 AcfeDetectionMethod::LawEnforcement => 0.01,
2795 AcfeDetectionMethod::Other => 0.03,
2796 }
2797 }
2798
2799 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2801 &[
2802 AcfeDetectionMethod::Tip,
2803 AcfeDetectionMethod::InternalAudit,
2804 AcfeDetectionMethod::ManagementReview,
2805 AcfeDetectionMethod::ExternalAudit,
2806 AcfeDetectionMethod::AccountReconciliation,
2807 AcfeDetectionMethod::DocumentExamination,
2808 AcfeDetectionMethod::ByAccident,
2809 AcfeDetectionMethod::ItControls,
2810 AcfeDetectionMethod::Surveillance,
2811 AcfeDetectionMethod::Confession,
2812 AcfeDetectionMethod::LawEnforcement,
2813 AcfeDetectionMethod::Other,
2814 ]
2815 }
2816}
2817
2818#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2820pub enum PerpetratorDepartment {
2821 Accounting,
2823 Operations,
2825 Executive,
2827 Sales,
2829 CustomerService,
2831 Purchasing,
2833 It,
2835 HumanResources,
2837 Administrative,
2839 Warehouse,
2841 BoardOfDirectors,
2843 Other,
2845}
2846
2847impl PerpetratorDepartment {
2848 pub fn typical_occurrence_rate(&self) -> f64 {
2850 match self {
2851 PerpetratorDepartment::Accounting => 0.21,
2852 PerpetratorDepartment::Operations => 0.17,
2853 PerpetratorDepartment::Executive => 0.12,
2854 PerpetratorDepartment::Sales => 0.11,
2855 PerpetratorDepartment::CustomerService => 0.07,
2856 PerpetratorDepartment::Purchasing => 0.06,
2857 PerpetratorDepartment::It => 0.05,
2858 PerpetratorDepartment::HumanResources => 0.04,
2859 PerpetratorDepartment::Administrative => 0.04,
2860 PerpetratorDepartment::Warehouse => 0.03,
2861 PerpetratorDepartment::BoardOfDirectors => 0.02,
2862 PerpetratorDepartment::Other => 0.08,
2863 }
2864 }
2865
2866 pub fn typical_median_loss(&self) -> Decimal {
2868 match self {
2869 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
2870 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
2871 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
2872 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
2873 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
2874 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
2875 PerpetratorDepartment::It => Decimal::new(100_000, 0),
2876 _ => Decimal::new(80_000, 0),
2877 }
2878 }
2879}
2880
2881#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2883pub enum PerpetratorLevel {
2884 Employee,
2886 Manager,
2888 OwnerExecutive,
2890}
2891
2892impl PerpetratorLevel {
2893 pub fn typical_occurrence_rate(&self) -> f64 {
2895 match self {
2896 PerpetratorLevel::Employee => 0.42,
2897 PerpetratorLevel::Manager => 0.36,
2898 PerpetratorLevel::OwnerExecutive => 0.22,
2899 }
2900 }
2901
2902 pub fn typical_median_loss(&self) -> Decimal {
2904 match self {
2905 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
2906 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
2907 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
2908 }
2909 }
2910}
2911
2912#[derive(Debug, Clone, Serialize, Deserialize)]
2917pub struct AcfeCalibration {
2918 pub median_loss: Decimal,
2920 pub median_duration_months: u32,
2922 pub category_distribution: HashMap<String, f64>,
2924 pub detection_method_distribution: HashMap<String, f64>,
2926 pub department_distribution: HashMap<String, f64>,
2928 pub level_distribution: HashMap<String, f64>,
2930 pub avg_red_flags_per_case: f64,
2932 pub collusion_rate: f64,
2934}
2935
2936impl Default for AcfeCalibration {
2937 fn default() -> Self {
2938 let mut category_distribution = HashMap::new();
2939 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
2940 category_distribution.insert("corruption".to_string(), 0.33);
2941 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
2942
2943 let mut detection_method_distribution = HashMap::new();
2944 for method in AcfeDetectionMethod::all_variants() {
2945 detection_method_distribution.insert(
2946 format!("{method:?}").to_lowercase(),
2947 method.typical_detection_rate(),
2948 );
2949 }
2950
2951 let mut department_distribution = HashMap::new();
2952 department_distribution.insert("accounting".to_string(), 0.21);
2953 department_distribution.insert("operations".to_string(), 0.17);
2954 department_distribution.insert("executive".to_string(), 0.12);
2955 department_distribution.insert("sales".to_string(), 0.11);
2956 department_distribution.insert("customer_service".to_string(), 0.07);
2957 department_distribution.insert("purchasing".to_string(), 0.06);
2958 department_distribution.insert("other".to_string(), 0.26);
2959
2960 let mut level_distribution = HashMap::new();
2961 level_distribution.insert("employee".to_string(), 0.42);
2962 level_distribution.insert("manager".to_string(), 0.36);
2963 level_distribution.insert("owner_executive".to_string(), 0.22);
2964
2965 Self {
2966 median_loss: Decimal::new(117_000, 0),
2967 median_duration_months: 12,
2968 category_distribution,
2969 detection_method_distribution,
2970 department_distribution,
2971 level_distribution,
2972 avg_red_flags_per_case: 2.8,
2973 collusion_rate: 0.50,
2974 }
2975 }
2976}
2977
2978impl AcfeCalibration {
2979 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
2981 Self {
2982 median_loss,
2983 median_duration_months,
2984 ..Self::default()
2985 }
2986 }
2987
2988 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
2990 category.typical_median_loss()
2991 }
2992
2993 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
2995 category.typical_detection_months()
2996 }
2997
2998 pub fn validate(&self) -> Result<(), String> {
3000 if self.median_loss <= Decimal::ZERO {
3001 return Err("Median loss must be positive".to_string());
3002 }
3003 if self.median_duration_months == 0 {
3004 return Err("Median duration must be at least 1 month".to_string());
3005 }
3006 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
3007 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
3008 }
3009 Ok(())
3010 }
3011}
3012
3013#[derive(Debug, Clone, Serialize, Deserialize)]
3018pub struct FraudTriangle {
3019 pub pressure: PressureType,
3021 pub opportunities: Vec<OpportunityFactor>,
3023 pub rationalization: Rationalization,
3025}
3026
3027impl FraudTriangle {
3028 pub fn new(
3030 pressure: PressureType,
3031 opportunities: Vec<OpportunityFactor>,
3032 rationalization: Rationalization,
3033 ) -> Self {
3034 Self {
3035 pressure,
3036 opportunities,
3037 rationalization,
3038 }
3039 }
3040
3041 pub fn risk_score(&self) -> f64 {
3043 let pressure_score = self.pressure.risk_weight();
3044 let opportunity_score: f64 = self
3045 .opportunities
3046 .iter()
3047 .map(OpportunityFactor::risk_weight)
3048 .sum::<f64>()
3049 / self.opportunities.len().max(1) as f64;
3050 let rationalization_score = self.rationalization.risk_weight();
3051
3052 (pressure_score + opportunity_score + rationalization_score) / 3.0
3053 }
3054}
3055
3056#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3058pub enum PressureType {
3059 PersonalFinancialDifficulties,
3062 FinancialTargets,
3064 MarketExpectations,
3066 CovenantCompliance,
3068 CreditRatingMaintenance,
3070 AcquisitionValuation,
3072
3073 JobSecurity,
3076 StatusMaintenance,
3078 GamblingAddiction,
3080 SubstanceAbuse,
3082 FamilyPressure,
3084 Greed,
3086}
3087
3088impl PressureType {
3089 pub fn risk_weight(&self) -> f64 {
3091 match self {
3092 PressureType::PersonalFinancialDifficulties => 0.80,
3093 PressureType::FinancialTargets => 0.75,
3094 PressureType::MarketExpectations => 0.70,
3095 PressureType::CovenantCompliance => 0.85,
3096 PressureType::CreditRatingMaintenance => 0.70,
3097 PressureType::AcquisitionValuation => 0.75,
3098 PressureType::JobSecurity => 0.65,
3099 PressureType::StatusMaintenance => 0.55,
3100 PressureType::GamblingAddiction => 0.90,
3101 PressureType::SubstanceAbuse => 0.85,
3102 PressureType::FamilyPressure => 0.60,
3103 PressureType::Greed => 0.70,
3104 }
3105 }
3106}
3107
3108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3110pub enum OpportunityFactor {
3111 WeakInternalControls,
3113 LackOfSegregation,
3115 ManagementOverride,
3117 ComplexTransactions,
3119 RelatedPartyTransactions,
3121 PoorToneAtTop,
3123 InadequateSupervision,
3125 AssetAccess,
3127 PoorRecordKeeping,
3129 LackOfDiscipline,
3131 LackOfIndependentChecks,
3133}
3134
3135impl OpportunityFactor {
3136 pub fn risk_weight(&self) -> f64 {
3138 match self {
3139 OpportunityFactor::WeakInternalControls => 0.85,
3140 OpportunityFactor::LackOfSegregation => 0.80,
3141 OpportunityFactor::ManagementOverride => 0.90,
3142 OpportunityFactor::ComplexTransactions => 0.70,
3143 OpportunityFactor::RelatedPartyTransactions => 0.75,
3144 OpportunityFactor::PoorToneAtTop => 0.85,
3145 OpportunityFactor::InadequateSupervision => 0.75,
3146 OpportunityFactor::AssetAccess => 0.70,
3147 OpportunityFactor::PoorRecordKeeping => 0.65,
3148 OpportunityFactor::LackOfDiscipline => 0.60,
3149 OpportunityFactor::LackOfIndependentChecks => 0.75,
3150 }
3151 }
3152}
3153
3154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3156pub enum Rationalization {
3157 TemporaryBorrowing,
3159 EveryoneDoesIt,
3161 ForTheCompanyGood,
3163 Entitlement,
3165 FollowingOrders,
3167 TheyWontMissIt,
3169 NeedItMore,
3171 NotReallyStealing,
3173 Underpaid,
3175 VictimlessCrime,
3177}
3178
3179impl Rationalization {
3180 pub fn risk_weight(&self) -> f64 {
3182 match self {
3183 Rationalization::Entitlement => 0.85,
3185 Rationalization::EveryoneDoesIt => 0.80,
3186 Rationalization::NotReallyStealing => 0.80,
3187 Rationalization::TheyWontMissIt => 0.75,
3188 Rationalization::Underpaid => 0.70,
3190 Rationalization::ForTheCompanyGood => 0.65,
3191 Rationalization::NeedItMore => 0.65,
3192 Rationalization::TemporaryBorrowing => 0.60,
3194 Rationalization::FollowingOrders => 0.55,
3195 Rationalization::VictimlessCrime => 0.60,
3196 }
3197 }
3198}
3199
3200#[derive(Debug, Clone, Serialize, Deserialize)]
3206pub enum NearMissPattern {
3207 NearDuplicate {
3209 date_difference_days: u32,
3211 similar_transaction_id: String,
3213 },
3214 ThresholdProximity {
3216 threshold: Decimal,
3218 proximity: f64,
3220 },
3221 UnusualLegitimate {
3223 pattern_type: LegitimatePatternType,
3225 justification: String,
3227 },
3228 CorrectedError {
3230 correction_lag_days: u32,
3232 correction_document_id: String,
3234 },
3235}
3236
3237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3239pub enum LegitimatePatternType {
3240 YearEndBonus,
3242 ContractPrepayment,
3244 SettlementPayment,
3246 InsuranceClaim,
3248 OneTimePayment,
3250 AssetDisposal,
3252 SeasonalInventory,
3254 PromotionalSpending,
3256}
3257
3258impl LegitimatePatternType {
3259 pub fn description(&self) -> &'static str {
3261 match self {
3262 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3263 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3264 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3265 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3266 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3267 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3268 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3269 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3270 }
3271 }
3272}
3273
3274#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3276pub enum FalsePositiveTrigger {
3277 AmountNearThreshold,
3279 UnusualTiming,
3281 SimilarTransaction,
3283 NewCounterparty,
3285 UnusualAccountCombination,
3287 VolumeSpike,
3289 RoundAmount,
3291}
3292
3293#[derive(Debug, Clone, Serialize, Deserialize)]
3295pub struct NearMissLabel {
3296 pub document_id: String,
3298 pub pattern: NearMissPattern,
3300 pub suspicion_score: f64,
3302 pub false_positive_trigger: FalsePositiveTrigger,
3304 pub explanation: String,
3306}
3307
3308impl NearMissLabel {
3309 pub fn new(
3311 document_id: impl Into<String>,
3312 pattern: NearMissPattern,
3313 suspicion_score: f64,
3314 trigger: FalsePositiveTrigger,
3315 explanation: impl Into<String>,
3316 ) -> Self {
3317 Self {
3318 document_id: document_id.into(),
3319 pattern,
3320 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3321 false_positive_trigger: trigger,
3322 explanation: explanation.into(),
3323 }
3324 }
3325}
3326
3327#[derive(Debug, Clone, Serialize, Deserialize)]
3329pub struct AnomalyRateConfig {
3330 pub total_rate: f64,
3332 pub fraud_rate: f64,
3334 pub error_rate: f64,
3336 pub process_issue_rate: f64,
3338 pub statistical_rate: f64,
3340 pub relational_rate: f64,
3342}
3343
3344impl Default for AnomalyRateConfig {
3345 fn default() -> Self {
3346 Self {
3347 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, }
3354 }
3355}
3356
3357impl AnomalyRateConfig {
3358 pub fn validate(&self) -> Result<(), String> {
3360 let sum = self.fraud_rate
3361 + self.error_rate
3362 + self.process_issue_rate
3363 + self.statistical_rate
3364 + self.relational_rate;
3365
3366 if (sum - 1.0).abs() > 0.01 {
3367 return Err(format!("Anomaly category rates must sum to 1.0, got {sum}"));
3368 }
3369
3370 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3371 return Err(format!(
3372 "Total rate must be between 0.0 and 1.0, got {}",
3373 self.total_rate
3374 ));
3375 }
3376
3377 Ok(())
3378 }
3379}
3380
3381#[cfg(test)]
3382#[allow(clippy::unwrap_used)]
3383mod tests {
3384 use super::*;
3385 use rust_decimal_macros::dec;
3386
3387 #[test]
3388 fn test_anomaly_type_category() {
3389 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3390 assert_eq!(fraud.category(), "Fraud");
3391 assert!(fraud.is_intentional());
3392
3393 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3394 assert_eq!(error.category(), "Error");
3395 assert!(!error.is_intentional());
3396 }
3397
3398 #[test]
3399 fn test_labeled_anomaly() {
3400 let anomaly = LabeledAnomaly::new(
3401 "ANO001".to_string(),
3402 AnomalyType::Fraud(FraudType::SelfApproval),
3403 "JE001".to_string(),
3404 "JE".to_string(),
3405 "1000".to_string(),
3406 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3407 )
3408 .with_description("User approved their own expense report")
3409 .with_related_entity("USER001");
3410
3411 assert_eq!(anomaly.severity, 3);
3412 assert!(anomaly.is_injected);
3413 assert_eq!(anomaly.related_entities.len(), 1);
3414 }
3415
3416 #[test]
3417 fn test_labeled_anomaly_with_provenance() {
3418 let anomaly = LabeledAnomaly::new(
3419 "ANO001".to_string(),
3420 AnomalyType::Fraud(FraudType::SelfApproval),
3421 "JE001".to_string(),
3422 "JE".to_string(),
3423 "1000".to_string(),
3424 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3425 )
3426 .with_run_id("run-123")
3427 .with_generation_seed(42)
3428 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3429 .with_structured_strategy(InjectionStrategy::SelfApproval {
3430 user_id: "USER001".to_string(),
3431 })
3432 .with_scenario("scenario-001")
3433 .with_original_document_hash("abc123");
3434
3435 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3436 assert_eq!(anomaly.generation_seed, Some(42));
3437 assert!(anomaly.causal_reason.is_some());
3438 assert!(anomaly.structured_strategy.is_some());
3439 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3440 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3441
3442 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3444 }
3445
3446 #[test]
3447 fn test_labeled_anomaly_derivation_chain() {
3448 let parent = LabeledAnomaly::new(
3449 "ANO001".to_string(),
3450 AnomalyType::Fraud(FraudType::DuplicatePayment),
3451 "JE001".to_string(),
3452 "JE".to_string(),
3453 "1000".to_string(),
3454 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3455 );
3456
3457 let child = LabeledAnomaly::new(
3458 "ANO002".to_string(),
3459 AnomalyType::Error(ErrorType::DuplicateEntry),
3460 "JE002".to_string(),
3461 "JE".to_string(),
3462 "1000".to_string(),
3463 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3464 )
3465 .with_parent_anomaly(&parent.anomaly_id);
3466
3467 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3468 }
3469
3470 #[test]
3471 fn test_injection_strategy_description() {
3472 let strategy = InjectionStrategy::AmountManipulation {
3473 original: dec!(1000),
3474 factor: 2.5,
3475 };
3476 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3477 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3478
3479 let strategy = InjectionStrategy::ThresholdAvoidance {
3480 threshold: dec!(10000),
3481 adjusted_amount: dec!(9999),
3482 };
3483 assert_eq!(
3484 strategy.description(),
3485 "Amount adjusted to avoid 10000 threshold"
3486 );
3487
3488 let strategy = InjectionStrategy::DateShift {
3489 days_shifted: -5,
3490 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3491 };
3492 assert_eq!(strategy.description(), "Date backdated by 5 days");
3493
3494 let strategy = InjectionStrategy::DateShift {
3495 days_shifted: 3,
3496 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3497 };
3498 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3499 }
3500
3501 #[test]
3502 fn test_causal_reason_variants() {
3503 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3504 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3505 assert!((base_rate - 0.02).abs() < 0.001);
3506 }
3507
3508 let reason = AnomalyCausalReason::TemporalPattern {
3509 pattern_name: "year_end_spike".to_string(),
3510 };
3511 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3512 assert_eq!(pattern_name, "year_end_spike");
3513 }
3514
3515 let reason = AnomalyCausalReason::ScenarioStep {
3516 scenario_type: "kickback".to_string(),
3517 step_number: 3,
3518 };
3519 if let AnomalyCausalReason::ScenarioStep {
3520 scenario_type,
3521 step_number,
3522 } = reason
3523 {
3524 assert_eq!(scenario_type, "kickback");
3525 assert_eq!(step_number, 3);
3526 }
3527 }
3528
3529 #[test]
3530 fn test_feature_vector_length() {
3531 let anomaly = LabeledAnomaly::new(
3532 "ANO001".to_string(),
3533 AnomalyType::Fraud(FraudType::SelfApproval),
3534 "JE001".to_string(),
3535 "JE".to_string(),
3536 "1000".to_string(),
3537 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3538 );
3539
3540 let features = anomaly.to_features();
3541 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3542 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3543 }
3544
3545 #[test]
3546 fn test_feature_vector_with_provenance() {
3547 let anomaly = LabeledAnomaly::new(
3548 "ANO001".to_string(),
3549 AnomalyType::Fraud(FraudType::SelfApproval),
3550 "JE001".to_string(),
3551 "JE".to_string(),
3552 "1000".to_string(),
3553 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3554 )
3555 .with_scenario("scenario-001")
3556 .with_parent_anomaly("ANO000");
3557
3558 let features = anomaly.to_features();
3559
3560 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3564
3565 #[test]
3566 fn test_anomaly_summary() {
3567 let anomalies = vec![
3568 LabeledAnomaly::new(
3569 "ANO001".to_string(),
3570 AnomalyType::Fraud(FraudType::SelfApproval),
3571 "JE001".to_string(),
3572 "JE".to_string(),
3573 "1000".to_string(),
3574 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3575 ),
3576 LabeledAnomaly::new(
3577 "ANO002".to_string(),
3578 AnomalyType::Error(ErrorType::DuplicateEntry),
3579 "JE002".to_string(),
3580 "JE".to_string(),
3581 "1000".to_string(),
3582 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3583 ),
3584 ];
3585
3586 let summary = AnomalySummary::from_anomalies(&anomalies);
3587
3588 assert_eq!(summary.total_count, 2);
3589 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3590 assert_eq!(summary.by_category.get("Error"), Some(&1));
3591 }
3592
3593 #[test]
3594 fn test_rate_config_validation() {
3595 let config = AnomalyRateConfig::default();
3596 assert!(config.validate().is_ok());
3597
3598 let bad_config = AnomalyRateConfig {
3599 fraud_rate: 0.5,
3600 error_rate: 0.5,
3601 process_issue_rate: 0.5, ..Default::default()
3603 };
3604 assert!(bad_config.validate().is_err());
3605 }
3606
3607 #[test]
3608 fn test_injection_strategy_serialization() {
3609 let strategy = InjectionStrategy::SoDViolation {
3610 duty1: "CreatePO".to_string(),
3611 duty2: "ApprovePO".to_string(),
3612 violating_user: "USER001".to_string(),
3613 };
3614
3615 let json = serde_json::to_string(&strategy).unwrap();
3616 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3617
3618 assert_eq!(strategy, deserialized);
3619 }
3620
3621 #[test]
3622 fn test_labeled_anomaly_serialization_with_provenance() {
3623 let anomaly = LabeledAnomaly::new(
3624 "ANO001".to_string(),
3625 AnomalyType::Fraud(FraudType::SelfApproval),
3626 "JE001".to_string(),
3627 "JE".to_string(),
3628 "1000".to_string(),
3629 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3630 )
3631 .with_run_id("run-123")
3632 .with_generation_seed(42)
3633 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3634
3635 let json = serde_json::to_string(&anomaly).unwrap();
3636 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3637
3638 assert_eq!(anomaly.run_id, deserialized.run_id);
3639 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3640 }
3641
3642 #[test]
3647 fn test_anomaly_category_from_anomaly_type() {
3648 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3650 assert_eq!(
3651 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3652 AnomalyCategory::FictitiousVendor
3653 );
3654
3655 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3656 assert_eq!(
3657 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3658 AnomalyCategory::VendorKickback
3659 );
3660
3661 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3662 assert_eq!(
3663 AnomalyCategory::from_anomaly_type(&fraud_structured),
3664 AnomalyCategory::StructuredTransaction
3665 );
3666
3667 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3669 assert_eq!(
3670 AnomalyCategory::from_anomaly_type(&error_duplicate),
3671 AnomalyCategory::DuplicatePayment
3672 );
3673
3674 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3676 assert_eq!(
3677 AnomalyCategory::from_anomaly_type(&process_skip),
3678 AnomalyCategory::MissingApproval
3679 );
3680
3681 let relational_circular =
3683 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3684 assert_eq!(
3685 AnomalyCategory::from_anomaly_type(&relational_circular),
3686 AnomalyCategory::CircularFlow
3687 );
3688 }
3689
3690 #[test]
3691 fn test_anomaly_category_ordinal() {
3692 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3693 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3694 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3695 }
3696
3697 #[test]
3698 fn test_contributing_factor() {
3699 let factor = ContributingFactor::new(
3700 FactorType::AmountDeviation,
3701 15000.0,
3702 10000.0,
3703 true,
3704 0.5,
3705 "Amount exceeds threshold",
3706 );
3707
3708 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3709 assert_eq!(factor.value, 15000.0);
3710 assert_eq!(factor.threshold, 10000.0);
3711 assert!(factor.direction_greater);
3712
3713 let contribution = factor.contribution();
3715 assert!((contribution - 0.25).abs() < 0.01);
3716 }
3717
3718 #[test]
3719 fn test_contributing_factor_with_evidence() {
3720 let mut data = HashMap::new();
3721 data.insert("expected".to_string(), "10000".to_string());
3722 data.insert("actual".to_string(), "15000".to_string());
3723
3724 let factor = ContributingFactor::new(
3725 FactorType::AmountDeviation,
3726 15000.0,
3727 10000.0,
3728 true,
3729 0.5,
3730 "Amount deviation detected",
3731 )
3732 .with_evidence("transaction_history", data);
3733
3734 assert!(factor.evidence.is_some());
3735 let evidence = factor.evidence.unwrap();
3736 assert_eq!(evidence.source, "transaction_history");
3737 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3738 }
3739
3740 #[test]
3741 fn test_enhanced_anomaly_label() {
3742 let base = LabeledAnomaly::new(
3743 "ANO001".to_string(),
3744 AnomalyType::Fraud(FraudType::DuplicatePayment),
3745 "JE001".to_string(),
3746 "JE".to_string(),
3747 "1000".to_string(),
3748 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3749 );
3750
3751 let enhanced = EnhancedAnomalyLabel::from_base(base)
3752 .with_confidence(0.85)
3753 .with_severity(0.7)
3754 .with_factor(ContributingFactor::new(
3755 FactorType::DuplicateIndicator,
3756 1.0,
3757 0.5,
3758 true,
3759 0.4,
3760 "Duplicate payment detected",
3761 ))
3762 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3763
3764 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3765 assert_eq!(enhanced.enhanced_confidence, 0.85);
3766 assert_eq!(enhanced.enhanced_severity, 0.7);
3767 assert_eq!(enhanced.contributing_factors.len(), 1);
3768 assert_eq!(enhanced.secondary_categories.len(), 1);
3769 }
3770
3771 #[test]
3772 fn test_enhanced_anomaly_label_features() {
3773 let base = LabeledAnomaly::new(
3774 "ANO001".to_string(),
3775 AnomalyType::Fraud(FraudType::SelfApproval),
3776 "JE001".to_string(),
3777 "JE".to_string(),
3778 "1000".to_string(),
3779 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3780 );
3781
3782 let enhanced = EnhancedAnomalyLabel::from_base(base)
3783 .with_confidence(0.9)
3784 .with_severity(0.8)
3785 .with_factor(ContributingFactor::new(
3786 FactorType::ControlBypass,
3787 1.0,
3788 0.0,
3789 true,
3790 0.5,
3791 "Control bypass detected",
3792 ));
3793
3794 let features = enhanced.to_features();
3795
3796 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3798 assert_eq!(features.len(), 25);
3799
3800 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3806
3807 #[test]
3808 fn test_enhanced_anomaly_label_feature_names() {
3809 let names = EnhancedAnomalyLabel::feature_names();
3810 assert_eq!(names.len(), 25);
3811 assert!(names.contains(&"enhanced_confidence"));
3812 assert!(names.contains(&"enhanced_severity"));
3813 assert!(names.contains(&"has_control_bypass"));
3814 }
3815
3816 #[test]
3817 fn test_factor_type_names() {
3818 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3819 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3820 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3821 }
3822
3823 #[test]
3824 fn test_anomaly_category_serialization() {
3825 let category = AnomalyCategory::CircularFlow;
3826 let json = serde_json::to_string(&category).unwrap();
3827 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3828 assert_eq!(category, deserialized);
3829
3830 let custom = AnomalyCategory::Custom("custom_type".to_string());
3831 let json = serde_json::to_string(&custom).unwrap();
3832 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3833 assert_eq!(custom, deserialized);
3834 }
3835
3836 #[test]
3837 fn test_enhanced_label_secondary_category_dedup() {
3838 let base = LabeledAnomaly::new(
3839 "ANO001".to_string(),
3840 AnomalyType::Fraud(FraudType::DuplicatePayment),
3841 "JE001".to_string(),
3842 "JE".to_string(),
3843 "1000".to_string(),
3844 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3845 );
3846
3847 let enhanced = EnhancedAnomalyLabel::from_base(base)
3848 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3850 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3852 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3854
3855 assert_eq!(enhanced.secondary_categories.len(), 1);
3857 assert_eq!(
3858 enhanced.secondary_categories[0],
3859 AnomalyCategory::TimingAnomaly
3860 );
3861 }
3862
3863 #[test]
3868 fn test_revenue_recognition_fraud_types() {
3869 let fraud_types = [
3871 FraudType::ImproperRevenueRecognition,
3872 FraudType::ImproperPoAllocation,
3873 FraudType::VariableConsiderationManipulation,
3874 FraudType::ContractModificationMisstatement,
3875 ];
3876
3877 for fraud_type in fraud_types {
3878 let anomaly_type = AnomalyType::Fraud(fraud_type);
3879 assert_eq!(anomaly_type.category(), "Fraud");
3880 assert!(anomaly_type.is_intentional());
3881 assert!(anomaly_type.severity() >= 3);
3882 }
3883 }
3884
3885 #[test]
3886 fn test_lease_accounting_fraud_types() {
3887 let fraud_types = [
3889 FraudType::LeaseClassificationManipulation,
3890 FraudType::OffBalanceSheetLease,
3891 FraudType::LeaseLiabilityUnderstatement,
3892 FraudType::RouAssetMisstatement,
3893 ];
3894
3895 for fraud_type in fraud_types {
3896 let anomaly_type = AnomalyType::Fraud(fraud_type);
3897 assert_eq!(anomaly_type.category(), "Fraud");
3898 assert!(anomaly_type.is_intentional());
3899 assert!(anomaly_type.severity() >= 3);
3900 }
3901
3902 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3904 }
3905
3906 #[test]
3907 fn test_fair_value_fraud_types() {
3908 let fraud_types = [
3910 FraudType::FairValueHierarchyManipulation,
3911 FraudType::Level3InputManipulation,
3912 FraudType::ValuationTechniqueManipulation,
3913 ];
3914
3915 for fraud_type in fraud_types {
3916 let anomaly_type = AnomalyType::Fraud(fraud_type);
3917 assert_eq!(anomaly_type.category(), "Fraud");
3918 assert!(anomaly_type.is_intentional());
3919 assert!(anomaly_type.severity() >= 4);
3920 }
3921
3922 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3924 }
3925
3926 #[test]
3927 fn test_impairment_fraud_types() {
3928 let fraud_types = [
3930 FraudType::DelayedImpairment,
3931 FraudType::ImpairmentTestAvoidance,
3932 FraudType::CashFlowProjectionManipulation,
3933 FraudType::ImproperImpairmentReversal,
3934 ];
3935
3936 for fraud_type in fraud_types {
3937 let anomaly_type = AnomalyType::Fraud(fraud_type);
3938 assert_eq!(anomaly_type.category(), "Fraud");
3939 assert!(anomaly_type.is_intentional());
3940 assert!(anomaly_type.severity() >= 3);
3941 }
3942
3943 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3945 }
3946
3947 #[test]
3952 fn test_standards_error_types() {
3953 let error_types = [
3955 ErrorType::RevenueTimingError,
3956 ErrorType::PoAllocationError,
3957 ErrorType::LeaseClassificationError,
3958 ErrorType::LeaseCalculationError,
3959 ErrorType::FairValueError,
3960 ErrorType::ImpairmentCalculationError,
3961 ErrorType::DiscountRateError,
3962 ErrorType::FrameworkApplicationError,
3963 ];
3964
3965 for error_type in error_types {
3966 let anomaly_type = AnomalyType::Error(error_type);
3967 assert_eq!(anomaly_type.category(), "Error");
3968 assert!(!anomaly_type.is_intentional());
3969 assert!(anomaly_type.severity() >= 3);
3970 }
3971 }
3972
3973 #[test]
3974 fn test_framework_application_error() {
3975 let error_type = ErrorType::FrameworkApplicationError;
3977 assert_eq!(error_type.severity(), 4);
3978
3979 let anomaly = LabeledAnomaly::new(
3980 "ERR001".to_string(),
3981 AnomalyType::Error(error_type),
3982 "JE100".to_string(),
3983 "JE".to_string(),
3984 "1000".to_string(),
3985 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3986 )
3987 .with_description("LIFO inventory method used under IFRS (not permitted)")
3988 .with_metadata("framework", "IFRS")
3989 .with_metadata("standard_violated", "IAS 2");
3990
3991 assert_eq!(anomaly.anomaly_type.category(), "Error");
3992 assert_eq!(
3993 anomaly.metadata.get("standard_violated"),
3994 Some(&"IAS 2".to_string())
3995 );
3996 }
3997
3998 #[test]
3999 fn test_standards_anomaly_serialization() {
4000 let fraud_types = [
4002 FraudType::ImproperRevenueRecognition,
4003 FraudType::LeaseClassificationManipulation,
4004 FraudType::FairValueHierarchyManipulation,
4005 FraudType::DelayedImpairment,
4006 ];
4007
4008 for fraud_type in fraud_types {
4009 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
4010 let deserialized: FraudType =
4011 serde_json::from_str(&json).expect("Failed to deserialize");
4012 assert_eq!(fraud_type, deserialized);
4013 }
4014
4015 let error_types = [
4017 ErrorType::RevenueTimingError,
4018 ErrorType::LeaseCalculationError,
4019 ErrorType::FairValueError,
4020 ErrorType::FrameworkApplicationError,
4021 ];
4022
4023 for error_type in error_types {
4024 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
4025 let deserialized: ErrorType =
4026 serde_json::from_str(&json).expect("Failed to deserialize");
4027 assert_eq!(error_type, deserialized);
4028 }
4029 }
4030
4031 #[test]
4032 fn test_standards_labeled_anomaly() {
4033 let anomaly = LabeledAnomaly::new(
4035 "STD001".to_string(),
4036 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
4037 "CONTRACT-2024-001".to_string(),
4038 "Revenue".to_string(),
4039 "1000".to_string(),
4040 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
4041 )
4042 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
4043 .with_monetary_impact(dec!(500000))
4044 .with_metadata("standard", "ASC 606")
4045 .with_metadata("paragraph", "606-10-25-1")
4046 .with_metadata("contract_id", "C-2024-001")
4047 .with_related_entity("CONTRACT-2024-001")
4048 .with_related_entity("CUSTOMER-500");
4049
4050 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
4052 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
4053 assert_eq!(anomaly.related_entities.len(), 2);
4054 assert_eq!(
4055 anomaly.metadata.get("standard"),
4056 Some(&"ASC 606".to_string())
4057 );
4058 }
4059
4060 #[test]
4065 fn test_severity_level() {
4066 assert_eq!(SeverityLevel::Low.numeric(), 1);
4067 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4068
4069 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4070 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4071
4072 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4073 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4074
4075 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4076 }
4077
4078 #[test]
4079 fn test_anomaly_severity() {
4080 let severity =
4081 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4082
4083 assert_eq!(severity.level, SeverityLevel::High);
4084 assert!(severity.is_material);
4085 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4086
4087 let low_severity =
4089 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4090 assert!(!low_severity.is_material);
4091 }
4092
4093 #[test]
4094 fn test_detection_difficulty() {
4095 assert!(
4096 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4097 );
4098 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4099
4100 assert_eq!(
4101 AnomalyDetectionDifficulty::from_score(0.05),
4102 AnomalyDetectionDifficulty::Trivial
4103 );
4104 assert_eq!(
4105 AnomalyDetectionDifficulty::from_score(0.90),
4106 AnomalyDetectionDifficulty::Expert
4107 );
4108
4109 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4110 }
4111
4112 #[test]
4113 fn test_ground_truth_certainty() {
4114 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4115 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4116 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4117 }
4118
4119 #[test]
4120 fn test_detection_method() {
4121 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4122 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4123 }
4124
4125 #[test]
4126 fn test_extended_anomaly_label() {
4127 let base = LabeledAnomaly::new(
4128 "ANO001".to_string(),
4129 AnomalyType::Fraud(FraudType::FictitiousVendor),
4130 "JE001".to_string(),
4131 "JE".to_string(),
4132 "1000".to_string(),
4133 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4134 )
4135 .with_monetary_impact(dec!(100000));
4136
4137 let extended = ExtendedAnomalyLabel::from_base(base)
4138 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4139 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4140 .with_method(DetectionMethod::GraphBased)
4141 .with_method(DetectionMethod::ForensicAudit)
4142 .with_indicator("New vendor with no history")
4143 .with_indicator("Large first transaction")
4144 .with_certainty(GroundTruthCertainty::Definite)
4145 .with_entity("V001")
4146 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4147 .with_scheme("SCHEME001", 2);
4148
4149 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4150 assert_eq!(
4151 extended.detection_difficulty,
4152 AnomalyDetectionDifficulty::Hard
4153 );
4154 assert_eq!(extended.recommended_methods.len(), 3);
4156 assert_eq!(extended.key_indicators.len(), 2);
4157 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4158 assert_eq!(extended.scheme_stage, Some(2));
4159 }
4160
4161 #[test]
4162 fn test_extended_anomaly_label_features() {
4163 let base = LabeledAnomaly::new(
4164 "ANO001".to_string(),
4165 AnomalyType::Fraud(FraudType::SelfApproval),
4166 "JE001".to_string(),
4167 "JE".to_string(),
4168 "1000".to_string(),
4169 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4170 );
4171
4172 let extended =
4173 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4174
4175 let features = extended.to_features();
4176 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4177 assert_eq!(features.len(), 30);
4178
4179 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4182 }
4183
4184 #[test]
4185 fn test_extended_label_near_miss() {
4186 let base = LabeledAnomaly::new(
4187 "ANO001".to_string(),
4188 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4189 "JE001".to_string(),
4190 "JE".to_string(),
4191 "1000".to_string(),
4192 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4193 );
4194
4195 let extended = ExtendedAnomalyLabel::from_base(base)
4196 .as_near_miss("Year-end bonus payment, legitimately high");
4197
4198 assert!(extended.is_near_miss);
4199 assert!(extended.near_miss_explanation.is_some());
4200 }
4201
4202 #[test]
4203 fn test_scheme_type() {
4204 assert_eq!(
4205 SchemeType::GradualEmbezzlement.name(),
4206 "gradual_embezzlement"
4207 );
4208 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4209 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4210 }
4211
4212 #[test]
4213 fn test_concealment_technique() {
4214 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4215 assert!(
4216 ConcealmentTechnique::Collusion.difficulty_bonus()
4217 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4218 );
4219 }
4220
4221 #[test]
4222 fn test_near_miss_label() {
4223 let near_miss = NearMissLabel::new(
4224 "JE001",
4225 NearMissPattern::ThresholdProximity {
4226 threshold: dec!(10000),
4227 proximity: 0.95,
4228 },
4229 0.7,
4230 FalsePositiveTrigger::AmountNearThreshold,
4231 "Transaction is 95% of threshold but business justified",
4232 );
4233
4234 assert_eq!(near_miss.document_id, "JE001");
4235 assert_eq!(near_miss.suspicion_score, 0.7);
4236 assert_eq!(
4237 near_miss.false_positive_trigger,
4238 FalsePositiveTrigger::AmountNearThreshold
4239 );
4240 }
4241
4242 #[test]
4243 fn test_legitimate_pattern_type() {
4244 assert_eq!(
4245 LegitimatePatternType::YearEndBonus.description(),
4246 "Year-end bonus payment"
4247 );
4248 assert_eq!(
4249 LegitimatePatternType::InsuranceClaim.description(),
4250 "Insurance claim reimbursement"
4251 );
4252 }
4253
4254 #[test]
4255 fn test_severity_detection_difficulty_serialization() {
4256 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4257 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4258 let deserialized: AnomalySeverity =
4259 serde_json::from_str(&json).expect("Failed to deserialize");
4260 assert_eq!(severity.level, deserialized.level);
4261
4262 let difficulty = AnomalyDetectionDifficulty::Hard;
4263 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4264 let deserialized: AnomalyDetectionDifficulty =
4265 serde_json::from_str(&json).expect("Failed to deserialize");
4266 assert_eq!(difficulty, deserialized);
4267 }
4268
4269 #[test]
4274 fn test_acfe_fraud_category() {
4275 let asset = AcfeFraudCategory::AssetMisappropriation;
4276 assert_eq!(asset.name(), "asset_misappropriation");
4277 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4278 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4279 assert_eq!(asset.typical_detection_months(), 12);
4280
4281 let corruption = AcfeFraudCategory::Corruption;
4282 assert_eq!(corruption.name(), "corruption");
4283 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4284
4285 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4286 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4287 assert_eq!(fs_fraud.typical_detection_months(), 24);
4288 }
4289
4290 #[test]
4291 fn test_cash_fraud_scheme() {
4292 let shell = CashFraudScheme::ShellCompany;
4293 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4294 assert_eq!(shell.subcategory(), "billing_schemes");
4295 assert_eq!(shell.severity(), 5);
4296 assert_eq!(
4297 shell.detection_difficulty(),
4298 AnomalyDetectionDifficulty::Hard
4299 );
4300
4301 let ghost = CashFraudScheme::GhostEmployee;
4302 assert_eq!(ghost.subcategory(), "payroll_schemes");
4303 assert_eq!(ghost.severity(), 5);
4304
4305 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4307 }
4308
4309 #[test]
4310 fn test_asset_fraud_scheme() {
4311 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4312 assert_eq!(
4313 ip_theft.category(),
4314 AcfeFraudCategory::AssetMisappropriation
4315 );
4316 assert_eq!(ip_theft.subcategory(), "other_assets");
4317 assert_eq!(ip_theft.severity(), 5);
4318
4319 let inv_theft = AssetFraudScheme::InventoryTheft;
4320 assert_eq!(inv_theft.subcategory(), "inventory");
4321 assert_eq!(inv_theft.severity(), 4);
4322 }
4323
4324 #[test]
4325 fn test_corruption_scheme() {
4326 let kickback = CorruptionScheme::InvoiceKickback;
4327 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4328 assert_eq!(kickback.subcategory(), "bribery");
4329 assert_eq!(kickback.severity(), 5);
4330 assert_eq!(
4331 kickback.detection_difficulty(),
4332 AnomalyDetectionDifficulty::Expert
4333 );
4334
4335 let bid_rigging = CorruptionScheme::BidRigging;
4336 assert_eq!(bid_rigging.subcategory(), "bribery");
4337 assert_eq!(
4338 bid_rigging.detection_difficulty(),
4339 AnomalyDetectionDifficulty::Hard
4340 );
4341
4342 let purchasing = CorruptionScheme::PurchasingConflict;
4343 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4344
4345 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4347 }
4348
4349 #[test]
4350 fn test_financial_statement_scheme() {
4351 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4352 assert_eq!(
4353 fictitious.category(),
4354 AcfeFraudCategory::FinancialStatementFraud
4355 );
4356 assert_eq!(fictitious.subcategory(), "overstatement");
4357 assert_eq!(fictitious.severity(), 5);
4358 assert_eq!(
4359 fictitious.detection_difficulty(),
4360 AnomalyDetectionDifficulty::Expert
4361 );
4362
4363 let understated = FinancialStatementScheme::UnderstatedRevenues;
4364 assert_eq!(understated.subcategory(), "understatement");
4365
4366 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4368 }
4369
4370 #[test]
4371 fn test_acfe_scheme_unified() {
4372 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4373 assert_eq!(
4374 cash_scheme.category(),
4375 AcfeFraudCategory::AssetMisappropriation
4376 );
4377 assert_eq!(cash_scheme.severity(), 5);
4378
4379 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4380 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4381
4382 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4383 assert_eq!(
4384 fs_scheme.category(),
4385 AcfeFraudCategory::FinancialStatementFraud
4386 );
4387 }
4388
4389 #[test]
4390 fn test_acfe_detection_method() {
4391 let tip = AcfeDetectionMethod::Tip;
4392 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4393
4394 let internal_audit = AcfeDetectionMethod::InternalAudit;
4395 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4396
4397 let external_audit = AcfeDetectionMethod::ExternalAudit;
4398 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4399
4400 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4402 }
4403
4404 #[test]
4405 fn test_perpetrator_department() {
4406 let accounting = PerpetratorDepartment::Accounting;
4407 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4408 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4409
4410 let executive = PerpetratorDepartment::Executive;
4411 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4412 }
4413
4414 #[test]
4415 fn test_perpetrator_level() {
4416 let employee = PerpetratorLevel::Employee;
4417 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4418 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4419
4420 let exec = PerpetratorLevel::OwnerExecutive;
4421 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4422 }
4423
4424 #[test]
4425 fn test_acfe_calibration() {
4426 let cal = AcfeCalibration::default();
4427 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4428 assert_eq!(cal.median_duration_months, 12);
4429 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4430 assert!(cal.validate().is_ok());
4431
4432 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4434 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4435 assert_eq!(custom_cal.median_duration_months, 18);
4436
4437 let bad_cal = AcfeCalibration {
4439 collusion_rate: 1.5,
4440 ..Default::default()
4441 };
4442 assert!(bad_cal.validate().is_err());
4443 }
4444
4445 #[test]
4446 fn test_fraud_triangle() {
4447 let triangle = FraudTriangle::new(
4448 PressureType::FinancialTargets,
4449 vec![
4450 OpportunityFactor::WeakInternalControls,
4451 OpportunityFactor::ManagementOverride,
4452 ],
4453 Rationalization::ForTheCompanyGood,
4454 );
4455
4456 let risk = triangle.risk_score();
4458 assert!((0.0..=1.0).contains(&risk));
4459 assert!(risk > 0.5);
4461 }
4462
4463 #[test]
4464 fn test_pressure_types() {
4465 let financial = PressureType::FinancialTargets;
4466 assert!(financial.risk_weight() > 0.5);
4467
4468 let gambling = PressureType::GamblingAddiction;
4469 assert_eq!(gambling.risk_weight(), 0.90);
4470 }
4471
4472 #[test]
4473 fn test_opportunity_factors() {
4474 let override_factor = OpportunityFactor::ManagementOverride;
4475 assert_eq!(override_factor.risk_weight(), 0.90);
4476
4477 let weak_controls = OpportunityFactor::WeakInternalControls;
4478 assert!(weak_controls.risk_weight() > 0.8);
4479 }
4480
4481 #[test]
4482 fn test_rationalizations() {
4483 let entitlement = Rationalization::Entitlement;
4484 assert!(entitlement.risk_weight() > 0.8);
4485
4486 let borrowing = Rationalization::TemporaryBorrowing;
4487 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4488 }
4489
4490 #[test]
4491 fn test_acfe_scheme_serialization() {
4492 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4493 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4494 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4495 assert_eq!(scheme, deserialized);
4496
4497 let calibration = AcfeCalibration::default();
4498 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4499 let deserialized: AcfeCalibration =
4500 serde_json::from_str(&json).expect("Failed to deserialize");
4501 assert_eq!(calibration.median_loss, deserialized.median_loss);
4502 }
4503}