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 #[serde(with = "crate::serde_timestamp::naive")]
784 pub detection_timestamp: NaiveDateTime,
785 pub confidence: f64,
787 pub severity: u8,
789 pub description: String,
791 pub related_entities: Vec<String>,
793 pub monetary_impact: Option<Decimal>,
795 pub metadata: HashMap<String, String>,
797 pub is_injected: bool,
799 pub injection_strategy: Option<String>,
801 pub cluster_id: Option<String>,
803
804 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub original_document_hash: Option<String>,
811
812 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub causal_reason: Option<AnomalyCausalReason>,
816
817 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub structured_strategy: Option<InjectionStrategy>,
821
822 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub parent_anomaly_id: Option<String>,
826
827 #[serde(default, skip_serializing_if = "Vec::is_empty")]
829 pub child_anomaly_ids: Vec<String>,
830
831 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub scenario_id: Option<String>,
834
835 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub run_id: Option<String>,
839
840 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub generation_seed: Option<u64>,
844}
845
846impl LabeledAnomaly {
847 pub fn new(
849 anomaly_id: String,
850 anomaly_type: AnomalyType,
851 document_id: String,
852 document_type: String,
853 company_code: String,
854 anomaly_date: NaiveDate,
855 ) -> Self {
856 let severity = anomaly_type.severity();
857 let description = format!(
858 "{} - {} in document {}",
859 anomaly_type.category(),
860 anomaly_type.type_name(),
861 document_id
862 );
863
864 Self {
865 anomaly_id,
866 anomaly_type,
867 document_id,
868 document_type,
869 company_code,
870 anomaly_date,
871 detection_timestamp: chrono::Local::now().naive_local(),
872 confidence: 1.0,
873 severity,
874 description,
875 related_entities: Vec::new(),
876 monetary_impact: None,
877 metadata: HashMap::new(),
878 is_injected: true,
879 injection_strategy: None,
880 cluster_id: None,
881 original_document_hash: None,
883 causal_reason: None,
884 structured_strategy: None,
885 parent_anomaly_id: None,
886 child_anomaly_ids: Vec::new(),
887 scenario_id: None,
888 run_id: None,
889 generation_seed: None,
890 }
891 }
892
893 pub fn with_description(mut self, description: &str) -> Self {
895 self.description = description.to_string();
896 self
897 }
898
899 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
901 self.monetary_impact = Some(impact);
902 self
903 }
904
905 pub fn with_related_entity(mut self, entity: &str) -> Self {
907 self.related_entities.push(entity.to_string());
908 self
909 }
910
911 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
913 self.metadata.insert(key.to_string(), value.to_string());
914 self
915 }
916
917 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
919 self.injection_strategy = Some(strategy.to_string());
920 self
921 }
922
923 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
925 self.cluster_id = Some(cluster_id.to_string());
926 self
927 }
928
929 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
935 self.original_document_hash = Some(hash.to_string());
936 self
937 }
938
939 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
941 self.causal_reason = Some(reason);
942 self
943 }
944
945 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
947 self.injection_strategy = Some(strategy.strategy_type().to_string());
949 self.structured_strategy = Some(strategy);
950 self
951 }
952
953 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
955 self.parent_anomaly_id = Some(parent_id.to_string());
956 self
957 }
958
959 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
961 self.child_anomaly_ids.push(child_id.to_string());
962 self
963 }
964
965 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
967 self.scenario_id = Some(scenario_id.to_string());
968 self
969 }
970
971 pub fn with_run_id(mut self, run_id: &str) -> Self {
973 self.run_id = Some(run_id.to_string());
974 self
975 }
976
977 pub fn with_generation_seed(mut self, seed: u64) -> Self {
979 self.generation_seed = Some(seed);
980 self
981 }
982
983 pub fn with_provenance(
985 mut self,
986 run_id: Option<&str>,
987 seed: Option<u64>,
988 causal_reason: Option<AnomalyCausalReason>,
989 ) -> Self {
990 if let Some(id) = run_id {
991 self.run_id = Some(id.to_string());
992 }
993 self.generation_seed = seed;
994 self.causal_reason = causal_reason;
995 self
996 }
997
998 pub fn to_features(&self) -> Vec<f64> {
1012 let mut features = Vec::new();
1013
1014 let categories = [
1016 "Fraud",
1017 "Error",
1018 "ProcessIssue",
1019 "Statistical",
1020 "Relational",
1021 "Custom",
1022 ];
1023 for cat in &categories {
1024 features.push(if self.anomaly_type.category() == *cat {
1025 1.0
1026 } else {
1027 0.0
1028 });
1029 }
1030
1031 features.push(self.severity as f64 / 5.0);
1033
1034 features.push(self.confidence);
1036
1037 features.push(if self.monetary_impact.is_some() {
1039 1.0
1040 } else {
1041 0.0
1042 });
1043
1044 if let Some(impact) = self.monetary_impact {
1046 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
1047 features.push((impact_f64.abs() + 1.0).ln());
1048 } else {
1049 features.push(0.0);
1050 }
1051
1052 features.push(if self.anomaly_type.is_intentional() {
1054 1.0
1055 } else {
1056 0.0
1057 });
1058
1059 features.push(self.related_entities.len() as f64);
1061
1062 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1064
1065 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1068
1069 features.push(if self.parent_anomaly_id.is_some() {
1071 1.0
1072 } else {
1073 0.0
1074 });
1075
1076 features
1077 }
1078
1079 pub fn feature_count() -> usize {
1081 15 }
1083
1084 pub fn feature_names() -> Vec<&'static str> {
1086 vec![
1087 "category_fraud",
1088 "category_error",
1089 "category_process_issue",
1090 "category_statistical",
1091 "category_relational",
1092 "category_custom",
1093 "severity_normalized",
1094 "confidence",
1095 "has_monetary_impact",
1096 "monetary_impact_log",
1097 "is_intentional",
1098 "related_entity_count",
1099 "is_clustered",
1100 "is_scenario_part",
1101 "is_derived",
1102 ]
1103 }
1104}
1105
1106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1108pub struct AnomalySummary {
1109 pub total_count: usize,
1111 pub by_category: HashMap<String, usize>,
1113 pub by_type: HashMap<String, usize>,
1115 pub by_severity: HashMap<u8, usize>,
1117 pub by_company: HashMap<String, usize>,
1119 pub total_monetary_impact: Decimal,
1121 pub date_range: Option<(NaiveDate, NaiveDate)>,
1123 pub cluster_count: usize,
1125}
1126
1127impl AnomalySummary {
1128 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1130 let mut summary = AnomalySummary {
1131 total_count: anomalies.len(),
1132 ..Default::default()
1133 };
1134
1135 let mut min_date: Option<NaiveDate> = None;
1136 let mut max_date: Option<NaiveDate> = None;
1137 let mut clusters = std::collections::HashSet::new();
1138
1139 for anomaly in anomalies {
1140 *summary
1142 .by_category
1143 .entry(anomaly.anomaly_type.category().to_string())
1144 .or_insert(0) += 1;
1145
1146 *summary
1148 .by_type
1149 .entry(anomaly.anomaly_type.type_name())
1150 .or_insert(0) += 1;
1151
1152 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1154
1155 *summary
1157 .by_company
1158 .entry(anomaly.company_code.clone())
1159 .or_insert(0) += 1;
1160
1161 if let Some(impact) = anomaly.monetary_impact {
1163 summary.total_monetary_impact += impact;
1164 }
1165
1166 match min_date {
1168 None => min_date = Some(anomaly.anomaly_date),
1169 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1170 _ => {}
1171 }
1172 match max_date {
1173 None => max_date = Some(anomaly.anomaly_date),
1174 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1175 _ => {}
1176 }
1177
1178 if let Some(cluster_id) = &anomaly.cluster_id {
1180 clusters.insert(cluster_id.clone());
1181 }
1182 }
1183
1184 summary.date_range = min_date.zip(max_date);
1185 summary.cluster_count = clusters.len();
1186
1187 summary
1188 }
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1200pub enum AnomalyCategory {
1201 FictitiousVendor,
1204 VendorKickback,
1206 RelatedPartyVendor,
1208
1209 DuplicatePayment,
1212 UnauthorizedTransaction,
1214 StructuredTransaction,
1216
1217 CircularFlow,
1220 BehavioralAnomaly,
1222 TimingAnomaly,
1224
1225 JournalAnomaly,
1228 ManualOverride,
1230 MissingApproval,
1232
1233 StatisticalOutlier,
1236 DistributionAnomaly,
1238
1239 Custom(String),
1242}
1243
1244impl AnomalyCategory {
1245 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1247 match anomaly_type {
1248 AnomalyType::Fraud(fraud_type) => match fraud_type {
1249 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1250 AnomalyCategory::FictitiousVendor
1251 }
1252 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1253 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1254 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1255 AnomalyCategory::StructuredTransaction
1256 }
1257 FraudType::SelfApproval
1258 | FraudType::UnauthorizedApproval
1259 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1260 FraudType::TimingAnomaly
1261 | FraudType::RoundDollarManipulation
1262 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1263 _ => AnomalyCategory::BehavioralAnomaly,
1264 },
1265 AnomalyType::Error(error_type) => match error_type {
1266 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1267 ErrorType::WrongPeriod
1268 | ErrorType::BackdatedEntry
1269 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1270 _ => AnomalyCategory::JournalAnomaly,
1271 },
1272 AnomalyType::ProcessIssue(process_type) => match process_type {
1273 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1274 AnomalyCategory::MissingApproval
1275 }
1276 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1277 AnomalyCategory::ManualOverride
1278 }
1279 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1280 AnomalyCategory::TimingAnomaly
1281 }
1282 _ => AnomalyCategory::BehavioralAnomaly,
1283 },
1284 AnomalyType::Statistical(stat_type) => match stat_type {
1285 StatisticalAnomalyType::BenfordViolation
1286 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1287 _ => AnomalyCategory::StatisticalOutlier,
1288 },
1289 AnomalyType::Relational(rel_type) => match rel_type {
1290 RelationalAnomalyType::CircularTransaction
1291 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1292 _ => AnomalyCategory::BehavioralAnomaly,
1293 },
1294 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1295 }
1296 }
1297
1298 pub fn name(&self) -> &str {
1300 match self {
1301 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1302 AnomalyCategory::VendorKickback => "vendor_kickback",
1303 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1304 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1305 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1306 AnomalyCategory::StructuredTransaction => "structured_transaction",
1307 AnomalyCategory::CircularFlow => "circular_flow",
1308 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1309 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1310 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1311 AnomalyCategory::ManualOverride => "manual_override",
1312 AnomalyCategory::MissingApproval => "missing_approval",
1313 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1314 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1315 AnomalyCategory::Custom(s) => s.as_str(),
1316 }
1317 }
1318
1319 pub fn ordinal(&self) -> u8 {
1321 match self {
1322 AnomalyCategory::FictitiousVendor => 0,
1323 AnomalyCategory::VendorKickback => 1,
1324 AnomalyCategory::RelatedPartyVendor => 2,
1325 AnomalyCategory::DuplicatePayment => 3,
1326 AnomalyCategory::UnauthorizedTransaction => 4,
1327 AnomalyCategory::StructuredTransaction => 5,
1328 AnomalyCategory::CircularFlow => 6,
1329 AnomalyCategory::BehavioralAnomaly => 7,
1330 AnomalyCategory::TimingAnomaly => 8,
1331 AnomalyCategory::JournalAnomaly => 9,
1332 AnomalyCategory::ManualOverride => 10,
1333 AnomalyCategory::MissingApproval => 11,
1334 AnomalyCategory::StatisticalOutlier => 12,
1335 AnomalyCategory::DistributionAnomaly => 13,
1336 AnomalyCategory::Custom(_) => 14,
1337 }
1338 }
1339
1340 pub fn category_count() -> usize {
1342 15 }
1344}
1345
1346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1348pub enum FactorType {
1349 AmountDeviation,
1351 ThresholdProximity,
1353 TimingAnomaly,
1355 EntityRisk,
1357 PatternMatch,
1359 FrequencyDeviation,
1361 RelationshipAnomaly,
1363 ControlBypass,
1365 BenfordViolation,
1367 DuplicateIndicator,
1369 ApprovalChainIssue,
1371 DocumentationGap,
1373 Custom,
1375}
1376
1377impl FactorType {
1378 pub fn name(&self) -> &'static str {
1380 match self {
1381 FactorType::AmountDeviation => "amount_deviation",
1382 FactorType::ThresholdProximity => "threshold_proximity",
1383 FactorType::TimingAnomaly => "timing_anomaly",
1384 FactorType::EntityRisk => "entity_risk",
1385 FactorType::PatternMatch => "pattern_match",
1386 FactorType::FrequencyDeviation => "frequency_deviation",
1387 FactorType::RelationshipAnomaly => "relationship_anomaly",
1388 FactorType::ControlBypass => "control_bypass",
1389 FactorType::BenfordViolation => "benford_violation",
1390 FactorType::DuplicateIndicator => "duplicate_indicator",
1391 FactorType::ApprovalChainIssue => "approval_chain_issue",
1392 FactorType::DocumentationGap => "documentation_gap",
1393 FactorType::Custom => "custom",
1394 }
1395 }
1396}
1397
1398#[derive(Debug, Clone, Serialize, Deserialize)]
1400pub struct FactorEvidence {
1401 pub source: String,
1403 pub data: HashMap<String, String>,
1405}
1406
1407#[derive(Debug, Clone, Serialize, Deserialize)]
1409pub struct ContributingFactor {
1410 pub factor_type: FactorType,
1412 pub value: f64,
1414 pub threshold: f64,
1416 pub direction_greater: bool,
1418 pub weight: f64,
1420 pub description: String,
1422 pub evidence: Option<FactorEvidence>,
1424}
1425
1426impl ContributingFactor {
1427 pub fn new(
1429 factor_type: FactorType,
1430 value: f64,
1431 threshold: f64,
1432 direction_greater: bool,
1433 weight: f64,
1434 description: &str,
1435 ) -> Self {
1436 Self {
1437 factor_type,
1438 value,
1439 threshold,
1440 direction_greater,
1441 weight,
1442 description: description.to_string(),
1443 evidence: None,
1444 }
1445 }
1446
1447 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1449 self.evidence = Some(FactorEvidence {
1450 source: source.to_string(),
1451 data,
1452 });
1453 self
1454 }
1455
1456 pub fn contribution(&self) -> f64 {
1458 let deviation = if self.direction_greater {
1459 (self.value - self.threshold).max(0.0)
1460 } else {
1461 (self.threshold - self.value).max(0.0)
1462 };
1463
1464 let relative_deviation = if self.threshold.abs() > 0.001 {
1466 deviation / self.threshold.abs()
1467 } else {
1468 deviation
1469 };
1470
1471 (relative_deviation * self.weight).min(1.0)
1473 }
1474}
1475
1476#[derive(Debug, Clone, Serialize, Deserialize)]
1478pub struct EnhancedAnomalyLabel {
1479 pub base: LabeledAnomaly,
1481 pub category: AnomalyCategory,
1483 pub enhanced_confidence: f64,
1485 pub enhanced_severity: f64,
1487 pub contributing_factors: Vec<ContributingFactor>,
1489 pub secondary_categories: Vec<AnomalyCategory>,
1491}
1492
1493impl EnhancedAnomalyLabel {
1494 pub fn from_base(base: LabeledAnomaly) -> Self {
1496 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1497 let enhanced_confidence = base.confidence;
1498 let enhanced_severity = base.severity as f64 / 5.0;
1499
1500 Self {
1501 base,
1502 category,
1503 enhanced_confidence,
1504 enhanced_severity,
1505 contributing_factors: Vec::new(),
1506 secondary_categories: Vec::new(),
1507 }
1508 }
1509
1510 pub fn with_confidence(mut self, confidence: f64) -> Self {
1512 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1513 self
1514 }
1515
1516 pub fn with_severity(mut self, severity: f64) -> Self {
1518 self.enhanced_severity = severity.clamp(0.0, 1.0);
1519 self
1520 }
1521
1522 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1524 self.contributing_factors.push(factor);
1525 self
1526 }
1527
1528 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1530 if !self.secondary_categories.contains(&category) && category != self.category {
1531 self.secondary_categories.push(category);
1532 }
1533 self
1534 }
1535
1536 pub fn to_features(&self) -> Vec<f64> {
1540 let mut features = self.base.to_features();
1541
1542 features.push(self.enhanced_confidence);
1544 features.push(self.enhanced_severity);
1545 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1546 features.push(self.secondary_categories.len() as f64);
1547 features.push(self.contributing_factors.len() as f64);
1548
1549 let max_weight = self
1551 .contributing_factors
1552 .iter()
1553 .map(|f| f.weight)
1554 .fold(0.0, f64::max);
1555 features.push(max_weight);
1556
1557 let has_control_bypass = self
1559 .contributing_factors
1560 .iter()
1561 .any(|f| f.factor_type == FactorType::ControlBypass);
1562 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1563
1564 let has_amount_deviation = self
1565 .contributing_factors
1566 .iter()
1567 .any(|f| f.factor_type == FactorType::AmountDeviation);
1568 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1569
1570 let has_timing = self
1571 .contributing_factors
1572 .iter()
1573 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1574 features.push(if has_timing { 1.0 } else { 0.0 });
1575
1576 let has_pattern_match = self
1577 .contributing_factors
1578 .iter()
1579 .any(|f| f.factor_type == FactorType::PatternMatch);
1580 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1581
1582 features
1583 }
1584
1585 pub fn feature_count() -> usize {
1587 25 }
1589
1590 pub fn feature_names() -> Vec<&'static str> {
1592 let mut names = LabeledAnomaly::feature_names();
1593 names.extend(vec![
1594 "enhanced_confidence",
1595 "enhanced_severity",
1596 "category_ordinal",
1597 "secondary_category_count",
1598 "contributing_factor_count",
1599 "max_factor_weight",
1600 "has_control_bypass",
1601 "has_amount_deviation",
1602 "has_timing_factor",
1603 "has_pattern_match",
1604 ]);
1605 names
1606 }
1607}
1608
1609#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1615pub enum SeverityLevel {
1616 Low,
1618 #[default]
1620 Medium,
1621 High,
1623 Critical,
1625}
1626
1627impl SeverityLevel {
1628 pub fn numeric(&self) -> u8 {
1630 match self {
1631 SeverityLevel::Low => 1,
1632 SeverityLevel::Medium => 2,
1633 SeverityLevel::High => 3,
1634 SeverityLevel::Critical => 4,
1635 }
1636 }
1637
1638 pub fn from_numeric(value: u8) -> Self {
1640 match value {
1641 1 => SeverityLevel::Low,
1642 2 => SeverityLevel::Medium,
1643 3 => SeverityLevel::High,
1644 _ => SeverityLevel::Critical,
1645 }
1646 }
1647
1648 pub fn from_score(score: f64) -> Self {
1650 match score {
1651 s if s < 0.25 => SeverityLevel::Low,
1652 s if s < 0.50 => SeverityLevel::Medium,
1653 s if s < 0.75 => SeverityLevel::High,
1654 _ => SeverityLevel::Critical,
1655 }
1656 }
1657
1658 pub fn to_score(&self) -> f64 {
1660 match self {
1661 SeverityLevel::Low => 0.125,
1662 SeverityLevel::Medium => 0.375,
1663 SeverityLevel::High => 0.625,
1664 SeverityLevel::Critical => 0.875,
1665 }
1666 }
1667}
1668
1669#[derive(Debug, Clone, Serialize, Deserialize)]
1671pub struct AnomalySeverity {
1672 pub level: SeverityLevel,
1674 pub score: f64,
1676 pub financial_impact: Decimal,
1678 pub is_material: bool,
1680 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 pub materiality_threshold: Option<Decimal>,
1683}
1684
1685impl AnomalySeverity {
1686 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1688 Self {
1689 level,
1690 score: level.to_score(),
1691 financial_impact,
1692 is_material: false,
1693 materiality_threshold: None,
1694 }
1695 }
1696
1697 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1699 Self {
1700 level: SeverityLevel::from_score(score),
1701 score: score.clamp(0.0, 1.0),
1702 financial_impact,
1703 is_material: false,
1704 materiality_threshold: None,
1705 }
1706 }
1707
1708 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1710 self.materiality_threshold = Some(threshold);
1711 self.is_material = self.financial_impact.abs() >= threshold;
1712 self
1713 }
1714}
1715
1716impl Default for AnomalySeverity {
1717 fn default() -> Self {
1718 Self {
1719 level: SeverityLevel::Medium,
1720 score: 0.5,
1721 financial_impact: Decimal::ZERO,
1722 is_material: false,
1723 materiality_threshold: None,
1724 }
1725 }
1726}
1727
1728#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1736pub enum AnomalyDetectionDifficulty {
1737 Trivial,
1739 Easy,
1741 #[default]
1743 Moderate,
1744 Hard,
1746 Expert,
1748}
1749
1750impl AnomalyDetectionDifficulty {
1751 pub fn expected_detection_rate(&self) -> f64 {
1753 match self {
1754 AnomalyDetectionDifficulty::Trivial => 0.99,
1755 AnomalyDetectionDifficulty::Easy => 0.90,
1756 AnomalyDetectionDifficulty::Moderate => 0.70,
1757 AnomalyDetectionDifficulty::Hard => 0.40,
1758 AnomalyDetectionDifficulty::Expert => 0.15,
1759 }
1760 }
1761
1762 pub fn difficulty_score(&self) -> f64 {
1764 match self {
1765 AnomalyDetectionDifficulty::Trivial => 0.05,
1766 AnomalyDetectionDifficulty::Easy => 0.25,
1767 AnomalyDetectionDifficulty::Moderate => 0.50,
1768 AnomalyDetectionDifficulty::Hard => 0.75,
1769 AnomalyDetectionDifficulty::Expert => 0.95,
1770 }
1771 }
1772
1773 pub fn from_score(score: f64) -> Self {
1775 match score {
1776 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1777 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1778 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1779 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1780 _ => AnomalyDetectionDifficulty::Expert,
1781 }
1782 }
1783
1784 pub fn name(&self) -> &'static str {
1786 match self {
1787 AnomalyDetectionDifficulty::Trivial => "trivial",
1788 AnomalyDetectionDifficulty::Easy => "easy",
1789 AnomalyDetectionDifficulty::Moderate => "moderate",
1790 AnomalyDetectionDifficulty::Hard => "hard",
1791 AnomalyDetectionDifficulty::Expert => "expert",
1792 }
1793 }
1794}
1795
1796#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1800pub enum GroundTruthCertainty {
1801 #[default]
1803 Definite,
1804 Probable,
1806 Possible,
1808}
1809
1810impl GroundTruthCertainty {
1811 pub fn certainty_score(&self) -> f64 {
1813 match self {
1814 GroundTruthCertainty::Definite => 1.0,
1815 GroundTruthCertainty::Probable => 0.8,
1816 GroundTruthCertainty::Possible => 0.5,
1817 }
1818 }
1819
1820 pub fn name(&self) -> &'static str {
1822 match self {
1823 GroundTruthCertainty::Definite => "definite",
1824 GroundTruthCertainty::Probable => "probable",
1825 GroundTruthCertainty::Possible => "possible",
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1834pub enum DetectionMethod {
1835 RuleBased,
1837 Statistical,
1839 MachineLearning,
1841 GraphBased,
1843 ForensicAudit,
1845 Hybrid,
1847}
1848
1849impl DetectionMethod {
1850 pub fn name(&self) -> &'static str {
1852 match self {
1853 DetectionMethod::RuleBased => "rule_based",
1854 DetectionMethod::Statistical => "statistical",
1855 DetectionMethod::MachineLearning => "machine_learning",
1856 DetectionMethod::GraphBased => "graph_based",
1857 DetectionMethod::ForensicAudit => "forensic_audit",
1858 DetectionMethod::Hybrid => "hybrid",
1859 }
1860 }
1861
1862 pub fn description(&self) -> &'static str {
1864 match self {
1865 DetectionMethod::RuleBased => "Simple threshold and filter rules",
1866 DetectionMethod::Statistical => "Statistical distribution analysis",
1867 DetectionMethod::MachineLearning => "ML classification models",
1868 DetectionMethod::GraphBased => "Network and relationship analysis",
1869 DetectionMethod::ForensicAudit => "Manual forensic procedures",
1870 DetectionMethod::Hybrid => "Combined multi-method approach",
1871 }
1872 }
1873}
1874
1875#[derive(Debug, Clone, Serialize, Deserialize)]
1880pub struct ExtendedAnomalyLabel {
1881 pub base: LabeledAnomaly,
1883 pub category: AnomalyCategory,
1885 pub severity: AnomalySeverity,
1887 pub detection_difficulty: AnomalyDetectionDifficulty,
1889 pub recommended_methods: Vec<DetectionMethod>,
1891 pub key_indicators: Vec<String>,
1893 pub ground_truth_certainty: GroundTruthCertainty,
1895 pub contributing_factors: Vec<ContributingFactor>,
1897 pub related_entity_ids: Vec<String>,
1899 pub secondary_categories: Vec<AnomalyCategory>,
1901 #[serde(default, skip_serializing_if = "Option::is_none")]
1903 pub scheme_id: Option<String>,
1904 #[serde(default, skip_serializing_if = "Option::is_none")]
1906 pub scheme_stage: Option<u32>,
1907 #[serde(default)]
1909 pub is_near_miss: bool,
1910 #[serde(default, skip_serializing_if = "Option::is_none")]
1912 pub near_miss_explanation: Option<String>,
1913}
1914
1915impl ExtendedAnomalyLabel {
1916 pub fn from_base(base: LabeledAnomaly) -> Self {
1918 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1919 let severity = AnomalySeverity {
1920 level: SeverityLevel::from_numeric(base.severity),
1921 score: base.severity as f64 / 5.0,
1922 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
1923 is_material: false,
1924 materiality_threshold: None,
1925 };
1926
1927 Self {
1928 base,
1929 category,
1930 severity,
1931 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
1932 recommended_methods: vec![DetectionMethod::RuleBased],
1933 key_indicators: Vec::new(),
1934 ground_truth_certainty: GroundTruthCertainty::Definite,
1935 contributing_factors: Vec::new(),
1936 related_entity_ids: Vec::new(),
1937 secondary_categories: Vec::new(),
1938 scheme_id: None,
1939 scheme_stage: None,
1940 is_near_miss: false,
1941 near_miss_explanation: None,
1942 }
1943 }
1944
1945 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
1947 self.severity = severity;
1948 self
1949 }
1950
1951 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
1953 self.detection_difficulty = difficulty;
1954 self
1955 }
1956
1957 pub fn with_method(mut self, method: DetectionMethod) -> Self {
1959 if !self.recommended_methods.contains(&method) {
1960 self.recommended_methods.push(method);
1961 }
1962 self
1963 }
1964
1965 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
1967 self.recommended_methods = methods;
1968 self
1969 }
1970
1971 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
1973 self.key_indicators.push(indicator.into());
1974 self
1975 }
1976
1977 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
1979 self.ground_truth_certainty = certainty;
1980 self
1981 }
1982
1983 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1985 self.contributing_factors.push(factor);
1986 self
1987 }
1988
1989 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
1991 self.related_entity_ids.push(entity_id.into());
1992 self
1993 }
1994
1995 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1997 if category != self.category && !self.secondary_categories.contains(&category) {
1998 self.secondary_categories.push(category);
1999 }
2000 self
2001 }
2002
2003 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
2005 self.scheme_id = Some(scheme_id.into());
2006 self.scheme_stage = Some(stage);
2007 self
2008 }
2009
2010 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
2012 self.is_near_miss = true;
2013 self.near_miss_explanation = Some(explanation.into());
2014 self
2015 }
2016
2017 pub fn to_features(&self) -> Vec<f64> {
2021 let mut features = self.base.to_features();
2022
2023 features.push(self.severity.score);
2025 features.push(self.severity.level.to_score());
2026 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
2027 features.push(self.detection_difficulty.difficulty_score());
2028 features.push(self.detection_difficulty.expected_detection_rate());
2029 features.push(self.ground_truth_certainty.certainty_score());
2030 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
2031 features.push(self.secondary_categories.len() as f64);
2032 features.push(self.contributing_factors.len() as f64);
2033 features.push(self.key_indicators.len() as f64);
2034 features.push(self.recommended_methods.len() as f64);
2035 features.push(self.related_entity_ids.len() as f64);
2036 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
2037 features.push(self.scheme_stage.unwrap_or(0) as f64);
2038 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
2039
2040 features
2041 }
2042
2043 pub fn feature_count() -> usize {
2045 30 }
2047
2048 pub fn feature_names() -> Vec<&'static str> {
2050 let mut names = LabeledAnomaly::feature_names();
2051 names.extend(vec![
2052 "severity_score",
2053 "severity_level_score",
2054 "is_material",
2055 "difficulty_score",
2056 "expected_detection_rate",
2057 "ground_truth_certainty",
2058 "category_ordinal",
2059 "secondary_category_count",
2060 "contributing_factor_count",
2061 "key_indicator_count",
2062 "recommended_method_count",
2063 "related_entity_count",
2064 "is_part_of_scheme",
2065 "scheme_stage",
2066 "is_near_miss",
2067 ]);
2068 names
2069 }
2070}
2071
2072#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2078pub enum SchemeType {
2079 GradualEmbezzlement,
2081 RevenueManipulation,
2083 VendorKickback,
2085 RoundTripping,
2087 GhostEmployee,
2089 ExpenseReimbursement,
2091 InventoryTheft,
2093 Custom,
2095}
2096
2097impl SchemeType {
2098 pub fn name(&self) -> &'static str {
2100 match self {
2101 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2102 SchemeType::RevenueManipulation => "revenue_manipulation",
2103 SchemeType::VendorKickback => "vendor_kickback",
2104 SchemeType::RoundTripping => "round_tripping",
2105 SchemeType::GhostEmployee => "ghost_employee",
2106 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2107 SchemeType::InventoryTheft => "inventory_theft",
2108 SchemeType::Custom => "custom",
2109 }
2110 }
2111
2112 pub fn typical_stages(&self) -> u32 {
2114 match self {
2115 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2123 }
2124 }
2125}
2126
2127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2129pub enum SchemeDetectionStatus {
2130 #[default]
2132 Undetected,
2133 UnderInvestigation,
2135 PartiallyDetected,
2137 FullyDetected,
2139}
2140
2141#[derive(Debug, Clone, Serialize, Deserialize)]
2143pub struct SchemeTransactionRef {
2144 pub document_id: String,
2146 pub date: chrono::NaiveDate,
2148 pub amount: Decimal,
2150 pub stage: u32,
2152 #[serde(default, skip_serializing_if = "Option::is_none")]
2154 pub anomaly_id: Option<String>,
2155}
2156
2157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2159pub enum ConcealmentTechnique {
2160 DocumentManipulation,
2162 ApprovalCircumvention,
2164 TimingExploitation,
2166 TransactionSplitting,
2168 AccountMisclassification,
2170 Collusion,
2172 DataAlteration,
2174 FalseDocumentation,
2176}
2177
2178impl ConcealmentTechnique {
2179 pub fn difficulty_bonus(&self) -> f64 {
2181 match self {
2182 ConcealmentTechnique::DocumentManipulation => 0.20,
2183 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2184 ConcealmentTechnique::TimingExploitation => 0.10,
2185 ConcealmentTechnique::TransactionSplitting => 0.15,
2186 ConcealmentTechnique::AccountMisclassification => 0.10,
2187 ConcealmentTechnique::Collusion => 0.25,
2188 ConcealmentTechnique::DataAlteration => 0.20,
2189 ConcealmentTechnique::FalseDocumentation => 0.15,
2190 }
2191 }
2192}
2193
2194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2211pub enum AcfeFraudCategory {
2212 #[default]
2215 AssetMisappropriation,
2216 Corruption,
2219 FinancialStatementFraud,
2222}
2223
2224impl AcfeFraudCategory {
2225 pub fn name(&self) -> &'static str {
2227 match self {
2228 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2229 AcfeFraudCategory::Corruption => "corruption",
2230 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2231 }
2232 }
2233
2234 pub fn typical_occurrence_rate(&self) -> f64 {
2236 match self {
2237 AcfeFraudCategory::AssetMisappropriation => 0.86,
2238 AcfeFraudCategory::Corruption => 0.33,
2239 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2240 }
2241 }
2242
2243 pub fn typical_median_loss(&self) -> Decimal {
2245 match self {
2246 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2247 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2248 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2249 }
2250 }
2251
2252 pub fn typical_detection_months(&self) -> u32 {
2254 match self {
2255 AcfeFraudCategory::AssetMisappropriation => 12,
2256 AcfeFraudCategory::Corruption => 18,
2257 AcfeFraudCategory::FinancialStatementFraud => 24,
2258 }
2259 }
2260}
2261
2262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2269pub enum CashFraudScheme {
2270 Larceny,
2273 Skimming,
2275
2276 SalesSkimming,
2279 ReceivablesSkimming,
2281 RefundSchemes,
2283
2284 ShellCompany,
2287 NonAccompliceVendor,
2289 PersonalPurchases,
2291
2292 GhostEmployee,
2295 FalsifiedWages,
2297 CommissionSchemes,
2299
2300 MischaracterizedExpenses,
2303 OverstatedExpenses,
2305 FictitiousExpenses,
2307
2308 ForgedMaker,
2311 ForgedEndorsement,
2313 AlteredPayee,
2315 AuthorizedMaker,
2317
2318 FalseVoids,
2321 FalseRefunds,
2323}
2324
2325impl CashFraudScheme {
2326 pub fn category(&self) -> AcfeFraudCategory {
2328 AcfeFraudCategory::AssetMisappropriation
2329 }
2330
2331 pub fn subcategory(&self) -> &'static str {
2333 match self {
2334 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2335 CashFraudScheme::SalesSkimming
2336 | CashFraudScheme::ReceivablesSkimming
2337 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2338 CashFraudScheme::ShellCompany
2339 | CashFraudScheme::NonAccompliceVendor
2340 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2341 CashFraudScheme::GhostEmployee
2342 | CashFraudScheme::FalsifiedWages
2343 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2344 CashFraudScheme::MischaracterizedExpenses
2345 | CashFraudScheme::OverstatedExpenses
2346 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2347 CashFraudScheme::ForgedMaker
2348 | CashFraudScheme::ForgedEndorsement
2349 | CashFraudScheme::AlteredPayee
2350 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2351 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2352 }
2353 }
2354
2355 pub fn severity(&self) -> u8 {
2357 match self {
2358 CashFraudScheme::FalseVoids
2360 | CashFraudScheme::FalseRefunds
2361 | CashFraudScheme::MischaracterizedExpenses => 3,
2362 CashFraudScheme::OverstatedExpenses
2364 | CashFraudScheme::Skimming
2365 | CashFraudScheme::Larceny
2366 | CashFraudScheme::PersonalPurchases
2367 | CashFraudScheme::FalsifiedWages => 4,
2368 CashFraudScheme::ShellCompany
2370 | CashFraudScheme::GhostEmployee
2371 | CashFraudScheme::FictitiousExpenses
2372 | CashFraudScheme::ForgedMaker
2373 | CashFraudScheme::AuthorizedMaker => 5,
2374 _ => 4,
2375 }
2376 }
2377
2378 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2380 match self {
2381 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2383 AnomalyDetectionDifficulty::Easy
2384 }
2385 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2387 AnomalyDetectionDifficulty::Moderate
2388 }
2389 CashFraudScheme::Skimming
2391 | CashFraudScheme::ShellCompany
2392 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2393 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2395 AnomalyDetectionDifficulty::Expert
2396 }
2397 _ => AnomalyDetectionDifficulty::Moderate,
2398 }
2399 }
2400
2401 pub fn all_variants() -> &'static [CashFraudScheme] {
2403 &[
2404 CashFraudScheme::Larceny,
2405 CashFraudScheme::Skimming,
2406 CashFraudScheme::SalesSkimming,
2407 CashFraudScheme::ReceivablesSkimming,
2408 CashFraudScheme::RefundSchemes,
2409 CashFraudScheme::ShellCompany,
2410 CashFraudScheme::NonAccompliceVendor,
2411 CashFraudScheme::PersonalPurchases,
2412 CashFraudScheme::GhostEmployee,
2413 CashFraudScheme::FalsifiedWages,
2414 CashFraudScheme::CommissionSchemes,
2415 CashFraudScheme::MischaracterizedExpenses,
2416 CashFraudScheme::OverstatedExpenses,
2417 CashFraudScheme::FictitiousExpenses,
2418 CashFraudScheme::ForgedMaker,
2419 CashFraudScheme::ForgedEndorsement,
2420 CashFraudScheme::AlteredPayee,
2421 CashFraudScheme::AuthorizedMaker,
2422 CashFraudScheme::FalseVoids,
2423 CashFraudScheme::FalseRefunds,
2424 ]
2425 }
2426}
2427
2428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2430pub enum AssetFraudScheme {
2431 InventoryMisuse,
2434 InventoryTheft,
2436 InventoryPurchasingScheme,
2438 InventoryReceivingScheme,
2440
2441 EquipmentMisuse,
2444 EquipmentTheft,
2446 IntellectualPropertyTheft,
2448 TimeTheft,
2450}
2451
2452impl AssetFraudScheme {
2453 pub fn category(&self) -> AcfeFraudCategory {
2455 AcfeFraudCategory::AssetMisappropriation
2456 }
2457
2458 pub fn subcategory(&self) -> &'static str {
2460 match self {
2461 AssetFraudScheme::InventoryMisuse
2462 | AssetFraudScheme::InventoryTheft
2463 | AssetFraudScheme::InventoryPurchasingScheme
2464 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2465 _ => "other_assets",
2466 }
2467 }
2468
2469 pub fn severity(&self) -> u8 {
2471 match self {
2472 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2473 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2474 AssetFraudScheme::InventoryTheft
2475 | AssetFraudScheme::InventoryPurchasingScheme
2476 | AssetFraudScheme::InventoryReceivingScheme => 4,
2477 AssetFraudScheme::IntellectualPropertyTheft => 5,
2478 }
2479 }
2480}
2481
2482#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2487pub enum CorruptionScheme {
2488 PurchasingConflict,
2491 SalesConflict,
2493 OutsideBusinessInterest,
2495 NepotismConflict,
2497
2498 InvoiceKickback,
2501 BidRigging,
2503 CashBribery,
2505 PublicOfficial,
2507
2508 IllegalGratuity,
2511
2512 EconomicExtortion,
2515}
2516
2517impl CorruptionScheme {
2518 pub fn category(&self) -> AcfeFraudCategory {
2520 AcfeFraudCategory::Corruption
2521 }
2522
2523 pub fn subcategory(&self) -> &'static str {
2525 match self {
2526 CorruptionScheme::PurchasingConflict
2527 | CorruptionScheme::SalesConflict
2528 | CorruptionScheme::OutsideBusinessInterest
2529 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2530 CorruptionScheme::InvoiceKickback
2531 | CorruptionScheme::BidRigging
2532 | CorruptionScheme::CashBribery
2533 | CorruptionScheme::PublicOfficial => "bribery",
2534 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2535 CorruptionScheme::EconomicExtortion => "economic_extortion",
2536 }
2537 }
2538
2539 pub fn severity(&self) -> u8 {
2541 match self {
2542 CorruptionScheme::NepotismConflict => 3,
2544 CorruptionScheme::PurchasingConflict
2546 | CorruptionScheme::SalesConflict
2547 | CorruptionScheme::OutsideBusinessInterest
2548 | CorruptionScheme::IllegalGratuity => 4,
2549 CorruptionScheme::InvoiceKickback
2551 | CorruptionScheme::BidRigging
2552 | CorruptionScheme::CashBribery
2553 | CorruptionScheme::EconomicExtortion => 5,
2554 CorruptionScheme::PublicOfficial => 5,
2556 }
2557 }
2558
2559 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2561 match self {
2562 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2564 AnomalyDetectionDifficulty::Moderate
2565 }
2566 CorruptionScheme::PurchasingConflict
2568 | CorruptionScheme::SalesConflict
2569 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2570 CorruptionScheme::InvoiceKickback
2572 | CorruptionScheme::CashBribery
2573 | CorruptionScheme::PublicOfficial
2574 | CorruptionScheme::IllegalGratuity
2575 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2576 }
2577 }
2578
2579 pub fn all_variants() -> &'static [CorruptionScheme] {
2581 &[
2582 CorruptionScheme::PurchasingConflict,
2583 CorruptionScheme::SalesConflict,
2584 CorruptionScheme::OutsideBusinessInterest,
2585 CorruptionScheme::NepotismConflict,
2586 CorruptionScheme::InvoiceKickback,
2587 CorruptionScheme::BidRigging,
2588 CorruptionScheme::CashBribery,
2589 CorruptionScheme::PublicOfficial,
2590 CorruptionScheme::IllegalGratuity,
2591 CorruptionScheme::EconomicExtortion,
2592 ]
2593 }
2594}
2595
2596#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2601pub enum FinancialStatementScheme {
2602 PrematureRevenue,
2605 DelayedExpenses,
2607 FictitiousRevenues,
2609 ConcealedLiabilities,
2611 ImproperAssetValuations,
2613 ImproperDisclosures,
2615 ChannelStuffing,
2617 BillAndHold,
2619 ImproperCapitalization,
2621
2622 UnderstatedRevenues,
2625 OverstatedExpenses,
2627 OverstatedLiabilities,
2629 ImproperAssetWritedowns,
2631}
2632
2633impl FinancialStatementScheme {
2634 pub fn category(&self) -> AcfeFraudCategory {
2636 AcfeFraudCategory::FinancialStatementFraud
2637 }
2638
2639 pub fn subcategory(&self) -> &'static str {
2641 match self {
2642 FinancialStatementScheme::UnderstatedRevenues
2643 | FinancialStatementScheme::OverstatedExpenses
2644 | FinancialStatementScheme::OverstatedLiabilities
2645 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2646 _ => "overstatement",
2647 }
2648 }
2649
2650 pub fn severity(&self) -> u8 {
2652 5
2654 }
2655
2656 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2658 match self {
2659 FinancialStatementScheme::ChannelStuffing
2661 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2662 FinancialStatementScheme::PrematureRevenue
2664 | FinancialStatementScheme::ImproperCapitalization
2665 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2666 FinancialStatementScheme::FictitiousRevenues
2668 | FinancialStatementScheme::ConcealedLiabilities
2669 | FinancialStatementScheme::ImproperAssetValuations
2670 | FinancialStatementScheme::ImproperDisclosures
2671 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2672 _ => AnomalyDetectionDifficulty::Hard,
2673 }
2674 }
2675
2676 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2678 &[
2679 FinancialStatementScheme::PrematureRevenue,
2680 FinancialStatementScheme::DelayedExpenses,
2681 FinancialStatementScheme::FictitiousRevenues,
2682 FinancialStatementScheme::ConcealedLiabilities,
2683 FinancialStatementScheme::ImproperAssetValuations,
2684 FinancialStatementScheme::ImproperDisclosures,
2685 FinancialStatementScheme::ChannelStuffing,
2686 FinancialStatementScheme::BillAndHold,
2687 FinancialStatementScheme::ImproperCapitalization,
2688 FinancialStatementScheme::UnderstatedRevenues,
2689 FinancialStatementScheme::OverstatedExpenses,
2690 FinancialStatementScheme::OverstatedLiabilities,
2691 FinancialStatementScheme::ImproperAssetWritedowns,
2692 ]
2693 }
2694}
2695
2696#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2698pub enum AcfeScheme {
2699 Cash(CashFraudScheme),
2701 Asset(AssetFraudScheme),
2703 Corruption(CorruptionScheme),
2705 FinancialStatement(FinancialStatementScheme),
2707}
2708
2709impl AcfeScheme {
2710 pub fn category(&self) -> AcfeFraudCategory {
2712 match self {
2713 AcfeScheme::Cash(s) => s.category(),
2714 AcfeScheme::Asset(s) => s.category(),
2715 AcfeScheme::Corruption(s) => s.category(),
2716 AcfeScheme::FinancialStatement(s) => s.category(),
2717 }
2718 }
2719
2720 pub fn severity(&self) -> u8 {
2722 match self {
2723 AcfeScheme::Cash(s) => s.severity(),
2724 AcfeScheme::Asset(s) => s.severity(),
2725 AcfeScheme::Corruption(s) => s.severity(),
2726 AcfeScheme::FinancialStatement(s) => s.severity(),
2727 }
2728 }
2729
2730 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2732 match self {
2733 AcfeScheme::Cash(s) => s.detection_difficulty(),
2734 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2735 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2736 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2737 }
2738 }
2739}
2740
2741#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2743pub enum AcfeDetectionMethod {
2744 Tip,
2746 InternalAudit,
2748 ManagementReview,
2750 ExternalAudit,
2752 AccountReconciliation,
2754 DocumentExamination,
2756 ByAccident,
2758 ItControls,
2760 Surveillance,
2762 Confession,
2764 LawEnforcement,
2766 Other,
2768}
2769
2770impl AcfeDetectionMethod {
2771 pub fn typical_detection_rate(&self) -> f64 {
2773 match self {
2774 AcfeDetectionMethod::Tip => 0.42,
2775 AcfeDetectionMethod::InternalAudit => 0.16,
2776 AcfeDetectionMethod::ManagementReview => 0.12,
2777 AcfeDetectionMethod::ExternalAudit => 0.04,
2778 AcfeDetectionMethod::AccountReconciliation => 0.05,
2779 AcfeDetectionMethod::DocumentExamination => 0.04,
2780 AcfeDetectionMethod::ByAccident => 0.06,
2781 AcfeDetectionMethod::ItControls => 0.03,
2782 AcfeDetectionMethod::Surveillance => 0.02,
2783 AcfeDetectionMethod::Confession => 0.02,
2784 AcfeDetectionMethod::LawEnforcement => 0.01,
2785 AcfeDetectionMethod::Other => 0.03,
2786 }
2787 }
2788
2789 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2791 &[
2792 AcfeDetectionMethod::Tip,
2793 AcfeDetectionMethod::InternalAudit,
2794 AcfeDetectionMethod::ManagementReview,
2795 AcfeDetectionMethod::ExternalAudit,
2796 AcfeDetectionMethod::AccountReconciliation,
2797 AcfeDetectionMethod::DocumentExamination,
2798 AcfeDetectionMethod::ByAccident,
2799 AcfeDetectionMethod::ItControls,
2800 AcfeDetectionMethod::Surveillance,
2801 AcfeDetectionMethod::Confession,
2802 AcfeDetectionMethod::LawEnforcement,
2803 AcfeDetectionMethod::Other,
2804 ]
2805 }
2806}
2807
2808#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2810pub enum PerpetratorDepartment {
2811 Accounting,
2813 Operations,
2815 Executive,
2817 Sales,
2819 CustomerService,
2821 Purchasing,
2823 It,
2825 HumanResources,
2827 Administrative,
2829 Warehouse,
2831 BoardOfDirectors,
2833 Other,
2835}
2836
2837impl PerpetratorDepartment {
2838 pub fn typical_occurrence_rate(&self) -> f64 {
2840 match self {
2841 PerpetratorDepartment::Accounting => 0.21,
2842 PerpetratorDepartment::Operations => 0.17,
2843 PerpetratorDepartment::Executive => 0.12,
2844 PerpetratorDepartment::Sales => 0.11,
2845 PerpetratorDepartment::CustomerService => 0.07,
2846 PerpetratorDepartment::Purchasing => 0.06,
2847 PerpetratorDepartment::It => 0.05,
2848 PerpetratorDepartment::HumanResources => 0.04,
2849 PerpetratorDepartment::Administrative => 0.04,
2850 PerpetratorDepartment::Warehouse => 0.03,
2851 PerpetratorDepartment::BoardOfDirectors => 0.02,
2852 PerpetratorDepartment::Other => 0.08,
2853 }
2854 }
2855
2856 pub fn typical_median_loss(&self) -> Decimal {
2858 match self {
2859 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
2860 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
2861 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
2862 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
2863 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
2864 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
2865 PerpetratorDepartment::It => Decimal::new(100_000, 0),
2866 _ => Decimal::new(80_000, 0),
2867 }
2868 }
2869}
2870
2871#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2873pub enum PerpetratorLevel {
2874 Employee,
2876 Manager,
2878 OwnerExecutive,
2880}
2881
2882impl PerpetratorLevel {
2883 pub fn typical_occurrence_rate(&self) -> f64 {
2885 match self {
2886 PerpetratorLevel::Employee => 0.42,
2887 PerpetratorLevel::Manager => 0.36,
2888 PerpetratorLevel::OwnerExecutive => 0.22,
2889 }
2890 }
2891
2892 pub fn typical_median_loss(&self) -> Decimal {
2894 match self {
2895 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
2896 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
2897 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
2898 }
2899 }
2900}
2901
2902#[derive(Debug, Clone, Serialize, Deserialize)]
2907pub struct AcfeCalibration {
2908 pub median_loss: Decimal,
2910 pub median_duration_months: u32,
2912 pub category_distribution: HashMap<String, f64>,
2914 pub detection_method_distribution: HashMap<String, f64>,
2916 pub department_distribution: HashMap<String, f64>,
2918 pub level_distribution: HashMap<String, f64>,
2920 pub avg_red_flags_per_case: f64,
2922 pub collusion_rate: f64,
2924}
2925
2926impl Default for AcfeCalibration {
2927 fn default() -> Self {
2928 let mut category_distribution = HashMap::new();
2929 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
2930 category_distribution.insert("corruption".to_string(), 0.33);
2931 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
2932
2933 let mut detection_method_distribution = HashMap::new();
2934 for method in AcfeDetectionMethod::all_variants() {
2935 detection_method_distribution.insert(
2936 format!("{method:?}").to_lowercase(),
2937 method.typical_detection_rate(),
2938 );
2939 }
2940
2941 let mut department_distribution = HashMap::new();
2942 department_distribution.insert("accounting".to_string(), 0.21);
2943 department_distribution.insert("operations".to_string(), 0.17);
2944 department_distribution.insert("executive".to_string(), 0.12);
2945 department_distribution.insert("sales".to_string(), 0.11);
2946 department_distribution.insert("customer_service".to_string(), 0.07);
2947 department_distribution.insert("purchasing".to_string(), 0.06);
2948 department_distribution.insert("other".to_string(), 0.26);
2949
2950 let mut level_distribution = HashMap::new();
2951 level_distribution.insert("employee".to_string(), 0.42);
2952 level_distribution.insert("manager".to_string(), 0.36);
2953 level_distribution.insert("owner_executive".to_string(), 0.22);
2954
2955 Self {
2956 median_loss: Decimal::new(117_000, 0),
2957 median_duration_months: 12,
2958 category_distribution,
2959 detection_method_distribution,
2960 department_distribution,
2961 level_distribution,
2962 avg_red_flags_per_case: 2.8,
2963 collusion_rate: 0.50,
2964 }
2965 }
2966}
2967
2968impl AcfeCalibration {
2969 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
2971 Self {
2972 median_loss,
2973 median_duration_months,
2974 ..Self::default()
2975 }
2976 }
2977
2978 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
2980 category.typical_median_loss()
2981 }
2982
2983 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
2985 category.typical_detection_months()
2986 }
2987
2988 pub fn validate(&self) -> Result<(), String> {
2990 if self.median_loss <= Decimal::ZERO {
2991 return Err("Median loss must be positive".to_string());
2992 }
2993 if self.median_duration_months == 0 {
2994 return Err("Median duration must be at least 1 month".to_string());
2995 }
2996 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
2997 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
2998 }
2999 Ok(())
3000 }
3001}
3002
3003#[derive(Debug, Clone, Serialize, Deserialize)]
3008pub struct FraudTriangle {
3009 pub pressure: PressureType,
3011 pub opportunities: Vec<OpportunityFactor>,
3013 pub rationalization: Rationalization,
3015}
3016
3017impl FraudTriangle {
3018 pub fn new(
3020 pressure: PressureType,
3021 opportunities: Vec<OpportunityFactor>,
3022 rationalization: Rationalization,
3023 ) -> Self {
3024 Self {
3025 pressure,
3026 opportunities,
3027 rationalization,
3028 }
3029 }
3030
3031 pub fn risk_score(&self) -> f64 {
3033 let pressure_score = self.pressure.risk_weight();
3034 let opportunity_score: f64 = self
3035 .opportunities
3036 .iter()
3037 .map(OpportunityFactor::risk_weight)
3038 .sum::<f64>()
3039 / self.opportunities.len().max(1) as f64;
3040 let rationalization_score = self.rationalization.risk_weight();
3041
3042 (pressure_score + opportunity_score + rationalization_score) / 3.0
3043 }
3044}
3045
3046#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3048pub enum PressureType {
3049 PersonalFinancialDifficulties,
3052 FinancialTargets,
3054 MarketExpectations,
3056 CovenantCompliance,
3058 CreditRatingMaintenance,
3060 AcquisitionValuation,
3062
3063 JobSecurity,
3066 StatusMaintenance,
3068 GamblingAddiction,
3070 SubstanceAbuse,
3072 FamilyPressure,
3074 Greed,
3076}
3077
3078impl PressureType {
3079 pub fn risk_weight(&self) -> f64 {
3081 match self {
3082 PressureType::PersonalFinancialDifficulties => 0.80,
3083 PressureType::FinancialTargets => 0.75,
3084 PressureType::MarketExpectations => 0.70,
3085 PressureType::CovenantCompliance => 0.85,
3086 PressureType::CreditRatingMaintenance => 0.70,
3087 PressureType::AcquisitionValuation => 0.75,
3088 PressureType::JobSecurity => 0.65,
3089 PressureType::StatusMaintenance => 0.55,
3090 PressureType::GamblingAddiction => 0.90,
3091 PressureType::SubstanceAbuse => 0.85,
3092 PressureType::FamilyPressure => 0.60,
3093 PressureType::Greed => 0.70,
3094 }
3095 }
3096}
3097
3098#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3100pub enum OpportunityFactor {
3101 WeakInternalControls,
3103 LackOfSegregation,
3105 ManagementOverride,
3107 ComplexTransactions,
3109 RelatedPartyTransactions,
3111 PoorToneAtTop,
3113 InadequateSupervision,
3115 AssetAccess,
3117 PoorRecordKeeping,
3119 LackOfDiscipline,
3121 LackOfIndependentChecks,
3123}
3124
3125impl OpportunityFactor {
3126 pub fn risk_weight(&self) -> f64 {
3128 match self {
3129 OpportunityFactor::WeakInternalControls => 0.85,
3130 OpportunityFactor::LackOfSegregation => 0.80,
3131 OpportunityFactor::ManagementOverride => 0.90,
3132 OpportunityFactor::ComplexTransactions => 0.70,
3133 OpportunityFactor::RelatedPartyTransactions => 0.75,
3134 OpportunityFactor::PoorToneAtTop => 0.85,
3135 OpportunityFactor::InadequateSupervision => 0.75,
3136 OpportunityFactor::AssetAccess => 0.70,
3137 OpportunityFactor::PoorRecordKeeping => 0.65,
3138 OpportunityFactor::LackOfDiscipline => 0.60,
3139 OpportunityFactor::LackOfIndependentChecks => 0.75,
3140 }
3141 }
3142}
3143
3144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3146pub enum Rationalization {
3147 TemporaryBorrowing,
3149 EveryoneDoesIt,
3151 ForTheCompanyGood,
3153 Entitlement,
3155 FollowingOrders,
3157 TheyWontMissIt,
3159 NeedItMore,
3161 NotReallyStealing,
3163 Underpaid,
3165 VictimlessCrime,
3167}
3168
3169impl Rationalization {
3170 pub fn risk_weight(&self) -> f64 {
3172 match self {
3173 Rationalization::Entitlement => 0.85,
3175 Rationalization::EveryoneDoesIt => 0.80,
3176 Rationalization::NotReallyStealing => 0.80,
3177 Rationalization::TheyWontMissIt => 0.75,
3178 Rationalization::Underpaid => 0.70,
3180 Rationalization::ForTheCompanyGood => 0.65,
3181 Rationalization::NeedItMore => 0.65,
3182 Rationalization::TemporaryBorrowing => 0.60,
3184 Rationalization::FollowingOrders => 0.55,
3185 Rationalization::VictimlessCrime => 0.60,
3186 }
3187 }
3188}
3189
3190#[derive(Debug, Clone, Serialize, Deserialize)]
3196pub enum NearMissPattern {
3197 NearDuplicate {
3199 date_difference_days: u32,
3201 similar_transaction_id: String,
3203 },
3204 ThresholdProximity {
3206 threshold: Decimal,
3208 proximity: f64,
3210 },
3211 UnusualLegitimate {
3213 pattern_type: LegitimatePatternType,
3215 justification: String,
3217 },
3218 CorrectedError {
3220 correction_lag_days: u32,
3222 correction_document_id: String,
3224 },
3225}
3226
3227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3229pub enum LegitimatePatternType {
3230 YearEndBonus,
3232 ContractPrepayment,
3234 SettlementPayment,
3236 InsuranceClaim,
3238 OneTimePayment,
3240 AssetDisposal,
3242 SeasonalInventory,
3244 PromotionalSpending,
3246}
3247
3248impl LegitimatePatternType {
3249 pub fn description(&self) -> &'static str {
3251 match self {
3252 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3253 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3254 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3255 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3256 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3257 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3258 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3259 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3260 }
3261 }
3262}
3263
3264#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3266pub enum FalsePositiveTrigger {
3267 AmountNearThreshold,
3269 UnusualTiming,
3271 SimilarTransaction,
3273 NewCounterparty,
3275 UnusualAccountCombination,
3277 VolumeSpike,
3279 RoundAmount,
3281}
3282
3283#[derive(Debug, Clone, Serialize, Deserialize)]
3285pub struct NearMissLabel {
3286 pub document_id: String,
3288 pub pattern: NearMissPattern,
3290 pub suspicion_score: f64,
3292 pub false_positive_trigger: FalsePositiveTrigger,
3294 pub explanation: String,
3296}
3297
3298impl NearMissLabel {
3299 pub fn new(
3301 document_id: impl Into<String>,
3302 pattern: NearMissPattern,
3303 suspicion_score: f64,
3304 trigger: FalsePositiveTrigger,
3305 explanation: impl Into<String>,
3306 ) -> Self {
3307 Self {
3308 document_id: document_id.into(),
3309 pattern,
3310 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3311 false_positive_trigger: trigger,
3312 explanation: explanation.into(),
3313 }
3314 }
3315}
3316
3317#[derive(Debug, Clone, Serialize, Deserialize)]
3319pub struct AnomalyRateConfig {
3320 pub total_rate: f64,
3322 pub fraud_rate: f64,
3324 pub error_rate: f64,
3326 pub process_issue_rate: f64,
3328 pub statistical_rate: f64,
3330 pub relational_rate: f64,
3332}
3333
3334impl Default for AnomalyRateConfig {
3335 fn default() -> Self {
3336 Self {
3337 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, }
3344 }
3345}
3346
3347impl AnomalyRateConfig {
3348 pub fn validate(&self) -> Result<(), String> {
3350 let sum = self.fraud_rate
3351 + self.error_rate
3352 + self.process_issue_rate
3353 + self.statistical_rate
3354 + self.relational_rate;
3355
3356 if (sum - 1.0).abs() > 0.01 {
3357 return Err(format!("Anomaly category rates must sum to 1.0, got {sum}"));
3358 }
3359
3360 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3361 return Err(format!(
3362 "Total rate must be between 0.0 and 1.0, got {}",
3363 self.total_rate
3364 ));
3365 }
3366
3367 Ok(())
3368 }
3369}
3370
3371#[cfg(test)]
3372#[allow(clippy::unwrap_used)]
3373mod tests {
3374 use super::*;
3375 use rust_decimal_macros::dec;
3376
3377 #[test]
3378 fn test_anomaly_type_category() {
3379 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3380 assert_eq!(fraud.category(), "Fraud");
3381 assert!(fraud.is_intentional());
3382
3383 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3384 assert_eq!(error.category(), "Error");
3385 assert!(!error.is_intentional());
3386 }
3387
3388 #[test]
3389 fn test_labeled_anomaly() {
3390 let anomaly = LabeledAnomaly::new(
3391 "ANO001".to_string(),
3392 AnomalyType::Fraud(FraudType::SelfApproval),
3393 "JE001".to_string(),
3394 "JE".to_string(),
3395 "1000".to_string(),
3396 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3397 )
3398 .with_description("User approved their own expense report")
3399 .with_related_entity("USER001");
3400
3401 assert_eq!(anomaly.severity, 3);
3402 assert!(anomaly.is_injected);
3403 assert_eq!(anomaly.related_entities.len(), 1);
3404 }
3405
3406 #[test]
3407 fn test_labeled_anomaly_with_provenance() {
3408 let anomaly = LabeledAnomaly::new(
3409 "ANO001".to_string(),
3410 AnomalyType::Fraud(FraudType::SelfApproval),
3411 "JE001".to_string(),
3412 "JE".to_string(),
3413 "1000".to_string(),
3414 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3415 )
3416 .with_run_id("run-123")
3417 .with_generation_seed(42)
3418 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3419 .with_structured_strategy(InjectionStrategy::SelfApproval {
3420 user_id: "USER001".to_string(),
3421 })
3422 .with_scenario("scenario-001")
3423 .with_original_document_hash("abc123");
3424
3425 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3426 assert_eq!(anomaly.generation_seed, Some(42));
3427 assert!(anomaly.causal_reason.is_some());
3428 assert!(anomaly.structured_strategy.is_some());
3429 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3430 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3431
3432 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3434 }
3435
3436 #[test]
3437 fn test_labeled_anomaly_derivation_chain() {
3438 let parent = LabeledAnomaly::new(
3439 "ANO001".to_string(),
3440 AnomalyType::Fraud(FraudType::DuplicatePayment),
3441 "JE001".to_string(),
3442 "JE".to_string(),
3443 "1000".to_string(),
3444 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3445 );
3446
3447 let child = LabeledAnomaly::new(
3448 "ANO002".to_string(),
3449 AnomalyType::Error(ErrorType::DuplicateEntry),
3450 "JE002".to_string(),
3451 "JE".to_string(),
3452 "1000".to_string(),
3453 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3454 )
3455 .with_parent_anomaly(&parent.anomaly_id);
3456
3457 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3458 }
3459
3460 #[test]
3461 fn test_injection_strategy_description() {
3462 let strategy = InjectionStrategy::AmountManipulation {
3463 original: dec!(1000),
3464 factor: 2.5,
3465 };
3466 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3467 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3468
3469 let strategy = InjectionStrategy::ThresholdAvoidance {
3470 threshold: dec!(10000),
3471 adjusted_amount: dec!(9999),
3472 };
3473 assert_eq!(
3474 strategy.description(),
3475 "Amount adjusted to avoid 10000 threshold"
3476 );
3477
3478 let strategy = InjectionStrategy::DateShift {
3479 days_shifted: -5,
3480 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3481 };
3482 assert_eq!(strategy.description(), "Date backdated by 5 days");
3483
3484 let strategy = InjectionStrategy::DateShift {
3485 days_shifted: 3,
3486 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3487 };
3488 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3489 }
3490
3491 #[test]
3492 fn test_causal_reason_variants() {
3493 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3494 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3495 assert!((base_rate - 0.02).abs() < 0.001);
3496 }
3497
3498 let reason = AnomalyCausalReason::TemporalPattern {
3499 pattern_name: "year_end_spike".to_string(),
3500 };
3501 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3502 assert_eq!(pattern_name, "year_end_spike");
3503 }
3504
3505 let reason = AnomalyCausalReason::ScenarioStep {
3506 scenario_type: "kickback".to_string(),
3507 step_number: 3,
3508 };
3509 if let AnomalyCausalReason::ScenarioStep {
3510 scenario_type,
3511 step_number,
3512 } = reason
3513 {
3514 assert_eq!(scenario_type, "kickback");
3515 assert_eq!(step_number, 3);
3516 }
3517 }
3518
3519 #[test]
3520 fn test_feature_vector_length() {
3521 let anomaly = LabeledAnomaly::new(
3522 "ANO001".to_string(),
3523 AnomalyType::Fraud(FraudType::SelfApproval),
3524 "JE001".to_string(),
3525 "JE".to_string(),
3526 "1000".to_string(),
3527 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3528 );
3529
3530 let features = anomaly.to_features();
3531 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3532 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3533 }
3534
3535 #[test]
3536 fn test_feature_vector_with_provenance() {
3537 let anomaly = LabeledAnomaly::new(
3538 "ANO001".to_string(),
3539 AnomalyType::Fraud(FraudType::SelfApproval),
3540 "JE001".to_string(),
3541 "JE".to_string(),
3542 "1000".to_string(),
3543 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3544 )
3545 .with_scenario("scenario-001")
3546 .with_parent_anomaly("ANO000");
3547
3548 let features = anomaly.to_features();
3549
3550 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3554
3555 #[test]
3556 fn test_anomaly_summary() {
3557 let anomalies = vec![
3558 LabeledAnomaly::new(
3559 "ANO001".to_string(),
3560 AnomalyType::Fraud(FraudType::SelfApproval),
3561 "JE001".to_string(),
3562 "JE".to_string(),
3563 "1000".to_string(),
3564 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3565 ),
3566 LabeledAnomaly::new(
3567 "ANO002".to_string(),
3568 AnomalyType::Error(ErrorType::DuplicateEntry),
3569 "JE002".to_string(),
3570 "JE".to_string(),
3571 "1000".to_string(),
3572 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3573 ),
3574 ];
3575
3576 let summary = AnomalySummary::from_anomalies(&anomalies);
3577
3578 assert_eq!(summary.total_count, 2);
3579 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3580 assert_eq!(summary.by_category.get("Error"), Some(&1));
3581 }
3582
3583 #[test]
3584 fn test_rate_config_validation() {
3585 let config = AnomalyRateConfig::default();
3586 assert!(config.validate().is_ok());
3587
3588 let bad_config = AnomalyRateConfig {
3589 fraud_rate: 0.5,
3590 error_rate: 0.5,
3591 process_issue_rate: 0.5, ..Default::default()
3593 };
3594 assert!(bad_config.validate().is_err());
3595 }
3596
3597 #[test]
3598 fn test_injection_strategy_serialization() {
3599 let strategy = InjectionStrategy::SoDViolation {
3600 duty1: "CreatePO".to_string(),
3601 duty2: "ApprovePO".to_string(),
3602 violating_user: "USER001".to_string(),
3603 };
3604
3605 let json = serde_json::to_string(&strategy).unwrap();
3606 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3607
3608 assert_eq!(strategy, deserialized);
3609 }
3610
3611 #[test]
3612 fn test_labeled_anomaly_serialization_with_provenance() {
3613 let anomaly = LabeledAnomaly::new(
3614 "ANO001".to_string(),
3615 AnomalyType::Fraud(FraudType::SelfApproval),
3616 "JE001".to_string(),
3617 "JE".to_string(),
3618 "1000".to_string(),
3619 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3620 )
3621 .with_run_id("run-123")
3622 .with_generation_seed(42)
3623 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3624
3625 let json = serde_json::to_string(&anomaly).unwrap();
3626 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3627
3628 assert_eq!(anomaly.run_id, deserialized.run_id);
3629 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3630 }
3631
3632 #[test]
3637 fn test_anomaly_category_from_anomaly_type() {
3638 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3640 assert_eq!(
3641 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3642 AnomalyCategory::FictitiousVendor
3643 );
3644
3645 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3646 assert_eq!(
3647 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3648 AnomalyCategory::VendorKickback
3649 );
3650
3651 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3652 assert_eq!(
3653 AnomalyCategory::from_anomaly_type(&fraud_structured),
3654 AnomalyCategory::StructuredTransaction
3655 );
3656
3657 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3659 assert_eq!(
3660 AnomalyCategory::from_anomaly_type(&error_duplicate),
3661 AnomalyCategory::DuplicatePayment
3662 );
3663
3664 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3666 assert_eq!(
3667 AnomalyCategory::from_anomaly_type(&process_skip),
3668 AnomalyCategory::MissingApproval
3669 );
3670
3671 let relational_circular =
3673 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3674 assert_eq!(
3675 AnomalyCategory::from_anomaly_type(&relational_circular),
3676 AnomalyCategory::CircularFlow
3677 );
3678 }
3679
3680 #[test]
3681 fn test_anomaly_category_ordinal() {
3682 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3683 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3684 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3685 }
3686
3687 #[test]
3688 fn test_contributing_factor() {
3689 let factor = ContributingFactor::new(
3690 FactorType::AmountDeviation,
3691 15000.0,
3692 10000.0,
3693 true,
3694 0.5,
3695 "Amount exceeds threshold",
3696 );
3697
3698 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3699 assert_eq!(factor.value, 15000.0);
3700 assert_eq!(factor.threshold, 10000.0);
3701 assert!(factor.direction_greater);
3702
3703 let contribution = factor.contribution();
3705 assert!((contribution - 0.25).abs() < 0.01);
3706 }
3707
3708 #[test]
3709 fn test_contributing_factor_with_evidence() {
3710 let mut data = HashMap::new();
3711 data.insert("expected".to_string(), "10000".to_string());
3712 data.insert("actual".to_string(), "15000".to_string());
3713
3714 let factor = ContributingFactor::new(
3715 FactorType::AmountDeviation,
3716 15000.0,
3717 10000.0,
3718 true,
3719 0.5,
3720 "Amount deviation detected",
3721 )
3722 .with_evidence("transaction_history", data);
3723
3724 assert!(factor.evidence.is_some());
3725 let evidence = factor.evidence.unwrap();
3726 assert_eq!(evidence.source, "transaction_history");
3727 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3728 }
3729
3730 #[test]
3731 fn test_enhanced_anomaly_label() {
3732 let base = LabeledAnomaly::new(
3733 "ANO001".to_string(),
3734 AnomalyType::Fraud(FraudType::DuplicatePayment),
3735 "JE001".to_string(),
3736 "JE".to_string(),
3737 "1000".to_string(),
3738 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3739 );
3740
3741 let enhanced = EnhancedAnomalyLabel::from_base(base)
3742 .with_confidence(0.85)
3743 .with_severity(0.7)
3744 .with_factor(ContributingFactor::new(
3745 FactorType::DuplicateIndicator,
3746 1.0,
3747 0.5,
3748 true,
3749 0.4,
3750 "Duplicate payment detected",
3751 ))
3752 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3753
3754 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3755 assert_eq!(enhanced.enhanced_confidence, 0.85);
3756 assert_eq!(enhanced.enhanced_severity, 0.7);
3757 assert_eq!(enhanced.contributing_factors.len(), 1);
3758 assert_eq!(enhanced.secondary_categories.len(), 1);
3759 }
3760
3761 #[test]
3762 fn test_enhanced_anomaly_label_features() {
3763 let base = LabeledAnomaly::new(
3764 "ANO001".to_string(),
3765 AnomalyType::Fraud(FraudType::SelfApproval),
3766 "JE001".to_string(),
3767 "JE".to_string(),
3768 "1000".to_string(),
3769 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3770 );
3771
3772 let enhanced = EnhancedAnomalyLabel::from_base(base)
3773 .with_confidence(0.9)
3774 .with_severity(0.8)
3775 .with_factor(ContributingFactor::new(
3776 FactorType::ControlBypass,
3777 1.0,
3778 0.0,
3779 true,
3780 0.5,
3781 "Control bypass detected",
3782 ));
3783
3784 let features = enhanced.to_features();
3785
3786 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3788 assert_eq!(features.len(), 25);
3789
3790 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3796
3797 #[test]
3798 fn test_enhanced_anomaly_label_feature_names() {
3799 let names = EnhancedAnomalyLabel::feature_names();
3800 assert_eq!(names.len(), 25);
3801 assert!(names.contains(&"enhanced_confidence"));
3802 assert!(names.contains(&"enhanced_severity"));
3803 assert!(names.contains(&"has_control_bypass"));
3804 }
3805
3806 #[test]
3807 fn test_factor_type_names() {
3808 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3809 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3810 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3811 }
3812
3813 #[test]
3814 fn test_anomaly_category_serialization() {
3815 let category = AnomalyCategory::CircularFlow;
3816 let json = serde_json::to_string(&category).unwrap();
3817 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3818 assert_eq!(category, deserialized);
3819
3820 let custom = AnomalyCategory::Custom("custom_type".to_string());
3821 let json = serde_json::to_string(&custom).unwrap();
3822 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3823 assert_eq!(custom, deserialized);
3824 }
3825
3826 #[test]
3827 fn test_enhanced_label_secondary_category_dedup() {
3828 let base = LabeledAnomaly::new(
3829 "ANO001".to_string(),
3830 AnomalyType::Fraud(FraudType::DuplicatePayment),
3831 "JE001".to_string(),
3832 "JE".to_string(),
3833 "1000".to_string(),
3834 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3835 );
3836
3837 let enhanced = EnhancedAnomalyLabel::from_base(base)
3838 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3840 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3842 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3844
3845 assert_eq!(enhanced.secondary_categories.len(), 1);
3847 assert_eq!(
3848 enhanced.secondary_categories[0],
3849 AnomalyCategory::TimingAnomaly
3850 );
3851 }
3852
3853 #[test]
3858 fn test_revenue_recognition_fraud_types() {
3859 let fraud_types = [
3861 FraudType::ImproperRevenueRecognition,
3862 FraudType::ImproperPoAllocation,
3863 FraudType::VariableConsiderationManipulation,
3864 FraudType::ContractModificationMisstatement,
3865 ];
3866
3867 for fraud_type in fraud_types {
3868 let anomaly_type = AnomalyType::Fraud(fraud_type);
3869 assert_eq!(anomaly_type.category(), "Fraud");
3870 assert!(anomaly_type.is_intentional());
3871 assert!(anomaly_type.severity() >= 3);
3872 }
3873 }
3874
3875 #[test]
3876 fn test_lease_accounting_fraud_types() {
3877 let fraud_types = [
3879 FraudType::LeaseClassificationManipulation,
3880 FraudType::OffBalanceSheetLease,
3881 FraudType::LeaseLiabilityUnderstatement,
3882 FraudType::RouAssetMisstatement,
3883 ];
3884
3885 for fraud_type in fraud_types {
3886 let anomaly_type = AnomalyType::Fraud(fraud_type);
3887 assert_eq!(anomaly_type.category(), "Fraud");
3888 assert!(anomaly_type.is_intentional());
3889 assert!(anomaly_type.severity() >= 3);
3890 }
3891
3892 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3894 }
3895
3896 #[test]
3897 fn test_fair_value_fraud_types() {
3898 let fraud_types = [
3900 FraudType::FairValueHierarchyManipulation,
3901 FraudType::Level3InputManipulation,
3902 FraudType::ValuationTechniqueManipulation,
3903 ];
3904
3905 for fraud_type in fraud_types {
3906 let anomaly_type = AnomalyType::Fraud(fraud_type);
3907 assert_eq!(anomaly_type.category(), "Fraud");
3908 assert!(anomaly_type.is_intentional());
3909 assert!(anomaly_type.severity() >= 4);
3910 }
3911
3912 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3914 }
3915
3916 #[test]
3917 fn test_impairment_fraud_types() {
3918 let fraud_types = [
3920 FraudType::DelayedImpairment,
3921 FraudType::ImpairmentTestAvoidance,
3922 FraudType::CashFlowProjectionManipulation,
3923 FraudType::ImproperImpairmentReversal,
3924 ];
3925
3926 for fraud_type in fraud_types {
3927 let anomaly_type = AnomalyType::Fraud(fraud_type);
3928 assert_eq!(anomaly_type.category(), "Fraud");
3929 assert!(anomaly_type.is_intentional());
3930 assert!(anomaly_type.severity() >= 3);
3931 }
3932
3933 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3935 }
3936
3937 #[test]
3942 fn test_standards_error_types() {
3943 let error_types = [
3945 ErrorType::RevenueTimingError,
3946 ErrorType::PoAllocationError,
3947 ErrorType::LeaseClassificationError,
3948 ErrorType::LeaseCalculationError,
3949 ErrorType::FairValueError,
3950 ErrorType::ImpairmentCalculationError,
3951 ErrorType::DiscountRateError,
3952 ErrorType::FrameworkApplicationError,
3953 ];
3954
3955 for error_type in error_types {
3956 let anomaly_type = AnomalyType::Error(error_type);
3957 assert_eq!(anomaly_type.category(), "Error");
3958 assert!(!anomaly_type.is_intentional());
3959 assert!(anomaly_type.severity() >= 3);
3960 }
3961 }
3962
3963 #[test]
3964 fn test_framework_application_error() {
3965 let error_type = ErrorType::FrameworkApplicationError;
3967 assert_eq!(error_type.severity(), 4);
3968
3969 let anomaly = LabeledAnomaly::new(
3970 "ERR001".to_string(),
3971 AnomalyType::Error(error_type),
3972 "JE100".to_string(),
3973 "JE".to_string(),
3974 "1000".to_string(),
3975 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3976 )
3977 .with_description("LIFO inventory method used under IFRS (not permitted)")
3978 .with_metadata("framework", "IFRS")
3979 .with_metadata("standard_violated", "IAS 2");
3980
3981 assert_eq!(anomaly.anomaly_type.category(), "Error");
3982 assert_eq!(
3983 anomaly.metadata.get("standard_violated"),
3984 Some(&"IAS 2".to_string())
3985 );
3986 }
3987
3988 #[test]
3989 fn test_standards_anomaly_serialization() {
3990 let fraud_types = [
3992 FraudType::ImproperRevenueRecognition,
3993 FraudType::LeaseClassificationManipulation,
3994 FraudType::FairValueHierarchyManipulation,
3995 FraudType::DelayedImpairment,
3996 ];
3997
3998 for fraud_type in fraud_types {
3999 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
4000 let deserialized: FraudType =
4001 serde_json::from_str(&json).expect("Failed to deserialize");
4002 assert_eq!(fraud_type, deserialized);
4003 }
4004
4005 let error_types = [
4007 ErrorType::RevenueTimingError,
4008 ErrorType::LeaseCalculationError,
4009 ErrorType::FairValueError,
4010 ErrorType::FrameworkApplicationError,
4011 ];
4012
4013 for error_type in error_types {
4014 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
4015 let deserialized: ErrorType =
4016 serde_json::from_str(&json).expect("Failed to deserialize");
4017 assert_eq!(error_type, deserialized);
4018 }
4019 }
4020
4021 #[test]
4022 fn test_standards_labeled_anomaly() {
4023 let anomaly = LabeledAnomaly::new(
4025 "STD001".to_string(),
4026 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
4027 "CONTRACT-2024-001".to_string(),
4028 "Revenue".to_string(),
4029 "1000".to_string(),
4030 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
4031 )
4032 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
4033 .with_monetary_impact(dec!(500000))
4034 .with_metadata("standard", "ASC 606")
4035 .with_metadata("paragraph", "606-10-25-1")
4036 .with_metadata("contract_id", "C-2024-001")
4037 .with_related_entity("CONTRACT-2024-001")
4038 .with_related_entity("CUSTOMER-500");
4039
4040 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
4042 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
4043 assert_eq!(anomaly.related_entities.len(), 2);
4044 assert_eq!(
4045 anomaly.metadata.get("standard"),
4046 Some(&"ASC 606".to_string())
4047 );
4048 }
4049
4050 #[test]
4055 fn test_severity_level() {
4056 assert_eq!(SeverityLevel::Low.numeric(), 1);
4057 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4058
4059 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4060 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4061
4062 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4063 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4064
4065 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4066 }
4067
4068 #[test]
4069 fn test_anomaly_severity() {
4070 let severity =
4071 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4072
4073 assert_eq!(severity.level, SeverityLevel::High);
4074 assert!(severity.is_material);
4075 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4076
4077 let low_severity =
4079 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4080 assert!(!low_severity.is_material);
4081 }
4082
4083 #[test]
4084 fn test_detection_difficulty() {
4085 assert!(
4086 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4087 );
4088 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4089
4090 assert_eq!(
4091 AnomalyDetectionDifficulty::from_score(0.05),
4092 AnomalyDetectionDifficulty::Trivial
4093 );
4094 assert_eq!(
4095 AnomalyDetectionDifficulty::from_score(0.90),
4096 AnomalyDetectionDifficulty::Expert
4097 );
4098
4099 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4100 }
4101
4102 #[test]
4103 fn test_ground_truth_certainty() {
4104 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4105 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4106 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4107 }
4108
4109 #[test]
4110 fn test_detection_method() {
4111 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4112 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4113 }
4114
4115 #[test]
4116 fn test_extended_anomaly_label() {
4117 let base = LabeledAnomaly::new(
4118 "ANO001".to_string(),
4119 AnomalyType::Fraud(FraudType::FictitiousVendor),
4120 "JE001".to_string(),
4121 "JE".to_string(),
4122 "1000".to_string(),
4123 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4124 )
4125 .with_monetary_impact(dec!(100000));
4126
4127 let extended = ExtendedAnomalyLabel::from_base(base)
4128 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4129 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4130 .with_method(DetectionMethod::GraphBased)
4131 .with_method(DetectionMethod::ForensicAudit)
4132 .with_indicator("New vendor with no history")
4133 .with_indicator("Large first transaction")
4134 .with_certainty(GroundTruthCertainty::Definite)
4135 .with_entity("V001")
4136 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4137 .with_scheme("SCHEME001", 2);
4138
4139 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4140 assert_eq!(
4141 extended.detection_difficulty,
4142 AnomalyDetectionDifficulty::Hard
4143 );
4144 assert_eq!(extended.recommended_methods.len(), 3);
4146 assert_eq!(extended.key_indicators.len(), 2);
4147 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4148 assert_eq!(extended.scheme_stage, Some(2));
4149 }
4150
4151 #[test]
4152 fn test_extended_anomaly_label_features() {
4153 let base = LabeledAnomaly::new(
4154 "ANO001".to_string(),
4155 AnomalyType::Fraud(FraudType::SelfApproval),
4156 "JE001".to_string(),
4157 "JE".to_string(),
4158 "1000".to_string(),
4159 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4160 );
4161
4162 let extended =
4163 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4164
4165 let features = extended.to_features();
4166 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4167 assert_eq!(features.len(), 30);
4168
4169 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4172 }
4173
4174 #[test]
4175 fn test_extended_label_near_miss() {
4176 let base = LabeledAnomaly::new(
4177 "ANO001".to_string(),
4178 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4179 "JE001".to_string(),
4180 "JE".to_string(),
4181 "1000".to_string(),
4182 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4183 );
4184
4185 let extended = ExtendedAnomalyLabel::from_base(base)
4186 .as_near_miss("Year-end bonus payment, legitimately high");
4187
4188 assert!(extended.is_near_miss);
4189 assert!(extended.near_miss_explanation.is_some());
4190 }
4191
4192 #[test]
4193 fn test_scheme_type() {
4194 assert_eq!(
4195 SchemeType::GradualEmbezzlement.name(),
4196 "gradual_embezzlement"
4197 );
4198 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4199 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4200 }
4201
4202 #[test]
4203 fn test_concealment_technique() {
4204 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4205 assert!(
4206 ConcealmentTechnique::Collusion.difficulty_bonus()
4207 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4208 );
4209 }
4210
4211 #[test]
4212 fn test_near_miss_label() {
4213 let near_miss = NearMissLabel::new(
4214 "JE001",
4215 NearMissPattern::ThresholdProximity {
4216 threshold: dec!(10000),
4217 proximity: 0.95,
4218 },
4219 0.7,
4220 FalsePositiveTrigger::AmountNearThreshold,
4221 "Transaction is 95% of threshold but business justified",
4222 );
4223
4224 assert_eq!(near_miss.document_id, "JE001");
4225 assert_eq!(near_miss.suspicion_score, 0.7);
4226 assert_eq!(
4227 near_miss.false_positive_trigger,
4228 FalsePositiveTrigger::AmountNearThreshold
4229 );
4230 }
4231
4232 #[test]
4233 fn test_legitimate_pattern_type() {
4234 assert_eq!(
4235 LegitimatePatternType::YearEndBonus.description(),
4236 "Year-end bonus payment"
4237 );
4238 assert_eq!(
4239 LegitimatePatternType::InsuranceClaim.description(),
4240 "Insurance claim reimbursement"
4241 );
4242 }
4243
4244 #[test]
4245 fn test_severity_detection_difficulty_serialization() {
4246 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4247 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4248 let deserialized: AnomalySeverity =
4249 serde_json::from_str(&json).expect("Failed to deserialize");
4250 assert_eq!(severity.level, deserialized.level);
4251
4252 let difficulty = AnomalyDetectionDifficulty::Hard;
4253 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4254 let deserialized: AnomalyDetectionDifficulty =
4255 serde_json::from_str(&json).expect("Failed to deserialize");
4256 assert_eq!(difficulty, deserialized);
4257 }
4258
4259 #[test]
4264 fn test_acfe_fraud_category() {
4265 let asset = AcfeFraudCategory::AssetMisappropriation;
4266 assert_eq!(asset.name(), "asset_misappropriation");
4267 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4268 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4269 assert_eq!(asset.typical_detection_months(), 12);
4270
4271 let corruption = AcfeFraudCategory::Corruption;
4272 assert_eq!(corruption.name(), "corruption");
4273 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4274
4275 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4276 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4277 assert_eq!(fs_fraud.typical_detection_months(), 24);
4278 }
4279
4280 #[test]
4281 fn test_cash_fraud_scheme() {
4282 let shell = CashFraudScheme::ShellCompany;
4283 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4284 assert_eq!(shell.subcategory(), "billing_schemes");
4285 assert_eq!(shell.severity(), 5);
4286 assert_eq!(
4287 shell.detection_difficulty(),
4288 AnomalyDetectionDifficulty::Hard
4289 );
4290
4291 let ghost = CashFraudScheme::GhostEmployee;
4292 assert_eq!(ghost.subcategory(), "payroll_schemes");
4293 assert_eq!(ghost.severity(), 5);
4294
4295 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4297 }
4298
4299 #[test]
4300 fn test_asset_fraud_scheme() {
4301 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4302 assert_eq!(
4303 ip_theft.category(),
4304 AcfeFraudCategory::AssetMisappropriation
4305 );
4306 assert_eq!(ip_theft.subcategory(), "other_assets");
4307 assert_eq!(ip_theft.severity(), 5);
4308
4309 let inv_theft = AssetFraudScheme::InventoryTheft;
4310 assert_eq!(inv_theft.subcategory(), "inventory");
4311 assert_eq!(inv_theft.severity(), 4);
4312 }
4313
4314 #[test]
4315 fn test_corruption_scheme() {
4316 let kickback = CorruptionScheme::InvoiceKickback;
4317 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4318 assert_eq!(kickback.subcategory(), "bribery");
4319 assert_eq!(kickback.severity(), 5);
4320 assert_eq!(
4321 kickback.detection_difficulty(),
4322 AnomalyDetectionDifficulty::Expert
4323 );
4324
4325 let bid_rigging = CorruptionScheme::BidRigging;
4326 assert_eq!(bid_rigging.subcategory(), "bribery");
4327 assert_eq!(
4328 bid_rigging.detection_difficulty(),
4329 AnomalyDetectionDifficulty::Hard
4330 );
4331
4332 let purchasing = CorruptionScheme::PurchasingConflict;
4333 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4334
4335 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4337 }
4338
4339 #[test]
4340 fn test_financial_statement_scheme() {
4341 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4342 assert_eq!(
4343 fictitious.category(),
4344 AcfeFraudCategory::FinancialStatementFraud
4345 );
4346 assert_eq!(fictitious.subcategory(), "overstatement");
4347 assert_eq!(fictitious.severity(), 5);
4348 assert_eq!(
4349 fictitious.detection_difficulty(),
4350 AnomalyDetectionDifficulty::Expert
4351 );
4352
4353 let understated = FinancialStatementScheme::UnderstatedRevenues;
4354 assert_eq!(understated.subcategory(), "understatement");
4355
4356 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4358 }
4359
4360 #[test]
4361 fn test_acfe_scheme_unified() {
4362 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4363 assert_eq!(
4364 cash_scheme.category(),
4365 AcfeFraudCategory::AssetMisappropriation
4366 );
4367 assert_eq!(cash_scheme.severity(), 5);
4368
4369 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4370 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4371
4372 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4373 assert_eq!(
4374 fs_scheme.category(),
4375 AcfeFraudCategory::FinancialStatementFraud
4376 );
4377 }
4378
4379 #[test]
4380 fn test_acfe_detection_method() {
4381 let tip = AcfeDetectionMethod::Tip;
4382 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4383
4384 let internal_audit = AcfeDetectionMethod::InternalAudit;
4385 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4386
4387 let external_audit = AcfeDetectionMethod::ExternalAudit;
4388 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4389
4390 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4392 }
4393
4394 #[test]
4395 fn test_perpetrator_department() {
4396 let accounting = PerpetratorDepartment::Accounting;
4397 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4398 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4399
4400 let executive = PerpetratorDepartment::Executive;
4401 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4402 }
4403
4404 #[test]
4405 fn test_perpetrator_level() {
4406 let employee = PerpetratorLevel::Employee;
4407 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4408 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4409
4410 let exec = PerpetratorLevel::OwnerExecutive;
4411 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4412 }
4413
4414 #[test]
4415 fn test_acfe_calibration() {
4416 let cal = AcfeCalibration::default();
4417 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4418 assert_eq!(cal.median_duration_months, 12);
4419 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4420 assert!(cal.validate().is_ok());
4421
4422 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4424 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4425 assert_eq!(custom_cal.median_duration_months, 18);
4426
4427 let bad_cal = AcfeCalibration {
4429 collusion_rate: 1.5,
4430 ..Default::default()
4431 };
4432 assert!(bad_cal.validate().is_err());
4433 }
4434
4435 #[test]
4436 fn test_fraud_triangle() {
4437 let triangle = FraudTriangle::new(
4438 PressureType::FinancialTargets,
4439 vec![
4440 OpportunityFactor::WeakInternalControls,
4441 OpportunityFactor::ManagementOverride,
4442 ],
4443 Rationalization::ForTheCompanyGood,
4444 );
4445
4446 let risk = triangle.risk_score();
4448 assert!((0.0..=1.0).contains(&risk));
4449 assert!(risk > 0.5);
4451 }
4452
4453 #[test]
4454 fn test_pressure_types() {
4455 let financial = PressureType::FinancialTargets;
4456 assert!(financial.risk_weight() > 0.5);
4457
4458 let gambling = PressureType::GamblingAddiction;
4459 assert_eq!(gambling.risk_weight(), 0.90);
4460 }
4461
4462 #[test]
4463 fn test_opportunity_factors() {
4464 let override_factor = OpportunityFactor::ManagementOverride;
4465 assert_eq!(override_factor.risk_weight(), 0.90);
4466
4467 let weak_controls = OpportunityFactor::WeakInternalControls;
4468 assert!(weak_controls.risk_weight() > 0.8);
4469 }
4470
4471 #[test]
4472 fn test_rationalizations() {
4473 let entitlement = Rationalization::Entitlement;
4474 assert!(entitlement.risk_weight() > 0.8);
4475
4476 let borrowing = Rationalization::TemporaryBorrowing;
4477 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4478 }
4479
4480 #[test]
4481 fn test_acfe_scheme_serialization() {
4482 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4483 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4484 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4485 assert_eq!(scheme, deserialized);
4486
4487 let calibration = AcfeCalibration::default();
4488 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4489 let deserialized: AcfeCalibration =
4490 serde_json::from_str(&json).expect("Failed to deserialize");
4491 assert_eq!(calibration.median_loss, deserialized.median_loss);
4492 }
4493}