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 InvoiceManipulation,
353
354 AssetMisappropriation,
357 InventoryTheft,
359 GhostEmployee,
361
362 PrematureRevenue,
365 UnderstatedLiabilities,
367 OverstatedAssets,
369 ChannelStuffing,
371
372 ImproperRevenueRecognition,
375 ImproperPoAllocation,
377 VariableConsiderationManipulation,
379 ContractModificationMisstatement,
381
382 LeaseClassificationManipulation,
385 OffBalanceSheetLease,
387 LeaseLiabilityUnderstatement,
389 RouAssetMisstatement,
391
392 FairValueHierarchyManipulation,
395 Level3InputManipulation,
397 ValuationTechniqueManipulation,
399
400 DelayedImpairment,
403 ImpairmentTestAvoidance,
405 CashFlowProjectionManipulation,
407 ImproperImpairmentReversal,
409
410 BidRigging,
413 PhantomVendorContract,
415 SplitContractThreshold,
417 ConflictOfInterestSourcing,
419
420 GhostEmployeePayroll,
423 PayrollInflation,
425 DuplicateExpenseReport,
427 FictitiousExpense,
429 SplitExpenseToAvoidApproval,
431
432 RevenueTimingManipulation,
435 QuotePriceOverride,
437}
438
439impl FraudType {
440 pub fn severity(&self) -> u8 {
442 match self {
443 FraudType::RoundDollarManipulation => 2,
444 FraudType::JustBelowThreshold => 3,
445 FraudType::SelfApproval => 3,
446 FraudType::ExceededApprovalLimit => 3,
447 FraudType::DuplicatePayment => 3,
448 FraudType::FictitiousEntry => 4,
449 FraudType::RevenueManipulation => 5,
450 FraudType::FictitiousVendor => 5,
451 FraudType::ShellCompanyPayment => 5,
452 FraudType::AssetMisappropriation => 5,
453 FraudType::SegregationOfDutiesViolation => 4,
454 FraudType::CollusiveApproval => 5,
455 FraudType::ImproperRevenueRecognition => 5,
457 FraudType::ImproperPoAllocation => 4,
458 FraudType::VariableConsiderationManipulation => 4,
459 FraudType::ContractModificationMisstatement => 3,
460 FraudType::LeaseClassificationManipulation => 4,
462 FraudType::OffBalanceSheetLease => 5,
463 FraudType::LeaseLiabilityUnderstatement => 4,
464 FraudType::RouAssetMisstatement => 3,
465 FraudType::FairValueHierarchyManipulation => 4,
467 FraudType::Level3InputManipulation => 5,
468 FraudType::ValuationTechniqueManipulation => 4,
469 FraudType::DelayedImpairment => 4,
471 FraudType::ImpairmentTestAvoidance => 4,
472 FraudType::CashFlowProjectionManipulation => 5,
473 FraudType::ImproperImpairmentReversal => 3,
474 _ => 4,
475 }
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
481pub enum ErrorType {
482 DuplicateEntry,
485 ReversedAmount,
487 TransposedDigits,
489 DecimalError,
491 MissingField,
493 InvalidAccount,
495
496 WrongPeriod,
499 BackdatedEntry,
501 FutureDatedEntry,
503 CutoffError,
505
506 MisclassifiedAccount,
509 WrongCostCenter,
511 WrongCompanyCode,
513
514 UnbalancedEntry,
517 RoundingError,
519 CurrencyError,
521 TaxCalculationError,
523
524 RevenueTimingError,
527 PoAllocationError,
529 LeaseClassificationError,
531 LeaseCalculationError,
533 FairValueError,
535 ImpairmentCalculationError,
537 DiscountRateError,
539 FrameworkApplicationError,
541}
542
543impl ErrorType {
544 pub fn severity(&self) -> u8 {
546 match self {
547 ErrorType::RoundingError => 1,
548 ErrorType::MissingField => 2,
549 ErrorType::TransposedDigits => 2,
550 ErrorType::DecimalError => 3,
551 ErrorType::DuplicateEntry => 3,
552 ErrorType::ReversedAmount => 3,
553 ErrorType::WrongPeriod => 4,
554 ErrorType::UnbalancedEntry => 5,
555 ErrorType::CurrencyError => 4,
556 ErrorType::RevenueTimingError => 4,
558 ErrorType::PoAllocationError => 3,
559 ErrorType::LeaseClassificationError => 3,
560 ErrorType::LeaseCalculationError => 3,
561 ErrorType::FairValueError => 4,
562 ErrorType::ImpairmentCalculationError => 4,
563 ErrorType::DiscountRateError => 3,
564 ErrorType::FrameworkApplicationError => 4,
565 _ => 3,
566 }
567 }
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
572pub enum ProcessIssueType {
573 SkippedApproval,
576 LateApproval,
578 MissingDocumentation,
580 IncompleteApprovalChain,
582
583 LatePosting,
586 AfterHoursPosting,
588 WeekendPosting,
590 RushedPeriodEnd,
592
593 ManualOverride,
596 UnusualAccess,
598 SystemBypass,
600 BatchAnomaly,
602
603 VagueDescription,
606 PostFactoChange,
608 IncompleteAuditTrail,
610
611 MaverickSpend,
614 ExpiredContractPurchase,
616 ContractPriceOverride,
618 SingleBidAward,
620 QualificationBypass,
622
623 ExpiredQuoteConversion,
626}
627
628impl ProcessIssueType {
629 pub fn severity(&self) -> u8 {
631 match self {
632 ProcessIssueType::VagueDescription => 1,
633 ProcessIssueType::LatePosting => 2,
634 ProcessIssueType::AfterHoursPosting => 2,
635 ProcessIssueType::WeekendPosting => 2,
636 ProcessIssueType::SkippedApproval => 4,
637 ProcessIssueType::ManualOverride => 4,
638 ProcessIssueType::SystemBypass => 5,
639 ProcessIssueType::IncompleteAuditTrail => 4,
640 _ => 3,
641 }
642 }
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
647pub enum StatisticalAnomalyType {
648 UnusuallyHighAmount,
651 UnusuallyLowAmount,
653 BenfordViolation,
655 ExactDuplicateAmount,
657 RepeatingAmount,
659
660 UnusualFrequency,
663 TransactionBurst,
665 UnusualTiming,
667
668 TrendBreak,
671 LevelShift,
673 SeasonalAnomaly,
675
676 StatisticalOutlier,
679 VarianceChange,
681 DistributionShift,
683
684 SlaBreachPattern,
687 UnusedContract,
689
690 OvertimeAnomaly,
693}
694
695impl StatisticalAnomalyType {
696 pub fn severity(&self) -> u8 {
698 match self {
699 StatisticalAnomalyType::UnusualTiming => 1,
700 StatisticalAnomalyType::UnusualFrequency => 2,
701 StatisticalAnomalyType::BenfordViolation => 2,
702 StatisticalAnomalyType::UnusuallyHighAmount => 3,
703 StatisticalAnomalyType::TrendBreak => 3,
704 StatisticalAnomalyType::TransactionBurst => 4,
705 StatisticalAnomalyType::ExactDuplicateAmount => 3,
706 _ => 3,
707 }
708 }
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
713pub enum RelationalAnomalyType {
714 CircularTransaction,
717 UnusualAccountPair,
719 NewCounterparty,
721 DormantAccountActivity,
723
724 CentralityAnomaly,
727 IsolatedCluster,
729 BridgeNodeAnomaly,
731 CommunityAnomaly,
733
734 MissingRelationship,
737 UnexpectedRelationship,
739 RelationshipStrengthChange,
741
742 UnmatchedIntercompany,
745 CircularIntercompany,
747 TransferPricingAnomaly,
749}
750
751impl RelationalAnomalyType {
752 pub fn severity(&self) -> u8 {
754 match self {
755 RelationalAnomalyType::NewCounterparty => 1,
756 RelationalAnomalyType::DormantAccountActivity => 2,
757 RelationalAnomalyType::UnusualAccountPair => 2,
758 RelationalAnomalyType::CircularTransaction => 4,
759 RelationalAnomalyType::CircularIntercompany => 4,
760 RelationalAnomalyType::TransferPricingAnomaly => 4,
761 RelationalAnomalyType::UnmatchedIntercompany => 3,
762 _ => 3,
763 }
764 }
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
769pub struct LabeledAnomaly {
770 pub anomaly_id: String,
772 pub anomaly_type: AnomalyType,
774 pub document_id: String,
776 pub document_type: String,
778 pub company_code: String,
780 pub anomaly_date: NaiveDate,
782 pub detection_timestamp: NaiveDateTime,
784 pub confidence: f64,
786 pub severity: u8,
788 pub description: String,
790 pub related_entities: Vec<String>,
792 pub monetary_impact: Option<Decimal>,
794 pub metadata: HashMap<String, String>,
796 pub is_injected: bool,
798 pub injection_strategy: Option<String>,
800 pub cluster_id: Option<String>,
802
803 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub original_document_hash: Option<String>,
810
811 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub causal_reason: Option<AnomalyCausalReason>,
815
816 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub structured_strategy: Option<InjectionStrategy>,
820
821 #[serde(default, skip_serializing_if = "Option::is_none")]
824 pub parent_anomaly_id: Option<String>,
825
826 #[serde(default, skip_serializing_if = "Vec::is_empty")]
828 pub child_anomaly_ids: Vec<String>,
829
830 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub scenario_id: Option<String>,
833
834 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub run_id: Option<String>,
838
839 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub generation_seed: Option<u64>,
843}
844
845impl LabeledAnomaly {
846 pub fn new(
848 anomaly_id: String,
849 anomaly_type: AnomalyType,
850 document_id: String,
851 document_type: String,
852 company_code: String,
853 anomaly_date: NaiveDate,
854 ) -> Self {
855 let severity = anomaly_type.severity();
856 let description = format!(
857 "{} - {} in document {}",
858 anomaly_type.category(),
859 anomaly_type.type_name(),
860 document_id
861 );
862
863 Self {
864 anomaly_id,
865 anomaly_type,
866 document_id,
867 document_type,
868 company_code,
869 anomaly_date,
870 detection_timestamp: chrono::Local::now().naive_local(),
871 confidence: 1.0,
872 severity,
873 description,
874 related_entities: Vec::new(),
875 monetary_impact: None,
876 metadata: HashMap::new(),
877 is_injected: true,
878 injection_strategy: None,
879 cluster_id: None,
880 original_document_hash: None,
882 causal_reason: None,
883 structured_strategy: None,
884 parent_anomaly_id: None,
885 child_anomaly_ids: Vec::new(),
886 scenario_id: None,
887 run_id: None,
888 generation_seed: None,
889 }
890 }
891
892 pub fn with_description(mut self, description: &str) -> Self {
894 self.description = description.to_string();
895 self
896 }
897
898 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
900 self.monetary_impact = Some(impact);
901 self
902 }
903
904 pub fn with_related_entity(mut self, entity: &str) -> Self {
906 self.related_entities.push(entity.to_string());
907 self
908 }
909
910 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
912 self.metadata.insert(key.to_string(), value.to_string());
913 self
914 }
915
916 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
918 self.injection_strategy = Some(strategy.to_string());
919 self
920 }
921
922 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
924 self.cluster_id = Some(cluster_id.to_string());
925 self
926 }
927
928 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
934 self.original_document_hash = Some(hash.to_string());
935 self
936 }
937
938 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
940 self.causal_reason = Some(reason);
941 self
942 }
943
944 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
946 self.injection_strategy = Some(strategy.strategy_type().to_string());
948 self.structured_strategy = Some(strategy);
949 self
950 }
951
952 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
954 self.parent_anomaly_id = Some(parent_id.to_string());
955 self
956 }
957
958 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
960 self.child_anomaly_ids.push(child_id.to_string());
961 self
962 }
963
964 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
966 self.scenario_id = Some(scenario_id.to_string());
967 self
968 }
969
970 pub fn with_run_id(mut self, run_id: &str) -> Self {
972 self.run_id = Some(run_id.to_string());
973 self
974 }
975
976 pub fn with_generation_seed(mut self, seed: u64) -> Self {
978 self.generation_seed = Some(seed);
979 self
980 }
981
982 pub fn with_provenance(
984 mut self,
985 run_id: Option<&str>,
986 seed: Option<u64>,
987 causal_reason: Option<AnomalyCausalReason>,
988 ) -> Self {
989 if let Some(id) = run_id {
990 self.run_id = Some(id.to_string());
991 }
992 self.generation_seed = seed;
993 self.causal_reason = causal_reason;
994 self
995 }
996
997 pub fn to_features(&self) -> Vec<f64> {
1011 let mut features = Vec::new();
1012
1013 let categories = [
1015 "Fraud",
1016 "Error",
1017 "ProcessIssue",
1018 "Statistical",
1019 "Relational",
1020 "Custom",
1021 ];
1022 for cat in &categories {
1023 features.push(if self.anomaly_type.category() == *cat {
1024 1.0
1025 } else {
1026 0.0
1027 });
1028 }
1029
1030 features.push(self.severity as f64 / 5.0);
1032
1033 features.push(self.confidence);
1035
1036 features.push(if self.monetary_impact.is_some() {
1038 1.0
1039 } else {
1040 0.0
1041 });
1042
1043 if let Some(impact) = self.monetary_impact {
1045 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
1046 features.push((impact_f64.abs() + 1.0).ln());
1047 } else {
1048 features.push(0.0);
1049 }
1050
1051 features.push(if self.anomaly_type.is_intentional() {
1053 1.0
1054 } else {
1055 0.0
1056 });
1057
1058 features.push(self.related_entities.len() as f64);
1060
1061 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1063
1064 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1067
1068 features.push(if self.parent_anomaly_id.is_some() {
1070 1.0
1071 } else {
1072 0.0
1073 });
1074
1075 features
1076 }
1077
1078 pub fn feature_count() -> usize {
1080 15 }
1082
1083 pub fn feature_names() -> Vec<&'static str> {
1085 vec![
1086 "category_fraud",
1087 "category_error",
1088 "category_process_issue",
1089 "category_statistical",
1090 "category_relational",
1091 "category_custom",
1092 "severity_normalized",
1093 "confidence",
1094 "has_monetary_impact",
1095 "monetary_impact_log",
1096 "is_intentional",
1097 "related_entity_count",
1098 "is_clustered",
1099 "is_scenario_part",
1100 "is_derived",
1101 ]
1102 }
1103}
1104
1105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1107pub struct AnomalySummary {
1108 pub total_count: usize,
1110 pub by_category: HashMap<String, usize>,
1112 pub by_type: HashMap<String, usize>,
1114 pub by_severity: HashMap<u8, usize>,
1116 pub by_company: HashMap<String, usize>,
1118 pub total_monetary_impact: Decimal,
1120 pub date_range: Option<(NaiveDate, NaiveDate)>,
1122 pub cluster_count: usize,
1124}
1125
1126impl AnomalySummary {
1127 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1129 let mut summary = AnomalySummary {
1130 total_count: anomalies.len(),
1131 ..Default::default()
1132 };
1133
1134 let mut min_date: Option<NaiveDate> = None;
1135 let mut max_date: Option<NaiveDate> = None;
1136 let mut clusters = std::collections::HashSet::new();
1137
1138 for anomaly in anomalies {
1139 *summary
1141 .by_category
1142 .entry(anomaly.anomaly_type.category().to_string())
1143 .or_insert(0) += 1;
1144
1145 *summary
1147 .by_type
1148 .entry(anomaly.anomaly_type.type_name())
1149 .or_insert(0) += 1;
1150
1151 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1153
1154 *summary
1156 .by_company
1157 .entry(anomaly.company_code.clone())
1158 .or_insert(0) += 1;
1159
1160 if let Some(impact) = anomaly.monetary_impact {
1162 summary.total_monetary_impact += impact;
1163 }
1164
1165 match min_date {
1167 None => min_date = Some(anomaly.anomaly_date),
1168 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1169 _ => {}
1170 }
1171 match max_date {
1172 None => max_date = Some(anomaly.anomaly_date),
1173 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1174 _ => {}
1175 }
1176
1177 if let Some(cluster_id) = &anomaly.cluster_id {
1179 clusters.insert(cluster_id.clone());
1180 }
1181 }
1182
1183 summary.date_range = min_date.zip(max_date);
1184 summary.cluster_count = clusters.len();
1185
1186 summary
1187 }
1188}
1189
1190#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1199pub enum AnomalyCategory {
1200 FictitiousVendor,
1203 VendorKickback,
1205 RelatedPartyVendor,
1207
1208 DuplicatePayment,
1211 UnauthorizedTransaction,
1213 StructuredTransaction,
1215
1216 CircularFlow,
1219 BehavioralAnomaly,
1221 TimingAnomaly,
1223
1224 JournalAnomaly,
1227 ManualOverride,
1229 MissingApproval,
1231
1232 StatisticalOutlier,
1235 DistributionAnomaly,
1237
1238 Custom(String),
1241}
1242
1243impl AnomalyCategory {
1244 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1246 match anomaly_type {
1247 AnomalyType::Fraud(fraud_type) => match fraud_type {
1248 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1249 AnomalyCategory::FictitiousVendor
1250 }
1251 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1252 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1253 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1254 AnomalyCategory::StructuredTransaction
1255 }
1256 FraudType::SelfApproval
1257 | FraudType::UnauthorizedApproval
1258 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1259 FraudType::TimingAnomaly
1260 | FraudType::RoundDollarManipulation
1261 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1262 _ => AnomalyCategory::BehavioralAnomaly,
1263 },
1264 AnomalyType::Error(error_type) => match error_type {
1265 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1266 ErrorType::WrongPeriod
1267 | ErrorType::BackdatedEntry
1268 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1269 _ => AnomalyCategory::JournalAnomaly,
1270 },
1271 AnomalyType::ProcessIssue(process_type) => match process_type {
1272 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1273 AnomalyCategory::MissingApproval
1274 }
1275 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1276 AnomalyCategory::ManualOverride
1277 }
1278 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1279 AnomalyCategory::TimingAnomaly
1280 }
1281 _ => AnomalyCategory::BehavioralAnomaly,
1282 },
1283 AnomalyType::Statistical(stat_type) => match stat_type {
1284 StatisticalAnomalyType::BenfordViolation
1285 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1286 _ => AnomalyCategory::StatisticalOutlier,
1287 },
1288 AnomalyType::Relational(rel_type) => match rel_type {
1289 RelationalAnomalyType::CircularTransaction
1290 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1291 _ => AnomalyCategory::BehavioralAnomaly,
1292 },
1293 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1294 }
1295 }
1296
1297 pub fn name(&self) -> &str {
1299 match self {
1300 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1301 AnomalyCategory::VendorKickback => "vendor_kickback",
1302 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1303 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1304 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1305 AnomalyCategory::StructuredTransaction => "structured_transaction",
1306 AnomalyCategory::CircularFlow => "circular_flow",
1307 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1308 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1309 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1310 AnomalyCategory::ManualOverride => "manual_override",
1311 AnomalyCategory::MissingApproval => "missing_approval",
1312 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1313 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1314 AnomalyCategory::Custom(s) => s.as_str(),
1315 }
1316 }
1317
1318 pub fn ordinal(&self) -> u8 {
1320 match self {
1321 AnomalyCategory::FictitiousVendor => 0,
1322 AnomalyCategory::VendorKickback => 1,
1323 AnomalyCategory::RelatedPartyVendor => 2,
1324 AnomalyCategory::DuplicatePayment => 3,
1325 AnomalyCategory::UnauthorizedTransaction => 4,
1326 AnomalyCategory::StructuredTransaction => 5,
1327 AnomalyCategory::CircularFlow => 6,
1328 AnomalyCategory::BehavioralAnomaly => 7,
1329 AnomalyCategory::TimingAnomaly => 8,
1330 AnomalyCategory::JournalAnomaly => 9,
1331 AnomalyCategory::ManualOverride => 10,
1332 AnomalyCategory::MissingApproval => 11,
1333 AnomalyCategory::StatisticalOutlier => 12,
1334 AnomalyCategory::DistributionAnomaly => 13,
1335 AnomalyCategory::Custom(_) => 14,
1336 }
1337 }
1338
1339 pub fn category_count() -> usize {
1341 15 }
1343}
1344
1345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1347pub enum FactorType {
1348 AmountDeviation,
1350 ThresholdProximity,
1352 TimingAnomaly,
1354 EntityRisk,
1356 PatternMatch,
1358 FrequencyDeviation,
1360 RelationshipAnomaly,
1362 ControlBypass,
1364 BenfordViolation,
1366 DuplicateIndicator,
1368 ApprovalChainIssue,
1370 DocumentationGap,
1372 Custom,
1374}
1375
1376impl FactorType {
1377 pub fn name(&self) -> &'static str {
1379 match self {
1380 FactorType::AmountDeviation => "amount_deviation",
1381 FactorType::ThresholdProximity => "threshold_proximity",
1382 FactorType::TimingAnomaly => "timing_anomaly",
1383 FactorType::EntityRisk => "entity_risk",
1384 FactorType::PatternMatch => "pattern_match",
1385 FactorType::FrequencyDeviation => "frequency_deviation",
1386 FactorType::RelationshipAnomaly => "relationship_anomaly",
1387 FactorType::ControlBypass => "control_bypass",
1388 FactorType::BenfordViolation => "benford_violation",
1389 FactorType::DuplicateIndicator => "duplicate_indicator",
1390 FactorType::ApprovalChainIssue => "approval_chain_issue",
1391 FactorType::DocumentationGap => "documentation_gap",
1392 FactorType::Custom => "custom",
1393 }
1394 }
1395}
1396
1397#[derive(Debug, Clone, Serialize, Deserialize)]
1399pub struct FactorEvidence {
1400 pub source: String,
1402 pub data: HashMap<String, String>,
1404}
1405
1406#[derive(Debug, Clone, Serialize, Deserialize)]
1408pub struct ContributingFactor {
1409 pub factor_type: FactorType,
1411 pub value: f64,
1413 pub threshold: f64,
1415 pub direction_greater: bool,
1417 pub weight: f64,
1419 pub description: String,
1421 pub evidence: Option<FactorEvidence>,
1423}
1424
1425impl ContributingFactor {
1426 pub fn new(
1428 factor_type: FactorType,
1429 value: f64,
1430 threshold: f64,
1431 direction_greater: bool,
1432 weight: f64,
1433 description: &str,
1434 ) -> Self {
1435 Self {
1436 factor_type,
1437 value,
1438 threshold,
1439 direction_greater,
1440 weight,
1441 description: description.to_string(),
1442 evidence: None,
1443 }
1444 }
1445
1446 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1448 self.evidence = Some(FactorEvidence {
1449 source: source.to_string(),
1450 data,
1451 });
1452 self
1453 }
1454
1455 pub fn contribution(&self) -> f64 {
1457 let deviation = if self.direction_greater {
1458 (self.value - self.threshold).max(0.0)
1459 } else {
1460 (self.threshold - self.value).max(0.0)
1461 };
1462
1463 let relative_deviation = if self.threshold.abs() > 0.001 {
1465 deviation / self.threshold.abs()
1466 } else {
1467 deviation
1468 };
1469
1470 (relative_deviation * self.weight).min(1.0)
1472 }
1473}
1474
1475#[derive(Debug, Clone, Serialize, Deserialize)]
1477pub struct EnhancedAnomalyLabel {
1478 pub base: LabeledAnomaly,
1480 pub category: AnomalyCategory,
1482 pub enhanced_confidence: f64,
1484 pub enhanced_severity: f64,
1486 pub contributing_factors: Vec<ContributingFactor>,
1488 pub secondary_categories: Vec<AnomalyCategory>,
1490}
1491
1492impl EnhancedAnomalyLabel {
1493 pub fn from_base(base: LabeledAnomaly) -> Self {
1495 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1496 let enhanced_confidence = base.confidence;
1497 let enhanced_severity = base.severity as f64 / 5.0;
1498
1499 Self {
1500 base,
1501 category,
1502 enhanced_confidence,
1503 enhanced_severity,
1504 contributing_factors: Vec::new(),
1505 secondary_categories: Vec::new(),
1506 }
1507 }
1508
1509 pub fn with_confidence(mut self, confidence: f64) -> Self {
1511 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1512 self
1513 }
1514
1515 pub fn with_severity(mut self, severity: f64) -> Self {
1517 self.enhanced_severity = severity.clamp(0.0, 1.0);
1518 self
1519 }
1520
1521 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1523 self.contributing_factors.push(factor);
1524 self
1525 }
1526
1527 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1529 if !self.secondary_categories.contains(&category) && category != self.category {
1530 self.secondary_categories.push(category);
1531 }
1532 self
1533 }
1534
1535 pub fn to_features(&self) -> Vec<f64> {
1539 let mut features = self.base.to_features();
1540
1541 features.push(self.enhanced_confidence);
1543 features.push(self.enhanced_severity);
1544 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1545 features.push(self.secondary_categories.len() as f64);
1546 features.push(self.contributing_factors.len() as f64);
1547
1548 let max_weight = self
1550 .contributing_factors
1551 .iter()
1552 .map(|f| f.weight)
1553 .fold(0.0, f64::max);
1554 features.push(max_weight);
1555
1556 let has_control_bypass = self
1558 .contributing_factors
1559 .iter()
1560 .any(|f| f.factor_type == FactorType::ControlBypass);
1561 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1562
1563 let has_amount_deviation = self
1564 .contributing_factors
1565 .iter()
1566 .any(|f| f.factor_type == FactorType::AmountDeviation);
1567 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1568
1569 let has_timing = self
1570 .contributing_factors
1571 .iter()
1572 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1573 features.push(if has_timing { 1.0 } else { 0.0 });
1574
1575 let has_pattern_match = self
1576 .contributing_factors
1577 .iter()
1578 .any(|f| f.factor_type == FactorType::PatternMatch);
1579 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1580
1581 features
1582 }
1583
1584 pub fn feature_count() -> usize {
1586 25 }
1588
1589 pub fn feature_names() -> Vec<&'static str> {
1591 let mut names = LabeledAnomaly::feature_names();
1592 names.extend(vec![
1593 "enhanced_confidence",
1594 "enhanced_severity",
1595 "category_ordinal",
1596 "secondary_category_count",
1597 "contributing_factor_count",
1598 "max_factor_weight",
1599 "has_control_bypass",
1600 "has_amount_deviation",
1601 "has_timing_factor",
1602 "has_pattern_match",
1603 ]);
1604 names
1605 }
1606}
1607
1608#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1614pub enum SeverityLevel {
1615 Low,
1617 #[default]
1619 Medium,
1620 High,
1622 Critical,
1624}
1625
1626impl SeverityLevel {
1627 pub fn numeric(&self) -> u8 {
1629 match self {
1630 SeverityLevel::Low => 1,
1631 SeverityLevel::Medium => 2,
1632 SeverityLevel::High => 3,
1633 SeverityLevel::Critical => 4,
1634 }
1635 }
1636
1637 pub fn from_numeric(value: u8) -> Self {
1639 match value {
1640 1 => SeverityLevel::Low,
1641 2 => SeverityLevel::Medium,
1642 3 => SeverityLevel::High,
1643 _ => SeverityLevel::Critical,
1644 }
1645 }
1646
1647 pub fn from_score(score: f64) -> Self {
1649 match score {
1650 s if s < 0.25 => SeverityLevel::Low,
1651 s if s < 0.50 => SeverityLevel::Medium,
1652 s if s < 0.75 => SeverityLevel::High,
1653 _ => SeverityLevel::Critical,
1654 }
1655 }
1656
1657 pub fn to_score(&self) -> f64 {
1659 match self {
1660 SeverityLevel::Low => 0.125,
1661 SeverityLevel::Medium => 0.375,
1662 SeverityLevel::High => 0.625,
1663 SeverityLevel::Critical => 0.875,
1664 }
1665 }
1666}
1667
1668#[derive(Debug, Clone, Serialize, Deserialize)]
1670pub struct AnomalySeverity {
1671 pub level: SeverityLevel,
1673 pub score: f64,
1675 pub financial_impact: Decimal,
1677 pub is_material: bool,
1679 #[serde(default, skip_serializing_if = "Option::is_none")]
1681 pub materiality_threshold: Option<Decimal>,
1682}
1683
1684impl AnomalySeverity {
1685 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1687 Self {
1688 level,
1689 score: level.to_score(),
1690 financial_impact,
1691 is_material: false,
1692 materiality_threshold: None,
1693 }
1694 }
1695
1696 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1698 Self {
1699 level: SeverityLevel::from_score(score),
1700 score: score.clamp(0.0, 1.0),
1701 financial_impact,
1702 is_material: false,
1703 materiality_threshold: None,
1704 }
1705 }
1706
1707 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1709 self.materiality_threshold = Some(threshold);
1710 self.is_material = self.financial_impact.abs() >= threshold;
1711 self
1712 }
1713}
1714
1715impl Default for AnomalySeverity {
1716 fn default() -> Self {
1717 Self {
1718 level: SeverityLevel::Medium,
1719 score: 0.5,
1720 financial_impact: Decimal::ZERO,
1721 is_material: false,
1722 materiality_threshold: None,
1723 }
1724 }
1725}
1726
1727#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1735pub enum AnomalyDetectionDifficulty {
1736 Trivial,
1738 Easy,
1740 #[default]
1742 Moderate,
1743 Hard,
1745 Expert,
1747}
1748
1749impl AnomalyDetectionDifficulty {
1750 pub fn expected_detection_rate(&self) -> f64 {
1752 match self {
1753 AnomalyDetectionDifficulty::Trivial => 0.99,
1754 AnomalyDetectionDifficulty::Easy => 0.90,
1755 AnomalyDetectionDifficulty::Moderate => 0.70,
1756 AnomalyDetectionDifficulty::Hard => 0.40,
1757 AnomalyDetectionDifficulty::Expert => 0.15,
1758 }
1759 }
1760
1761 pub fn difficulty_score(&self) -> f64 {
1763 match self {
1764 AnomalyDetectionDifficulty::Trivial => 0.05,
1765 AnomalyDetectionDifficulty::Easy => 0.25,
1766 AnomalyDetectionDifficulty::Moderate => 0.50,
1767 AnomalyDetectionDifficulty::Hard => 0.75,
1768 AnomalyDetectionDifficulty::Expert => 0.95,
1769 }
1770 }
1771
1772 pub fn from_score(score: f64) -> Self {
1774 match score {
1775 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1776 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1777 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1778 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1779 _ => AnomalyDetectionDifficulty::Expert,
1780 }
1781 }
1782
1783 pub fn name(&self) -> &'static str {
1785 match self {
1786 AnomalyDetectionDifficulty::Trivial => "trivial",
1787 AnomalyDetectionDifficulty::Easy => "easy",
1788 AnomalyDetectionDifficulty::Moderate => "moderate",
1789 AnomalyDetectionDifficulty::Hard => "hard",
1790 AnomalyDetectionDifficulty::Expert => "expert",
1791 }
1792 }
1793}
1794
1795#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1799pub enum GroundTruthCertainty {
1800 #[default]
1802 Definite,
1803 Probable,
1805 Possible,
1807}
1808
1809impl GroundTruthCertainty {
1810 pub fn certainty_score(&self) -> f64 {
1812 match self {
1813 GroundTruthCertainty::Definite => 1.0,
1814 GroundTruthCertainty::Probable => 0.8,
1815 GroundTruthCertainty::Possible => 0.5,
1816 }
1817 }
1818
1819 pub fn name(&self) -> &'static str {
1821 match self {
1822 GroundTruthCertainty::Definite => "definite",
1823 GroundTruthCertainty::Probable => "probable",
1824 GroundTruthCertainty::Possible => "possible",
1825 }
1826 }
1827}
1828
1829#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1833pub enum DetectionMethod {
1834 RuleBased,
1836 Statistical,
1838 MachineLearning,
1840 GraphBased,
1842 ForensicAudit,
1844 Hybrid,
1846}
1847
1848impl DetectionMethod {
1849 pub fn name(&self) -> &'static str {
1851 match self {
1852 DetectionMethod::RuleBased => "rule_based",
1853 DetectionMethod::Statistical => "statistical",
1854 DetectionMethod::MachineLearning => "machine_learning",
1855 DetectionMethod::GraphBased => "graph_based",
1856 DetectionMethod::ForensicAudit => "forensic_audit",
1857 DetectionMethod::Hybrid => "hybrid",
1858 }
1859 }
1860
1861 pub fn description(&self) -> &'static str {
1863 match self {
1864 DetectionMethod::RuleBased => "Simple threshold and filter rules",
1865 DetectionMethod::Statistical => "Statistical distribution analysis",
1866 DetectionMethod::MachineLearning => "ML classification models",
1867 DetectionMethod::GraphBased => "Network and relationship analysis",
1868 DetectionMethod::ForensicAudit => "Manual forensic procedures",
1869 DetectionMethod::Hybrid => "Combined multi-method approach",
1870 }
1871 }
1872}
1873
1874#[derive(Debug, Clone, Serialize, Deserialize)]
1879pub struct ExtendedAnomalyLabel {
1880 pub base: LabeledAnomaly,
1882 pub category: AnomalyCategory,
1884 pub severity: AnomalySeverity,
1886 pub detection_difficulty: AnomalyDetectionDifficulty,
1888 pub recommended_methods: Vec<DetectionMethod>,
1890 pub key_indicators: Vec<String>,
1892 pub ground_truth_certainty: GroundTruthCertainty,
1894 pub contributing_factors: Vec<ContributingFactor>,
1896 pub related_entity_ids: Vec<String>,
1898 pub secondary_categories: Vec<AnomalyCategory>,
1900 #[serde(default, skip_serializing_if = "Option::is_none")]
1902 pub scheme_id: Option<String>,
1903 #[serde(default, skip_serializing_if = "Option::is_none")]
1905 pub scheme_stage: Option<u32>,
1906 #[serde(default)]
1908 pub is_near_miss: bool,
1909 #[serde(default, skip_serializing_if = "Option::is_none")]
1911 pub near_miss_explanation: Option<String>,
1912}
1913
1914impl ExtendedAnomalyLabel {
1915 pub fn from_base(base: LabeledAnomaly) -> Self {
1917 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1918 let severity = AnomalySeverity {
1919 level: SeverityLevel::from_numeric(base.severity),
1920 score: base.severity as f64 / 5.0,
1921 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
1922 is_material: false,
1923 materiality_threshold: None,
1924 };
1925
1926 Self {
1927 base,
1928 category,
1929 severity,
1930 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
1931 recommended_methods: vec![DetectionMethod::RuleBased],
1932 key_indicators: Vec::new(),
1933 ground_truth_certainty: GroundTruthCertainty::Definite,
1934 contributing_factors: Vec::new(),
1935 related_entity_ids: Vec::new(),
1936 secondary_categories: Vec::new(),
1937 scheme_id: None,
1938 scheme_stage: None,
1939 is_near_miss: false,
1940 near_miss_explanation: None,
1941 }
1942 }
1943
1944 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
1946 self.severity = severity;
1947 self
1948 }
1949
1950 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
1952 self.detection_difficulty = difficulty;
1953 self
1954 }
1955
1956 pub fn with_method(mut self, method: DetectionMethod) -> Self {
1958 if !self.recommended_methods.contains(&method) {
1959 self.recommended_methods.push(method);
1960 }
1961 self
1962 }
1963
1964 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
1966 self.recommended_methods = methods;
1967 self
1968 }
1969
1970 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
1972 self.key_indicators.push(indicator.into());
1973 self
1974 }
1975
1976 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
1978 self.ground_truth_certainty = certainty;
1979 self
1980 }
1981
1982 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1984 self.contributing_factors.push(factor);
1985 self
1986 }
1987
1988 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
1990 self.related_entity_ids.push(entity_id.into());
1991 self
1992 }
1993
1994 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1996 if category != self.category && !self.secondary_categories.contains(&category) {
1997 self.secondary_categories.push(category);
1998 }
1999 self
2000 }
2001
2002 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
2004 self.scheme_id = Some(scheme_id.into());
2005 self.scheme_stage = Some(stage);
2006 self
2007 }
2008
2009 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
2011 self.is_near_miss = true;
2012 self.near_miss_explanation = Some(explanation.into());
2013 self
2014 }
2015
2016 pub fn to_features(&self) -> Vec<f64> {
2020 let mut features = self.base.to_features();
2021
2022 features.push(self.severity.score);
2024 features.push(self.severity.level.to_score());
2025 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
2026 features.push(self.detection_difficulty.difficulty_score());
2027 features.push(self.detection_difficulty.expected_detection_rate());
2028 features.push(self.ground_truth_certainty.certainty_score());
2029 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
2030 features.push(self.secondary_categories.len() as f64);
2031 features.push(self.contributing_factors.len() as f64);
2032 features.push(self.key_indicators.len() as f64);
2033 features.push(self.recommended_methods.len() as f64);
2034 features.push(self.related_entity_ids.len() as f64);
2035 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
2036 features.push(self.scheme_stage.unwrap_or(0) as f64);
2037 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
2038
2039 features
2040 }
2041
2042 pub fn feature_count() -> usize {
2044 30 }
2046
2047 pub fn feature_names() -> Vec<&'static str> {
2049 let mut names = LabeledAnomaly::feature_names();
2050 names.extend(vec![
2051 "severity_score",
2052 "severity_level_score",
2053 "is_material",
2054 "difficulty_score",
2055 "expected_detection_rate",
2056 "ground_truth_certainty",
2057 "category_ordinal",
2058 "secondary_category_count",
2059 "contributing_factor_count",
2060 "key_indicator_count",
2061 "recommended_method_count",
2062 "related_entity_count",
2063 "is_part_of_scheme",
2064 "scheme_stage",
2065 "is_near_miss",
2066 ]);
2067 names
2068 }
2069}
2070
2071#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2077pub enum SchemeType {
2078 GradualEmbezzlement,
2080 RevenueManipulation,
2082 VendorKickback,
2084 RoundTripping,
2086 GhostEmployee,
2088 ExpenseReimbursement,
2090 InventoryTheft,
2092 Custom,
2094}
2095
2096impl SchemeType {
2097 pub fn name(&self) -> &'static str {
2099 match self {
2100 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2101 SchemeType::RevenueManipulation => "revenue_manipulation",
2102 SchemeType::VendorKickback => "vendor_kickback",
2103 SchemeType::RoundTripping => "round_tripping",
2104 SchemeType::GhostEmployee => "ghost_employee",
2105 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2106 SchemeType::InventoryTheft => "inventory_theft",
2107 SchemeType::Custom => "custom",
2108 }
2109 }
2110
2111 pub fn typical_stages(&self) -> u32 {
2113 match self {
2114 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2122 }
2123 }
2124}
2125
2126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2128pub enum SchemeDetectionStatus {
2129 #[default]
2131 Undetected,
2132 UnderInvestigation,
2134 PartiallyDetected,
2136 FullyDetected,
2138}
2139
2140#[derive(Debug, Clone, Serialize, Deserialize)]
2142pub struct SchemeTransactionRef {
2143 pub document_id: String,
2145 pub date: chrono::NaiveDate,
2147 pub amount: Decimal,
2149 pub stage: u32,
2151 #[serde(default, skip_serializing_if = "Option::is_none")]
2153 pub anomaly_id: Option<String>,
2154}
2155
2156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2158pub enum ConcealmentTechnique {
2159 DocumentManipulation,
2161 ApprovalCircumvention,
2163 TimingExploitation,
2165 TransactionSplitting,
2167 AccountMisclassification,
2169 Collusion,
2171 DataAlteration,
2173 FalseDocumentation,
2175}
2176
2177impl ConcealmentTechnique {
2178 pub fn difficulty_bonus(&self) -> f64 {
2180 match self {
2181 ConcealmentTechnique::DocumentManipulation => 0.20,
2182 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2183 ConcealmentTechnique::TimingExploitation => 0.10,
2184 ConcealmentTechnique::TransactionSplitting => 0.15,
2185 ConcealmentTechnique::AccountMisclassification => 0.10,
2186 ConcealmentTechnique::Collusion => 0.25,
2187 ConcealmentTechnique::DataAlteration => 0.20,
2188 ConcealmentTechnique::FalseDocumentation => 0.15,
2189 }
2190 }
2191}
2192
2193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2210pub enum AcfeFraudCategory {
2211 #[default]
2214 AssetMisappropriation,
2215 Corruption,
2218 FinancialStatementFraud,
2221}
2222
2223impl AcfeFraudCategory {
2224 pub fn name(&self) -> &'static str {
2226 match self {
2227 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2228 AcfeFraudCategory::Corruption => "corruption",
2229 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2230 }
2231 }
2232
2233 pub fn typical_occurrence_rate(&self) -> f64 {
2235 match self {
2236 AcfeFraudCategory::AssetMisappropriation => 0.86,
2237 AcfeFraudCategory::Corruption => 0.33,
2238 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2239 }
2240 }
2241
2242 pub fn typical_median_loss(&self) -> Decimal {
2244 match self {
2245 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2246 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2247 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2248 }
2249 }
2250
2251 pub fn typical_detection_months(&self) -> u32 {
2253 match self {
2254 AcfeFraudCategory::AssetMisappropriation => 12,
2255 AcfeFraudCategory::Corruption => 18,
2256 AcfeFraudCategory::FinancialStatementFraud => 24,
2257 }
2258 }
2259}
2260
2261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2268pub enum CashFraudScheme {
2269 Larceny,
2272 Skimming,
2274
2275 SalesSkimming,
2278 ReceivablesSkimming,
2280 RefundSchemes,
2282
2283 ShellCompany,
2286 NonAccompliceVendor,
2288 PersonalPurchases,
2290
2291 GhostEmployee,
2294 FalsifiedWages,
2296 CommissionSchemes,
2298
2299 MischaracterizedExpenses,
2302 OverstatedExpenses,
2304 FictitiousExpenses,
2306
2307 ForgedMaker,
2310 ForgedEndorsement,
2312 AlteredPayee,
2314 AuthorizedMaker,
2316
2317 FalseVoids,
2320 FalseRefunds,
2322}
2323
2324impl CashFraudScheme {
2325 pub fn category(&self) -> AcfeFraudCategory {
2327 AcfeFraudCategory::AssetMisappropriation
2328 }
2329
2330 pub fn subcategory(&self) -> &'static str {
2332 match self {
2333 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2334 CashFraudScheme::SalesSkimming
2335 | CashFraudScheme::ReceivablesSkimming
2336 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2337 CashFraudScheme::ShellCompany
2338 | CashFraudScheme::NonAccompliceVendor
2339 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2340 CashFraudScheme::GhostEmployee
2341 | CashFraudScheme::FalsifiedWages
2342 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2343 CashFraudScheme::MischaracterizedExpenses
2344 | CashFraudScheme::OverstatedExpenses
2345 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2346 CashFraudScheme::ForgedMaker
2347 | CashFraudScheme::ForgedEndorsement
2348 | CashFraudScheme::AlteredPayee
2349 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2350 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2351 }
2352 }
2353
2354 pub fn severity(&self) -> u8 {
2356 match self {
2357 CashFraudScheme::FalseVoids
2359 | CashFraudScheme::FalseRefunds
2360 | CashFraudScheme::MischaracterizedExpenses => 3,
2361 CashFraudScheme::OverstatedExpenses
2363 | CashFraudScheme::Skimming
2364 | CashFraudScheme::Larceny
2365 | CashFraudScheme::PersonalPurchases
2366 | CashFraudScheme::FalsifiedWages => 4,
2367 CashFraudScheme::ShellCompany
2369 | CashFraudScheme::GhostEmployee
2370 | CashFraudScheme::FictitiousExpenses
2371 | CashFraudScheme::ForgedMaker
2372 | CashFraudScheme::AuthorizedMaker => 5,
2373 _ => 4,
2374 }
2375 }
2376
2377 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2379 match self {
2380 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2382 AnomalyDetectionDifficulty::Easy
2383 }
2384 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2386 AnomalyDetectionDifficulty::Moderate
2387 }
2388 CashFraudScheme::Skimming
2390 | CashFraudScheme::ShellCompany
2391 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2392 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2394 AnomalyDetectionDifficulty::Expert
2395 }
2396 _ => AnomalyDetectionDifficulty::Moderate,
2397 }
2398 }
2399
2400 pub fn all_variants() -> &'static [CashFraudScheme] {
2402 &[
2403 CashFraudScheme::Larceny,
2404 CashFraudScheme::Skimming,
2405 CashFraudScheme::SalesSkimming,
2406 CashFraudScheme::ReceivablesSkimming,
2407 CashFraudScheme::RefundSchemes,
2408 CashFraudScheme::ShellCompany,
2409 CashFraudScheme::NonAccompliceVendor,
2410 CashFraudScheme::PersonalPurchases,
2411 CashFraudScheme::GhostEmployee,
2412 CashFraudScheme::FalsifiedWages,
2413 CashFraudScheme::CommissionSchemes,
2414 CashFraudScheme::MischaracterizedExpenses,
2415 CashFraudScheme::OverstatedExpenses,
2416 CashFraudScheme::FictitiousExpenses,
2417 CashFraudScheme::ForgedMaker,
2418 CashFraudScheme::ForgedEndorsement,
2419 CashFraudScheme::AlteredPayee,
2420 CashFraudScheme::AuthorizedMaker,
2421 CashFraudScheme::FalseVoids,
2422 CashFraudScheme::FalseRefunds,
2423 ]
2424 }
2425}
2426
2427#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2429pub enum AssetFraudScheme {
2430 InventoryMisuse,
2433 InventoryTheft,
2435 InventoryPurchasingScheme,
2437 InventoryReceivingScheme,
2439
2440 EquipmentMisuse,
2443 EquipmentTheft,
2445 IntellectualPropertyTheft,
2447 TimeTheft,
2449}
2450
2451impl AssetFraudScheme {
2452 pub fn category(&self) -> AcfeFraudCategory {
2454 AcfeFraudCategory::AssetMisappropriation
2455 }
2456
2457 pub fn subcategory(&self) -> &'static str {
2459 match self {
2460 AssetFraudScheme::InventoryMisuse
2461 | AssetFraudScheme::InventoryTheft
2462 | AssetFraudScheme::InventoryPurchasingScheme
2463 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2464 _ => "other_assets",
2465 }
2466 }
2467
2468 pub fn severity(&self) -> u8 {
2470 match self {
2471 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2472 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2473 AssetFraudScheme::InventoryTheft
2474 | AssetFraudScheme::InventoryPurchasingScheme
2475 | AssetFraudScheme::InventoryReceivingScheme => 4,
2476 AssetFraudScheme::IntellectualPropertyTheft => 5,
2477 }
2478 }
2479}
2480
2481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2486pub enum CorruptionScheme {
2487 PurchasingConflict,
2490 SalesConflict,
2492 OutsideBusinessInterest,
2494 NepotismConflict,
2496
2497 InvoiceKickback,
2500 BidRigging,
2502 CashBribery,
2504 PublicOfficial,
2506
2507 IllegalGratuity,
2510
2511 EconomicExtortion,
2514}
2515
2516impl CorruptionScheme {
2517 pub fn category(&self) -> AcfeFraudCategory {
2519 AcfeFraudCategory::Corruption
2520 }
2521
2522 pub fn subcategory(&self) -> &'static str {
2524 match self {
2525 CorruptionScheme::PurchasingConflict
2526 | CorruptionScheme::SalesConflict
2527 | CorruptionScheme::OutsideBusinessInterest
2528 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2529 CorruptionScheme::InvoiceKickback
2530 | CorruptionScheme::BidRigging
2531 | CorruptionScheme::CashBribery
2532 | CorruptionScheme::PublicOfficial => "bribery",
2533 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2534 CorruptionScheme::EconomicExtortion => "economic_extortion",
2535 }
2536 }
2537
2538 pub fn severity(&self) -> u8 {
2540 match self {
2541 CorruptionScheme::NepotismConflict => 3,
2543 CorruptionScheme::PurchasingConflict
2545 | CorruptionScheme::SalesConflict
2546 | CorruptionScheme::OutsideBusinessInterest
2547 | CorruptionScheme::IllegalGratuity => 4,
2548 CorruptionScheme::InvoiceKickback
2550 | CorruptionScheme::BidRigging
2551 | CorruptionScheme::CashBribery
2552 | CorruptionScheme::EconomicExtortion => 5,
2553 CorruptionScheme::PublicOfficial => 5,
2555 }
2556 }
2557
2558 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2560 match self {
2561 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2563 AnomalyDetectionDifficulty::Moderate
2564 }
2565 CorruptionScheme::PurchasingConflict
2567 | CorruptionScheme::SalesConflict
2568 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2569 CorruptionScheme::InvoiceKickback
2571 | CorruptionScheme::CashBribery
2572 | CorruptionScheme::PublicOfficial
2573 | CorruptionScheme::IllegalGratuity
2574 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2575 }
2576 }
2577
2578 pub fn all_variants() -> &'static [CorruptionScheme] {
2580 &[
2581 CorruptionScheme::PurchasingConflict,
2582 CorruptionScheme::SalesConflict,
2583 CorruptionScheme::OutsideBusinessInterest,
2584 CorruptionScheme::NepotismConflict,
2585 CorruptionScheme::InvoiceKickback,
2586 CorruptionScheme::BidRigging,
2587 CorruptionScheme::CashBribery,
2588 CorruptionScheme::PublicOfficial,
2589 CorruptionScheme::IllegalGratuity,
2590 CorruptionScheme::EconomicExtortion,
2591 ]
2592 }
2593}
2594
2595#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2600pub enum FinancialStatementScheme {
2601 PrematureRevenue,
2604 DelayedExpenses,
2606 FictitiousRevenues,
2608 ConcealedLiabilities,
2610 ImproperAssetValuations,
2612 ImproperDisclosures,
2614 ChannelStuffing,
2616 BillAndHold,
2618 ImproperCapitalization,
2620
2621 UnderstatedRevenues,
2624 OverstatedExpenses,
2626 OverstatedLiabilities,
2628 ImproperAssetWritedowns,
2630}
2631
2632impl FinancialStatementScheme {
2633 pub fn category(&self) -> AcfeFraudCategory {
2635 AcfeFraudCategory::FinancialStatementFraud
2636 }
2637
2638 pub fn subcategory(&self) -> &'static str {
2640 match self {
2641 FinancialStatementScheme::UnderstatedRevenues
2642 | FinancialStatementScheme::OverstatedExpenses
2643 | FinancialStatementScheme::OverstatedLiabilities
2644 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2645 _ => "overstatement",
2646 }
2647 }
2648
2649 pub fn severity(&self) -> u8 {
2651 5
2653 }
2654
2655 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2657 match self {
2658 FinancialStatementScheme::ChannelStuffing
2660 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2661 FinancialStatementScheme::PrematureRevenue
2663 | FinancialStatementScheme::ImproperCapitalization
2664 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2665 FinancialStatementScheme::FictitiousRevenues
2667 | FinancialStatementScheme::ConcealedLiabilities
2668 | FinancialStatementScheme::ImproperAssetValuations
2669 | FinancialStatementScheme::ImproperDisclosures
2670 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2671 _ => AnomalyDetectionDifficulty::Hard,
2672 }
2673 }
2674
2675 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2677 &[
2678 FinancialStatementScheme::PrematureRevenue,
2679 FinancialStatementScheme::DelayedExpenses,
2680 FinancialStatementScheme::FictitiousRevenues,
2681 FinancialStatementScheme::ConcealedLiabilities,
2682 FinancialStatementScheme::ImproperAssetValuations,
2683 FinancialStatementScheme::ImproperDisclosures,
2684 FinancialStatementScheme::ChannelStuffing,
2685 FinancialStatementScheme::BillAndHold,
2686 FinancialStatementScheme::ImproperCapitalization,
2687 FinancialStatementScheme::UnderstatedRevenues,
2688 FinancialStatementScheme::OverstatedExpenses,
2689 FinancialStatementScheme::OverstatedLiabilities,
2690 FinancialStatementScheme::ImproperAssetWritedowns,
2691 ]
2692 }
2693}
2694
2695#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2697pub enum AcfeScheme {
2698 Cash(CashFraudScheme),
2700 Asset(AssetFraudScheme),
2702 Corruption(CorruptionScheme),
2704 FinancialStatement(FinancialStatementScheme),
2706}
2707
2708impl AcfeScheme {
2709 pub fn category(&self) -> AcfeFraudCategory {
2711 match self {
2712 AcfeScheme::Cash(s) => s.category(),
2713 AcfeScheme::Asset(s) => s.category(),
2714 AcfeScheme::Corruption(s) => s.category(),
2715 AcfeScheme::FinancialStatement(s) => s.category(),
2716 }
2717 }
2718
2719 pub fn severity(&self) -> u8 {
2721 match self {
2722 AcfeScheme::Cash(s) => s.severity(),
2723 AcfeScheme::Asset(s) => s.severity(),
2724 AcfeScheme::Corruption(s) => s.severity(),
2725 AcfeScheme::FinancialStatement(s) => s.severity(),
2726 }
2727 }
2728
2729 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2731 match self {
2732 AcfeScheme::Cash(s) => s.detection_difficulty(),
2733 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2734 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2735 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2736 }
2737 }
2738}
2739
2740#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2742pub enum AcfeDetectionMethod {
2743 Tip,
2745 InternalAudit,
2747 ManagementReview,
2749 ExternalAudit,
2751 AccountReconciliation,
2753 DocumentExamination,
2755 ByAccident,
2757 ItControls,
2759 Surveillance,
2761 Confession,
2763 LawEnforcement,
2765 Other,
2767}
2768
2769impl AcfeDetectionMethod {
2770 pub fn typical_detection_rate(&self) -> f64 {
2772 match self {
2773 AcfeDetectionMethod::Tip => 0.42,
2774 AcfeDetectionMethod::InternalAudit => 0.16,
2775 AcfeDetectionMethod::ManagementReview => 0.12,
2776 AcfeDetectionMethod::ExternalAudit => 0.04,
2777 AcfeDetectionMethod::AccountReconciliation => 0.05,
2778 AcfeDetectionMethod::DocumentExamination => 0.04,
2779 AcfeDetectionMethod::ByAccident => 0.06,
2780 AcfeDetectionMethod::ItControls => 0.03,
2781 AcfeDetectionMethod::Surveillance => 0.02,
2782 AcfeDetectionMethod::Confession => 0.02,
2783 AcfeDetectionMethod::LawEnforcement => 0.01,
2784 AcfeDetectionMethod::Other => 0.03,
2785 }
2786 }
2787
2788 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2790 &[
2791 AcfeDetectionMethod::Tip,
2792 AcfeDetectionMethod::InternalAudit,
2793 AcfeDetectionMethod::ManagementReview,
2794 AcfeDetectionMethod::ExternalAudit,
2795 AcfeDetectionMethod::AccountReconciliation,
2796 AcfeDetectionMethod::DocumentExamination,
2797 AcfeDetectionMethod::ByAccident,
2798 AcfeDetectionMethod::ItControls,
2799 AcfeDetectionMethod::Surveillance,
2800 AcfeDetectionMethod::Confession,
2801 AcfeDetectionMethod::LawEnforcement,
2802 AcfeDetectionMethod::Other,
2803 ]
2804 }
2805}
2806
2807#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2809pub enum PerpetratorDepartment {
2810 Accounting,
2812 Operations,
2814 Executive,
2816 Sales,
2818 CustomerService,
2820 Purchasing,
2822 It,
2824 HumanResources,
2826 Administrative,
2828 Warehouse,
2830 BoardOfDirectors,
2832 Other,
2834}
2835
2836impl PerpetratorDepartment {
2837 pub fn typical_occurrence_rate(&self) -> f64 {
2839 match self {
2840 PerpetratorDepartment::Accounting => 0.21,
2841 PerpetratorDepartment::Operations => 0.17,
2842 PerpetratorDepartment::Executive => 0.12,
2843 PerpetratorDepartment::Sales => 0.11,
2844 PerpetratorDepartment::CustomerService => 0.07,
2845 PerpetratorDepartment::Purchasing => 0.06,
2846 PerpetratorDepartment::It => 0.05,
2847 PerpetratorDepartment::HumanResources => 0.04,
2848 PerpetratorDepartment::Administrative => 0.04,
2849 PerpetratorDepartment::Warehouse => 0.03,
2850 PerpetratorDepartment::BoardOfDirectors => 0.02,
2851 PerpetratorDepartment::Other => 0.08,
2852 }
2853 }
2854
2855 pub fn typical_median_loss(&self) -> Decimal {
2857 match self {
2858 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
2859 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
2860 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
2861 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
2862 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
2863 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
2864 PerpetratorDepartment::It => Decimal::new(100_000, 0),
2865 _ => Decimal::new(80_000, 0),
2866 }
2867 }
2868}
2869
2870#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2872pub enum PerpetratorLevel {
2873 Employee,
2875 Manager,
2877 OwnerExecutive,
2879}
2880
2881impl PerpetratorLevel {
2882 pub fn typical_occurrence_rate(&self) -> f64 {
2884 match self {
2885 PerpetratorLevel::Employee => 0.42,
2886 PerpetratorLevel::Manager => 0.36,
2887 PerpetratorLevel::OwnerExecutive => 0.22,
2888 }
2889 }
2890
2891 pub fn typical_median_loss(&self) -> Decimal {
2893 match self {
2894 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
2895 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
2896 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
2897 }
2898 }
2899}
2900
2901#[derive(Debug, Clone, Serialize, Deserialize)]
2906pub struct AcfeCalibration {
2907 pub median_loss: Decimal,
2909 pub median_duration_months: u32,
2911 pub category_distribution: HashMap<String, f64>,
2913 pub detection_method_distribution: HashMap<String, f64>,
2915 pub department_distribution: HashMap<String, f64>,
2917 pub level_distribution: HashMap<String, f64>,
2919 pub avg_red_flags_per_case: f64,
2921 pub collusion_rate: f64,
2923}
2924
2925impl Default for AcfeCalibration {
2926 fn default() -> Self {
2927 let mut category_distribution = HashMap::new();
2928 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
2929 category_distribution.insert("corruption".to_string(), 0.33);
2930 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
2931
2932 let mut detection_method_distribution = HashMap::new();
2933 for method in AcfeDetectionMethod::all_variants() {
2934 detection_method_distribution.insert(
2935 format!("{method:?}").to_lowercase(),
2936 method.typical_detection_rate(),
2937 );
2938 }
2939
2940 let mut department_distribution = HashMap::new();
2941 department_distribution.insert("accounting".to_string(), 0.21);
2942 department_distribution.insert("operations".to_string(), 0.17);
2943 department_distribution.insert("executive".to_string(), 0.12);
2944 department_distribution.insert("sales".to_string(), 0.11);
2945 department_distribution.insert("customer_service".to_string(), 0.07);
2946 department_distribution.insert("purchasing".to_string(), 0.06);
2947 department_distribution.insert("other".to_string(), 0.26);
2948
2949 let mut level_distribution = HashMap::new();
2950 level_distribution.insert("employee".to_string(), 0.42);
2951 level_distribution.insert("manager".to_string(), 0.36);
2952 level_distribution.insert("owner_executive".to_string(), 0.22);
2953
2954 Self {
2955 median_loss: Decimal::new(117_000, 0),
2956 median_duration_months: 12,
2957 category_distribution,
2958 detection_method_distribution,
2959 department_distribution,
2960 level_distribution,
2961 avg_red_flags_per_case: 2.8,
2962 collusion_rate: 0.50,
2963 }
2964 }
2965}
2966
2967impl AcfeCalibration {
2968 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
2970 Self {
2971 median_loss,
2972 median_duration_months,
2973 ..Self::default()
2974 }
2975 }
2976
2977 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
2979 category.typical_median_loss()
2980 }
2981
2982 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
2984 category.typical_detection_months()
2985 }
2986
2987 pub fn validate(&self) -> Result<(), String> {
2989 if self.median_loss <= Decimal::ZERO {
2990 return Err("Median loss must be positive".to_string());
2991 }
2992 if self.median_duration_months == 0 {
2993 return Err("Median duration must be at least 1 month".to_string());
2994 }
2995 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
2996 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
2997 }
2998 Ok(())
2999 }
3000}
3001
3002#[derive(Debug, Clone, Serialize, Deserialize)]
3007pub struct FraudTriangle {
3008 pub pressure: PressureType,
3010 pub opportunities: Vec<OpportunityFactor>,
3012 pub rationalization: Rationalization,
3014}
3015
3016impl FraudTriangle {
3017 pub fn new(
3019 pressure: PressureType,
3020 opportunities: Vec<OpportunityFactor>,
3021 rationalization: Rationalization,
3022 ) -> Self {
3023 Self {
3024 pressure,
3025 opportunities,
3026 rationalization,
3027 }
3028 }
3029
3030 pub fn risk_score(&self) -> f64 {
3032 let pressure_score = self.pressure.risk_weight();
3033 let opportunity_score: f64 = self
3034 .opportunities
3035 .iter()
3036 .map(OpportunityFactor::risk_weight)
3037 .sum::<f64>()
3038 / self.opportunities.len().max(1) as f64;
3039 let rationalization_score = self.rationalization.risk_weight();
3040
3041 (pressure_score + opportunity_score + rationalization_score) / 3.0
3042 }
3043}
3044
3045#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3047pub enum PressureType {
3048 PersonalFinancialDifficulties,
3051 FinancialTargets,
3053 MarketExpectations,
3055 CovenantCompliance,
3057 CreditRatingMaintenance,
3059 AcquisitionValuation,
3061
3062 JobSecurity,
3065 StatusMaintenance,
3067 GamblingAddiction,
3069 SubstanceAbuse,
3071 FamilyPressure,
3073 Greed,
3075}
3076
3077impl PressureType {
3078 pub fn risk_weight(&self) -> f64 {
3080 match self {
3081 PressureType::PersonalFinancialDifficulties => 0.80,
3082 PressureType::FinancialTargets => 0.75,
3083 PressureType::MarketExpectations => 0.70,
3084 PressureType::CovenantCompliance => 0.85,
3085 PressureType::CreditRatingMaintenance => 0.70,
3086 PressureType::AcquisitionValuation => 0.75,
3087 PressureType::JobSecurity => 0.65,
3088 PressureType::StatusMaintenance => 0.55,
3089 PressureType::GamblingAddiction => 0.90,
3090 PressureType::SubstanceAbuse => 0.85,
3091 PressureType::FamilyPressure => 0.60,
3092 PressureType::Greed => 0.70,
3093 }
3094 }
3095}
3096
3097#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3099pub enum OpportunityFactor {
3100 WeakInternalControls,
3102 LackOfSegregation,
3104 ManagementOverride,
3106 ComplexTransactions,
3108 RelatedPartyTransactions,
3110 PoorToneAtTop,
3112 InadequateSupervision,
3114 AssetAccess,
3116 PoorRecordKeeping,
3118 LackOfDiscipline,
3120 LackOfIndependentChecks,
3122}
3123
3124impl OpportunityFactor {
3125 pub fn risk_weight(&self) -> f64 {
3127 match self {
3128 OpportunityFactor::WeakInternalControls => 0.85,
3129 OpportunityFactor::LackOfSegregation => 0.80,
3130 OpportunityFactor::ManagementOverride => 0.90,
3131 OpportunityFactor::ComplexTransactions => 0.70,
3132 OpportunityFactor::RelatedPartyTransactions => 0.75,
3133 OpportunityFactor::PoorToneAtTop => 0.85,
3134 OpportunityFactor::InadequateSupervision => 0.75,
3135 OpportunityFactor::AssetAccess => 0.70,
3136 OpportunityFactor::PoorRecordKeeping => 0.65,
3137 OpportunityFactor::LackOfDiscipline => 0.60,
3138 OpportunityFactor::LackOfIndependentChecks => 0.75,
3139 }
3140 }
3141}
3142
3143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3145pub enum Rationalization {
3146 TemporaryBorrowing,
3148 EveryoneDoesIt,
3150 ForTheCompanyGood,
3152 Entitlement,
3154 FollowingOrders,
3156 TheyWontMissIt,
3158 NeedItMore,
3160 NotReallyStealing,
3162 Underpaid,
3164 VictimlessCrime,
3166}
3167
3168impl Rationalization {
3169 pub fn risk_weight(&self) -> f64 {
3171 match self {
3172 Rationalization::Entitlement => 0.85,
3174 Rationalization::EveryoneDoesIt => 0.80,
3175 Rationalization::NotReallyStealing => 0.80,
3176 Rationalization::TheyWontMissIt => 0.75,
3177 Rationalization::Underpaid => 0.70,
3179 Rationalization::ForTheCompanyGood => 0.65,
3180 Rationalization::NeedItMore => 0.65,
3181 Rationalization::TemporaryBorrowing => 0.60,
3183 Rationalization::FollowingOrders => 0.55,
3184 Rationalization::VictimlessCrime => 0.60,
3185 }
3186 }
3187}
3188
3189#[derive(Debug, Clone, Serialize, Deserialize)]
3195pub enum NearMissPattern {
3196 NearDuplicate {
3198 date_difference_days: u32,
3200 similar_transaction_id: String,
3202 },
3203 ThresholdProximity {
3205 threshold: Decimal,
3207 proximity: f64,
3209 },
3210 UnusualLegitimate {
3212 pattern_type: LegitimatePatternType,
3214 justification: String,
3216 },
3217 CorrectedError {
3219 correction_lag_days: u32,
3221 correction_document_id: String,
3223 },
3224}
3225
3226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3228pub enum LegitimatePatternType {
3229 YearEndBonus,
3231 ContractPrepayment,
3233 SettlementPayment,
3235 InsuranceClaim,
3237 OneTimePayment,
3239 AssetDisposal,
3241 SeasonalInventory,
3243 PromotionalSpending,
3245}
3246
3247impl LegitimatePatternType {
3248 pub fn description(&self) -> &'static str {
3250 match self {
3251 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3252 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3253 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3254 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3255 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3256 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3257 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3258 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3259 }
3260 }
3261}
3262
3263#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3265pub enum FalsePositiveTrigger {
3266 AmountNearThreshold,
3268 UnusualTiming,
3270 SimilarTransaction,
3272 NewCounterparty,
3274 UnusualAccountCombination,
3276 VolumeSpike,
3278 RoundAmount,
3280}
3281
3282#[derive(Debug, Clone, Serialize, Deserialize)]
3284pub struct NearMissLabel {
3285 pub document_id: String,
3287 pub pattern: NearMissPattern,
3289 pub suspicion_score: f64,
3291 pub false_positive_trigger: FalsePositiveTrigger,
3293 pub explanation: String,
3295}
3296
3297impl NearMissLabel {
3298 pub fn new(
3300 document_id: impl Into<String>,
3301 pattern: NearMissPattern,
3302 suspicion_score: f64,
3303 trigger: FalsePositiveTrigger,
3304 explanation: impl Into<String>,
3305 ) -> Self {
3306 Self {
3307 document_id: document_id.into(),
3308 pattern,
3309 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3310 false_positive_trigger: trigger,
3311 explanation: explanation.into(),
3312 }
3313 }
3314}
3315
3316#[derive(Debug, Clone, Serialize, Deserialize)]
3318pub struct AnomalyRateConfig {
3319 pub total_rate: f64,
3321 pub fraud_rate: f64,
3323 pub error_rate: f64,
3325 pub process_issue_rate: f64,
3327 pub statistical_rate: f64,
3329 pub relational_rate: f64,
3331}
3332
3333impl Default for AnomalyRateConfig {
3334 fn default() -> Self {
3335 Self {
3336 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, }
3343 }
3344}
3345
3346impl AnomalyRateConfig {
3347 pub fn validate(&self) -> Result<(), String> {
3349 let sum = self.fraud_rate
3350 + self.error_rate
3351 + self.process_issue_rate
3352 + self.statistical_rate
3353 + self.relational_rate;
3354
3355 if (sum - 1.0).abs() > 0.01 {
3356 return Err(format!("Anomaly category rates must sum to 1.0, got {sum}"));
3357 }
3358
3359 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3360 return Err(format!(
3361 "Total rate must be between 0.0 and 1.0, got {}",
3362 self.total_rate
3363 ));
3364 }
3365
3366 Ok(())
3367 }
3368}
3369
3370#[cfg(test)]
3371#[allow(clippy::unwrap_used)]
3372mod tests {
3373 use super::*;
3374 use rust_decimal_macros::dec;
3375
3376 #[test]
3377 fn test_anomaly_type_category() {
3378 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3379 assert_eq!(fraud.category(), "Fraud");
3380 assert!(fraud.is_intentional());
3381
3382 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3383 assert_eq!(error.category(), "Error");
3384 assert!(!error.is_intentional());
3385 }
3386
3387 #[test]
3388 fn test_labeled_anomaly() {
3389 let anomaly = LabeledAnomaly::new(
3390 "ANO001".to_string(),
3391 AnomalyType::Fraud(FraudType::SelfApproval),
3392 "JE001".to_string(),
3393 "JE".to_string(),
3394 "1000".to_string(),
3395 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3396 )
3397 .with_description("User approved their own expense report")
3398 .with_related_entity("USER001");
3399
3400 assert_eq!(anomaly.severity, 3);
3401 assert!(anomaly.is_injected);
3402 assert_eq!(anomaly.related_entities.len(), 1);
3403 }
3404
3405 #[test]
3406 fn test_labeled_anomaly_with_provenance() {
3407 let anomaly = LabeledAnomaly::new(
3408 "ANO001".to_string(),
3409 AnomalyType::Fraud(FraudType::SelfApproval),
3410 "JE001".to_string(),
3411 "JE".to_string(),
3412 "1000".to_string(),
3413 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3414 )
3415 .with_run_id("run-123")
3416 .with_generation_seed(42)
3417 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3418 .with_structured_strategy(InjectionStrategy::SelfApproval {
3419 user_id: "USER001".to_string(),
3420 })
3421 .with_scenario("scenario-001")
3422 .with_original_document_hash("abc123");
3423
3424 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3425 assert_eq!(anomaly.generation_seed, Some(42));
3426 assert!(anomaly.causal_reason.is_some());
3427 assert!(anomaly.structured_strategy.is_some());
3428 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3429 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3430
3431 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3433 }
3434
3435 #[test]
3436 fn test_labeled_anomaly_derivation_chain() {
3437 let parent = LabeledAnomaly::new(
3438 "ANO001".to_string(),
3439 AnomalyType::Fraud(FraudType::DuplicatePayment),
3440 "JE001".to_string(),
3441 "JE".to_string(),
3442 "1000".to_string(),
3443 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3444 );
3445
3446 let child = LabeledAnomaly::new(
3447 "ANO002".to_string(),
3448 AnomalyType::Error(ErrorType::DuplicateEntry),
3449 "JE002".to_string(),
3450 "JE".to_string(),
3451 "1000".to_string(),
3452 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3453 )
3454 .with_parent_anomaly(&parent.anomaly_id);
3455
3456 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3457 }
3458
3459 #[test]
3460 fn test_injection_strategy_description() {
3461 let strategy = InjectionStrategy::AmountManipulation {
3462 original: dec!(1000),
3463 factor: 2.5,
3464 };
3465 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3466 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3467
3468 let strategy = InjectionStrategy::ThresholdAvoidance {
3469 threshold: dec!(10000),
3470 adjusted_amount: dec!(9999),
3471 };
3472 assert_eq!(
3473 strategy.description(),
3474 "Amount adjusted to avoid 10000 threshold"
3475 );
3476
3477 let strategy = InjectionStrategy::DateShift {
3478 days_shifted: -5,
3479 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3480 };
3481 assert_eq!(strategy.description(), "Date backdated by 5 days");
3482
3483 let strategy = InjectionStrategy::DateShift {
3484 days_shifted: 3,
3485 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3486 };
3487 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3488 }
3489
3490 #[test]
3491 fn test_causal_reason_variants() {
3492 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3493 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3494 assert!((base_rate - 0.02).abs() < 0.001);
3495 }
3496
3497 let reason = AnomalyCausalReason::TemporalPattern {
3498 pattern_name: "year_end_spike".to_string(),
3499 };
3500 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3501 assert_eq!(pattern_name, "year_end_spike");
3502 }
3503
3504 let reason = AnomalyCausalReason::ScenarioStep {
3505 scenario_type: "kickback".to_string(),
3506 step_number: 3,
3507 };
3508 if let AnomalyCausalReason::ScenarioStep {
3509 scenario_type,
3510 step_number,
3511 } = reason
3512 {
3513 assert_eq!(scenario_type, "kickback");
3514 assert_eq!(step_number, 3);
3515 }
3516 }
3517
3518 #[test]
3519 fn test_feature_vector_length() {
3520 let anomaly = LabeledAnomaly::new(
3521 "ANO001".to_string(),
3522 AnomalyType::Fraud(FraudType::SelfApproval),
3523 "JE001".to_string(),
3524 "JE".to_string(),
3525 "1000".to_string(),
3526 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3527 );
3528
3529 let features = anomaly.to_features();
3530 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3531 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3532 }
3533
3534 #[test]
3535 fn test_feature_vector_with_provenance() {
3536 let anomaly = LabeledAnomaly::new(
3537 "ANO001".to_string(),
3538 AnomalyType::Fraud(FraudType::SelfApproval),
3539 "JE001".to_string(),
3540 "JE".to_string(),
3541 "1000".to_string(),
3542 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3543 )
3544 .with_scenario("scenario-001")
3545 .with_parent_anomaly("ANO000");
3546
3547 let features = anomaly.to_features();
3548
3549 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3553
3554 #[test]
3555 fn test_anomaly_summary() {
3556 let anomalies = vec![
3557 LabeledAnomaly::new(
3558 "ANO001".to_string(),
3559 AnomalyType::Fraud(FraudType::SelfApproval),
3560 "JE001".to_string(),
3561 "JE".to_string(),
3562 "1000".to_string(),
3563 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3564 ),
3565 LabeledAnomaly::new(
3566 "ANO002".to_string(),
3567 AnomalyType::Error(ErrorType::DuplicateEntry),
3568 "JE002".to_string(),
3569 "JE".to_string(),
3570 "1000".to_string(),
3571 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3572 ),
3573 ];
3574
3575 let summary = AnomalySummary::from_anomalies(&anomalies);
3576
3577 assert_eq!(summary.total_count, 2);
3578 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3579 assert_eq!(summary.by_category.get("Error"), Some(&1));
3580 }
3581
3582 #[test]
3583 fn test_rate_config_validation() {
3584 let config = AnomalyRateConfig::default();
3585 assert!(config.validate().is_ok());
3586
3587 let bad_config = AnomalyRateConfig {
3588 fraud_rate: 0.5,
3589 error_rate: 0.5,
3590 process_issue_rate: 0.5, ..Default::default()
3592 };
3593 assert!(bad_config.validate().is_err());
3594 }
3595
3596 #[test]
3597 fn test_injection_strategy_serialization() {
3598 let strategy = InjectionStrategy::SoDViolation {
3599 duty1: "CreatePO".to_string(),
3600 duty2: "ApprovePO".to_string(),
3601 violating_user: "USER001".to_string(),
3602 };
3603
3604 let json = serde_json::to_string(&strategy).unwrap();
3605 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3606
3607 assert_eq!(strategy, deserialized);
3608 }
3609
3610 #[test]
3611 fn test_labeled_anomaly_serialization_with_provenance() {
3612 let anomaly = LabeledAnomaly::new(
3613 "ANO001".to_string(),
3614 AnomalyType::Fraud(FraudType::SelfApproval),
3615 "JE001".to_string(),
3616 "JE".to_string(),
3617 "1000".to_string(),
3618 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3619 )
3620 .with_run_id("run-123")
3621 .with_generation_seed(42)
3622 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3623
3624 let json = serde_json::to_string(&anomaly).unwrap();
3625 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3626
3627 assert_eq!(anomaly.run_id, deserialized.run_id);
3628 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3629 }
3630
3631 #[test]
3636 fn test_anomaly_category_from_anomaly_type() {
3637 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3639 assert_eq!(
3640 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3641 AnomalyCategory::FictitiousVendor
3642 );
3643
3644 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3645 assert_eq!(
3646 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3647 AnomalyCategory::VendorKickback
3648 );
3649
3650 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3651 assert_eq!(
3652 AnomalyCategory::from_anomaly_type(&fraud_structured),
3653 AnomalyCategory::StructuredTransaction
3654 );
3655
3656 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3658 assert_eq!(
3659 AnomalyCategory::from_anomaly_type(&error_duplicate),
3660 AnomalyCategory::DuplicatePayment
3661 );
3662
3663 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3665 assert_eq!(
3666 AnomalyCategory::from_anomaly_type(&process_skip),
3667 AnomalyCategory::MissingApproval
3668 );
3669
3670 let relational_circular =
3672 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3673 assert_eq!(
3674 AnomalyCategory::from_anomaly_type(&relational_circular),
3675 AnomalyCategory::CircularFlow
3676 );
3677 }
3678
3679 #[test]
3680 fn test_anomaly_category_ordinal() {
3681 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3682 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3683 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3684 }
3685
3686 #[test]
3687 fn test_contributing_factor() {
3688 let factor = ContributingFactor::new(
3689 FactorType::AmountDeviation,
3690 15000.0,
3691 10000.0,
3692 true,
3693 0.5,
3694 "Amount exceeds threshold",
3695 );
3696
3697 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3698 assert_eq!(factor.value, 15000.0);
3699 assert_eq!(factor.threshold, 10000.0);
3700 assert!(factor.direction_greater);
3701
3702 let contribution = factor.contribution();
3704 assert!((contribution - 0.25).abs() < 0.01);
3705 }
3706
3707 #[test]
3708 fn test_contributing_factor_with_evidence() {
3709 let mut data = HashMap::new();
3710 data.insert("expected".to_string(), "10000".to_string());
3711 data.insert("actual".to_string(), "15000".to_string());
3712
3713 let factor = ContributingFactor::new(
3714 FactorType::AmountDeviation,
3715 15000.0,
3716 10000.0,
3717 true,
3718 0.5,
3719 "Amount deviation detected",
3720 )
3721 .with_evidence("transaction_history", data);
3722
3723 assert!(factor.evidence.is_some());
3724 let evidence = factor.evidence.unwrap();
3725 assert_eq!(evidence.source, "transaction_history");
3726 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3727 }
3728
3729 #[test]
3730 fn test_enhanced_anomaly_label() {
3731 let base = LabeledAnomaly::new(
3732 "ANO001".to_string(),
3733 AnomalyType::Fraud(FraudType::DuplicatePayment),
3734 "JE001".to_string(),
3735 "JE".to_string(),
3736 "1000".to_string(),
3737 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3738 );
3739
3740 let enhanced = EnhancedAnomalyLabel::from_base(base)
3741 .with_confidence(0.85)
3742 .with_severity(0.7)
3743 .with_factor(ContributingFactor::new(
3744 FactorType::DuplicateIndicator,
3745 1.0,
3746 0.5,
3747 true,
3748 0.4,
3749 "Duplicate payment detected",
3750 ))
3751 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3752
3753 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3754 assert_eq!(enhanced.enhanced_confidence, 0.85);
3755 assert_eq!(enhanced.enhanced_severity, 0.7);
3756 assert_eq!(enhanced.contributing_factors.len(), 1);
3757 assert_eq!(enhanced.secondary_categories.len(), 1);
3758 }
3759
3760 #[test]
3761 fn test_enhanced_anomaly_label_features() {
3762 let base = LabeledAnomaly::new(
3763 "ANO001".to_string(),
3764 AnomalyType::Fraud(FraudType::SelfApproval),
3765 "JE001".to_string(),
3766 "JE".to_string(),
3767 "1000".to_string(),
3768 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3769 );
3770
3771 let enhanced = EnhancedAnomalyLabel::from_base(base)
3772 .with_confidence(0.9)
3773 .with_severity(0.8)
3774 .with_factor(ContributingFactor::new(
3775 FactorType::ControlBypass,
3776 1.0,
3777 0.0,
3778 true,
3779 0.5,
3780 "Control bypass detected",
3781 ));
3782
3783 let features = enhanced.to_features();
3784
3785 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3787 assert_eq!(features.len(), 25);
3788
3789 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3795
3796 #[test]
3797 fn test_enhanced_anomaly_label_feature_names() {
3798 let names = EnhancedAnomalyLabel::feature_names();
3799 assert_eq!(names.len(), 25);
3800 assert!(names.contains(&"enhanced_confidence"));
3801 assert!(names.contains(&"enhanced_severity"));
3802 assert!(names.contains(&"has_control_bypass"));
3803 }
3804
3805 #[test]
3806 fn test_factor_type_names() {
3807 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3808 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3809 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3810 }
3811
3812 #[test]
3813 fn test_anomaly_category_serialization() {
3814 let category = AnomalyCategory::CircularFlow;
3815 let json = serde_json::to_string(&category).unwrap();
3816 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3817 assert_eq!(category, deserialized);
3818
3819 let custom = AnomalyCategory::Custom("custom_type".to_string());
3820 let json = serde_json::to_string(&custom).unwrap();
3821 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3822 assert_eq!(custom, deserialized);
3823 }
3824
3825 #[test]
3826 fn test_enhanced_label_secondary_category_dedup() {
3827 let base = LabeledAnomaly::new(
3828 "ANO001".to_string(),
3829 AnomalyType::Fraud(FraudType::DuplicatePayment),
3830 "JE001".to_string(),
3831 "JE".to_string(),
3832 "1000".to_string(),
3833 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3834 );
3835
3836 let enhanced = EnhancedAnomalyLabel::from_base(base)
3837 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3839 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3841 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3843
3844 assert_eq!(enhanced.secondary_categories.len(), 1);
3846 assert_eq!(
3847 enhanced.secondary_categories[0],
3848 AnomalyCategory::TimingAnomaly
3849 );
3850 }
3851
3852 #[test]
3857 fn test_revenue_recognition_fraud_types() {
3858 let fraud_types = [
3860 FraudType::ImproperRevenueRecognition,
3861 FraudType::ImproperPoAllocation,
3862 FraudType::VariableConsiderationManipulation,
3863 FraudType::ContractModificationMisstatement,
3864 ];
3865
3866 for fraud_type in fraud_types {
3867 let anomaly_type = AnomalyType::Fraud(fraud_type);
3868 assert_eq!(anomaly_type.category(), "Fraud");
3869 assert!(anomaly_type.is_intentional());
3870 assert!(anomaly_type.severity() >= 3);
3871 }
3872 }
3873
3874 #[test]
3875 fn test_lease_accounting_fraud_types() {
3876 let fraud_types = [
3878 FraudType::LeaseClassificationManipulation,
3879 FraudType::OffBalanceSheetLease,
3880 FraudType::LeaseLiabilityUnderstatement,
3881 FraudType::RouAssetMisstatement,
3882 ];
3883
3884 for fraud_type in fraud_types {
3885 let anomaly_type = AnomalyType::Fraud(fraud_type);
3886 assert_eq!(anomaly_type.category(), "Fraud");
3887 assert!(anomaly_type.is_intentional());
3888 assert!(anomaly_type.severity() >= 3);
3889 }
3890
3891 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3893 }
3894
3895 #[test]
3896 fn test_fair_value_fraud_types() {
3897 let fraud_types = [
3899 FraudType::FairValueHierarchyManipulation,
3900 FraudType::Level3InputManipulation,
3901 FraudType::ValuationTechniqueManipulation,
3902 ];
3903
3904 for fraud_type in fraud_types {
3905 let anomaly_type = AnomalyType::Fraud(fraud_type);
3906 assert_eq!(anomaly_type.category(), "Fraud");
3907 assert!(anomaly_type.is_intentional());
3908 assert!(anomaly_type.severity() >= 4);
3909 }
3910
3911 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3913 }
3914
3915 #[test]
3916 fn test_impairment_fraud_types() {
3917 let fraud_types = [
3919 FraudType::DelayedImpairment,
3920 FraudType::ImpairmentTestAvoidance,
3921 FraudType::CashFlowProjectionManipulation,
3922 FraudType::ImproperImpairmentReversal,
3923 ];
3924
3925 for fraud_type in fraud_types {
3926 let anomaly_type = AnomalyType::Fraud(fraud_type);
3927 assert_eq!(anomaly_type.category(), "Fraud");
3928 assert!(anomaly_type.is_intentional());
3929 assert!(anomaly_type.severity() >= 3);
3930 }
3931
3932 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3934 }
3935
3936 #[test]
3941 fn test_standards_error_types() {
3942 let error_types = [
3944 ErrorType::RevenueTimingError,
3945 ErrorType::PoAllocationError,
3946 ErrorType::LeaseClassificationError,
3947 ErrorType::LeaseCalculationError,
3948 ErrorType::FairValueError,
3949 ErrorType::ImpairmentCalculationError,
3950 ErrorType::DiscountRateError,
3951 ErrorType::FrameworkApplicationError,
3952 ];
3953
3954 for error_type in error_types {
3955 let anomaly_type = AnomalyType::Error(error_type);
3956 assert_eq!(anomaly_type.category(), "Error");
3957 assert!(!anomaly_type.is_intentional());
3958 assert!(anomaly_type.severity() >= 3);
3959 }
3960 }
3961
3962 #[test]
3963 fn test_framework_application_error() {
3964 let error_type = ErrorType::FrameworkApplicationError;
3966 assert_eq!(error_type.severity(), 4);
3967
3968 let anomaly = LabeledAnomaly::new(
3969 "ERR001".to_string(),
3970 AnomalyType::Error(error_type),
3971 "JE100".to_string(),
3972 "JE".to_string(),
3973 "1000".to_string(),
3974 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3975 )
3976 .with_description("LIFO inventory method used under IFRS (not permitted)")
3977 .with_metadata("framework", "IFRS")
3978 .with_metadata("standard_violated", "IAS 2");
3979
3980 assert_eq!(anomaly.anomaly_type.category(), "Error");
3981 assert_eq!(
3982 anomaly.metadata.get("standard_violated"),
3983 Some(&"IAS 2".to_string())
3984 );
3985 }
3986
3987 #[test]
3988 fn test_standards_anomaly_serialization() {
3989 let fraud_types = [
3991 FraudType::ImproperRevenueRecognition,
3992 FraudType::LeaseClassificationManipulation,
3993 FraudType::FairValueHierarchyManipulation,
3994 FraudType::DelayedImpairment,
3995 ];
3996
3997 for fraud_type in fraud_types {
3998 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
3999 let deserialized: FraudType =
4000 serde_json::from_str(&json).expect("Failed to deserialize");
4001 assert_eq!(fraud_type, deserialized);
4002 }
4003
4004 let error_types = [
4006 ErrorType::RevenueTimingError,
4007 ErrorType::LeaseCalculationError,
4008 ErrorType::FairValueError,
4009 ErrorType::FrameworkApplicationError,
4010 ];
4011
4012 for error_type in error_types {
4013 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
4014 let deserialized: ErrorType =
4015 serde_json::from_str(&json).expect("Failed to deserialize");
4016 assert_eq!(error_type, deserialized);
4017 }
4018 }
4019
4020 #[test]
4021 fn test_standards_labeled_anomaly() {
4022 let anomaly = LabeledAnomaly::new(
4024 "STD001".to_string(),
4025 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
4026 "CONTRACT-2024-001".to_string(),
4027 "Revenue".to_string(),
4028 "1000".to_string(),
4029 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
4030 )
4031 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
4032 .with_monetary_impact(dec!(500000))
4033 .with_metadata("standard", "ASC 606")
4034 .with_metadata("paragraph", "606-10-25-1")
4035 .with_metadata("contract_id", "C-2024-001")
4036 .with_related_entity("CONTRACT-2024-001")
4037 .with_related_entity("CUSTOMER-500");
4038
4039 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
4041 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
4042 assert_eq!(anomaly.related_entities.len(), 2);
4043 assert_eq!(
4044 anomaly.metadata.get("standard"),
4045 Some(&"ASC 606".to_string())
4046 );
4047 }
4048
4049 #[test]
4054 fn test_severity_level() {
4055 assert_eq!(SeverityLevel::Low.numeric(), 1);
4056 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4057
4058 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4059 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4060
4061 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4062 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4063
4064 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4065 }
4066
4067 #[test]
4068 fn test_anomaly_severity() {
4069 let severity =
4070 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4071
4072 assert_eq!(severity.level, SeverityLevel::High);
4073 assert!(severity.is_material);
4074 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4075
4076 let low_severity =
4078 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4079 assert!(!low_severity.is_material);
4080 }
4081
4082 #[test]
4083 fn test_detection_difficulty() {
4084 assert!(
4085 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4086 );
4087 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4088
4089 assert_eq!(
4090 AnomalyDetectionDifficulty::from_score(0.05),
4091 AnomalyDetectionDifficulty::Trivial
4092 );
4093 assert_eq!(
4094 AnomalyDetectionDifficulty::from_score(0.90),
4095 AnomalyDetectionDifficulty::Expert
4096 );
4097
4098 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4099 }
4100
4101 #[test]
4102 fn test_ground_truth_certainty() {
4103 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4104 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4105 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4106 }
4107
4108 #[test]
4109 fn test_detection_method() {
4110 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4111 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4112 }
4113
4114 #[test]
4115 fn test_extended_anomaly_label() {
4116 let base = LabeledAnomaly::new(
4117 "ANO001".to_string(),
4118 AnomalyType::Fraud(FraudType::FictitiousVendor),
4119 "JE001".to_string(),
4120 "JE".to_string(),
4121 "1000".to_string(),
4122 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4123 )
4124 .with_monetary_impact(dec!(100000));
4125
4126 let extended = ExtendedAnomalyLabel::from_base(base)
4127 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4128 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4129 .with_method(DetectionMethod::GraphBased)
4130 .with_method(DetectionMethod::ForensicAudit)
4131 .with_indicator("New vendor with no history")
4132 .with_indicator("Large first transaction")
4133 .with_certainty(GroundTruthCertainty::Definite)
4134 .with_entity("V001")
4135 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4136 .with_scheme("SCHEME001", 2);
4137
4138 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4139 assert_eq!(
4140 extended.detection_difficulty,
4141 AnomalyDetectionDifficulty::Hard
4142 );
4143 assert_eq!(extended.recommended_methods.len(), 3);
4145 assert_eq!(extended.key_indicators.len(), 2);
4146 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4147 assert_eq!(extended.scheme_stage, Some(2));
4148 }
4149
4150 #[test]
4151 fn test_extended_anomaly_label_features() {
4152 let base = LabeledAnomaly::new(
4153 "ANO001".to_string(),
4154 AnomalyType::Fraud(FraudType::SelfApproval),
4155 "JE001".to_string(),
4156 "JE".to_string(),
4157 "1000".to_string(),
4158 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4159 );
4160
4161 let extended =
4162 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4163
4164 let features = extended.to_features();
4165 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4166 assert_eq!(features.len(), 30);
4167
4168 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4171 }
4172
4173 #[test]
4174 fn test_extended_label_near_miss() {
4175 let base = LabeledAnomaly::new(
4176 "ANO001".to_string(),
4177 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4178 "JE001".to_string(),
4179 "JE".to_string(),
4180 "1000".to_string(),
4181 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4182 );
4183
4184 let extended = ExtendedAnomalyLabel::from_base(base)
4185 .as_near_miss("Year-end bonus payment, legitimately high");
4186
4187 assert!(extended.is_near_miss);
4188 assert!(extended.near_miss_explanation.is_some());
4189 }
4190
4191 #[test]
4192 fn test_scheme_type() {
4193 assert_eq!(
4194 SchemeType::GradualEmbezzlement.name(),
4195 "gradual_embezzlement"
4196 );
4197 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4198 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4199 }
4200
4201 #[test]
4202 fn test_concealment_technique() {
4203 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4204 assert!(
4205 ConcealmentTechnique::Collusion.difficulty_bonus()
4206 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4207 );
4208 }
4209
4210 #[test]
4211 fn test_near_miss_label() {
4212 let near_miss = NearMissLabel::new(
4213 "JE001",
4214 NearMissPattern::ThresholdProximity {
4215 threshold: dec!(10000),
4216 proximity: 0.95,
4217 },
4218 0.7,
4219 FalsePositiveTrigger::AmountNearThreshold,
4220 "Transaction is 95% of threshold but business justified",
4221 );
4222
4223 assert_eq!(near_miss.document_id, "JE001");
4224 assert_eq!(near_miss.suspicion_score, 0.7);
4225 assert_eq!(
4226 near_miss.false_positive_trigger,
4227 FalsePositiveTrigger::AmountNearThreshold
4228 );
4229 }
4230
4231 #[test]
4232 fn test_legitimate_pattern_type() {
4233 assert_eq!(
4234 LegitimatePatternType::YearEndBonus.description(),
4235 "Year-end bonus payment"
4236 );
4237 assert_eq!(
4238 LegitimatePatternType::InsuranceClaim.description(),
4239 "Insurance claim reimbursement"
4240 );
4241 }
4242
4243 #[test]
4244 fn test_severity_detection_difficulty_serialization() {
4245 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4246 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4247 let deserialized: AnomalySeverity =
4248 serde_json::from_str(&json).expect("Failed to deserialize");
4249 assert_eq!(severity.level, deserialized.level);
4250
4251 let difficulty = AnomalyDetectionDifficulty::Hard;
4252 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4253 let deserialized: AnomalyDetectionDifficulty =
4254 serde_json::from_str(&json).expect("Failed to deserialize");
4255 assert_eq!(difficulty, deserialized);
4256 }
4257
4258 #[test]
4263 fn test_acfe_fraud_category() {
4264 let asset = AcfeFraudCategory::AssetMisappropriation;
4265 assert_eq!(asset.name(), "asset_misappropriation");
4266 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4267 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4268 assert_eq!(asset.typical_detection_months(), 12);
4269
4270 let corruption = AcfeFraudCategory::Corruption;
4271 assert_eq!(corruption.name(), "corruption");
4272 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4273
4274 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4275 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4276 assert_eq!(fs_fraud.typical_detection_months(), 24);
4277 }
4278
4279 #[test]
4280 fn test_cash_fraud_scheme() {
4281 let shell = CashFraudScheme::ShellCompany;
4282 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4283 assert_eq!(shell.subcategory(), "billing_schemes");
4284 assert_eq!(shell.severity(), 5);
4285 assert_eq!(
4286 shell.detection_difficulty(),
4287 AnomalyDetectionDifficulty::Hard
4288 );
4289
4290 let ghost = CashFraudScheme::GhostEmployee;
4291 assert_eq!(ghost.subcategory(), "payroll_schemes");
4292 assert_eq!(ghost.severity(), 5);
4293
4294 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4296 }
4297
4298 #[test]
4299 fn test_asset_fraud_scheme() {
4300 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4301 assert_eq!(
4302 ip_theft.category(),
4303 AcfeFraudCategory::AssetMisappropriation
4304 );
4305 assert_eq!(ip_theft.subcategory(), "other_assets");
4306 assert_eq!(ip_theft.severity(), 5);
4307
4308 let inv_theft = AssetFraudScheme::InventoryTheft;
4309 assert_eq!(inv_theft.subcategory(), "inventory");
4310 assert_eq!(inv_theft.severity(), 4);
4311 }
4312
4313 #[test]
4314 fn test_corruption_scheme() {
4315 let kickback = CorruptionScheme::InvoiceKickback;
4316 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4317 assert_eq!(kickback.subcategory(), "bribery");
4318 assert_eq!(kickback.severity(), 5);
4319 assert_eq!(
4320 kickback.detection_difficulty(),
4321 AnomalyDetectionDifficulty::Expert
4322 );
4323
4324 let bid_rigging = CorruptionScheme::BidRigging;
4325 assert_eq!(bid_rigging.subcategory(), "bribery");
4326 assert_eq!(
4327 bid_rigging.detection_difficulty(),
4328 AnomalyDetectionDifficulty::Hard
4329 );
4330
4331 let purchasing = CorruptionScheme::PurchasingConflict;
4332 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4333
4334 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4336 }
4337
4338 #[test]
4339 fn test_financial_statement_scheme() {
4340 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4341 assert_eq!(
4342 fictitious.category(),
4343 AcfeFraudCategory::FinancialStatementFraud
4344 );
4345 assert_eq!(fictitious.subcategory(), "overstatement");
4346 assert_eq!(fictitious.severity(), 5);
4347 assert_eq!(
4348 fictitious.detection_difficulty(),
4349 AnomalyDetectionDifficulty::Expert
4350 );
4351
4352 let understated = FinancialStatementScheme::UnderstatedRevenues;
4353 assert_eq!(understated.subcategory(), "understatement");
4354
4355 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4357 }
4358
4359 #[test]
4360 fn test_acfe_scheme_unified() {
4361 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4362 assert_eq!(
4363 cash_scheme.category(),
4364 AcfeFraudCategory::AssetMisappropriation
4365 );
4366 assert_eq!(cash_scheme.severity(), 5);
4367
4368 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4369 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4370
4371 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4372 assert_eq!(
4373 fs_scheme.category(),
4374 AcfeFraudCategory::FinancialStatementFraud
4375 );
4376 }
4377
4378 #[test]
4379 fn test_acfe_detection_method() {
4380 let tip = AcfeDetectionMethod::Tip;
4381 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4382
4383 let internal_audit = AcfeDetectionMethod::InternalAudit;
4384 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4385
4386 let external_audit = AcfeDetectionMethod::ExternalAudit;
4387 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4388
4389 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4391 }
4392
4393 #[test]
4394 fn test_perpetrator_department() {
4395 let accounting = PerpetratorDepartment::Accounting;
4396 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4397 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4398
4399 let executive = PerpetratorDepartment::Executive;
4400 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4401 }
4402
4403 #[test]
4404 fn test_perpetrator_level() {
4405 let employee = PerpetratorLevel::Employee;
4406 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4407 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4408
4409 let exec = PerpetratorLevel::OwnerExecutive;
4410 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4411 }
4412
4413 #[test]
4414 fn test_acfe_calibration() {
4415 let cal = AcfeCalibration::default();
4416 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4417 assert_eq!(cal.median_duration_months, 12);
4418 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4419 assert!(cal.validate().is_ok());
4420
4421 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4423 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4424 assert_eq!(custom_cal.median_duration_months, 18);
4425
4426 let bad_cal = AcfeCalibration {
4428 collusion_rate: 1.5,
4429 ..Default::default()
4430 };
4431 assert!(bad_cal.validate().is_err());
4432 }
4433
4434 #[test]
4435 fn test_fraud_triangle() {
4436 let triangle = FraudTriangle::new(
4437 PressureType::FinancialTargets,
4438 vec![
4439 OpportunityFactor::WeakInternalControls,
4440 OpportunityFactor::ManagementOverride,
4441 ],
4442 Rationalization::ForTheCompanyGood,
4443 );
4444
4445 let risk = triangle.risk_score();
4447 assert!((0.0..=1.0).contains(&risk));
4448 assert!(risk > 0.5);
4450 }
4451
4452 #[test]
4453 fn test_pressure_types() {
4454 let financial = PressureType::FinancialTargets;
4455 assert!(financial.risk_weight() > 0.5);
4456
4457 let gambling = PressureType::GamblingAddiction;
4458 assert_eq!(gambling.risk_weight(), 0.90);
4459 }
4460
4461 #[test]
4462 fn test_opportunity_factors() {
4463 let override_factor = OpportunityFactor::ManagementOverride;
4464 assert_eq!(override_factor.risk_weight(), 0.90);
4465
4466 let weak_controls = OpportunityFactor::WeakInternalControls;
4467 assert!(weak_controls.risk_weight() > 0.8);
4468 }
4469
4470 #[test]
4471 fn test_rationalizations() {
4472 let entitlement = Rationalization::Entitlement;
4473 assert!(entitlement.risk_weight() > 0.8);
4474
4475 let borrowing = Rationalization::TemporaryBorrowing;
4476 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4477 }
4478
4479 #[test]
4480 fn test_acfe_scheme_serialization() {
4481 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4482 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4483 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4484 assert_eq!(scheme, deserialized);
4485
4486 let calibration = AcfeCalibration::default();
4487 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4488 let deserialized: AcfeCalibration =
4489 serde_json::from_str(&json).expect("Failed to deserialize");
4490 assert_eq!(calibration.median_loss, deserialized.median_loss);
4491 }
4492}