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