1use chrono::{NaiveDate, NaiveDateTime};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum AnomalyCausalReason {
20 RandomRate {
22 base_rate: f64,
24 },
25 TemporalPattern {
27 pattern_name: String,
29 },
30 EntityTargeting {
32 target_type: String,
34 target_id: String,
36 },
37 ClusterMembership {
39 cluster_id: String,
41 },
42 ScenarioStep {
44 scenario_type: String,
46 step_number: u32,
48 },
49 DataQualityProfile {
51 profile: String,
53 },
54 MLTrainingBalance {
56 target_class: String,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub enum InjectionStrategy {
67 AmountManipulation {
69 original: Decimal,
71 factor: f64,
73 },
74 ThresholdAvoidance {
76 threshold: Decimal,
78 adjusted_amount: Decimal,
80 },
81 DateShift {
83 days_shifted: i32,
85 original_date: NaiveDate,
87 },
88 SelfApproval {
90 user_id: String,
92 },
93 SoDViolation {
95 duty1: String,
97 duty2: String,
99 violating_user: String,
101 },
102 ExactDuplicate {
104 original_doc_id: String,
106 },
107 NearDuplicate {
109 original_doc_id: String,
111 varied_fields: Vec<String>,
113 },
114 CircularFlow {
116 entity_chain: Vec<String>,
118 },
119 SplitTransaction {
121 original_amount: Decimal,
123 split_count: u32,
125 split_doc_ids: Vec<String>,
127 },
128 RoundNumbering {
130 original_amount: Decimal,
132 rounded_amount: Decimal,
134 },
135 TimingManipulation {
137 timing_type: String,
139 original_time: Option<NaiveDateTime>,
141 },
142 AccountMisclassification {
144 correct_account: String,
146 incorrect_account: String,
148 },
149 MissingField {
151 field_name: String,
153 },
154 Custom {
156 name: String,
158 parameters: HashMap<String, String>,
160 },
161}
162
163impl InjectionStrategy {
164 pub fn description(&self) -> String {
166 match self {
167 InjectionStrategy::AmountManipulation { factor, .. } => {
168 format!("Amount multiplied by {factor:.2}")
169 }
170 InjectionStrategy::ThresholdAvoidance { threshold, .. } => {
171 format!("Amount adjusted to avoid {threshold} threshold")
172 }
173 InjectionStrategy::DateShift { days_shifted, .. } => {
174 if *days_shifted < 0 {
175 format!("Date backdated by {} days", days_shifted.abs())
176 } else {
177 format!("Date forward-dated by {days_shifted} days")
178 }
179 }
180 InjectionStrategy::SelfApproval { user_id } => {
181 format!("Self-approval by user {user_id}")
182 }
183 InjectionStrategy::SoDViolation { duty1, duty2, .. } => {
184 format!("SoD violation: {duty1} and {duty2}")
185 }
186 InjectionStrategy::ExactDuplicate { original_doc_id } => {
187 format!("Exact duplicate of {original_doc_id}")
188 }
189 InjectionStrategy::NearDuplicate {
190 original_doc_id,
191 varied_fields,
192 } => {
193 format!("Near-duplicate of {original_doc_id} (varied: {varied_fields:?})")
194 }
195 InjectionStrategy::CircularFlow { entity_chain } => {
196 format!("Circular flow through {} entities", entity_chain.len())
197 }
198 InjectionStrategy::SplitTransaction { split_count, .. } => {
199 format!("Split into {split_count} transactions")
200 }
201 InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
202 InjectionStrategy::TimingManipulation { timing_type, .. } => {
203 format!("Timing manipulation: {timing_type}")
204 }
205 InjectionStrategy::AccountMisclassification {
206 correct_account,
207 incorrect_account,
208 } => {
209 format!("Misclassified from {correct_account} to {incorrect_account}")
210 }
211 InjectionStrategy::MissingField { field_name } => {
212 format!("Missing required field: {field_name}")
213 }
214 InjectionStrategy::Custom { name, .. } => format!("Custom: {name}"),
215 }
216 }
217
218 pub fn strategy_type(&self) -> &'static str {
220 match self {
221 InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
222 InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
223 InjectionStrategy::DateShift { .. } => "DateShift",
224 InjectionStrategy::SelfApproval { .. } => "SelfApproval",
225 InjectionStrategy::SoDViolation { .. } => "SoDViolation",
226 InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
227 InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
228 InjectionStrategy::CircularFlow { .. } => "CircularFlow",
229 InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
230 InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
231 InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
232 InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
233 InjectionStrategy::MissingField { .. } => "MissingField",
234 InjectionStrategy::Custom { .. } => "Custom",
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
241pub enum AnomalyType {
242 Fraud(FraudType),
244 Error(ErrorType),
246 ProcessIssue(ProcessIssueType),
248 Statistical(StatisticalAnomalyType),
250 Relational(RelationalAnomalyType),
252 Custom(String),
254}
255
256impl AnomalyType {
257 pub fn category(&self) -> &'static str {
259 match self {
260 AnomalyType::Fraud(_) => "Fraud",
261 AnomalyType::Error(_) => "Error",
262 AnomalyType::ProcessIssue(_) => "ProcessIssue",
263 AnomalyType::Statistical(_) => "Statistical",
264 AnomalyType::Relational(_) => "Relational",
265 AnomalyType::Custom(_) => "Custom",
266 }
267 }
268
269 pub fn type_name(&self) -> String {
271 match self {
272 AnomalyType::Fraud(t) => format!("{t:?}"),
273 AnomalyType::Error(t) => format!("{t:?}"),
274 AnomalyType::ProcessIssue(t) => format!("{t:?}"),
275 AnomalyType::Statistical(t) => format!("{t:?}"),
276 AnomalyType::Relational(t) => format!("{t:?}"),
277 AnomalyType::Custom(s) => s.clone(),
278 }
279 }
280
281 pub fn severity(&self) -> u8 {
283 match self {
284 AnomalyType::Fraud(t) => t.severity(),
285 AnomalyType::Error(t) => t.severity(),
286 AnomalyType::ProcessIssue(t) => t.severity(),
287 AnomalyType::Statistical(t) => t.severity(),
288 AnomalyType::Relational(t) => t.severity(),
289 AnomalyType::Custom(_) => 3,
290 }
291 }
292
293 pub fn is_intentional(&self) -> bool {
295 matches!(self, AnomalyType::Fraud(_))
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
301pub enum FraudType {
302 FictitiousEntry,
305 FictitiousTransaction,
307 RoundDollarManipulation,
309 JustBelowThreshold,
311 RevenueManipulation,
313 ImproperCapitalization,
315 ExpenseCapitalization,
317 ReserveManipulation,
319 SuspenseAccountAbuse,
321 SplitTransaction,
323 TimingAnomaly,
325 UnauthorizedAccess,
327
328 SelfApproval,
331 ExceededApprovalLimit,
333 SegregationOfDutiesViolation,
335 UnauthorizedApproval,
337 CollusiveApproval,
339
340 FictitiousVendor,
343 DuplicatePayment,
345 ShellCompanyPayment,
347 Kickback,
349 KickbackScheme,
351 UnauthorizedDiscount,
353 RoundTripping,
356 InvoiceManipulation,
358
359 AssetMisappropriation,
362 InventoryTheft,
364 GhostEmployee,
366
367 PrematureRevenue,
370 UnderstatedLiabilities,
372 OverstatedAssets,
374 ChannelStuffing,
376
377 ImproperRevenueRecognition,
380 ImproperPoAllocation,
382 VariableConsiderationManipulation,
384 ContractModificationMisstatement,
386
387 LeaseClassificationManipulation,
390 OffBalanceSheetLease,
392 LeaseLiabilityUnderstatement,
394 RouAssetMisstatement,
396
397 FairValueHierarchyManipulation,
400 Level3InputManipulation,
402 ValuationTechniqueManipulation,
404
405 DelayedImpairment,
408 ImpairmentTestAvoidance,
410 CashFlowProjectionManipulation,
412 ImproperImpairmentReversal,
414
415 BidRigging,
418 PhantomVendorContract,
420 SplitContractThreshold,
422 ConflictOfInterestSourcing,
424
425 GhostEmployeePayroll,
428 PayrollInflation,
430 DuplicateExpenseReport,
432 FictitiousExpense,
434 SplitExpenseToAvoidApproval,
436
437 RevenueTimingManipulation,
440 QuotePriceOverride,
442}
443
444impl FraudType {
445 pub fn severity(&self) -> u8 {
447 match self {
448 FraudType::RoundDollarManipulation => 2,
449 FraudType::JustBelowThreshold => 3,
450 FraudType::SelfApproval => 3,
451 FraudType::ExceededApprovalLimit => 3,
452 FraudType::DuplicatePayment => 3,
453 FraudType::FictitiousEntry => 4,
454 FraudType::RevenueManipulation => 5,
455 FraudType::FictitiousVendor => 5,
456 FraudType::ShellCompanyPayment => 5,
457 FraudType::AssetMisappropriation => 5,
458 FraudType::SegregationOfDutiesViolation => 4,
459 FraudType::CollusiveApproval => 5,
460 FraudType::ImproperRevenueRecognition => 5,
462 FraudType::ImproperPoAllocation => 4,
463 FraudType::VariableConsiderationManipulation => 4,
464 FraudType::ContractModificationMisstatement => 3,
465 FraudType::LeaseClassificationManipulation => 4,
467 FraudType::OffBalanceSheetLease => 5,
468 FraudType::LeaseLiabilityUnderstatement => 4,
469 FraudType::RouAssetMisstatement => 3,
470 FraudType::FairValueHierarchyManipulation => 4,
472 FraudType::Level3InputManipulation => 5,
473 FraudType::ValuationTechniqueManipulation => 4,
474 FraudType::DelayedImpairment => 4,
476 FraudType::ImpairmentTestAvoidance => 4,
477 FraudType::CashFlowProjectionManipulation => 5,
478 FraudType::ImproperImpairmentReversal => 3,
479 _ => 4,
480 }
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
486pub enum ErrorType {
487 DuplicateEntry,
490 ReversedAmount,
492 TransposedDigits,
494 DecimalError,
496 MissingField,
498 InvalidAccount,
500
501 WrongPeriod,
504 BackdatedEntry,
506 FutureDatedEntry,
508 CutoffError,
510
511 MisclassifiedAccount,
514 WrongCostCenter,
516 WrongCompanyCode,
518
519 UnbalancedEntry,
522 RoundingError,
524 CurrencyError,
526 TaxCalculationError,
528
529 RevenueTimingError,
532 PoAllocationError,
534 LeaseClassificationError,
536 LeaseCalculationError,
538 FairValueError,
540 ImpairmentCalculationError,
542 DiscountRateError,
544 FrameworkApplicationError,
546}
547
548impl ErrorType {
549 pub fn severity(&self) -> u8 {
551 match self {
552 ErrorType::RoundingError => 1,
553 ErrorType::MissingField => 2,
554 ErrorType::TransposedDigits => 2,
555 ErrorType::DecimalError => 3,
556 ErrorType::DuplicateEntry => 3,
557 ErrorType::ReversedAmount => 3,
558 ErrorType::WrongPeriod => 4,
559 ErrorType::UnbalancedEntry => 5,
560 ErrorType::CurrencyError => 4,
561 ErrorType::RevenueTimingError => 4,
563 ErrorType::PoAllocationError => 3,
564 ErrorType::LeaseClassificationError => 3,
565 ErrorType::LeaseCalculationError => 3,
566 ErrorType::FairValueError => 4,
567 ErrorType::ImpairmentCalculationError => 4,
568 ErrorType::DiscountRateError => 3,
569 ErrorType::FrameworkApplicationError => 4,
570 _ => 3,
571 }
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
577pub enum ProcessIssueType {
578 SkippedApproval,
581 LateApproval,
583 MissingDocumentation,
585 IncompleteApprovalChain,
587
588 LatePosting,
591 AfterHoursPosting,
593 WeekendPosting,
595 RushedPeriodEnd,
597 PostClosePosting,
601
602 ManualOverride,
605 UnusualAccess,
607 SystemBypass,
609 BatchAnomaly,
611
612 VagueDescription,
615 PostFactoChange,
617 IncompleteAuditTrail,
619
620 MaverickSpend,
623 ExpiredContractPurchase,
625 ContractPriceOverride,
627 SingleBidAward,
629 QualificationBypass,
631
632 ExpiredQuoteConversion,
635}
636
637impl ProcessIssueType {
638 pub fn severity(&self) -> u8 {
640 match self {
641 ProcessIssueType::VagueDescription => 1,
642 ProcessIssueType::LatePosting => 2,
643 ProcessIssueType::AfterHoursPosting => 2,
644 ProcessIssueType::WeekendPosting => 2,
645 ProcessIssueType::PostClosePosting => 4,
646 ProcessIssueType::SkippedApproval => 4,
647 ProcessIssueType::ManualOverride => 4,
648 ProcessIssueType::SystemBypass => 5,
649 ProcessIssueType::IncompleteAuditTrail => 4,
650 _ => 3,
651 }
652 }
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
657pub enum StatisticalAnomalyType {
658 UnusuallyHighAmount,
661 UnusuallyLowAmount,
663 BenfordViolation,
665 ExactDuplicateAmount,
667 RepeatingAmount,
669
670 UnusualFrequency,
673 TransactionBurst,
675 UnusualTiming,
677
678 TrendBreak,
681 LevelShift,
683 SeasonalAnomaly,
685
686 StatisticalOutlier,
689 VarianceChange,
691 DistributionShift,
693
694 SlaBreachPattern,
697 UnusedContract,
699
700 OvertimeAnomaly,
703}
704
705impl StatisticalAnomalyType {
706 pub fn severity(&self) -> u8 {
708 match self {
709 StatisticalAnomalyType::UnusualTiming => 1,
710 StatisticalAnomalyType::UnusualFrequency => 2,
711 StatisticalAnomalyType::BenfordViolation => 2,
712 StatisticalAnomalyType::UnusuallyHighAmount => 3,
713 StatisticalAnomalyType::TrendBreak => 3,
714 StatisticalAnomalyType::TransactionBurst => 4,
715 StatisticalAnomalyType::ExactDuplicateAmount => 3,
716 _ => 3,
717 }
718 }
719}
720
721#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
723pub enum RelationalAnomalyType {
724 CircularTransaction,
727 UnusualAccountPair,
729 NewCounterparty,
731 DormantAccountActivity,
733
734 CentralityAnomaly,
737 IsolatedCluster,
739 BridgeNodeAnomaly,
741 CommunityAnomaly,
743
744 MissingRelationship,
747 UnexpectedRelationship,
749 RelationshipStrengthChange,
751
752 UnmatchedIntercompany,
755 CircularIntercompany,
757 TransferPricingAnomaly,
759
760 SourceConditionalRarity,
766}
767
768impl RelationalAnomalyType {
769 pub fn severity(&self) -> u8 {
771 match self {
772 RelationalAnomalyType::NewCounterparty => 1,
773 RelationalAnomalyType::DormantAccountActivity => 2,
774 RelationalAnomalyType::UnusualAccountPair => 2,
775 RelationalAnomalyType::CircularTransaction => 4,
776 RelationalAnomalyType::CircularIntercompany => 4,
777 RelationalAnomalyType::TransferPricingAnomaly => 4,
778 RelationalAnomalyType::UnmatchedIntercompany => 3,
779 RelationalAnomalyType::SourceConditionalRarity => 2,
780 _ => 3,
781 }
782 }
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize)]
787pub struct LabeledAnomaly {
788 pub anomaly_id: String,
790 pub anomaly_type: AnomalyType,
792 pub document_id: String,
794 pub document_type: String,
796 pub company_code: String,
798 pub anomaly_date: NaiveDate,
800 #[serde(with = "crate::serde_timestamp::naive")]
802 pub detection_timestamp: NaiveDateTime,
803 pub confidence: f64,
805 pub severity: u8,
807 pub description: String,
809 pub related_entities: Vec<String>,
811 pub monetary_impact: Option<Decimal>,
813 pub metadata: HashMap<String, String>,
815 pub is_injected: bool,
817 pub injection_strategy: Option<String>,
819 pub cluster_id: Option<String>,
821
822 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub original_document_hash: Option<String>,
829
830 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub causal_reason: Option<AnomalyCausalReason>,
834
835 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub structured_strategy: Option<InjectionStrategy>,
839
840 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub parent_anomaly_id: Option<String>,
844
845 #[serde(default, skip_serializing_if = "Vec::is_empty")]
847 pub child_anomaly_ids: Vec<String>,
848
849 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub scenario_id: Option<String>,
852
853 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub run_id: Option<String>,
857
858 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub generation_seed: Option<u64>,
862}
863
864impl LabeledAnomaly {
865 pub fn new(
867 anomaly_id: String,
868 anomaly_type: AnomalyType,
869 document_id: String,
870 document_type: String,
871 company_code: String,
872 anomaly_date: NaiveDate,
873 ) -> Self {
874 let severity = anomaly_type.severity();
875 let description = format!(
876 "{} - {} in document {}",
877 anomaly_type.category(),
878 anomaly_type.type_name(),
879 document_id
880 );
881
882 Self {
883 anomaly_id,
884 anomaly_type,
885 document_id,
886 document_type,
887 company_code,
888 anomaly_date,
889 detection_timestamp: chrono::Local::now().naive_local(),
890 confidence: 1.0,
891 severity,
892 description,
893 related_entities: Vec::new(),
894 monetary_impact: None,
895 metadata: HashMap::new(),
896 is_injected: true,
897 injection_strategy: None,
898 cluster_id: None,
899 original_document_hash: None,
901 causal_reason: None,
902 structured_strategy: None,
903 parent_anomaly_id: None,
904 child_anomaly_ids: Vec::new(),
905 scenario_id: None,
906 run_id: None,
907 generation_seed: None,
908 }
909 }
910
911 pub fn with_description(mut self, description: &str) -> Self {
913 self.description = description.to_string();
914 self
915 }
916
917 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
919 self.monetary_impact = Some(impact);
920 self
921 }
922
923 pub fn with_related_entity(mut self, entity: &str) -> Self {
925 self.related_entities.push(entity.to_string());
926 self
927 }
928
929 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
931 self.metadata.insert(key.to_string(), value.to_string());
932 self
933 }
934
935 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
937 self.injection_strategy = Some(strategy.to_string());
938 self
939 }
940
941 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
943 self.cluster_id = Some(cluster_id.to_string());
944 self
945 }
946
947 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
953 self.original_document_hash = Some(hash.to_string());
954 self
955 }
956
957 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
959 self.causal_reason = Some(reason);
960 self
961 }
962
963 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
965 self.injection_strategy = Some(strategy.strategy_type().to_string());
967 self.structured_strategy = Some(strategy);
968 self
969 }
970
971 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
973 self.parent_anomaly_id = Some(parent_id.to_string());
974 self
975 }
976
977 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
979 self.child_anomaly_ids.push(child_id.to_string());
980 self
981 }
982
983 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
985 self.scenario_id = Some(scenario_id.to_string());
986 self
987 }
988
989 pub fn with_run_id(mut self, run_id: &str) -> Self {
991 self.run_id = Some(run_id.to_string());
992 self
993 }
994
995 pub fn with_generation_seed(mut self, seed: u64) -> Self {
997 self.generation_seed = Some(seed);
998 self
999 }
1000
1001 pub fn with_provenance(
1003 mut self,
1004 run_id: Option<&str>,
1005 seed: Option<u64>,
1006 causal_reason: Option<AnomalyCausalReason>,
1007 ) -> Self {
1008 if let Some(id) = run_id {
1009 self.run_id = Some(id.to_string());
1010 }
1011 self.generation_seed = seed;
1012 self.causal_reason = causal_reason;
1013 self
1014 }
1015
1016 pub fn to_features(&self) -> Vec<f64> {
1030 let mut features = Vec::new();
1031
1032 let categories = [
1034 "Fraud",
1035 "Error",
1036 "ProcessIssue",
1037 "Statistical",
1038 "Relational",
1039 "Custom",
1040 ];
1041 for cat in &categories {
1042 features.push(if self.anomaly_type.category() == *cat {
1043 1.0
1044 } else {
1045 0.0
1046 });
1047 }
1048
1049 features.push(self.severity as f64 / 5.0);
1051
1052 features.push(self.confidence);
1054
1055 features.push(if self.monetary_impact.is_some() {
1057 1.0
1058 } else {
1059 0.0
1060 });
1061
1062 if let Some(impact) = self.monetary_impact {
1064 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
1065 features.push((impact_f64.abs() + 1.0).ln());
1066 } else {
1067 features.push(0.0);
1068 }
1069
1070 features.push(if self.anomaly_type.is_intentional() {
1072 1.0
1073 } else {
1074 0.0
1075 });
1076
1077 features.push(self.related_entities.len() as f64);
1079
1080 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1082
1083 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1086
1087 features.push(if self.parent_anomaly_id.is_some() {
1089 1.0
1090 } else {
1091 0.0
1092 });
1093
1094 features
1095 }
1096
1097 pub fn feature_count() -> usize {
1099 15 }
1101
1102 pub fn feature_names() -> Vec<&'static str> {
1104 vec![
1105 "category_fraud",
1106 "category_error",
1107 "category_process_issue",
1108 "category_statistical",
1109 "category_relational",
1110 "category_custom",
1111 "severity_normalized",
1112 "confidence",
1113 "has_monetary_impact",
1114 "monetary_impact_log",
1115 "is_intentional",
1116 "related_entity_count",
1117 "is_clustered",
1118 "is_scenario_part",
1119 "is_derived",
1120 ]
1121 }
1122}
1123
1124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1126pub struct AnomalySummary {
1127 pub total_count: usize,
1129 pub by_category: HashMap<String, usize>,
1131 pub by_type: HashMap<String, usize>,
1133 pub by_severity: HashMap<u8, usize>,
1135 pub by_company: HashMap<String, usize>,
1137 pub total_monetary_impact: Decimal,
1139 pub date_range: Option<(NaiveDate, NaiveDate)>,
1141 pub cluster_count: usize,
1143}
1144
1145impl AnomalySummary {
1146 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1148 let mut summary = AnomalySummary {
1149 total_count: anomalies.len(),
1150 ..Default::default()
1151 };
1152
1153 let mut min_date: Option<NaiveDate> = None;
1154 let mut max_date: Option<NaiveDate> = None;
1155 let mut clusters = std::collections::HashSet::new();
1156
1157 for anomaly in anomalies {
1158 *summary
1160 .by_category
1161 .entry(anomaly.anomaly_type.category().to_string())
1162 .or_insert(0) += 1;
1163
1164 *summary
1166 .by_type
1167 .entry(anomaly.anomaly_type.type_name())
1168 .or_insert(0) += 1;
1169
1170 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1172
1173 *summary
1175 .by_company
1176 .entry(anomaly.company_code.clone())
1177 .or_insert(0) += 1;
1178
1179 if let Some(impact) = anomaly.monetary_impact {
1181 summary.total_monetary_impact += impact;
1182 }
1183
1184 match min_date {
1186 None => min_date = Some(anomaly.anomaly_date),
1187 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1188 _ => {}
1189 }
1190 match max_date {
1191 None => max_date = Some(anomaly.anomaly_date),
1192 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1193 _ => {}
1194 }
1195
1196 if let Some(cluster_id) = &anomaly.cluster_id {
1198 clusters.insert(cluster_id.clone());
1199 }
1200 }
1201
1202 summary.date_range = min_date.zip(max_date);
1203 summary.cluster_count = clusters.len();
1204
1205 summary
1206 }
1207}
1208
1209#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1218pub enum AnomalyCategory {
1219 FictitiousVendor,
1222 VendorKickback,
1224 RelatedPartyVendor,
1226
1227 DuplicatePayment,
1230 UnauthorizedTransaction,
1232 StructuredTransaction,
1234
1235 CircularFlow,
1238 BehavioralAnomaly,
1240 TimingAnomaly,
1242
1243 JournalAnomaly,
1246 ManualOverride,
1248 MissingApproval,
1250
1251 StatisticalOutlier,
1254 DistributionAnomaly,
1256
1257 Custom(String),
1260}
1261
1262impl AnomalyCategory {
1263 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1265 match anomaly_type {
1266 AnomalyType::Fraud(fraud_type) => match fraud_type {
1267 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1268 AnomalyCategory::FictitiousVendor
1269 }
1270 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1271 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1272 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1273 AnomalyCategory::StructuredTransaction
1274 }
1275 FraudType::SelfApproval
1276 | FraudType::UnauthorizedApproval
1277 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1278 FraudType::TimingAnomaly
1279 | FraudType::RoundDollarManipulation
1280 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1281 _ => AnomalyCategory::BehavioralAnomaly,
1282 },
1283 AnomalyType::Error(error_type) => match error_type {
1284 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1285 ErrorType::WrongPeriod
1286 | ErrorType::BackdatedEntry
1287 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1288 _ => AnomalyCategory::JournalAnomaly,
1289 },
1290 AnomalyType::ProcessIssue(process_type) => match process_type {
1291 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1292 AnomalyCategory::MissingApproval
1293 }
1294 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1295 AnomalyCategory::ManualOverride
1296 }
1297 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1298 AnomalyCategory::TimingAnomaly
1299 }
1300 _ => AnomalyCategory::BehavioralAnomaly,
1301 },
1302 AnomalyType::Statistical(stat_type) => match stat_type {
1303 StatisticalAnomalyType::BenfordViolation
1304 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1305 _ => AnomalyCategory::StatisticalOutlier,
1306 },
1307 AnomalyType::Relational(rel_type) => match rel_type {
1308 RelationalAnomalyType::CircularTransaction
1309 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1310 _ => AnomalyCategory::BehavioralAnomaly,
1311 },
1312 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1313 }
1314 }
1315
1316 pub fn name(&self) -> &str {
1318 match self {
1319 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1320 AnomalyCategory::VendorKickback => "vendor_kickback",
1321 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1322 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1323 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1324 AnomalyCategory::StructuredTransaction => "structured_transaction",
1325 AnomalyCategory::CircularFlow => "circular_flow",
1326 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1327 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1328 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1329 AnomalyCategory::ManualOverride => "manual_override",
1330 AnomalyCategory::MissingApproval => "missing_approval",
1331 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1332 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1333 AnomalyCategory::Custom(s) => s.as_str(),
1334 }
1335 }
1336
1337 pub fn ordinal(&self) -> u8 {
1339 match self {
1340 AnomalyCategory::FictitiousVendor => 0,
1341 AnomalyCategory::VendorKickback => 1,
1342 AnomalyCategory::RelatedPartyVendor => 2,
1343 AnomalyCategory::DuplicatePayment => 3,
1344 AnomalyCategory::UnauthorizedTransaction => 4,
1345 AnomalyCategory::StructuredTransaction => 5,
1346 AnomalyCategory::CircularFlow => 6,
1347 AnomalyCategory::BehavioralAnomaly => 7,
1348 AnomalyCategory::TimingAnomaly => 8,
1349 AnomalyCategory::JournalAnomaly => 9,
1350 AnomalyCategory::ManualOverride => 10,
1351 AnomalyCategory::MissingApproval => 11,
1352 AnomalyCategory::StatisticalOutlier => 12,
1353 AnomalyCategory::DistributionAnomaly => 13,
1354 AnomalyCategory::Custom(_) => 14,
1355 }
1356 }
1357
1358 pub fn category_count() -> usize {
1360 15 }
1362}
1363
1364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1366pub enum FactorType {
1367 AmountDeviation,
1369 ThresholdProximity,
1371 TimingAnomaly,
1373 EntityRisk,
1375 PatternMatch,
1377 FrequencyDeviation,
1379 RelationshipAnomaly,
1381 ControlBypass,
1383 BenfordViolation,
1385 DuplicateIndicator,
1387 ApprovalChainIssue,
1389 DocumentationGap,
1391 Custom,
1393}
1394
1395impl FactorType {
1396 pub fn name(&self) -> &'static str {
1398 match self {
1399 FactorType::AmountDeviation => "amount_deviation",
1400 FactorType::ThresholdProximity => "threshold_proximity",
1401 FactorType::TimingAnomaly => "timing_anomaly",
1402 FactorType::EntityRisk => "entity_risk",
1403 FactorType::PatternMatch => "pattern_match",
1404 FactorType::FrequencyDeviation => "frequency_deviation",
1405 FactorType::RelationshipAnomaly => "relationship_anomaly",
1406 FactorType::ControlBypass => "control_bypass",
1407 FactorType::BenfordViolation => "benford_violation",
1408 FactorType::DuplicateIndicator => "duplicate_indicator",
1409 FactorType::ApprovalChainIssue => "approval_chain_issue",
1410 FactorType::DocumentationGap => "documentation_gap",
1411 FactorType::Custom => "custom",
1412 }
1413 }
1414}
1415
1416#[derive(Debug, Clone, Serialize, Deserialize)]
1418pub struct FactorEvidence {
1419 pub source: String,
1421 pub data: HashMap<String, String>,
1423}
1424
1425#[derive(Debug, Clone, Serialize, Deserialize)]
1427pub struct ContributingFactor {
1428 pub factor_type: FactorType,
1430 pub value: f64,
1432 pub threshold: f64,
1434 pub direction_greater: bool,
1436 pub weight: f64,
1438 pub description: String,
1440 pub evidence: Option<FactorEvidence>,
1442}
1443
1444impl ContributingFactor {
1445 pub fn new(
1447 factor_type: FactorType,
1448 value: f64,
1449 threshold: f64,
1450 direction_greater: bool,
1451 weight: f64,
1452 description: &str,
1453 ) -> Self {
1454 Self {
1455 factor_type,
1456 value,
1457 threshold,
1458 direction_greater,
1459 weight,
1460 description: description.to_string(),
1461 evidence: None,
1462 }
1463 }
1464
1465 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1467 self.evidence = Some(FactorEvidence {
1468 source: source.to_string(),
1469 data,
1470 });
1471 self
1472 }
1473
1474 pub fn contribution(&self) -> f64 {
1476 let deviation = if self.direction_greater {
1477 (self.value - self.threshold).max(0.0)
1478 } else {
1479 (self.threshold - self.value).max(0.0)
1480 };
1481
1482 let relative_deviation = if self.threshold.abs() > 0.001 {
1484 deviation / self.threshold.abs()
1485 } else {
1486 deviation
1487 };
1488
1489 (relative_deviation * self.weight).min(1.0)
1491 }
1492}
1493
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1496pub struct EnhancedAnomalyLabel {
1497 pub base: LabeledAnomaly,
1499 pub category: AnomalyCategory,
1501 pub enhanced_confidence: f64,
1503 pub enhanced_severity: f64,
1505 pub contributing_factors: Vec<ContributingFactor>,
1507 pub secondary_categories: Vec<AnomalyCategory>,
1509}
1510
1511impl EnhancedAnomalyLabel {
1512 pub fn from_base(base: LabeledAnomaly) -> Self {
1514 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1515 let enhanced_confidence = base.confidence;
1516 let enhanced_severity = base.severity as f64 / 5.0;
1517
1518 Self {
1519 base,
1520 category,
1521 enhanced_confidence,
1522 enhanced_severity,
1523 contributing_factors: Vec::new(),
1524 secondary_categories: Vec::new(),
1525 }
1526 }
1527
1528 pub fn with_confidence(mut self, confidence: f64) -> Self {
1530 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1531 self
1532 }
1533
1534 pub fn with_severity(mut self, severity: f64) -> Self {
1536 self.enhanced_severity = severity.clamp(0.0, 1.0);
1537 self
1538 }
1539
1540 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1542 self.contributing_factors.push(factor);
1543 self
1544 }
1545
1546 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1548 if !self.secondary_categories.contains(&category) && category != self.category {
1549 self.secondary_categories.push(category);
1550 }
1551 self
1552 }
1553
1554 pub fn to_features(&self) -> Vec<f64> {
1558 let mut features = self.base.to_features();
1559
1560 features.push(self.enhanced_confidence);
1562 features.push(self.enhanced_severity);
1563 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1564 features.push(self.secondary_categories.len() as f64);
1565 features.push(self.contributing_factors.len() as f64);
1566
1567 let max_weight = self
1569 .contributing_factors
1570 .iter()
1571 .map(|f| f.weight)
1572 .fold(0.0, f64::max);
1573 features.push(max_weight);
1574
1575 let has_control_bypass = self
1577 .contributing_factors
1578 .iter()
1579 .any(|f| f.factor_type == FactorType::ControlBypass);
1580 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1581
1582 let has_amount_deviation = self
1583 .contributing_factors
1584 .iter()
1585 .any(|f| f.factor_type == FactorType::AmountDeviation);
1586 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1587
1588 let has_timing = self
1589 .contributing_factors
1590 .iter()
1591 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1592 features.push(if has_timing { 1.0 } else { 0.0 });
1593
1594 let has_pattern_match = self
1595 .contributing_factors
1596 .iter()
1597 .any(|f| f.factor_type == FactorType::PatternMatch);
1598 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1599
1600 features
1601 }
1602
1603 pub fn feature_count() -> usize {
1605 25 }
1607
1608 pub fn feature_names() -> Vec<&'static str> {
1610 let mut names = LabeledAnomaly::feature_names();
1611 names.extend(vec![
1612 "enhanced_confidence",
1613 "enhanced_severity",
1614 "category_ordinal",
1615 "secondary_category_count",
1616 "contributing_factor_count",
1617 "max_factor_weight",
1618 "has_control_bypass",
1619 "has_amount_deviation",
1620 "has_timing_factor",
1621 "has_pattern_match",
1622 ]);
1623 names
1624 }
1625}
1626
1627#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1633pub enum SeverityLevel {
1634 Low,
1636 #[default]
1638 Medium,
1639 High,
1641 Critical,
1643}
1644
1645impl SeverityLevel {
1646 pub fn numeric(&self) -> u8 {
1648 match self {
1649 SeverityLevel::Low => 1,
1650 SeverityLevel::Medium => 2,
1651 SeverityLevel::High => 3,
1652 SeverityLevel::Critical => 4,
1653 }
1654 }
1655
1656 pub fn from_numeric(value: u8) -> Self {
1658 match value {
1659 1 => SeverityLevel::Low,
1660 2 => SeverityLevel::Medium,
1661 3 => SeverityLevel::High,
1662 _ => SeverityLevel::Critical,
1663 }
1664 }
1665
1666 pub fn from_score(score: f64) -> Self {
1668 match score {
1669 s if s < 0.25 => SeverityLevel::Low,
1670 s if s < 0.50 => SeverityLevel::Medium,
1671 s if s < 0.75 => SeverityLevel::High,
1672 _ => SeverityLevel::Critical,
1673 }
1674 }
1675
1676 pub fn to_score(&self) -> f64 {
1678 match self {
1679 SeverityLevel::Low => 0.125,
1680 SeverityLevel::Medium => 0.375,
1681 SeverityLevel::High => 0.625,
1682 SeverityLevel::Critical => 0.875,
1683 }
1684 }
1685}
1686
1687#[derive(Debug, Clone, Serialize, Deserialize)]
1689pub struct AnomalySeverity {
1690 pub level: SeverityLevel,
1692 pub score: f64,
1694 pub financial_impact: Decimal,
1696 pub is_material: bool,
1698 #[serde(default, skip_serializing_if = "Option::is_none")]
1700 pub materiality_threshold: Option<Decimal>,
1701}
1702
1703impl AnomalySeverity {
1704 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1706 Self {
1707 level,
1708 score: level.to_score(),
1709 financial_impact,
1710 is_material: false,
1711 materiality_threshold: None,
1712 }
1713 }
1714
1715 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1717 Self {
1718 level: SeverityLevel::from_score(score),
1719 score: score.clamp(0.0, 1.0),
1720 financial_impact,
1721 is_material: false,
1722 materiality_threshold: None,
1723 }
1724 }
1725
1726 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1728 self.materiality_threshold = Some(threshold);
1729 self.is_material = self.financial_impact.abs() >= threshold;
1730 self
1731 }
1732}
1733
1734impl Default for AnomalySeverity {
1735 fn default() -> Self {
1736 Self {
1737 level: SeverityLevel::Medium,
1738 score: 0.5,
1739 financial_impact: Decimal::ZERO,
1740 is_material: false,
1741 materiality_threshold: None,
1742 }
1743 }
1744}
1745
1746#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1754pub enum AnomalyDetectionDifficulty {
1755 Trivial,
1757 Easy,
1759 #[default]
1761 Moderate,
1762 Hard,
1764 Expert,
1766}
1767
1768impl AnomalyDetectionDifficulty {
1769 pub fn expected_detection_rate(&self) -> f64 {
1771 match self {
1772 AnomalyDetectionDifficulty::Trivial => 0.99,
1773 AnomalyDetectionDifficulty::Easy => 0.90,
1774 AnomalyDetectionDifficulty::Moderate => 0.70,
1775 AnomalyDetectionDifficulty::Hard => 0.40,
1776 AnomalyDetectionDifficulty::Expert => 0.15,
1777 }
1778 }
1779
1780 pub fn difficulty_score(&self) -> f64 {
1782 match self {
1783 AnomalyDetectionDifficulty::Trivial => 0.05,
1784 AnomalyDetectionDifficulty::Easy => 0.25,
1785 AnomalyDetectionDifficulty::Moderate => 0.50,
1786 AnomalyDetectionDifficulty::Hard => 0.75,
1787 AnomalyDetectionDifficulty::Expert => 0.95,
1788 }
1789 }
1790
1791 pub fn from_score(score: f64) -> Self {
1793 match score {
1794 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1795 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1796 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1797 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1798 _ => AnomalyDetectionDifficulty::Expert,
1799 }
1800 }
1801
1802 pub fn name(&self) -> &'static str {
1804 match self {
1805 AnomalyDetectionDifficulty::Trivial => "trivial",
1806 AnomalyDetectionDifficulty::Easy => "easy",
1807 AnomalyDetectionDifficulty::Moderate => "moderate",
1808 AnomalyDetectionDifficulty::Hard => "hard",
1809 AnomalyDetectionDifficulty::Expert => "expert",
1810 }
1811 }
1812}
1813
1814#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1818pub enum GroundTruthCertainty {
1819 #[default]
1821 Definite,
1822 Probable,
1824 Possible,
1826}
1827
1828impl GroundTruthCertainty {
1829 pub fn certainty_score(&self) -> f64 {
1831 match self {
1832 GroundTruthCertainty::Definite => 1.0,
1833 GroundTruthCertainty::Probable => 0.8,
1834 GroundTruthCertainty::Possible => 0.5,
1835 }
1836 }
1837
1838 pub fn name(&self) -> &'static str {
1840 match self {
1841 GroundTruthCertainty::Definite => "definite",
1842 GroundTruthCertainty::Probable => "probable",
1843 GroundTruthCertainty::Possible => "possible",
1844 }
1845 }
1846}
1847
1848#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1852pub enum DetectionMethod {
1853 RuleBased,
1855 Statistical,
1857 MachineLearning,
1859 GraphBased,
1861 ForensicAudit,
1863 Hybrid,
1865}
1866
1867impl DetectionMethod {
1868 pub fn name(&self) -> &'static str {
1870 match self {
1871 DetectionMethod::RuleBased => "rule_based",
1872 DetectionMethod::Statistical => "statistical",
1873 DetectionMethod::MachineLearning => "machine_learning",
1874 DetectionMethod::GraphBased => "graph_based",
1875 DetectionMethod::ForensicAudit => "forensic_audit",
1876 DetectionMethod::Hybrid => "hybrid",
1877 }
1878 }
1879
1880 pub fn description(&self) -> &'static str {
1882 match self {
1883 DetectionMethod::RuleBased => "Simple threshold and filter rules",
1884 DetectionMethod::Statistical => "Statistical distribution analysis",
1885 DetectionMethod::MachineLearning => "ML classification models",
1886 DetectionMethod::GraphBased => "Network and relationship analysis",
1887 DetectionMethod::ForensicAudit => "Manual forensic procedures",
1888 DetectionMethod::Hybrid => "Combined multi-method approach",
1889 }
1890 }
1891}
1892
1893#[derive(Debug, Clone, Serialize, Deserialize)]
1898pub struct ExtendedAnomalyLabel {
1899 pub base: LabeledAnomaly,
1901 pub category: AnomalyCategory,
1903 pub severity: AnomalySeverity,
1905 pub detection_difficulty: AnomalyDetectionDifficulty,
1907 pub recommended_methods: Vec<DetectionMethod>,
1909 pub key_indicators: Vec<String>,
1911 pub ground_truth_certainty: GroundTruthCertainty,
1913 pub contributing_factors: Vec<ContributingFactor>,
1915 pub related_entity_ids: Vec<String>,
1917 pub secondary_categories: Vec<AnomalyCategory>,
1919 #[serde(default, skip_serializing_if = "Option::is_none")]
1921 pub scheme_id: Option<String>,
1922 #[serde(default, skip_serializing_if = "Option::is_none")]
1924 pub scheme_stage: Option<u32>,
1925 #[serde(default)]
1927 pub is_near_miss: bool,
1928 #[serde(default, skip_serializing_if = "Option::is_none")]
1930 pub near_miss_explanation: Option<String>,
1931}
1932
1933impl ExtendedAnomalyLabel {
1934 pub fn from_base(base: LabeledAnomaly) -> Self {
1936 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1937 let severity = AnomalySeverity {
1938 level: SeverityLevel::from_numeric(base.severity),
1939 score: base.severity as f64 / 5.0,
1940 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
1941 is_material: false,
1942 materiality_threshold: None,
1943 };
1944
1945 Self {
1946 base,
1947 category,
1948 severity,
1949 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
1950 recommended_methods: vec![DetectionMethod::RuleBased],
1951 key_indicators: Vec::new(),
1952 ground_truth_certainty: GroundTruthCertainty::Definite,
1953 contributing_factors: Vec::new(),
1954 related_entity_ids: Vec::new(),
1955 secondary_categories: Vec::new(),
1956 scheme_id: None,
1957 scheme_stage: None,
1958 is_near_miss: false,
1959 near_miss_explanation: None,
1960 }
1961 }
1962
1963 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
1965 self.severity = severity;
1966 self
1967 }
1968
1969 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
1971 self.detection_difficulty = difficulty;
1972 self
1973 }
1974
1975 pub fn with_method(mut self, method: DetectionMethod) -> Self {
1977 if !self.recommended_methods.contains(&method) {
1978 self.recommended_methods.push(method);
1979 }
1980 self
1981 }
1982
1983 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
1985 self.recommended_methods = methods;
1986 self
1987 }
1988
1989 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
1991 self.key_indicators.push(indicator.into());
1992 self
1993 }
1994
1995 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
1997 self.ground_truth_certainty = certainty;
1998 self
1999 }
2000
2001 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
2003 self.contributing_factors.push(factor);
2004 self
2005 }
2006
2007 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
2009 self.related_entity_ids.push(entity_id.into());
2010 self
2011 }
2012
2013 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
2015 if category != self.category && !self.secondary_categories.contains(&category) {
2016 self.secondary_categories.push(category);
2017 }
2018 self
2019 }
2020
2021 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
2023 self.scheme_id = Some(scheme_id.into());
2024 self.scheme_stage = Some(stage);
2025 self
2026 }
2027
2028 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
2030 self.is_near_miss = true;
2031 self.near_miss_explanation = Some(explanation.into());
2032 self
2033 }
2034
2035 pub fn to_features(&self) -> Vec<f64> {
2039 let mut features = self.base.to_features();
2040
2041 features.push(self.severity.score);
2043 features.push(self.severity.level.to_score());
2044 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
2045 features.push(self.detection_difficulty.difficulty_score());
2046 features.push(self.detection_difficulty.expected_detection_rate());
2047 features.push(self.ground_truth_certainty.certainty_score());
2048 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
2049 features.push(self.secondary_categories.len() as f64);
2050 features.push(self.contributing_factors.len() as f64);
2051 features.push(self.key_indicators.len() as f64);
2052 features.push(self.recommended_methods.len() as f64);
2053 features.push(self.related_entity_ids.len() as f64);
2054 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
2055 features.push(self.scheme_stage.unwrap_or(0) as f64);
2056 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
2057
2058 features
2059 }
2060
2061 pub fn feature_count() -> usize {
2063 30 }
2065
2066 pub fn feature_names() -> Vec<&'static str> {
2068 let mut names = LabeledAnomaly::feature_names();
2069 names.extend(vec![
2070 "severity_score",
2071 "severity_level_score",
2072 "is_material",
2073 "difficulty_score",
2074 "expected_detection_rate",
2075 "ground_truth_certainty",
2076 "category_ordinal",
2077 "secondary_category_count",
2078 "contributing_factor_count",
2079 "key_indicator_count",
2080 "recommended_method_count",
2081 "related_entity_count",
2082 "is_part_of_scheme",
2083 "scheme_stage",
2084 "is_near_miss",
2085 ]);
2086 names
2087 }
2088}
2089
2090#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2096pub enum SchemeType {
2097 GradualEmbezzlement,
2099 RevenueManipulation,
2101 VendorKickback,
2103 RoundTripping,
2105 GhostEmployee,
2107 ExpenseReimbursement,
2109 InventoryTheft,
2111 Custom,
2113}
2114
2115impl SchemeType {
2116 pub fn name(&self) -> &'static str {
2118 match self {
2119 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2120 SchemeType::RevenueManipulation => "revenue_manipulation",
2121 SchemeType::VendorKickback => "vendor_kickback",
2122 SchemeType::RoundTripping => "round_tripping",
2123 SchemeType::GhostEmployee => "ghost_employee",
2124 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2125 SchemeType::InventoryTheft => "inventory_theft",
2126 SchemeType::Custom => "custom",
2127 }
2128 }
2129
2130 pub fn typical_stages(&self) -> u32 {
2132 match self {
2133 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2141 }
2142 }
2143}
2144
2145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2147pub enum SchemeDetectionStatus {
2148 #[default]
2150 Undetected,
2151 UnderInvestigation,
2153 PartiallyDetected,
2155 FullyDetected,
2157}
2158
2159#[derive(Debug, Clone, Serialize, Deserialize)]
2161pub struct SchemeTransactionRef {
2162 pub document_id: String,
2164 pub date: chrono::NaiveDate,
2166 pub amount: Decimal,
2168 pub stage: u32,
2170 #[serde(default, skip_serializing_if = "Option::is_none")]
2172 pub anomaly_id: Option<String>,
2173}
2174
2175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2177pub enum ConcealmentTechnique {
2178 DocumentManipulation,
2180 ApprovalCircumvention,
2182 TimingExploitation,
2184 TransactionSplitting,
2186 AccountMisclassification,
2188 Collusion,
2190 DataAlteration,
2192 FalseDocumentation,
2194}
2195
2196impl ConcealmentTechnique {
2197 pub fn difficulty_bonus(&self) -> f64 {
2199 match self {
2200 ConcealmentTechnique::DocumentManipulation => 0.20,
2201 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2202 ConcealmentTechnique::TimingExploitation => 0.10,
2203 ConcealmentTechnique::TransactionSplitting => 0.15,
2204 ConcealmentTechnique::AccountMisclassification => 0.10,
2205 ConcealmentTechnique::Collusion => 0.25,
2206 ConcealmentTechnique::DataAlteration => 0.20,
2207 ConcealmentTechnique::FalseDocumentation => 0.15,
2208 }
2209 }
2210}
2211
2212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2229pub enum AcfeFraudCategory {
2230 #[default]
2233 AssetMisappropriation,
2234 Corruption,
2237 FinancialStatementFraud,
2240}
2241
2242impl AcfeFraudCategory {
2243 pub fn name(&self) -> &'static str {
2245 match self {
2246 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2247 AcfeFraudCategory::Corruption => "corruption",
2248 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2249 }
2250 }
2251
2252 pub fn typical_occurrence_rate(&self) -> f64 {
2254 match self {
2255 AcfeFraudCategory::AssetMisappropriation => 0.86,
2256 AcfeFraudCategory::Corruption => 0.33,
2257 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2258 }
2259 }
2260
2261 pub fn typical_median_loss(&self) -> Decimal {
2263 match self {
2264 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2265 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2266 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2267 }
2268 }
2269
2270 pub fn typical_detection_months(&self) -> u32 {
2272 match self {
2273 AcfeFraudCategory::AssetMisappropriation => 12,
2274 AcfeFraudCategory::Corruption => 18,
2275 AcfeFraudCategory::FinancialStatementFraud => 24,
2276 }
2277 }
2278}
2279
2280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2287pub enum CashFraudScheme {
2288 Larceny,
2291 Skimming,
2293
2294 SalesSkimming,
2297 ReceivablesSkimming,
2299 RefundSchemes,
2301
2302 ShellCompany,
2305 NonAccompliceVendor,
2307 PersonalPurchases,
2309
2310 GhostEmployee,
2313 FalsifiedWages,
2315 CommissionSchemes,
2317
2318 MischaracterizedExpenses,
2321 OverstatedExpenses,
2323 FictitiousExpenses,
2325
2326 ForgedMaker,
2329 ForgedEndorsement,
2331 AlteredPayee,
2333 AuthorizedMaker,
2335
2336 FalseVoids,
2339 FalseRefunds,
2341}
2342
2343impl CashFraudScheme {
2344 pub fn category(&self) -> AcfeFraudCategory {
2346 AcfeFraudCategory::AssetMisappropriation
2347 }
2348
2349 pub fn subcategory(&self) -> &'static str {
2351 match self {
2352 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2353 CashFraudScheme::SalesSkimming
2354 | CashFraudScheme::ReceivablesSkimming
2355 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2356 CashFraudScheme::ShellCompany
2357 | CashFraudScheme::NonAccompliceVendor
2358 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2359 CashFraudScheme::GhostEmployee
2360 | CashFraudScheme::FalsifiedWages
2361 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2362 CashFraudScheme::MischaracterizedExpenses
2363 | CashFraudScheme::OverstatedExpenses
2364 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2365 CashFraudScheme::ForgedMaker
2366 | CashFraudScheme::ForgedEndorsement
2367 | CashFraudScheme::AlteredPayee
2368 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2369 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2370 }
2371 }
2372
2373 pub fn severity(&self) -> u8 {
2375 match self {
2376 CashFraudScheme::FalseVoids
2378 | CashFraudScheme::FalseRefunds
2379 | CashFraudScheme::MischaracterizedExpenses => 3,
2380 CashFraudScheme::OverstatedExpenses
2382 | CashFraudScheme::Skimming
2383 | CashFraudScheme::Larceny
2384 | CashFraudScheme::PersonalPurchases
2385 | CashFraudScheme::FalsifiedWages => 4,
2386 CashFraudScheme::ShellCompany
2388 | CashFraudScheme::GhostEmployee
2389 | CashFraudScheme::FictitiousExpenses
2390 | CashFraudScheme::ForgedMaker
2391 | CashFraudScheme::AuthorizedMaker => 5,
2392 _ => 4,
2393 }
2394 }
2395
2396 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2398 match self {
2399 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2401 AnomalyDetectionDifficulty::Easy
2402 }
2403 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2405 AnomalyDetectionDifficulty::Moderate
2406 }
2407 CashFraudScheme::Skimming
2409 | CashFraudScheme::ShellCompany
2410 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2411 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2413 AnomalyDetectionDifficulty::Expert
2414 }
2415 _ => AnomalyDetectionDifficulty::Moderate,
2416 }
2417 }
2418
2419 pub fn all_variants() -> &'static [CashFraudScheme] {
2421 &[
2422 CashFraudScheme::Larceny,
2423 CashFraudScheme::Skimming,
2424 CashFraudScheme::SalesSkimming,
2425 CashFraudScheme::ReceivablesSkimming,
2426 CashFraudScheme::RefundSchemes,
2427 CashFraudScheme::ShellCompany,
2428 CashFraudScheme::NonAccompliceVendor,
2429 CashFraudScheme::PersonalPurchases,
2430 CashFraudScheme::GhostEmployee,
2431 CashFraudScheme::FalsifiedWages,
2432 CashFraudScheme::CommissionSchemes,
2433 CashFraudScheme::MischaracterizedExpenses,
2434 CashFraudScheme::OverstatedExpenses,
2435 CashFraudScheme::FictitiousExpenses,
2436 CashFraudScheme::ForgedMaker,
2437 CashFraudScheme::ForgedEndorsement,
2438 CashFraudScheme::AlteredPayee,
2439 CashFraudScheme::AuthorizedMaker,
2440 CashFraudScheme::FalseVoids,
2441 CashFraudScheme::FalseRefunds,
2442 ]
2443 }
2444}
2445
2446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2448pub enum AssetFraudScheme {
2449 InventoryMisuse,
2452 InventoryTheft,
2454 InventoryPurchasingScheme,
2456 InventoryReceivingScheme,
2458
2459 EquipmentMisuse,
2462 EquipmentTheft,
2464 IntellectualPropertyTheft,
2466 TimeTheft,
2468}
2469
2470impl AssetFraudScheme {
2471 pub fn category(&self) -> AcfeFraudCategory {
2473 AcfeFraudCategory::AssetMisappropriation
2474 }
2475
2476 pub fn subcategory(&self) -> &'static str {
2478 match self {
2479 AssetFraudScheme::InventoryMisuse
2480 | AssetFraudScheme::InventoryTheft
2481 | AssetFraudScheme::InventoryPurchasingScheme
2482 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2483 _ => "other_assets",
2484 }
2485 }
2486
2487 pub fn severity(&self) -> u8 {
2489 match self {
2490 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2491 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2492 AssetFraudScheme::InventoryTheft
2493 | AssetFraudScheme::InventoryPurchasingScheme
2494 | AssetFraudScheme::InventoryReceivingScheme => 4,
2495 AssetFraudScheme::IntellectualPropertyTheft => 5,
2496 }
2497 }
2498}
2499
2500#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2505pub enum CorruptionScheme {
2506 PurchasingConflict,
2509 SalesConflict,
2511 OutsideBusinessInterest,
2513 NepotismConflict,
2515
2516 InvoiceKickback,
2519 BidRigging,
2521 CashBribery,
2523 PublicOfficial,
2525
2526 IllegalGratuity,
2529
2530 EconomicExtortion,
2533}
2534
2535impl CorruptionScheme {
2536 pub fn category(&self) -> AcfeFraudCategory {
2538 AcfeFraudCategory::Corruption
2539 }
2540
2541 pub fn subcategory(&self) -> &'static str {
2543 match self {
2544 CorruptionScheme::PurchasingConflict
2545 | CorruptionScheme::SalesConflict
2546 | CorruptionScheme::OutsideBusinessInterest
2547 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2548 CorruptionScheme::InvoiceKickback
2549 | CorruptionScheme::BidRigging
2550 | CorruptionScheme::CashBribery
2551 | CorruptionScheme::PublicOfficial => "bribery",
2552 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2553 CorruptionScheme::EconomicExtortion => "economic_extortion",
2554 }
2555 }
2556
2557 pub fn severity(&self) -> u8 {
2559 match self {
2560 CorruptionScheme::NepotismConflict => 3,
2562 CorruptionScheme::PurchasingConflict
2564 | CorruptionScheme::SalesConflict
2565 | CorruptionScheme::OutsideBusinessInterest
2566 | CorruptionScheme::IllegalGratuity => 4,
2567 CorruptionScheme::InvoiceKickback
2569 | CorruptionScheme::BidRigging
2570 | CorruptionScheme::CashBribery
2571 | CorruptionScheme::EconomicExtortion => 5,
2572 CorruptionScheme::PublicOfficial => 5,
2574 }
2575 }
2576
2577 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2579 match self {
2580 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2582 AnomalyDetectionDifficulty::Moderate
2583 }
2584 CorruptionScheme::PurchasingConflict
2586 | CorruptionScheme::SalesConflict
2587 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2588 CorruptionScheme::InvoiceKickback
2590 | CorruptionScheme::CashBribery
2591 | CorruptionScheme::PublicOfficial
2592 | CorruptionScheme::IllegalGratuity
2593 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2594 }
2595 }
2596
2597 pub fn all_variants() -> &'static [CorruptionScheme] {
2599 &[
2600 CorruptionScheme::PurchasingConflict,
2601 CorruptionScheme::SalesConflict,
2602 CorruptionScheme::OutsideBusinessInterest,
2603 CorruptionScheme::NepotismConflict,
2604 CorruptionScheme::InvoiceKickback,
2605 CorruptionScheme::BidRigging,
2606 CorruptionScheme::CashBribery,
2607 CorruptionScheme::PublicOfficial,
2608 CorruptionScheme::IllegalGratuity,
2609 CorruptionScheme::EconomicExtortion,
2610 ]
2611 }
2612}
2613
2614#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2619pub enum FinancialStatementScheme {
2620 PrematureRevenue,
2623 DelayedExpenses,
2625 FictitiousRevenues,
2627 ConcealedLiabilities,
2629 ImproperAssetValuations,
2631 ImproperDisclosures,
2633 ChannelStuffing,
2635 BillAndHold,
2637 ImproperCapitalization,
2639
2640 UnderstatedRevenues,
2643 OverstatedExpenses,
2645 OverstatedLiabilities,
2647 ImproperAssetWritedowns,
2649}
2650
2651impl FinancialStatementScheme {
2652 pub fn category(&self) -> AcfeFraudCategory {
2654 AcfeFraudCategory::FinancialStatementFraud
2655 }
2656
2657 pub fn subcategory(&self) -> &'static str {
2659 match self {
2660 FinancialStatementScheme::UnderstatedRevenues
2661 | FinancialStatementScheme::OverstatedExpenses
2662 | FinancialStatementScheme::OverstatedLiabilities
2663 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2664 _ => "overstatement",
2665 }
2666 }
2667
2668 pub fn severity(&self) -> u8 {
2670 5
2672 }
2673
2674 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2676 match self {
2677 FinancialStatementScheme::ChannelStuffing
2679 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2680 FinancialStatementScheme::PrematureRevenue
2682 | FinancialStatementScheme::ImproperCapitalization
2683 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2684 FinancialStatementScheme::FictitiousRevenues
2686 | FinancialStatementScheme::ConcealedLiabilities
2687 | FinancialStatementScheme::ImproperAssetValuations
2688 | FinancialStatementScheme::ImproperDisclosures
2689 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2690 _ => AnomalyDetectionDifficulty::Hard,
2691 }
2692 }
2693
2694 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2696 &[
2697 FinancialStatementScheme::PrematureRevenue,
2698 FinancialStatementScheme::DelayedExpenses,
2699 FinancialStatementScheme::FictitiousRevenues,
2700 FinancialStatementScheme::ConcealedLiabilities,
2701 FinancialStatementScheme::ImproperAssetValuations,
2702 FinancialStatementScheme::ImproperDisclosures,
2703 FinancialStatementScheme::ChannelStuffing,
2704 FinancialStatementScheme::BillAndHold,
2705 FinancialStatementScheme::ImproperCapitalization,
2706 FinancialStatementScheme::UnderstatedRevenues,
2707 FinancialStatementScheme::OverstatedExpenses,
2708 FinancialStatementScheme::OverstatedLiabilities,
2709 FinancialStatementScheme::ImproperAssetWritedowns,
2710 ]
2711 }
2712}
2713
2714#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2716pub enum AcfeScheme {
2717 Cash(CashFraudScheme),
2719 Asset(AssetFraudScheme),
2721 Corruption(CorruptionScheme),
2723 FinancialStatement(FinancialStatementScheme),
2725}
2726
2727impl AcfeScheme {
2728 pub fn category(&self) -> AcfeFraudCategory {
2730 match self {
2731 AcfeScheme::Cash(s) => s.category(),
2732 AcfeScheme::Asset(s) => s.category(),
2733 AcfeScheme::Corruption(s) => s.category(),
2734 AcfeScheme::FinancialStatement(s) => s.category(),
2735 }
2736 }
2737
2738 pub fn severity(&self) -> u8 {
2740 match self {
2741 AcfeScheme::Cash(s) => s.severity(),
2742 AcfeScheme::Asset(s) => s.severity(),
2743 AcfeScheme::Corruption(s) => s.severity(),
2744 AcfeScheme::FinancialStatement(s) => s.severity(),
2745 }
2746 }
2747
2748 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2750 match self {
2751 AcfeScheme::Cash(s) => s.detection_difficulty(),
2752 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2753 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2754 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2755 }
2756 }
2757}
2758
2759#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2761pub enum AcfeDetectionMethod {
2762 Tip,
2764 InternalAudit,
2766 ManagementReview,
2768 ExternalAudit,
2770 AccountReconciliation,
2772 DocumentExamination,
2774 ByAccident,
2776 ItControls,
2778 Surveillance,
2780 Confession,
2782 LawEnforcement,
2784 Other,
2786}
2787
2788impl AcfeDetectionMethod {
2789 pub fn typical_detection_rate(&self) -> f64 {
2791 match self {
2792 AcfeDetectionMethod::Tip => 0.42,
2793 AcfeDetectionMethod::InternalAudit => 0.16,
2794 AcfeDetectionMethod::ManagementReview => 0.12,
2795 AcfeDetectionMethod::ExternalAudit => 0.04,
2796 AcfeDetectionMethod::AccountReconciliation => 0.05,
2797 AcfeDetectionMethod::DocumentExamination => 0.04,
2798 AcfeDetectionMethod::ByAccident => 0.06,
2799 AcfeDetectionMethod::ItControls => 0.03,
2800 AcfeDetectionMethod::Surveillance => 0.02,
2801 AcfeDetectionMethod::Confession => 0.02,
2802 AcfeDetectionMethod::LawEnforcement => 0.01,
2803 AcfeDetectionMethod::Other => 0.03,
2804 }
2805 }
2806
2807 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2809 &[
2810 AcfeDetectionMethod::Tip,
2811 AcfeDetectionMethod::InternalAudit,
2812 AcfeDetectionMethod::ManagementReview,
2813 AcfeDetectionMethod::ExternalAudit,
2814 AcfeDetectionMethod::AccountReconciliation,
2815 AcfeDetectionMethod::DocumentExamination,
2816 AcfeDetectionMethod::ByAccident,
2817 AcfeDetectionMethod::ItControls,
2818 AcfeDetectionMethod::Surveillance,
2819 AcfeDetectionMethod::Confession,
2820 AcfeDetectionMethod::LawEnforcement,
2821 AcfeDetectionMethod::Other,
2822 ]
2823 }
2824}
2825
2826#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2828pub enum PerpetratorDepartment {
2829 Accounting,
2831 Operations,
2833 Executive,
2835 Sales,
2837 CustomerService,
2839 Purchasing,
2841 It,
2843 HumanResources,
2845 Administrative,
2847 Warehouse,
2849 BoardOfDirectors,
2851 Other,
2853}
2854
2855impl PerpetratorDepartment {
2856 pub fn typical_occurrence_rate(&self) -> f64 {
2858 match self {
2859 PerpetratorDepartment::Accounting => 0.21,
2860 PerpetratorDepartment::Operations => 0.17,
2861 PerpetratorDepartment::Executive => 0.12,
2862 PerpetratorDepartment::Sales => 0.11,
2863 PerpetratorDepartment::CustomerService => 0.07,
2864 PerpetratorDepartment::Purchasing => 0.06,
2865 PerpetratorDepartment::It => 0.05,
2866 PerpetratorDepartment::HumanResources => 0.04,
2867 PerpetratorDepartment::Administrative => 0.04,
2868 PerpetratorDepartment::Warehouse => 0.03,
2869 PerpetratorDepartment::BoardOfDirectors => 0.02,
2870 PerpetratorDepartment::Other => 0.08,
2871 }
2872 }
2873
2874 pub fn typical_median_loss(&self) -> Decimal {
2876 match self {
2877 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
2878 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
2879 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
2880 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
2881 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
2882 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
2883 PerpetratorDepartment::It => Decimal::new(100_000, 0),
2884 _ => Decimal::new(80_000, 0),
2885 }
2886 }
2887}
2888
2889#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2891pub enum PerpetratorLevel {
2892 Employee,
2894 Manager,
2896 OwnerExecutive,
2898}
2899
2900impl PerpetratorLevel {
2901 pub fn typical_occurrence_rate(&self) -> f64 {
2903 match self {
2904 PerpetratorLevel::Employee => 0.42,
2905 PerpetratorLevel::Manager => 0.36,
2906 PerpetratorLevel::OwnerExecutive => 0.22,
2907 }
2908 }
2909
2910 pub fn typical_median_loss(&self) -> Decimal {
2912 match self {
2913 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
2914 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
2915 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
2916 }
2917 }
2918}
2919
2920#[derive(Debug, Clone, Serialize, Deserialize)]
2925pub struct AcfeCalibration {
2926 pub median_loss: Decimal,
2928 pub median_duration_months: u32,
2930 pub category_distribution: HashMap<String, f64>,
2932 pub detection_method_distribution: HashMap<String, f64>,
2934 pub department_distribution: HashMap<String, f64>,
2936 pub level_distribution: HashMap<String, f64>,
2938 pub avg_red_flags_per_case: f64,
2940 pub collusion_rate: f64,
2942}
2943
2944impl Default for AcfeCalibration {
2945 fn default() -> Self {
2946 let mut category_distribution = HashMap::new();
2947 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
2948 category_distribution.insert("corruption".to_string(), 0.33);
2949 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
2950
2951 let mut detection_method_distribution = HashMap::new();
2952 for method in AcfeDetectionMethod::all_variants() {
2953 detection_method_distribution.insert(
2954 format!("{method:?}").to_lowercase(),
2955 method.typical_detection_rate(),
2956 );
2957 }
2958
2959 let mut department_distribution = HashMap::new();
2960 department_distribution.insert("accounting".to_string(), 0.21);
2961 department_distribution.insert("operations".to_string(), 0.17);
2962 department_distribution.insert("executive".to_string(), 0.12);
2963 department_distribution.insert("sales".to_string(), 0.11);
2964 department_distribution.insert("customer_service".to_string(), 0.07);
2965 department_distribution.insert("purchasing".to_string(), 0.06);
2966 department_distribution.insert("other".to_string(), 0.26);
2967
2968 let mut level_distribution = HashMap::new();
2969 level_distribution.insert("employee".to_string(), 0.42);
2970 level_distribution.insert("manager".to_string(), 0.36);
2971 level_distribution.insert("owner_executive".to_string(), 0.22);
2972
2973 Self {
2974 median_loss: Decimal::new(117_000, 0),
2975 median_duration_months: 12,
2976 category_distribution,
2977 detection_method_distribution,
2978 department_distribution,
2979 level_distribution,
2980 avg_red_flags_per_case: 2.8,
2981 collusion_rate: 0.50,
2982 }
2983 }
2984}
2985
2986impl AcfeCalibration {
2987 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
2989 Self {
2990 median_loss,
2991 median_duration_months,
2992 ..Self::default()
2993 }
2994 }
2995
2996 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
2998 category.typical_median_loss()
2999 }
3000
3001 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
3003 category.typical_detection_months()
3004 }
3005
3006 pub fn validate(&self) -> Result<(), String> {
3008 if self.median_loss <= Decimal::ZERO {
3009 return Err("Median loss must be positive".to_string());
3010 }
3011 if self.median_duration_months == 0 {
3012 return Err("Median duration must be at least 1 month".to_string());
3013 }
3014 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
3015 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
3016 }
3017 Ok(())
3018 }
3019}
3020
3021#[derive(Debug, Clone, Serialize, Deserialize)]
3026pub struct FraudTriangle {
3027 pub pressure: PressureType,
3029 pub opportunities: Vec<OpportunityFactor>,
3031 pub rationalization: Rationalization,
3033}
3034
3035impl FraudTriangle {
3036 pub fn new(
3038 pressure: PressureType,
3039 opportunities: Vec<OpportunityFactor>,
3040 rationalization: Rationalization,
3041 ) -> Self {
3042 Self {
3043 pressure,
3044 opportunities,
3045 rationalization,
3046 }
3047 }
3048
3049 pub fn risk_score(&self) -> f64 {
3051 let pressure_score = self.pressure.risk_weight();
3052 let opportunity_score: f64 = self
3053 .opportunities
3054 .iter()
3055 .map(OpportunityFactor::risk_weight)
3056 .sum::<f64>()
3057 / self.opportunities.len().max(1) as f64;
3058 let rationalization_score = self.rationalization.risk_weight();
3059
3060 (pressure_score + opportunity_score + rationalization_score) / 3.0
3061 }
3062}
3063
3064#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3066pub enum PressureType {
3067 PersonalFinancialDifficulties,
3070 FinancialTargets,
3072 MarketExpectations,
3074 CovenantCompliance,
3076 CreditRatingMaintenance,
3078 AcquisitionValuation,
3080
3081 JobSecurity,
3084 StatusMaintenance,
3086 GamblingAddiction,
3088 SubstanceAbuse,
3090 FamilyPressure,
3092 Greed,
3094}
3095
3096impl PressureType {
3097 pub fn risk_weight(&self) -> f64 {
3099 match self {
3100 PressureType::PersonalFinancialDifficulties => 0.80,
3101 PressureType::FinancialTargets => 0.75,
3102 PressureType::MarketExpectations => 0.70,
3103 PressureType::CovenantCompliance => 0.85,
3104 PressureType::CreditRatingMaintenance => 0.70,
3105 PressureType::AcquisitionValuation => 0.75,
3106 PressureType::JobSecurity => 0.65,
3107 PressureType::StatusMaintenance => 0.55,
3108 PressureType::GamblingAddiction => 0.90,
3109 PressureType::SubstanceAbuse => 0.85,
3110 PressureType::FamilyPressure => 0.60,
3111 PressureType::Greed => 0.70,
3112 }
3113 }
3114}
3115
3116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3118pub enum OpportunityFactor {
3119 WeakInternalControls,
3121 LackOfSegregation,
3123 ManagementOverride,
3125 ComplexTransactions,
3127 RelatedPartyTransactions,
3129 PoorToneAtTop,
3131 InadequateSupervision,
3133 AssetAccess,
3135 PoorRecordKeeping,
3137 LackOfDiscipline,
3139 LackOfIndependentChecks,
3141}
3142
3143impl OpportunityFactor {
3144 pub fn risk_weight(&self) -> f64 {
3146 match self {
3147 OpportunityFactor::WeakInternalControls => 0.85,
3148 OpportunityFactor::LackOfSegregation => 0.80,
3149 OpportunityFactor::ManagementOverride => 0.90,
3150 OpportunityFactor::ComplexTransactions => 0.70,
3151 OpportunityFactor::RelatedPartyTransactions => 0.75,
3152 OpportunityFactor::PoorToneAtTop => 0.85,
3153 OpportunityFactor::InadequateSupervision => 0.75,
3154 OpportunityFactor::AssetAccess => 0.70,
3155 OpportunityFactor::PoorRecordKeeping => 0.65,
3156 OpportunityFactor::LackOfDiscipline => 0.60,
3157 OpportunityFactor::LackOfIndependentChecks => 0.75,
3158 }
3159 }
3160}
3161
3162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3164pub enum Rationalization {
3165 TemporaryBorrowing,
3167 EveryoneDoesIt,
3169 ForTheCompanyGood,
3171 Entitlement,
3173 FollowingOrders,
3175 TheyWontMissIt,
3177 NeedItMore,
3179 NotReallyStealing,
3181 Underpaid,
3183 VictimlessCrime,
3185}
3186
3187impl Rationalization {
3188 pub fn risk_weight(&self) -> f64 {
3190 match self {
3191 Rationalization::Entitlement => 0.85,
3193 Rationalization::EveryoneDoesIt => 0.80,
3194 Rationalization::NotReallyStealing => 0.80,
3195 Rationalization::TheyWontMissIt => 0.75,
3196 Rationalization::Underpaid => 0.70,
3198 Rationalization::ForTheCompanyGood => 0.65,
3199 Rationalization::NeedItMore => 0.65,
3200 Rationalization::TemporaryBorrowing => 0.60,
3202 Rationalization::FollowingOrders => 0.55,
3203 Rationalization::VictimlessCrime => 0.60,
3204 }
3205 }
3206}
3207
3208#[derive(Debug, Clone, Serialize, Deserialize)]
3214pub enum NearMissPattern {
3215 NearDuplicate {
3217 date_difference_days: u32,
3219 similar_transaction_id: String,
3221 },
3222 ThresholdProximity {
3224 threshold: Decimal,
3226 proximity: f64,
3228 },
3229 UnusualLegitimate {
3231 pattern_type: LegitimatePatternType,
3233 justification: String,
3235 },
3236 CorrectedError {
3238 correction_lag_days: u32,
3240 correction_document_id: String,
3242 },
3243}
3244
3245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3247pub enum LegitimatePatternType {
3248 YearEndBonus,
3250 ContractPrepayment,
3252 SettlementPayment,
3254 InsuranceClaim,
3256 OneTimePayment,
3258 AssetDisposal,
3260 SeasonalInventory,
3262 PromotionalSpending,
3264}
3265
3266impl LegitimatePatternType {
3267 pub fn description(&self) -> &'static str {
3269 match self {
3270 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3271 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3272 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3273 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3274 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3275 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3276 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3277 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3278 }
3279 }
3280}
3281
3282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3284pub enum FalsePositiveTrigger {
3285 AmountNearThreshold,
3287 UnusualTiming,
3289 SimilarTransaction,
3291 NewCounterparty,
3293 UnusualAccountCombination,
3295 VolumeSpike,
3297 RoundAmount,
3299}
3300
3301#[derive(Debug, Clone, Serialize, Deserialize)]
3303pub struct NearMissLabel {
3304 pub document_id: String,
3306 pub pattern: NearMissPattern,
3308 pub suspicion_score: f64,
3310 pub false_positive_trigger: FalsePositiveTrigger,
3312 pub explanation: String,
3314}
3315
3316impl NearMissLabel {
3317 pub fn new(
3319 document_id: impl Into<String>,
3320 pattern: NearMissPattern,
3321 suspicion_score: f64,
3322 trigger: FalsePositiveTrigger,
3323 explanation: impl Into<String>,
3324 ) -> Self {
3325 Self {
3326 document_id: document_id.into(),
3327 pattern,
3328 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3329 false_positive_trigger: trigger,
3330 explanation: explanation.into(),
3331 }
3332 }
3333}
3334
3335#[derive(Debug, Clone, Serialize, Deserialize)]
3337pub struct AnomalyRateConfig {
3338 pub total_rate: f64,
3340 pub fraud_rate: f64,
3342 pub error_rate: f64,
3344 pub process_issue_rate: f64,
3346 pub statistical_rate: f64,
3348 pub relational_rate: f64,
3350}
3351
3352impl Default for AnomalyRateConfig {
3353 fn default() -> Self {
3354 Self {
3355 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, }
3362 }
3363}
3364
3365impl AnomalyRateConfig {
3366 pub fn validate(&self) -> Result<(), String> {
3368 let sum = self.fraud_rate
3369 + self.error_rate
3370 + self.process_issue_rate
3371 + self.statistical_rate
3372 + self.relational_rate;
3373
3374 if (sum - 1.0).abs() > 0.01 {
3375 return Err(format!("Anomaly category rates must sum to 1.0, got {sum}"));
3376 }
3377
3378 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3379 return Err(format!(
3380 "Total rate must be between 0.0 and 1.0, got {}",
3381 self.total_rate
3382 ));
3383 }
3384
3385 Ok(())
3386 }
3387}
3388
3389#[cfg(test)]
3390mod tests {
3391 use super::*;
3392 use rust_decimal_macros::dec;
3393
3394 #[test]
3395 fn test_anomaly_type_category() {
3396 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3397 assert_eq!(fraud.category(), "Fraud");
3398 assert!(fraud.is_intentional());
3399
3400 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3401 assert_eq!(error.category(), "Error");
3402 assert!(!error.is_intentional());
3403 }
3404
3405 #[test]
3406 fn test_labeled_anomaly() {
3407 let anomaly = LabeledAnomaly::new(
3408 "ANO001".to_string(),
3409 AnomalyType::Fraud(FraudType::SelfApproval),
3410 "JE001".to_string(),
3411 "JE".to_string(),
3412 "1000".to_string(),
3413 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3414 )
3415 .with_description("User approved their own expense report")
3416 .with_related_entity("USER001");
3417
3418 assert_eq!(anomaly.severity, 3);
3419 assert!(anomaly.is_injected);
3420 assert_eq!(anomaly.related_entities.len(), 1);
3421 }
3422
3423 #[test]
3424 fn test_labeled_anomaly_with_provenance() {
3425 let anomaly = LabeledAnomaly::new(
3426 "ANO001".to_string(),
3427 AnomalyType::Fraud(FraudType::SelfApproval),
3428 "JE001".to_string(),
3429 "JE".to_string(),
3430 "1000".to_string(),
3431 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3432 )
3433 .with_run_id("run-123")
3434 .with_generation_seed(42)
3435 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3436 .with_structured_strategy(InjectionStrategy::SelfApproval {
3437 user_id: "USER001".to_string(),
3438 })
3439 .with_scenario("scenario-001")
3440 .with_original_document_hash("abc123");
3441
3442 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3443 assert_eq!(anomaly.generation_seed, Some(42));
3444 assert!(anomaly.causal_reason.is_some());
3445 assert!(anomaly.structured_strategy.is_some());
3446 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3447 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3448
3449 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3451 }
3452
3453 #[test]
3454 fn test_labeled_anomaly_derivation_chain() {
3455 let parent = LabeledAnomaly::new(
3456 "ANO001".to_string(),
3457 AnomalyType::Fraud(FraudType::DuplicatePayment),
3458 "JE001".to_string(),
3459 "JE".to_string(),
3460 "1000".to_string(),
3461 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3462 );
3463
3464 let child = LabeledAnomaly::new(
3465 "ANO002".to_string(),
3466 AnomalyType::Error(ErrorType::DuplicateEntry),
3467 "JE002".to_string(),
3468 "JE".to_string(),
3469 "1000".to_string(),
3470 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3471 )
3472 .with_parent_anomaly(&parent.anomaly_id);
3473
3474 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3475 }
3476
3477 #[test]
3478 fn test_injection_strategy_description() {
3479 let strategy = InjectionStrategy::AmountManipulation {
3480 original: dec!(1000),
3481 factor: 2.5,
3482 };
3483 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3484 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3485
3486 let strategy = InjectionStrategy::ThresholdAvoidance {
3487 threshold: dec!(10000),
3488 adjusted_amount: dec!(9999),
3489 };
3490 assert_eq!(
3491 strategy.description(),
3492 "Amount adjusted to avoid 10000 threshold"
3493 );
3494
3495 let strategy = InjectionStrategy::DateShift {
3496 days_shifted: -5,
3497 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3498 };
3499 assert_eq!(strategy.description(), "Date backdated by 5 days");
3500
3501 let strategy = InjectionStrategy::DateShift {
3502 days_shifted: 3,
3503 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3504 };
3505 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3506 }
3507
3508 #[test]
3509 fn test_causal_reason_variants() {
3510 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3511 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3512 assert!((base_rate - 0.02).abs() < 0.001);
3513 }
3514
3515 let reason = AnomalyCausalReason::TemporalPattern {
3516 pattern_name: "year_end_spike".to_string(),
3517 };
3518 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3519 assert_eq!(pattern_name, "year_end_spike");
3520 }
3521
3522 let reason = AnomalyCausalReason::ScenarioStep {
3523 scenario_type: "kickback".to_string(),
3524 step_number: 3,
3525 };
3526 if let AnomalyCausalReason::ScenarioStep {
3527 scenario_type,
3528 step_number,
3529 } = reason
3530 {
3531 assert_eq!(scenario_type, "kickback");
3532 assert_eq!(step_number, 3);
3533 }
3534 }
3535
3536 #[test]
3537 fn test_feature_vector_length() {
3538 let anomaly = LabeledAnomaly::new(
3539 "ANO001".to_string(),
3540 AnomalyType::Fraud(FraudType::SelfApproval),
3541 "JE001".to_string(),
3542 "JE".to_string(),
3543 "1000".to_string(),
3544 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3545 );
3546
3547 let features = anomaly.to_features();
3548 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3549 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3550 }
3551
3552 #[test]
3553 fn test_feature_vector_with_provenance() {
3554 let anomaly = LabeledAnomaly::new(
3555 "ANO001".to_string(),
3556 AnomalyType::Fraud(FraudType::SelfApproval),
3557 "JE001".to_string(),
3558 "JE".to_string(),
3559 "1000".to_string(),
3560 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3561 )
3562 .with_scenario("scenario-001")
3563 .with_parent_anomaly("ANO000");
3564
3565 let features = anomaly.to_features();
3566
3567 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3571
3572 #[test]
3573 fn test_anomaly_summary() {
3574 let anomalies = vec![
3575 LabeledAnomaly::new(
3576 "ANO001".to_string(),
3577 AnomalyType::Fraud(FraudType::SelfApproval),
3578 "JE001".to_string(),
3579 "JE".to_string(),
3580 "1000".to_string(),
3581 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3582 ),
3583 LabeledAnomaly::new(
3584 "ANO002".to_string(),
3585 AnomalyType::Error(ErrorType::DuplicateEntry),
3586 "JE002".to_string(),
3587 "JE".to_string(),
3588 "1000".to_string(),
3589 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3590 ),
3591 ];
3592
3593 let summary = AnomalySummary::from_anomalies(&anomalies);
3594
3595 assert_eq!(summary.total_count, 2);
3596 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3597 assert_eq!(summary.by_category.get("Error"), Some(&1));
3598 }
3599
3600 #[test]
3601 fn test_rate_config_validation() {
3602 let config = AnomalyRateConfig::default();
3603 assert!(config.validate().is_ok());
3604
3605 let bad_config = AnomalyRateConfig {
3606 fraud_rate: 0.5,
3607 error_rate: 0.5,
3608 process_issue_rate: 0.5, ..Default::default()
3610 };
3611 assert!(bad_config.validate().is_err());
3612 }
3613
3614 #[test]
3615 fn test_injection_strategy_serialization() {
3616 let strategy = InjectionStrategy::SoDViolation {
3617 duty1: "CreatePO".to_string(),
3618 duty2: "ApprovePO".to_string(),
3619 violating_user: "USER001".to_string(),
3620 };
3621
3622 let json = serde_json::to_string(&strategy).unwrap();
3623 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3624
3625 assert_eq!(strategy, deserialized);
3626 }
3627
3628 #[test]
3629 fn test_labeled_anomaly_serialization_with_provenance() {
3630 let anomaly = LabeledAnomaly::new(
3631 "ANO001".to_string(),
3632 AnomalyType::Fraud(FraudType::SelfApproval),
3633 "JE001".to_string(),
3634 "JE".to_string(),
3635 "1000".to_string(),
3636 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3637 )
3638 .with_run_id("run-123")
3639 .with_generation_seed(42)
3640 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3641
3642 let json = serde_json::to_string(&anomaly).unwrap();
3643 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3644
3645 assert_eq!(anomaly.run_id, deserialized.run_id);
3646 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3647 }
3648
3649 #[test]
3654 fn test_anomaly_category_from_anomaly_type() {
3655 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3657 assert_eq!(
3658 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3659 AnomalyCategory::FictitiousVendor
3660 );
3661
3662 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3663 assert_eq!(
3664 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3665 AnomalyCategory::VendorKickback
3666 );
3667
3668 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3669 assert_eq!(
3670 AnomalyCategory::from_anomaly_type(&fraud_structured),
3671 AnomalyCategory::StructuredTransaction
3672 );
3673
3674 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3676 assert_eq!(
3677 AnomalyCategory::from_anomaly_type(&error_duplicate),
3678 AnomalyCategory::DuplicatePayment
3679 );
3680
3681 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3683 assert_eq!(
3684 AnomalyCategory::from_anomaly_type(&process_skip),
3685 AnomalyCategory::MissingApproval
3686 );
3687
3688 let relational_circular =
3690 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3691 assert_eq!(
3692 AnomalyCategory::from_anomaly_type(&relational_circular),
3693 AnomalyCategory::CircularFlow
3694 );
3695 }
3696
3697 #[test]
3698 fn test_anomaly_category_ordinal() {
3699 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3700 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3701 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3702 }
3703
3704 #[test]
3705 fn test_contributing_factor() {
3706 let factor = ContributingFactor::new(
3707 FactorType::AmountDeviation,
3708 15000.0,
3709 10000.0,
3710 true,
3711 0.5,
3712 "Amount exceeds threshold",
3713 );
3714
3715 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3716 assert_eq!(factor.value, 15000.0);
3717 assert_eq!(factor.threshold, 10000.0);
3718 assert!(factor.direction_greater);
3719
3720 let contribution = factor.contribution();
3722 assert!((contribution - 0.25).abs() < 0.01);
3723 }
3724
3725 #[test]
3726 fn test_contributing_factor_with_evidence() {
3727 let mut data = HashMap::new();
3728 data.insert("expected".to_string(), "10000".to_string());
3729 data.insert("actual".to_string(), "15000".to_string());
3730
3731 let factor = ContributingFactor::new(
3732 FactorType::AmountDeviation,
3733 15000.0,
3734 10000.0,
3735 true,
3736 0.5,
3737 "Amount deviation detected",
3738 )
3739 .with_evidence("transaction_history", data);
3740
3741 assert!(factor.evidence.is_some());
3742 let evidence = factor.evidence.unwrap();
3743 assert_eq!(evidence.source, "transaction_history");
3744 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3745 }
3746
3747 #[test]
3748 fn test_enhanced_anomaly_label() {
3749 let base = LabeledAnomaly::new(
3750 "ANO001".to_string(),
3751 AnomalyType::Fraud(FraudType::DuplicatePayment),
3752 "JE001".to_string(),
3753 "JE".to_string(),
3754 "1000".to_string(),
3755 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3756 );
3757
3758 let enhanced = EnhancedAnomalyLabel::from_base(base)
3759 .with_confidence(0.85)
3760 .with_severity(0.7)
3761 .with_factor(ContributingFactor::new(
3762 FactorType::DuplicateIndicator,
3763 1.0,
3764 0.5,
3765 true,
3766 0.4,
3767 "Duplicate payment detected",
3768 ))
3769 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3770
3771 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3772 assert_eq!(enhanced.enhanced_confidence, 0.85);
3773 assert_eq!(enhanced.enhanced_severity, 0.7);
3774 assert_eq!(enhanced.contributing_factors.len(), 1);
3775 assert_eq!(enhanced.secondary_categories.len(), 1);
3776 }
3777
3778 #[test]
3779 fn test_enhanced_anomaly_label_features() {
3780 let base = LabeledAnomaly::new(
3781 "ANO001".to_string(),
3782 AnomalyType::Fraud(FraudType::SelfApproval),
3783 "JE001".to_string(),
3784 "JE".to_string(),
3785 "1000".to_string(),
3786 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3787 );
3788
3789 let enhanced = EnhancedAnomalyLabel::from_base(base)
3790 .with_confidence(0.9)
3791 .with_severity(0.8)
3792 .with_factor(ContributingFactor::new(
3793 FactorType::ControlBypass,
3794 1.0,
3795 0.0,
3796 true,
3797 0.5,
3798 "Control bypass detected",
3799 ));
3800
3801 let features = enhanced.to_features();
3802
3803 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3805 assert_eq!(features.len(), 25);
3806
3807 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3813
3814 #[test]
3815 fn test_enhanced_anomaly_label_feature_names() {
3816 let names = EnhancedAnomalyLabel::feature_names();
3817 assert_eq!(names.len(), 25);
3818 assert!(names.contains(&"enhanced_confidence"));
3819 assert!(names.contains(&"enhanced_severity"));
3820 assert!(names.contains(&"has_control_bypass"));
3821 }
3822
3823 #[test]
3824 fn test_factor_type_names() {
3825 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3826 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3827 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3828 }
3829
3830 #[test]
3831 fn test_anomaly_category_serialization() {
3832 let category = AnomalyCategory::CircularFlow;
3833 let json = serde_json::to_string(&category).unwrap();
3834 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3835 assert_eq!(category, deserialized);
3836
3837 let custom = AnomalyCategory::Custom("custom_type".to_string());
3838 let json = serde_json::to_string(&custom).unwrap();
3839 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3840 assert_eq!(custom, deserialized);
3841 }
3842
3843 #[test]
3844 fn test_enhanced_label_secondary_category_dedup() {
3845 let base = LabeledAnomaly::new(
3846 "ANO001".to_string(),
3847 AnomalyType::Fraud(FraudType::DuplicatePayment),
3848 "JE001".to_string(),
3849 "JE".to_string(),
3850 "1000".to_string(),
3851 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3852 );
3853
3854 let enhanced = EnhancedAnomalyLabel::from_base(base)
3855 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3857 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3859 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3861
3862 assert_eq!(enhanced.secondary_categories.len(), 1);
3864 assert_eq!(
3865 enhanced.secondary_categories[0],
3866 AnomalyCategory::TimingAnomaly
3867 );
3868 }
3869
3870 #[test]
3875 fn test_revenue_recognition_fraud_types() {
3876 let fraud_types = [
3878 FraudType::ImproperRevenueRecognition,
3879 FraudType::ImproperPoAllocation,
3880 FraudType::VariableConsiderationManipulation,
3881 FraudType::ContractModificationMisstatement,
3882 ];
3883
3884 for fraud_type in fraud_types {
3885 let anomaly_type = AnomalyType::Fraud(fraud_type);
3886 assert_eq!(anomaly_type.category(), "Fraud");
3887 assert!(anomaly_type.is_intentional());
3888 assert!(anomaly_type.severity() >= 3);
3889 }
3890 }
3891
3892 #[test]
3893 fn test_lease_accounting_fraud_types() {
3894 let fraud_types = [
3896 FraudType::LeaseClassificationManipulation,
3897 FraudType::OffBalanceSheetLease,
3898 FraudType::LeaseLiabilityUnderstatement,
3899 FraudType::RouAssetMisstatement,
3900 ];
3901
3902 for fraud_type in fraud_types {
3903 let anomaly_type = AnomalyType::Fraud(fraud_type);
3904 assert_eq!(anomaly_type.category(), "Fraud");
3905 assert!(anomaly_type.is_intentional());
3906 assert!(anomaly_type.severity() >= 3);
3907 }
3908
3909 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3911 }
3912
3913 #[test]
3914 fn test_fair_value_fraud_types() {
3915 let fraud_types = [
3917 FraudType::FairValueHierarchyManipulation,
3918 FraudType::Level3InputManipulation,
3919 FraudType::ValuationTechniqueManipulation,
3920 ];
3921
3922 for fraud_type in fraud_types {
3923 let anomaly_type = AnomalyType::Fraud(fraud_type);
3924 assert_eq!(anomaly_type.category(), "Fraud");
3925 assert!(anomaly_type.is_intentional());
3926 assert!(anomaly_type.severity() >= 4);
3927 }
3928
3929 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3931 }
3932
3933 #[test]
3934 fn test_impairment_fraud_types() {
3935 let fraud_types = [
3937 FraudType::DelayedImpairment,
3938 FraudType::ImpairmentTestAvoidance,
3939 FraudType::CashFlowProjectionManipulation,
3940 FraudType::ImproperImpairmentReversal,
3941 ];
3942
3943 for fraud_type in fraud_types {
3944 let anomaly_type = AnomalyType::Fraud(fraud_type);
3945 assert_eq!(anomaly_type.category(), "Fraud");
3946 assert!(anomaly_type.is_intentional());
3947 assert!(anomaly_type.severity() >= 3);
3948 }
3949
3950 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3952 }
3953
3954 #[test]
3959 fn test_standards_error_types() {
3960 let error_types = [
3962 ErrorType::RevenueTimingError,
3963 ErrorType::PoAllocationError,
3964 ErrorType::LeaseClassificationError,
3965 ErrorType::LeaseCalculationError,
3966 ErrorType::FairValueError,
3967 ErrorType::ImpairmentCalculationError,
3968 ErrorType::DiscountRateError,
3969 ErrorType::FrameworkApplicationError,
3970 ];
3971
3972 for error_type in error_types {
3973 let anomaly_type = AnomalyType::Error(error_type);
3974 assert_eq!(anomaly_type.category(), "Error");
3975 assert!(!anomaly_type.is_intentional());
3976 assert!(anomaly_type.severity() >= 3);
3977 }
3978 }
3979
3980 #[test]
3981 fn test_framework_application_error() {
3982 let error_type = ErrorType::FrameworkApplicationError;
3984 assert_eq!(error_type.severity(), 4);
3985
3986 let anomaly = LabeledAnomaly::new(
3987 "ERR001".to_string(),
3988 AnomalyType::Error(error_type),
3989 "JE100".to_string(),
3990 "JE".to_string(),
3991 "1000".to_string(),
3992 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3993 )
3994 .with_description("LIFO inventory method used under IFRS (not permitted)")
3995 .with_metadata("framework", "IFRS")
3996 .with_metadata("standard_violated", "IAS 2");
3997
3998 assert_eq!(anomaly.anomaly_type.category(), "Error");
3999 assert_eq!(
4000 anomaly.metadata.get("standard_violated"),
4001 Some(&"IAS 2".to_string())
4002 );
4003 }
4004
4005 #[test]
4006 fn test_standards_anomaly_serialization() {
4007 let fraud_types = [
4009 FraudType::ImproperRevenueRecognition,
4010 FraudType::LeaseClassificationManipulation,
4011 FraudType::FairValueHierarchyManipulation,
4012 FraudType::DelayedImpairment,
4013 ];
4014
4015 for fraud_type in fraud_types {
4016 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
4017 let deserialized: FraudType =
4018 serde_json::from_str(&json).expect("Failed to deserialize");
4019 assert_eq!(fraud_type, deserialized);
4020 }
4021
4022 let error_types = [
4024 ErrorType::RevenueTimingError,
4025 ErrorType::LeaseCalculationError,
4026 ErrorType::FairValueError,
4027 ErrorType::FrameworkApplicationError,
4028 ];
4029
4030 for error_type in error_types {
4031 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
4032 let deserialized: ErrorType =
4033 serde_json::from_str(&json).expect("Failed to deserialize");
4034 assert_eq!(error_type, deserialized);
4035 }
4036 }
4037
4038 #[test]
4039 fn test_standards_labeled_anomaly() {
4040 let anomaly = LabeledAnomaly::new(
4042 "STD001".to_string(),
4043 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
4044 "CONTRACT-2024-001".to_string(),
4045 "Revenue".to_string(),
4046 "1000".to_string(),
4047 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
4048 )
4049 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
4050 .with_monetary_impact(dec!(500000))
4051 .with_metadata("standard", "ASC 606")
4052 .with_metadata("paragraph", "606-10-25-1")
4053 .with_metadata("contract_id", "C-2024-001")
4054 .with_related_entity("CONTRACT-2024-001")
4055 .with_related_entity("CUSTOMER-500");
4056
4057 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
4059 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
4060 assert_eq!(anomaly.related_entities.len(), 2);
4061 assert_eq!(
4062 anomaly.metadata.get("standard"),
4063 Some(&"ASC 606".to_string())
4064 );
4065 }
4066
4067 #[test]
4072 fn test_severity_level() {
4073 assert_eq!(SeverityLevel::Low.numeric(), 1);
4074 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4075
4076 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4077 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4078
4079 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4080 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4081
4082 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4083 }
4084
4085 #[test]
4086 fn test_anomaly_severity() {
4087 let severity =
4088 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4089
4090 assert_eq!(severity.level, SeverityLevel::High);
4091 assert!(severity.is_material);
4092 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4093
4094 let low_severity =
4096 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4097 assert!(!low_severity.is_material);
4098 }
4099
4100 #[test]
4101 fn test_detection_difficulty() {
4102 assert!(
4103 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4104 );
4105 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4106
4107 assert_eq!(
4108 AnomalyDetectionDifficulty::from_score(0.05),
4109 AnomalyDetectionDifficulty::Trivial
4110 );
4111 assert_eq!(
4112 AnomalyDetectionDifficulty::from_score(0.90),
4113 AnomalyDetectionDifficulty::Expert
4114 );
4115
4116 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4117 }
4118
4119 #[test]
4120 fn test_ground_truth_certainty() {
4121 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4122 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4123 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4124 }
4125
4126 #[test]
4127 fn test_detection_method() {
4128 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4129 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4130 }
4131
4132 #[test]
4133 fn test_extended_anomaly_label() {
4134 let base = LabeledAnomaly::new(
4135 "ANO001".to_string(),
4136 AnomalyType::Fraud(FraudType::FictitiousVendor),
4137 "JE001".to_string(),
4138 "JE".to_string(),
4139 "1000".to_string(),
4140 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4141 )
4142 .with_monetary_impact(dec!(100000));
4143
4144 let extended = ExtendedAnomalyLabel::from_base(base)
4145 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4146 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4147 .with_method(DetectionMethod::GraphBased)
4148 .with_method(DetectionMethod::ForensicAudit)
4149 .with_indicator("New vendor with no history")
4150 .with_indicator("Large first transaction")
4151 .with_certainty(GroundTruthCertainty::Definite)
4152 .with_entity("V001")
4153 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4154 .with_scheme("SCHEME001", 2);
4155
4156 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4157 assert_eq!(
4158 extended.detection_difficulty,
4159 AnomalyDetectionDifficulty::Hard
4160 );
4161 assert_eq!(extended.recommended_methods.len(), 3);
4163 assert_eq!(extended.key_indicators.len(), 2);
4164 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4165 assert_eq!(extended.scheme_stage, Some(2));
4166 }
4167
4168 #[test]
4169 fn test_extended_anomaly_label_features() {
4170 let base = LabeledAnomaly::new(
4171 "ANO001".to_string(),
4172 AnomalyType::Fraud(FraudType::SelfApproval),
4173 "JE001".to_string(),
4174 "JE".to_string(),
4175 "1000".to_string(),
4176 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4177 );
4178
4179 let extended =
4180 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4181
4182 let features = extended.to_features();
4183 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4184 assert_eq!(features.len(), 30);
4185
4186 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4189 }
4190
4191 #[test]
4192 fn test_extended_label_near_miss() {
4193 let base = LabeledAnomaly::new(
4194 "ANO001".to_string(),
4195 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4196 "JE001".to_string(),
4197 "JE".to_string(),
4198 "1000".to_string(),
4199 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4200 );
4201
4202 let extended = ExtendedAnomalyLabel::from_base(base)
4203 .as_near_miss("Year-end bonus payment, legitimately high");
4204
4205 assert!(extended.is_near_miss);
4206 assert!(extended.near_miss_explanation.is_some());
4207 }
4208
4209 #[test]
4210 fn test_scheme_type() {
4211 assert_eq!(
4212 SchemeType::GradualEmbezzlement.name(),
4213 "gradual_embezzlement"
4214 );
4215 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4216 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4217 }
4218
4219 #[test]
4220 fn test_concealment_technique() {
4221 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4222 assert!(
4223 ConcealmentTechnique::Collusion.difficulty_bonus()
4224 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4225 );
4226 }
4227
4228 #[test]
4229 fn test_near_miss_label() {
4230 let near_miss = NearMissLabel::new(
4231 "JE001",
4232 NearMissPattern::ThresholdProximity {
4233 threshold: dec!(10000),
4234 proximity: 0.95,
4235 },
4236 0.7,
4237 FalsePositiveTrigger::AmountNearThreshold,
4238 "Transaction is 95% of threshold but business justified",
4239 );
4240
4241 assert_eq!(near_miss.document_id, "JE001");
4242 assert_eq!(near_miss.suspicion_score, 0.7);
4243 assert_eq!(
4244 near_miss.false_positive_trigger,
4245 FalsePositiveTrigger::AmountNearThreshold
4246 );
4247 }
4248
4249 #[test]
4250 fn test_legitimate_pattern_type() {
4251 assert_eq!(
4252 LegitimatePatternType::YearEndBonus.description(),
4253 "Year-end bonus payment"
4254 );
4255 assert_eq!(
4256 LegitimatePatternType::InsuranceClaim.description(),
4257 "Insurance claim reimbursement"
4258 );
4259 }
4260
4261 #[test]
4262 fn test_severity_detection_difficulty_serialization() {
4263 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4264 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4265 let deserialized: AnomalySeverity =
4266 serde_json::from_str(&json).expect("Failed to deserialize");
4267 assert_eq!(severity.level, deserialized.level);
4268
4269 let difficulty = AnomalyDetectionDifficulty::Hard;
4270 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4271 let deserialized: AnomalyDetectionDifficulty =
4272 serde_json::from_str(&json).expect("Failed to deserialize");
4273 assert_eq!(difficulty, deserialized);
4274 }
4275
4276 #[test]
4281 fn test_acfe_fraud_category() {
4282 let asset = AcfeFraudCategory::AssetMisappropriation;
4283 assert_eq!(asset.name(), "asset_misappropriation");
4284 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4285 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4286 assert_eq!(asset.typical_detection_months(), 12);
4287
4288 let corruption = AcfeFraudCategory::Corruption;
4289 assert_eq!(corruption.name(), "corruption");
4290 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4291
4292 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4293 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4294 assert_eq!(fs_fraud.typical_detection_months(), 24);
4295 }
4296
4297 #[test]
4298 fn test_cash_fraud_scheme() {
4299 let shell = CashFraudScheme::ShellCompany;
4300 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4301 assert_eq!(shell.subcategory(), "billing_schemes");
4302 assert_eq!(shell.severity(), 5);
4303 assert_eq!(
4304 shell.detection_difficulty(),
4305 AnomalyDetectionDifficulty::Hard
4306 );
4307
4308 let ghost = CashFraudScheme::GhostEmployee;
4309 assert_eq!(ghost.subcategory(), "payroll_schemes");
4310 assert_eq!(ghost.severity(), 5);
4311
4312 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4314 }
4315
4316 #[test]
4317 fn test_asset_fraud_scheme() {
4318 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4319 assert_eq!(
4320 ip_theft.category(),
4321 AcfeFraudCategory::AssetMisappropriation
4322 );
4323 assert_eq!(ip_theft.subcategory(), "other_assets");
4324 assert_eq!(ip_theft.severity(), 5);
4325
4326 let inv_theft = AssetFraudScheme::InventoryTheft;
4327 assert_eq!(inv_theft.subcategory(), "inventory");
4328 assert_eq!(inv_theft.severity(), 4);
4329 }
4330
4331 #[test]
4332 fn test_corruption_scheme() {
4333 let kickback = CorruptionScheme::InvoiceKickback;
4334 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4335 assert_eq!(kickback.subcategory(), "bribery");
4336 assert_eq!(kickback.severity(), 5);
4337 assert_eq!(
4338 kickback.detection_difficulty(),
4339 AnomalyDetectionDifficulty::Expert
4340 );
4341
4342 let bid_rigging = CorruptionScheme::BidRigging;
4343 assert_eq!(bid_rigging.subcategory(), "bribery");
4344 assert_eq!(
4345 bid_rigging.detection_difficulty(),
4346 AnomalyDetectionDifficulty::Hard
4347 );
4348
4349 let purchasing = CorruptionScheme::PurchasingConflict;
4350 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4351
4352 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4354 }
4355
4356 #[test]
4357 fn test_financial_statement_scheme() {
4358 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4359 assert_eq!(
4360 fictitious.category(),
4361 AcfeFraudCategory::FinancialStatementFraud
4362 );
4363 assert_eq!(fictitious.subcategory(), "overstatement");
4364 assert_eq!(fictitious.severity(), 5);
4365 assert_eq!(
4366 fictitious.detection_difficulty(),
4367 AnomalyDetectionDifficulty::Expert
4368 );
4369
4370 let understated = FinancialStatementScheme::UnderstatedRevenues;
4371 assert_eq!(understated.subcategory(), "understatement");
4372
4373 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4375 }
4376
4377 #[test]
4378 fn test_acfe_scheme_unified() {
4379 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4380 assert_eq!(
4381 cash_scheme.category(),
4382 AcfeFraudCategory::AssetMisappropriation
4383 );
4384 assert_eq!(cash_scheme.severity(), 5);
4385
4386 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4387 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4388
4389 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4390 assert_eq!(
4391 fs_scheme.category(),
4392 AcfeFraudCategory::FinancialStatementFraud
4393 );
4394 }
4395
4396 #[test]
4397 fn test_acfe_detection_method() {
4398 let tip = AcfeDetectionMethod::Tip;
4399 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4400
4401 let internal_audit = AcfeDetectionMethod::InternalAudit;
4402 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4403
4404 let external_audit = AcfeDetectionMethod::ExternalAudit;
4405 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4406
4407 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4409 }
4410
4411 #[test]
4412 fn test_perpetrator_department() {
4413 let accounting = PerpetratorDepartment::Accounting;
4414 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4415 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4416
4417 let executive = PerpetratorDepartment::Executive;
4418 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4419 }
4420
4421 #[test]
4422 fn test_perpetrator_level() {
4423 let employee = PerpetratorLevel::Employee;
4424 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4425 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4426
4427 let exec = PerpetratorLevel::OwnerExecutive;
4428 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4429 }
4430
4431 #[test]
4432 fn test_acfe_calibration() {
4433 let cal = AcfeCalibration::default();
4434 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4435 assert_eq!(cal.median_duration_months, 12);
4436 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4437 assert!(cal.validate().is_ok());
4438
4439 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4441 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4442 assert_eq!(custom_cal.median_duration_months, 18);
4443
4444 let bad_cal = AcfeCalibration {
4446 collusion_rate: 1.5,
4447 ..Default::default()
4448 };
4449 assert!(bad_cal.validate().is_err());
4450 }
4451
4452 #[test]
4453 fn test_fraud_triangle() {
4454 let triangle = FraudTriangle::new(
4455 PressureType::FinancialTargets,
4456 vec![
4457 OpportunityFactor::WeakInternalControls,
4458 OpportunityFactor::ManagementOverride,
4459 ],
4460 Rationalization::ForTheCompanyGood,
4461 );
4462
4463 let risk = triangle.risk_score();
4465 assert!((0.0..=1.0).contains(&risk));
4466 assert!(risk > 0.5);
4468 }
4469
4470 #[test]
4471 fn test_pressure_types() {
4472 let financial = PressureType::FinancialTargets;
4473 assert!(financial.risk_weight() > 0.5);
4474
4475 let gambling = PressureType::GamblingAddiction;
4476 assert_eq!(gambling.risk_weight(), 0.90);
4477 }
4478
4479 #[test]
4480 fn test_opportunity_factors() {
4481 let override_factor = OpportunityFactor::ManagementOverride;
4482 assert_eq!(override_factor.risk_weight(), 0.90);
4483
4484 let weak_controls = OpportunityFactor::WeakInternalControls;
4485 assert!(weak_controls.risk_weight() > 0.8);
4486 }
4487
4488 #[test]
4489 fn test_rationalizations() {
4490 let entitlement = Rationalization::Entitlement;
4491 assert!(entitlement.risk_weight() > 0.8);
4492
4493 let borrowing = Rationalization::TemporaryBorrowing;
4494 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4495 }
4496
4497 #[test]
4498 fn test_acfe_scheme_serialization() {
4499 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4500 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4501 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4502 assert_eq!(scheme, deserialized);
4503
4504 let calibration = AcfeCalibration::default();
4505 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4506 let deserialized: AcfeCalibration =
4507 serde_json::from_str(&json).expect("Failed to deserialize");
4508 assert_eq!(calibration.median_loss, deserialized.median_loss);
4509 }
4510}