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 {:.2}", factor)
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", days_shifted)
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: {} and {}", duty1, 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!(
194 "Near-duplicate of {} (varied: {:?})",
195 original_doc_id, varied_fields
196 )
197 }
198 InjectionStrategy::CircularFlow { entity_chain } => {
199 format!("Circular flow through {} entities", entity_chain.len())
200 }
201 InjectionStrategy::SplitTransaction { split_count, .. } => {
202 format!("Split into {} transactions", split_count)
203 }
204 InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
205 InjectionStrategy::TimingManipulation { timing_type, .. } => {
206 format!("Timing manipulation: {}", timing_type)
207 }
208 InjectionStrategy::AccountMisclassification {
209 correct_account,
210 incorrect_account,
211 } => {
212 format!(
213 "Misclassified from {} to {}",
214 correct_account, incorrect_account
215 )
216 }
217 InjectionStrategy::MissingField { field_name } => {
218 format!("Missing required field: {}", field_name)
219 }
220 InjectionStrategy::Custom { name, .. } => format!("Custom: {}", name),
221 }
222 }
223
224 pub fn strategy_type(&self) -> &'static str {
226 match self {
227 InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
228 InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
229 InjectionStrategy::DateShift { .. } => "DateShift",
230 InjectionStrategy::SelfApproval { .. } => "SelfApproval",
231 InjectionStrategy::SoDViolation { .. } => "SoDViolation",
232 InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
233 InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
234 InjectionStrategy::CircularFlow { .. } => "CircularFlow",
235 InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
236 InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
237 InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
238 InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
239 InjectionStrategy::MissingField { .. } => "MissingField",
240 InjectionStrategy::Custom { .. } => "Custom",
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
247pub enum AnomalyType {
248 Fraud(FraudType),
250 Error(ErrorType),
252 ProcessIssue(ProcessIssueType),
254 Statistical(StatisticalAnomalyType),
256 Relational(RelationalAnomalyType),
258 Custom(String),
260}
261
262impl AnomalyType {
263 pub fn category(&self) -> &'static str {
265 match self {
266 AnomalyType::Fraud(_) => "Fraud",
267 AnomalyType::Error(_) => "Error",
268 AnomalyType::ProcessIssue(_) => "ProcessIssue",
269 AnomalyType::Statistical(_) => "Statistical",
270 AnomalyType::Relational(_) => "Relational",
271 AnomalyType::Custom(_) => "Custom",
272 }
273 }
274
275 pub fn type_name(&self) -> String {
277 match self {
278 AnomalyType::Fraud(t) => format!("{:?}", t),
279 AnomalyType::Error(t) => format!("{:?}", t),
280 AnomalyType::ProcessIssue(t) => format!("{:?}", t),
281 AnomalyType::Statistical(t) => format!("{:?}", t),
282 AnomalyType::Relational(t) => format!("{:?}", t),
283 AnomalyType::Custom(s) => s.clone(),
284 }
285 }
286
287 pub fn severity(&self) -> u8 {
289 match self {
290 AnomalyType::Fraud(t) => t.severity(),
291 AnomalyType::Error(t) => t.severity(),
292 AnomalyType::ProcessIssue(t) => t.severity(),
293 AnomalyType::Statistical(t) => t.severity(),
294 AnomalyType::Relational(t) => t.severity(),
295 AnomalyType::Custom(_) => 3,
296 }
297 }
298
299 pub fn is_intentional(&self) -> bool {
301 matches!(self, AnomalyType::Fraud(_))
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
307pub enum FraudType {
308 FictitiousEntry,
311 FictitiousTransaction,
313 RoundDollarManipulation,
315 JustBelowThreshold,
317 RevenueManipulation,
319 ImproperCapitalization,
321 ExpenseCapitalization,
323 ReserveManipulation,
325 SuspenseAccountAbuse,
327 SplitTransaction,
329 TimingAnomaly,
331 UnauthorizedAccess,
333
334 SelfApproval,
337 ExceededApprovalLimit,
339 SegregationOfDutiesViolation,
341 UnauthorizedApproval,
343 CollusiveApproval,
345
346 FictitiousVendor,
349 DuplicatePayment,
351 ShellCompanyPayment,
353 Kickback,
355 KickbackScheme,
357 InvoiceManipulation,
359
360 AssetMisappropriation,
363 InventoryTheft,
365 GhostEmployee,
367
368 PrematureRevenue,
371 UnderstatedLiabilities,
373 OverstatedAssets,
375 ChannelStuffing,
377
378 ImproperRevenueRecognition,
381 ImproperPoAllocation,
383 VariableConsiderationManipulation,
385 ContractModificationMisstatement,
387
388 LeaseClassificationManipulation,
391 OffBalanceSheetLease,
393 LeaseLiabilityUnderstatement,
395 RouAssetMisstatement,
397
398 FairValueHierarchyManipulation,
401 Level3InputManipulation,
403 ValuationTechniqueManipulation,
405
406 DelayedImpairment,
409 ImpairmentTestAvoidance,
411 CashFlowProjectionManipulation,
413 ImproperImpairmentReversal,
415}
416
417impl FraudType {
418 pub fn severity(&self) -> u8 {
420 match self {
421 FraudType::RoundDollarManipulation => 2,
422 FraudType::JustBelowThreshold => 3,
423 FraudType::SelfApproval => 3,
424 FraudType::ExceededApprovalLimit => 3,
425 FraudType::DuplicatePayment => 3,
426 FraudType::FictitiousEntry => 4,
427 FraudType::RevenueManipulation => 5,
428 FraudType::FictitiousVendor => 5,
429 FraudType::ShellCompanyPayment => 5,
430 FraudType::AssetMisappropriation => 5,
431 FraudType::SegregationOfDutiesViolation => 4,
432 FraudType::CollusiveApproval => 5,
433 FraudType::ImproperRevenueRecognition => 5,
435 FraudType::ImproperPoAllocation => 4,
436 FraudType::VariableConsiderationManipulation => 4,
437 FraudType::ContractModificationMisstatement => 3,
438 FraudType::LeaseClassificationManipulation => 4,
440 FraudType::OffBalanceSheetLease => 5,
441 FraudType::LeaseLiabilityUnderstatement => 4,
442 FraudType::RouAssetMisstatement => 3,
443 FraudType::FairValueHierarchyManipulation => 4,
445 FraudType::Level3InputManipulation => 5,
446 FraudType::ValuationTechniqueManipulation => 4,
447 FraudType::DelayedImpairment => 4,
449 FraudType::ImpairmentTestAvoidance => 4,
450 FraudType::CashFlowProjectionManipulation => 5,
451 FraudType::ImproperImpairmentReversal => 3,
452 _ => 4,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
459pub enum ErrorType {
460 DuplicateEntry,
463 ReversedAmount,
465 TransposedDigits,
467 DecimalError,
469 MissingField,
471 InvalidAccount,
473
474 WrongPeriod,
477 BackdatedEntry,
479 FutureDatedEntry,
481 CutoffError,
483
484 MisclassifiedAccount,
487 WrongCostCenter,
489 WrongCompanyCode,
491
492 UnbalancedEntry,
495 RoundingError,
497 CurrencyError,
499 TaxCalculationError,
501
502 RevenueTimingError,
505 PoAllocationError,
507 LeaseClassificationError,
509 LeaseCalculationError,
511 FairValueError,
513 ImpairmentCalculationError,
515 DiscountRateError,
517 FrameworkApplicationError,
519}
520
521impl ErrorType {
522 pub fn severity(&self) -> u8 {
524 match self {
525 ErrorType::RoundingError => 1,
526 ErrorType::MissingField => 2,
527 ErrorType::TransposedDigits => 2,
528 ErrorType::DecimalError => 3,
529 ErrorType::DuplicateEntry => 3,
530 ErrorType::ReversedAmount => 3,
531 ErrorType::WrongPeriod => 4,
532 ErrorType::UnbalancedEntry => 5,
533 ErrorType::CurrencyError => 4,
534 ErrorType::RevenueTimingError => 4,
536 ErrorType::PoAllocationError => 3,
537 ErrorType::LeaseClassificationError => 3,
538 ErrorType::LeaseCalculationError => 3,
539 ErrorType::FairValueError => 4,
540 ErrorType::ImpairmentCalculationError => 4,
541 ErrorType::DiscountRateError => 3,
542 ErrorType::FrameworkApplicationError => 4,
543 _ => 3,
544 }
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
550pub enum ProcessIssueType {
551 SkippedApproval,
554 LateApproval,
556 MissingDocumentation,
558 IncompleteApprovalChain,
560
561 LatePosting,
564 AfterHoursPosting,
566 WeekendPosting,
568 RushedPeriodEnd,
570
571 ManualOverride,
574 UnusualAccess,
576 SystemBypass,
578 BatchAnomaly,
580
581 VagueDescription,
584 PostFactoChange,
586 IncompleteAuditTrail,
588}
589
590impl ProcessIssueType {
591 pub fn severity(&self) -> u8 {
593 match self {
594 ProcessIssueType::VagueDescription => 1,
595 ProcessIssueType::LatePosting => 2,
596 ProcessIssueType::AfterHoursPosting => 2,
597 ProcessIssueType::WeekendPosting => 2,
598 ProcessIssueType::SkippedApproval => 4,
599 ProcessIssueType::ManualOverride => 4,
600 ProcessIssueType::SystemBypass => 5,
601 ProcessIssueType::IncompleteAuditTrail => 4,
602 _ => 3,
603 }
604 }
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
609pub enum StatisticalAnomalyType {
610 UnusuallyHighAmount,
613 UnusuallyLowAmount,
615 BenfordViolation,
617 ExactDuplicateAmount,
619 RepeatingAmount,
621
622 UnusualFrequency,
625 TransactionBurst,
627 UnusualTiming,
629
630 TrendBreak,
633 LevelShift,
635 SeasonalAnomaly,
637
638 StatisticalOutlier,
641 VarianceChange,
643 DistributionShift,
645}
646
647impl StatisticalAnomalyType {
648 pub fn severity(&self) -> u8 {
650 match self {
651 StatisticalAnomalyType::UnusualTiming => 1,
652 StatisticalAnomalyType::UnusualFrequency => 2,
653 StatisticalAnomalyType::BenfordViolation => 2,
654 StatisticalAnomalyType::UnusuallyHighAmount => 3,
655 StatisticalAnomalyType::TrendBreak => 3,
656 StatisticalAnomalyType::TransactionBurst => 4,
657 StatisticalAnomalyType::ExactDuplicateAmount => 3,
658 _ => 3,
659 }
660 }
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
665pub enum RelationalAnomalyType {
666 CircularTransaction,
669 UnusualAccountPair,
671 NewCounterparty,
673 DormantAccountActivity,
675
676 CentralityAnomaly,
679 IsolatedCluster,
681 BridgeNodeAnomaly,
683 CommunityAnomaly,
685
686 MissingRelationship,
689 UnexpectedRelationship,
691 RelationshipStrengthChange,
693
694 UnmatchedIntercompany,
697 CircularIntercompany,
699 TransferPricingAnomaly,
701}
702
703impl RelationalAnomalyType {
704 pub fn severity(&self) -> u8 {
706 match self {
707 RelationalAnomalyType::NewCounterparty => 1,
708 RelationalAnomalyType::DormantAccountActivity => 2,
709 RelationalAnomalyType::UnusualAccountPair => 2,
710 RelationalAnomalyType::CircularTransaction => 4,
711 RelationalAnomalyType::CircularIntercompany => 4,
712 RelationalAnomalyType::TransferPricingAnomaly => 4,
713 RelationalAnomalyType::UnmatchedIntercompany => 3,
714 _ => 3,
715 }
716 }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct LabeledAnomaly {
722 pub anomaly_id: String,
724 pub anomaly_type: AnomalyType,
726 pub document_id: String,
728 pub document_type: String,
730 pub company_code: String,
732 pub anomaly_date: NaiveDate,
734 pub detection_timestamp: NaiveDateTime,
736 pub confidence: f64,
738 pub severity: u8,
740 pub description: String,
742 pub related_entities: Vec<String>,
744 pub monetary_impact: Option<Decimal>,
746 pub metadata: HashMap<String, String>,
748 pub is_injected: bool,
750 pub injection_strategy: Option<String>,
752 pub cluster_id: Option<String>,
754
755 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub original_document_hash: Option<String>,
762
763 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub causal_reason: Option<AnomalyCausalReason>,
767
768 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub structured_strategy: Option<InjectionStrategy>,
772
773 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub parent_anomaly_id: Option<String>,
777
778 #[serde(default, skip_serializing_if = "Vec::is_empty")]
780 pub child_anomaly_ids: Vec<String>,
781
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub scenario_id: Option<String>,
785
786 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub run_id: Option<String>,
790
791 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub generation_seed: Option<u64>,
795}
796
797impl LabeledAnomaly {
798 pub fn new(
800 anomaly_id: String,
801 anomaly_type: AnomalyType,
802 document_id: String,
803 document_type: String,
804 company_code: String,
805 anomaly_date: NaiveDate,
806 ) -> Self {
807 let severity = anomaly_type.severity();
808 let description = format!(
809 "{} - {} in document {}",
810 anomaly_type.category(),
811 anomaly_type.type_name(),
812 document_id
813 );
814
815 Self {
816 anomaly_id,
817 anomaly_type,
818 document_id,
819 document_type,
820 company_code,
821 anomaly_date,
822 detection_timestamp: chrono::Local::now().naive_local(),
823 confidence: 1.0,
824 severity,
825 description,
826 related_entities: Vec::new(),
827 monetary_impact: None,
828 metadata: HashMap::new(),
829 is_injected: true,
830 injection_strategy: None,
831 cluster_id: None,
832 original_document_hash: None,
834 causal_reason: None,
835 structured_strategy: None,
836 parent_anomaly_id: None,
837 child_anomaly_ids: Vec::new(),
838 scenario_id: None,
839 run_id: None,
840 generation_seed: None,
841 }
842 }
843
844 pub fn with_description(mut self, description: &str) -> Self {
846 self.description = description.to_string();
847 self
848 }
849
850 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
852 self.monetary_impact = Some(impact);
853 self
854 }
855
856 pub fn with_related_entity(mut self, entity: &str) -> Self {
858 self.related_entities.push(entity.to_string());
859 self
860 }
861
862 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
864 self.metadata.insert(key.to_string(), value.to_string());
865 self
866 }
867
868 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
870 self.injection_strategy = Some(strategy.to_string());
871 self
872 }
873
874 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
876 self.cluster_id = Some(cluster_id.to_string());
877 self
878 }
879
880 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
886 self.original_document_hash = Some(hash.to_string());
887 self
888 }
889
890 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
892 self.causal_reason = Some(reason);
893 self
894 }
895
896 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
898 self.injection_strategy = Some(strategy.strategy_type().to_string());
900 self.structured_strategy = Some(strategy);
901 self
902 }
903
904 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
906 self.parent_anomaly_id = Some(parent_id.to_string());
907 self
908 }
909
910 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
912 self.child_anomaly_ids.push(child_id.to_string());
913 self
914 }
915
916 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
918 self.scenario_id = Some(scenario_id.to_string());
919 self
920 }
921
922 pub fn with_run_id(mut self, run_id: &str) -> Self {
924 self.run_id = Some(run_id.to_string());
925 self
926 }
927
928 pub fn with_generation_seed(mut self, seed: u64) -> Self {
930 self.generation_seed = Some(seed);
931 self
932 }
933
934 pub fn with_provenance(
936 mut self,
937 run_id: Option<&str>,
938 seed: Option<u64>,
939 causal_reason: Option<AnomalyCausalReason>,
940 ) -> Self {
941 if let Some(id) = run_id {
942 self.run_id = Some(id.to_string());
943 }
944 self.generation_seed = seed;
945 self.causal_reason = causal_reason;
946 self
947 }
948
949 pub fn to_features(&self) -> Vec<f64> {
963 let mut features = Vec::new();
964
965 let categories = [
967 "Fraud",
968 "Error",
969 "ProcessIssue",
970 "Statistical",
971 "Relational",
972 "Custom",
973 ];
974 for cat in &categories {
975 features.push(if self.anomaly_type.category() == *cat {
976 1.0
977 } else {
978 0.0
979 });
980 }
981
982 features.push(self.severity as f64 / 5.0);
984
985 features.push(self.confidence);
987
988 features.push(if self.monetary_impact.is_some() {
990 1.0
991 } else {
992 0.0
993 });
994
995 if let Some(impact) = self.monetary_impact {
997 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
998 features.push((impact_f64.abs() + 1.0).ln());
999 } else {
1000 features.push(0.0);
1001 }
1002
1003 features.push(if self.anomaly_type.is_intentional() {
1005 1.0
1006 } else {
1007 0.0
1008 });
1009
1010 features.push(self.related_entities.len() as f64);
1012
1013 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1015
1016 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1019
1020 features.push(if self.parent_anomaly_id.is_some() {
1022 1.0
1023 } else {
1024 0.0
1025 });
1026
1027 features
1028 }
1029
1030 pub fn feature_count() -> usize {
1032 15 }
1034
1035 pub fn feature_names() -> Vec<&'static str> {
1037 vec![
1038 "category_fraud",
1039 "category_error",
1040 "category_process_issue",
1041 "category_statistical",
1042 "category_relational",
1043 "category_custom",
1044 "severity_normalized",
1045 "confidence",
1046 "has_monetary_impact",
1047 "monetary_impact_log",
1048 "is_intentional",
1049 "related_entity_count",
1050 "is_clustered",
1051 "is_scenario_part",
1052 "is_derived",
1053 ]
1054 }
1055}
1056
1057#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1059pub struct AnomalySummary {
1060 pub total_count: usize,
1062 pub by_category: HashMap<String, usize>,
1064 pub by_type: HashMap<String, usize>,
1066 pub by_severity: HashMap<u8, usize>,
1068 pub by_company: HashMap<String, usize>,
1070 pub total_monetary_impact: Decimal,
1072 pub date_range: Option<(NaiveDate, NaiveDate)>,
1074 pub cluster_count: usize,
1076}
1077
1078impl AnomalySummary {
1079 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1081 let mut summary = AnomalySummary {
1082 total_count: anomalies.len(),
1083 ..Default::default()
1084 };
1085
1086 let mut min_date: Option<NaiveDate> = None;
1087 let mut max_date: Option<NaiveDate> = None;
1088 let mut clusters = std::collections::HashSet::new();
1089
1090 for anomaly in anomalies {
1091 *summary
1093 .by_category
1094 .entry(anomaly.anomaly_type.category().to_string())
1095 .or_insert(0) += 1;
1096
1097 *summary
1099 .by_type
1100 .entry(anomaly.anomaly_type.type_name())
1101 .or_insert(0) += 1;
1102
1103 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1105
1106 *summary
1108 .by_company
1109 .entry(anomaly.company_code.clone())
1110 .or_insert(0) += 1;
1111
1112 if let Some(impact) = anomaly.monetary_impact {
1114 summary.total_monetary_impact += impact;
1115 }
1116
1117 match min_date {
1119 None => min_date = Some(anomaly.anomaly_date),
1120 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1121 _ => {}
1122 }
1123 match max_date {
1124 None => max_date = Some(anomaly.anomaly_date),
1125 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1126 _ => {}
1127 }
1128
1129 if let Some(cluster_id) = &anomaly.cluster_id {
1131 clusters.insert(cluster_id.clone());
1132 }
1133 }
1134
1135 summary.date_range = min_date.zip(max_date);
1136 summary.cluster_count = clusters.len();
1137
1138 summary
1139 }
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1151pub enum AnomalyCategory {
1152 FictitiousVendor,
1155 VendorKickback,
1157 RelatedPartyVendor,
1159
1160 DuplicatePayment,
1163 UnauthorizedTransaction,
1165 StructuredTransaction,
1167
1168 CircularFlow,
1171 BehavioralAnomaly,
1173 TimingAnomaly,
1175
1176 JournalAnomaly,
1179 ManualOverride,
1181 MissingApproval,
1183
1184 StatisticalOutlier,
1187 DistributionAnomaly,
1189
1190 Custom(String),
1193}
1194
1195impl AnomalyCategory {
1196 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1198 match anomaly_type {
1199 AnomalyType::Fraud(fraud_type) => match fraud_type {
1200 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1201 AnomalyCategory::FictitiousVendor
1202 }
1203 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1204 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1205 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1206 AnomalyCategory::StructuredTransaction
1207 }
1208 FraudType::SelfApproval
1209 | FraudType::UnauthorizedApproval
1210 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1211 FraudType::TimingAnomaly
1212 | FraudType::RoundDollarManipulation
1213 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1214 _ => AnomalyCategory::BehavioralAnomaly,
1215 },
1216 AnomalyType::Error(error_type) => match error_type {
1217 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1218 ErrorType::WrongPeriod
1219 | ErrorType::BackdatedEntry
1220 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1221 _ => AnomalyCategory::JournalAnomaly,
1222 },
1223 AnomalyType::ProcessIssue(process_type) => match process_type {
1224 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1225 AnomalyCategory::MissingApproval
1226 }
1227 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1228 AnomalyCategory::ManualOverride
1229 }
1230 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1231 AnomalyCategory::TimingAnomaly
1232 }
1233 _ => AnomalyCategory::BehavioralAnomaly,
1234 },
1235 AnomalyType::Statistical(stat_type) => match stat_type {
1236 StatisticalAnomalyType::BenfordViolation
1237 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1238 _ => AnomalyCategory::StatisticalOutlier,
1239 },
1240 AnomalyType::Relational(rel_type) => match rel_type {
1241 RelationalAnomalyType::CircularTransaction
1242 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1243 _ => AnomalyCategory::BehavioralAnomaly,
1244 },
1245 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1246 }
1247 }
1248
1249 pub fn name(&self) -> &str {
1251 match self {
1252 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1253 AnomalyCategory::VendorKickback => "vendor_kickback",
1254 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1255 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1256 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1257 AnomalyCategory::StructuredTransaction => "structured_transaction",
1258 AnomalyCategory::CircularFlow => "circular_flow",
1259 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1260 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1261 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1262 AnomalyCategory::ManualOverride => "manual_override",
1263 AnomalyCategory::MissingApproval => "missing_approval",
1264 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1265 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1266 AnomalyCategory::Custom(s) => s.as_str(),
1267 }
1268 }
1269
1270 pub fn ordinal(&self) -> u8 {
1272 match self {
1273 AnomalyCategory::FictitiousVendor => 0,
1274 AnomalyCategory::VendorKickback => 1,
1275 AnomalyCategory::RelatedPartyVendor => 2,
1276 AnomalyCategory::DuplicatePayment => 3,
1277 AnomalyCategory::UnauthorizedTransaction => 4,
1278 AnomalyCategory::StructuredTransaction => 5,
1279 AnomalyCategory::CircularFlow => 6,
1280 AnomalyCategory::BehavioralAnomaly => 7,
1281 AnomalyCategory::TimingAnomaly => 8,
1282 AnomalyCategory::JournalAnomaly => 9,
1283 AnomalyCategory::ManualOverride => 10,
1284 AnomalyCategory::MissingApproval => 11,
1285 AnomalyCategory::StatisticalOutlier => 12,
1286 AnomalyCategory::DistributionAnomaly => 13,
1287 AnomalyCategory::Custom(_) => 14,
1288 }
1289 }
1290
1291 pub fn category_count() -> usize {
1293 15 }
1295}
1296
1297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1299pub enum FactorType {
1300 AmountDeviation,
1302 ThresholdProximity,
1304 TimingAnomaly,
1306 EntityRisk,
1308 PatternMatch,
1310 FrequencyDeviation,
1312 RelationshipAnomaly,
1314 ControlBypass,
1316 BenfordViolation,
1318 DuplicateIndicator,
1320 ApprovalChainIssue,
1322 DocumentationGap,
1324 Custom,
1326}
1327
1328impl FactorType {
1329 pub fn name(&self) -> &'static str {
1331 match self {
1332 FactorType::AmountDeviation => "amount_deviation",
1333 FactorType::ThresholdProximity => "threshold_proximity",
1334 FactorType::TimingAnomaly => "timing_anomaly",
1335 FactorType::EntityRisk => "entity_risk",
1336 FactorType::PatternMatch => "pattern_match",
1337 FactorType::FrequencyDeviation => "frequency_deviation",
1338 FactorType::RelationshipAnomaly => "relationship_anomaly",
1339 FactorType::ControlBypass => "control_bypass",
1340 FactorType::BenfordViolation => "benford_violation",
1341 FactorType::DuplicateIndicator => "duplicate_indicator",
1342 FactorType::ApprovalChainIssue => "approval_chain_issue",
1343 FactorType::DocumentationGap => "documentation_gap",
1344 FactorType::Custom => "custom",
1345 }
1346 }
1347}
1348
1349#[derive(Debug, Clone, Serialize, Deserialize)]
1351pub struct FactorEvidence {
1352 pub source: String,
1354 pub data: HashMap<String, String>,
1356}
1357
1358#[derive(Debug, Clone, Serialize, Deserialize)]
1360pub struct ContributingFactor {
1361 pub factor_type: FactorType,
1363 pub value: f64,
1365 pub threshold: f64,
1367 pub direction_greater: bool,
1369 pub weight: f64,
1371 pub description: String,
1373 pub evidence: Option<FactorEvidence>,
1375}
1376
1377impl ContributingFactor {
1378 pub fn new(
1380 factor_type: FactorType,
1381 value: f64,
1382 threshold: f64,
1383 direction_greater: bool,
1384 weight: f64,
1385 description: &str,
1386 ) -> Self {
1387 Self {
1388 factor_type,
1389 value,
1390 threshold,
1391 direction_greater,
1392 weight,
1393 description: description.to_string(),
1394 evidence: None,
1395 }
1396 }
1397
1398 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1400 self.evidence = Some(FactorEvidence {
1401 source: source.to_string(),
1402 data,
1403 });
1404 self
1405 }
1406
1407 pub fn contribution(&self) -> f64 {
1409 let deviation = if self.direction_greater {
1410 (self.value - self.threshold).max(0.0)
1411 } else {
1412 (self.threshold - self.value).max(0.0)
1413 };
1414
1415 let relative_deviation = if self.threshold.abs() > 0.001 {
1417 deviation / self.threshold.abs()
1418 } else {
1419 deviation
1420 };
1421
1422 (relative_deviation * self.weight).min(1.0)
1424 }
1425}
1426
1427#[derive(Debug, Clone, Serialize, Deserialize)]
1429pub struct EnhancedAnomalyLabel {
1430 pub base: LabeledAnomaly,
1432 pub category: AnomalyCategory,
1434 pub enhanced_confidence: f64,
1436 pub enhanced_severity: f64,
1438 pub contributing_factors: Vec<ContributingFactor>,
1440 pub secondary_categories: Vec<AnomalyCategory>,
1442}
1443
1444impl EnhancedAnomalyLabel {
1445 pub fn from_base(base: LabeledAnomaly) -> Self {
1447 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1448 let enhanced_confidence = base.confidence;
1449 let enhanced_severity = base.severity as f64 / 5.0;
1450
1451 Self {
1452 base,
1453 category,
1454 enhanced_confidence,
1455 enhanced_severity,
1456 contributing_factors: Vec::new(),
1457 secondary_categories: Vec::new(),
1458 }
1459 }
1460
1461 pub fn with_confidence(mut self, confidence: f64) -> Self {
1463 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1464 self
1465 }
1466
1467 pub fn with_severity(mut self, severity: f64) -> Self {
1469 self.enhanced_severity = severity.clamp(0.0, 1.0);
1470 self
1471 }
1472
1473 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1475 self.contributing_factors.push(factor);
1476 self
1477 }
1478
1479 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1481 if !self.secondary_categories.contains(&category) && category != self.category {
1482 self.secondary_categories.push(category);
1483 }
1484 self
1485 }
1486
1487 pub fn to_features(&self) -> Vec<f64> {
1491 let mut features = self.base.to_features();
1492
1493 features.push(self.enhanced_confidence);
1495 features.push(self.enhanced_severity);
1496 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1497 features.push(self.secondary_categories.len() as f64);
1498 features.push(self.contributing_factors.len() as f64);
1499
1500 let max_weight = self
1502 .contributing_factors
1503 .iter()
1504 .map(|f| f.weight)
1505 .fold(0.0, f64::max);
1506 features.push(max_weight);
1507
1508 let has_control_bypass = self
1510 .contributing_factors
1511 .iter()
1512 .any(|f| f.factor_type == FactorType::ControlBypass);
1513 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1514
1515 let has_amount_deviation = self
1516 .contributing_factors
1517 .iter()
1518 .any(|f| f.factor_type == FactorType::AmountDeviation);
1519 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1520
1521 let has_timing = self
1522 .contributing_factors
1523 .iter()
1524 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1525 features.push(if has_timing { 1.0 } else { 0.0 });
1526
1527 let has_pattern_match = self
1528 .contributing_factors
1529 .iter()
1530 .any(|f| f.factor_type == FactorType::PatternMatch);
1531 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1532
1533 features
1534 }
1535
1536 pub fn feature_count() -> usize {
1538 25 }
1540
1541 pub fn feature_names() -> Vec<&'static str> {
1543 let mut names = LabeledAnomaly::feature_names();
1544 names.extend(vec![
1545 "enhanced_confidence",
1546 "enhanced_severity",
1547 "category_ordinal",
1548 "secondary_category_count",
1549 "contributing_factor_count",
1550 "max_factor_weight",
1551 "has_control_bypass",
1552 "has_amount_deviation",
1553 "has_timing_factor",
1554 "has_pattern_match",
1555 ]);
1556 names
1557 }
1558}
1559
1560#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1566pub enum SeverityLevel {
1567 Low,
1569 #[default]
1571 Medium,
1572 High,
1574 Critical,
1576}
1577
1578impl SeverityLevel {
1579 pub fn numeric(&self) -> u8 {
1581 match self {
1582 SeverityLevel::Low => 1,
1583 SeverityLevel::Medium => 2,
1584 SeverityLevel::High => 3,
1585 SeverityLevel::Critical => 4,
1586 }
1587 }
1588
1589 pub fn from_numeric(value: u8) -> Self {
1591 match value {
1592 1 => SeverityLevel::Low,
1593 2 => SeverityLevel::Medium,
1594 3 => SeverityLevel::High,
1595 _ => SeverityLevel::Critical,
1596 }
1597 }
1598
1599 pub fn from_score(score: f64) -> Self {
1601 match score {
1602 s if s < 0.25 => SeverityLevel::Low,
1603 s if s < 0.50 => SeverityLevel::Medium,
1604 s if s < 0.75 => SeverityLevel::High,
1605 _ => SeverityLevel::Critical,
1606 }
1607 }
1608
1609 pub fn to_score(&self) -> f64 {
1611 match self {
1612 SeverityLevel::Low => 0.125,
1613 SeverityLevel::Medium => 0.375,
1614 SeverityLevel::High => 0.625,
1615 SeverityLevel::Critical => 0.875,
1616 }
1617 }
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize)]
1622pub struct AnomalySeverity {
1623 pub level: SeverityLevel,
1625 pub score: f64,
1627 pub financial_impact: Decimal,
1629 pub is_material: bool,
1631 #[serde(default, skip_serializing_if = "Option::is_none")]
1633 pub materiality_threshold: Option<Decimal>,
1634}
1635
1636impl AnomalySeverity {
1637 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1639 Self {
1640 level,
1641 score: level.to_score(),
1642 financial_impact,
1643 is_material: false,
1644 materiality_threshold: None,
1645 }
1646 }
1647
1648 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1650 Self {
1651 level: SeverityLevel::from_score(score),
1652 score: score.clamp(0.0, 1.0),
1653 financial_impact,
1654 is_material: false,
1655 materiality_threshold: None,
1656 }
1657 }
1658
1659 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1661 self.materiality_threshold = Some(threshold);
1662 self.is_material = self.financial_impact.abs() >= threshold;
1663 self
1664 }
1665}
1666
1667impl Default for AnomalySeverity {
1668 fn default() -> Self {
1669 Self {
1670 level: SeverityLevel::Medium,
1671 score: 0.5,
1672 financial_impact: Decimal::ZERO,
1673 is_material: false,
1674 materiality_threshold: None,
1675 }
1676 }
1677}
1678
1679#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1687pub enum AnomalyDetectionDifficulty {
1688 Trivial,
1690 Easy,
1692 #[default]
1694 Moderate,
1695 Hard,
1697 Expert,
1699}
1700
1701impl AnomalyDetectionDifficulty {
1702 pub fn expected_detection_rate(&self) -> f64 {
1704 match self {
1705 AnomalyDetectionDifficulty::Trivial => 0.99,
1706 AnomalyDetectionDifficulty::Easy => 0.90,
1707 AnomalyDetectionDifficulty::Moderate => 0.70,
1708 AnomalyDetectionDifficulty::Hard => 0.40,
1709 AnomalyDetectionDifficulty::Expert => 0.15,
1710 }
1711 }
1712
1713 pub fn difficulty_score(&self) -> f64 {
1715 match self {
1716 AnomalyDetectionDifficulty::Trivial => 0.05,
1717 AnomalyDetectionDifficulty::Easy => 0.25,
1718 AnomalyDetectionDifficulty::Moderate => 0.50,
1719 AnomalyDetectionDifficulty::Hard => 0.75,
1720 AnomalyDetectionDifficulty::Expert => 0.95,
1721 }
1722 }
1723
1724 pub fn from_score(score: f64) -> Self {
1726 match score {
1727 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1728 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1729 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1730 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1731 _ => AnomalyDetectionDifficulty::Expert,
1732 }
1733 }
1734
1735 pub fn name(&self) -> &'static str {
1737 match self {
1738 AnomalyDetectionDifficulty::Trivial => "trivial",
1739 AnomalyDetectionDifficulty::Easy => "easy",
1740 AnomalyDetectionDifficulty::Moderate => "moderate",
1741 AnomalyDetectionDifficulty::Hard => "hard",
1742 AnomalyDetectionDifficulty::Expert => "expert",
1743 }
1744 }
1745}
1746
1747#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1751pub enum GroundTruthCertainty {
1752 #[default]
1754 Definite,
1755 Probable,
1757 Possible,
1759}
1760
1761impl GroundTruthCertainty {
1762 pub fn certainty_score(&self) -> f64 {
1764 match self {
1765 GroundTruthCertainty::Definite => 1.0,
1766 GroundTruthCertainty::Probable => 0.8,
1767 GroundTruthCertainty::Possible => 0.5,
1768 }
1769 }
1770
1771 pub fn name(&self) -> &'static str {
1773 match self {
1774 GroundTruthCertainty::Definite => "definite",
1775 GroundTruthCertainty::Probable => "probable",
1776 GroundTruthCertainty::Possible => "possible",
1777 }
1778 }
1779}
1780
1781#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1785pub enum DetectionMethod {
1786 RuleBased,
1788 Statistical,
1790 MachineLearning,
1792 GraphBased,
1794 ForensicAudit,
1796 Hybrid,
1798}
1799
1800impl DetectionMethod {
1801 pub fn name(&self) -> &'static str {
1803 match self {
1804 DetectionMethod::RuleBased => "rule_based",
1805 DetectionMethod::Statistical => "statistical",
1806 DetectionMethod::MachineLearning => "machine_learning",
1807 DetectionMethod::GraphBased => "graph_based",
1808 DetectionMethod::ForensicAudit => "forensic_audit",
1809 DetectionMethod::Hybrid => "hybrid",
1810 }
1811 }
1812
1813 pub fn description(&self) -> &'static str {
1815 match self {
1816 DetectionMethod::RuleBased => "Simple threshold and filter rules",
1817 DetectionMethod::Statistical => "Statistical distribution analysis",
1818 DetectionMethod::MachineLearning => "ML classification models",
1819 DetectionMethod::GraphBased => "Network and relationship analysis",
1820 DetectionMethod::ForensicAudit => "Manual forensic procedures",
1821 DetectionMethod::Hybrid => "Combined multi-method approach",
1822 }
1823 }
1824}
1825
1826#[derive(Debug, Clone, Serialize, Deserialize)]
1831pub struct ExtendedAnomalyLabel {
1832 pub base: LabeledAnomaly,
1834 pub category: AnomalyCategory,
1836 pub severity: AnomalySeverity,
1838 pub detection_difficulty: AnomalyDetectionDifficulty,
1840 pub recommended_methods: Vec<DetectionMethod>,
1842 pub key_indicators: Vec<String>,
1844 pub ground_truth_certainty: GroundTruthCertainty,
1846 pub contributing_factors: Vec<ContributingFactor>,
1848 pub related_entity_ids: Vec<String>,
1850 pub secondary_categories: Vec<AnomalyCategory>,
1852 #[serde(default, skip_serializing_if = "Option::is_none")]
1854 pub scheme_id: Option<String>,
1855 #[serde(default, skip_serializing_if = "Option::is_none")]
1857 pub scheme_stage: Option<u32>,
1858 #[serde(default)]
1860 pub is_near_miss: bool,
1861 #[serde(default, skip_serializing_if = "Option::is_none")]
1863 pub near_miss_explanation: Option<String>,
1864}
1865
1866impl ExtendedAnomalyLabel {
1867 pub fn from_base(base: LabeledAnomaly) -> Self {
1869 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1870 let severity = AnomalySeverity {
1871 level: SeverityLevel::from_numeric(base.severity),
1872 score: base.severity as f64 / 5.0,
1873 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
1874 is_material: false,
1875 materiality_threshold: None,
1876 };
1877
1878 Self {
1879 base,
1880 category,
1881 severity,
1882 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
1883 recommended_methods: vec![DetectionMethod::RuleBased],
1884 key_indicators: Vec::new(),
1885 ground_truth_certainty: GroundTruthCertainty::Definite,
1886 contributing_factors: Vec::new(),
1887 related_entity_ids: Vec::new(),
1888 secondary_categories: Vec::new(),
1889 scheme_id: None,
1890 scheme_stage: None,
1891 is_near_miss: false,
1892 near_miss_explanation: None,
1893 }
1894 }
1895
1896 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
1898 self.severity = severity;
1899 self
1900 }
1901
1902 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
1904 self.detection_difficulty = difficulty;
1905 self
1906 }
1907
1908 pub fn with_method(mut self, method: DetectionMethod) -> Self {
1910 if !self.recommended_methods.contains(&method) {
1911 self.recommended_methods.push(method);
1912 }
1913 self
1914 }
1915
1916 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
1918 self.recommended_methods = methods;
1919 self
1920 }
1921
1922 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
1924 self.key_indicators.push(indicator.into());
1925 self
1926 }
1927
1928 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
1930 self.ground_truth_certainty = certainty;
1931 self
1932 }
1933
1934 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1936 self.contributing_factors.push(factor);
1937 self
1938 }
1939
1940 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
1942 self.related_entity_ids.push(entity_id.into());
1943 self
1944 }
1945
1946 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1948 if category != self.category && !self.secondary_categories.contains(&category) {
1949 self.secondary_categories.push(category);
1950 }
1951 self
1952 }
1953
1954 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
1956 self.scheme_id = Some(scheme_id.into());
1957 self.scheme_stage = Some(stage);
1958 self
1959 }
1960
1961 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
1963 self.is_near_miss = true;
1964 self.near_miss_explanation = Some(explanation.into());
1965 self
1966 }
1967
1968 pub fn to_features(&self) -> Vec<f64> {
1972 let mut features = self.base.to_features();
1973
1974 features.push(self.severity.score);
1976 features.push(self.severity.level.to_score());
1977 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
1978 features.push(self.detection_difficulty.difficulty_score());
1979 features.push(self.detection_difficulty.expected_detection_rate());
1980 features.push(self.ground_truth_certainty.certainty_score());
1981 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1982 features.push(self.secondary_categories.len() as f64);
1983 features.push(self.contributing_factors.len() as f64);
1984 features.push(self.key_indicators.len() as f64);
1985 features.push(self.recommended_methods.len() as f64);
1986 features.push(self.related_entity_ids.len() as f64);
1987 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
1988 features.push(self.scheme_stage.unwrap_or(0) as f64);
1989 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
1990
1991 features
1992 }
1993
1994 pub fn feature_count() -> usize {
1996 30 }
1998
1999 pub fn feature_names() -> Vec<&'static str> {
2001 let mut names = LabeledAnomaly::feature_names();
2002 names.extend(vec![
2003 "severity_score",
2004 "severity_level_score",
2005 "is_material",
2006 "difficulty_score",
2007 "expected_detection_rate",
2008 "ground_truth_certainty",
2009 "category_ordinal",
2010 "secondary_category_count",
2011 "contributing_factor_count",
2012 "key_indicator_count",
2013 "recommended_method_count",
2014 "related_entity_count",
2015 "is_part_of_scheme",
2016 "scheme_stage",
2017 "is_near_miss",
2018 ]);
2019 names
2020 }
2021}
2022
2023#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2029pub enum SchemeType {
2030 GradualEmbezzlement,
2032 RevenueManipulation,
2034 VendorKickback,
2036 RoundTripping,
2038 GhostEmployee,
2040 ExpenseReimbursement,
2042 InventoryTheft,
2044 Custom,
2046}
2047
2048impl SchemeType {
2049 pub fn name(&self) -> &'static str {
2051 match self {
2052 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2053 SchemeType::RevenueManipulation => "revenue_manipulation",
2054 SchemeType::VendorKickback => "vendor_kickback",
2055 SchemeType::RoundTripping => "round_tripping",
2056 SchemeType::GhostEmployee => "ghost_employee",
2057 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2058 SchemeType::InventoryTheft => "inventory_theft",
2059 SchemeType::Custom => "custom",
2060 }
2061 }
2062
2063 pub fn typical_stages(&self) -> u32 {
2065 match self {
2066 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2074 }
2075 }
2076}
2077
2078#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2080pub enum SchemeDetectionStatus {
2081 #[default]
2083 Undetected,
2084 UnderInvestigation,
2086 PartiallyDetected,
2088 FullyDetected,
2090}
2091
2092#[derive(Debug, Clone, Serialize, Deserialize)]
2094pub struct SchemeTransactionRef {
2095 pub document_id: String,
2097 pub date: chrono::NaiveDate,
2099 pub amount: Decimal,
2101 pub stage: u32,
2103 #[serde(default, skip_serializing_if = "Option::is_none")]
2105 pub anomaly_id: Option<String>,
2106}
2107
2108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2110pub enum ConcealmentTechnique {
2111 DocumentManipulation,
2113 ApprovalCircumvention,
2115 TimingExploitation,
2117 TransactionSplitting,
2119 AccountMisclassification,
2121 Collusion,
2123 DataAlteration,
2125 FalseDocumentation,
2127}
2128
2129impl ConcealmentTechnique {
2130 pub fn difficulty_bonus(&self) -> f64 {
2132 match self {
2133 ConcealmentTechnique::DocumentManipulation => 0.20,
2134 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2135 ConcealmentTechnique::TimingExploitation => 0.10,
2136 ConcealmentTechnique::TransactionSplitting => 0.15,
2137 ConcealmentTechnique::AccountMisclassification => 0.10,
2138 ConcealmentTechnique::Collusion => 0.25,
2139 ConcealmentTechnique::DataAlteration => 0.20,
2140 ConcealmentTechnique::FalseDocumentation => 0.15,
2141 }
2142 }
2143}
2144
2145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2162pub enum AcfeFraudCategory {
2163 #[default]
2166 AssetMisappropriation,
2167 Corruption,
2170 FinancialStatementFraud,
2173}
2174
2175impl AcfeFraudCategory {
2176 pub fn name(&self) -> &'static str {
2178 match self {
2179 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2180 AcfeFraudCategory::Corruption => "corruption",
2181 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2182 }
2183 }
2184
2185 pub fn typical_occurrence_rate(&self) -> f64 {
2187 match self {
2188 AcfeFraudCategory::AssetMisappropriation => 0.86,
2189 AcfeFraudCategory::Corruption => 0.33,
2190 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2191 }
2192 }
2193
2194 pub fn typical_median_loss(&self) -> Decimal {
2196 match self {
2197 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2198 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2199 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2200 }
2201 }
2202
2203 pub fn typical_detection_months(&self) -> u32 {
2205 match self {
2206 AcfeFraudCategory::AssetMisappropriation => 12,
2207 AcfeFraudCategory::Corruption => 18,
2208 AcfeFraudCategory::FinancialStatementFraud => 24,
2209 }
2210 }
2211}
2212
2213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2220pub enum CashFraudScheme {
2221 Larceny,
2224 Skimming,
2226
2227 SalesSkimming,
2230 ReceivablesSkimming,
2232 RefundSchemes,
2234
2235 ShellCompany,
2238 NonAccompliceVendor,
2240 PersonalPurchases,
2242
2243 GhostEmployee,
2246 FalsifiedWages,
2248 CommissionSchemes,
2250
2251 MischaracterizedExpenses,
2254 OverstatedExpenses,
2256 FictitiousExpenses,
2258
2259 ForgedMaker,
2262 ForgedEndorsement,
2264 AlteredPayee,
2266 AuthorizedMaker,
2268
2269 FalseVoids,
2272 FalseRefunds,
2274}
2275
2276impl CashFraudScheme {
2277 pub fn category(&self) -> AcfeFraudCategory {
2279 AcfeFraudCategory::AssetMisappropriation
2280 }
2281
2282 pub fn subcategory(&self) -> &'static str {
2284 match self {
2285 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2286 CashFraudScheme::SalesSkimming
2287 | CashFraudScheme::ReceivablesSkimming
2288 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2289 CashFraudScheme::ShellCompany
2290 | CashFraudScheme::NonAccompliceVendor
2291 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2292 CashFraudScheme::GhostEmployee
2293 | CashFraudScheme::FalsifiedWages
2294 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2295 CashFraudScheme::MischaracterizedExpenses
2296 | CashFraudScheme::OverstatedExpenses
2297 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2298 CashFraudScheme::ForgedMaker
2299 | CashFraudScheme::ForgedEndorsement
2300 | CashFraudScheme::AlteredPayee
2301 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2302 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2303 }
2304 }
2305
2306 pub fn severity(&self) -> u8 {
2308 match self {
2309 CashFraudScheme::FalseVoids
2311 | CashFraudScheme::FalseRefunds
2312 | CashFraudScheme::MischaracterizedExpenses => 3,
2313 CashFraudScheme::OverstatedExpenses
2315 | CashFraudScheme::Skimming
2316 | CashFraudScheme::Larceny
2317 | CashFraudScheme::PersonalPurchases
2318 | CashFraudScheme::FalsifiedWages => 4,
2319 CashFraudScheme::ShellCompany
2321 | CashFraudScheme::GhostEmployee
2322 | CashFraudScheme::FictitiousExpenses
2323 | CashFraudScheme::ForgedMaker
2324 | CashFraudScheme::AuthorizedMaker => 5,
2325 _ => 4,
2326 }
2327 }
2328
2329 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2331 match self {
2332 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2334 AnomalyDetectionDifficulty::Easy
2335 }
2336 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2338 AnomalyDetectionDifficulty::Moderate
2339 }
2340 CashFraudScheme::Skimming
2342 | CashFraudScheme::ShellCompany
2343 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2344 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2346 AnomalyDetectionDifficulty::Expert
2347 }
2348 _ => AnomalyDetectionDifficulty::Moderate,
2349 }
2350 }
2351
2352 pub fn all_variants() -> &'static [CashFraudScheme] {
2354 &[
2355 CashFraudScheme::Larceny,
2356 CashFraudScheme::Skimming,
2357 CashFraudScheme::SalesSkimming,
2358 CashFraudScheme::ReceivablesSkimming,
2359 CashFraudScheme::RefundSchemes,
2360 CashFraudScheme::ShellCompany,
2361 CashFraudScheme::NonAccompliceVendor,
2362 CashFraudScheme::PersonalPurchases,
2363 CashFraudScheme::GhostEmployee,
2364 CashFraudScheme::FalsifiedWages,
2365 CashFraudScheme::CommissionSchemes,
2366 CashFraudScheme::MischaracterizedExpenses,
2367 CashFraudScheme::OverstatedExpenses,
2368 CashFraudScheme::FictitiousExpenses,
2369 CashFraudScheme::ForgedMaker,
2370 CashFraudScheme::ForgedEndorsement,
2371 CashFraudScheme::AlteredPayee,
2372 CashFraudScheme::AuthorizedMaker,
2373 CashFraudScheme::FalseVoids,
2374 CashFraudScheme::FalseRefunds,
2375 ]
2376 }
2377}
2378
2379#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2381pub enum AssetFraudScheme {
2382 InventoryMisuse,
2385 InventoryTheft,
2387 InventoryPurchasingScheme,
2389 InventoryReceivingScheme,
2391
2392 EquipmentMisuse,
2395 EquipmentTheft,
2397 IntellectualPropertyTheft,
2399 TimeTheft,
2401}
2402
2403impl AssetFraudScheme {
2404 pub fn category(&self) -> AcfeFraudCategory {
2406 AcfeFraudCategory::AssetMisappropriation
2407 }
2408
2409 pub fn subcategory(&self) -> &'static str {
2411 match self {
2412 AssetFraudScheme::InventoryMisuse
2413 | AssetFraudScheme::InventoryTheft
2414 | AssetFraudScheme::InventoryPurchasingScheme
2415 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2416 _ => "other_assets",
2417 }
2418 }
2419
2420 pub fn severity(&self) -> u8 {
2422 match self {
2423 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2424 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2425 AssetFraudScheme::InventoryTheft
2426 | AssetFraudScheme::InventoryPurchasingScheme
2427 | AssetFraudScheme::InventoryReceivingScheme => 4,
2428 AssetFraudScheme::IntellectualPropertyTheft => 5,
2429 }
2430 }
2431}
2432
2433#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2438pub enum CorruptionScheme {
2439 PurchasingConflict,
2442 SalesConflict,
2444 OutsideBusinessInterest,
2446 NepotismConflict,
2448
2449 InvoiceKickback,
2452 BidRigging,
2454 CashBribery,
2456 PublicOfficial,
2458
2459 IllegalGratuity,
2462
2463 EconomicExtortion,
2466}
2467
2468impl CorruptionScheme {
2469 pub fn category(&self) -> AcfeFraudCategory {
2471 AcfeFraudCategory::Corruption
2472 }
2473
2474 pub fn subcategory(&self) -> &'static str {
2476 match self {
2477 CorruptionScheme::PurchasingConflict
2478 | CorruptionScheme::SalesConflict
2479 | CorruptionScheme::OutsideBusinessInterest
2480 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2481 CorruptionScheme::InvoiceKickback
2482 | CorruptionScheme::BidRigging
2483 | CorruptionScheme::CashBribery
2484 | CorruptionScheme::PublicOfficial => "bribery",
2485 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2486 CorruptionScheme::EconomicExtortion => "economic_extortion",
2487 }
2488 }
2489
2490 pub fn severity(&self) -> u8 {
2492 match self {
2493 CorruptionScheme::NepotismConflict => 3,
2495 CorruptionScheme::PurchasingConflict
2497 | CorruptionScheme::SalesConflict
2498 | CorruptionScheme::OutsideBusinessInterest
2499 | CorruptionScheme::IllegalGratuity => 4,
2500 CorruptionScheme::InvoiceKickback
2502 | CorruptionScheme::BidRigging
2503 | CorruptionScheme::CashBribery
2504 | CorruptionScheme::EconomicExtortion => 5,
2505 CorruptionScheme::PublicOfficial => 5,
2507 }
2508 }
2509
2510 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2512 match self {
2513 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2515 AnomalyDetectionDifficulty::Moderate
2516 }
2517 CorruptionScheme::PurchasingConflict
2519 | CorruptionScheme::SalesConflict
2520 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2521 CorruptionScheme::InvoiceKickback
2523 | CorruptionScheme::CashBribery
2524 | CorruptionScheme::PublicOfficial
2525 | CorruptionScheme::IllegalGratuity
2526 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2527 }
2528 }
2529
2530 pub fn all_variants() -> &'static [CorruptionScheme] {
2532 &[
2533 CorruptionScheme::PurchasingConflict,
2534 CorruptionScheme::SalesConflict,
2535 CorruptionScheme::OutsideBusinessInterest,
2536 CorruptionScheme::NepotismConflict,
2537 CorruptionScheme::InvoiceKickback,
2538 CorruptionScheme::BidRigging,
2539 CorruptionScheme::CashBribery,
2540 CorruptionScheme::PublicOfficial,
2541 CorruptionScheme::IllegalGratuity,
2542 CorruptionScheme::EconomicExtortion,
2543 ]
2544 }
2545}
2546
2547#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2552pub enum FinancialStatementScheme {
2553 PrematureRevenue,
2556 DelayedExpenses,
2558 FictitiousRevenues,
2560 ConcealedLiabilities,
2562 ImproperAssetValuations,
2564 ImproperDisclosures,
2566 ChannelStuffing,
2568 BillAndHold,
2570 ImproperCapitalization,
2572
2573 UnderstatedRevenues,
2576 OverstatedExpenses,
2578 OverstatedLiabilities,
2580 ImproperAssetWritedowns,
2582}
2583
2584impl FinancialStatementScheme {
2585 pub fn category(&self) -> AcfeFraudCategory {
2587 AcfeFraudCategory::FinancialStatementFraud
2588 }
2589
2590 pub fn subcategory(&self) -> &'static str {
2592 match self {
2593 FinancialStatementScheme::UnderstatedRevenues
2594 | FinancialStatementScheme::OverstatedExpenses
2595 | FinancialStatementScheme::OverstatedLiabilities
2596 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2597 _ => "overstatement",
2598 }
2599 }
2600
2601 pub fn severity(&self) -> u8 {
2603 5
2605 }
2606
2607 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2609 match self {
2610 FinancialStatementScheme::ChannelStuffing
2612 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2613 FinancialStatementScheme::PrematureRevenue
2615 | FinancialStatementScheme::ImproperCapitalization
2616 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2617 FinancialStatementScheme::FictitiousRevenues
2619 | FinancialStatementScheme::ConcealedLiabilities
2620 | FinancialStatementScheme::ImproperAssetValuations
2621 | FinancialStatementScheme::ImproperDisclosures
2622 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2623 _ => AnomalyDetectionDifficulty::Hard,
2624 }
2625 }
2626
2627 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2629 &[
2630 FinancialStatementScheme::PrematureRevenue,
2631 FinancialStatementScheme::DelayedExpenses,
2632 FinancialStatementScheme::FictitiousRevenues,
2633 FinancialStatementScheme::ConcealedLiabilities,
2634 FinancialStatementScheme::ImproperAssetValuations,
2635 FinancialStatementScheme::ImproperDisclosures,
2636 FinancialStatementScheme::ChannelStuffing,
2637 FinancialStatementScheme::BillAndHold,
2638 FinancialStatementScheme::ImproperCapitalization,
2639 FinancialStatementScheme::UnderstatedRevenues,
2640 FinancialStatementScheme::OverstatedExpenses,
2641 FinancialStatementScheme::OverstatedLiabilities,
2642 FinancialStatementScheme::ImproperAssetWritedowns,
2643 ]
2644 }
2645}
2646
2647#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2649pub enum AcfeScheme {
2650 Cash(CashFraudScheme),
2652 Asset(AssetFraudScheme),
2654 Corruption(CorruptionScheme),
2656 FinancialStatement(FinancialStatementScheme),
2658}
2659
2660impl AcfeScheme {
2661 pub fn category(&self) -> AcfeFraudCategory {
2663 match self {
2664 AcfeScheme::Cash(s) => s.category(),
2665 AcfeScheme::Asset(s) => s.category(),
2666 AcfeScheme::Corruption(s) => s.category(),
2667 AcfeScheme::FinancialStatement(s) => s.category(),
2668 }
2669 }
2670
2671 pub fn severity(&self) -> u8 {
2673 match self {
2674 AcfeScheme::Cash(s) => s.severity(),
2675 AcfeScheme::Asset(s) => s.severity(),
2676 AcfeScheme::Corruption(s) => s.severity(),
2677 AcfeScheme::FinancialStatement(s) => s.severity(),
2678 }
2679 }
2680
2681 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2683 match self {
2684 AcfeScheme::Cash(s) => s.detection_difficulty(),
2685 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2686 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2687 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2688 }
2689 }
2690}
2691
2692#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2694pub enum AcfeDetectionMethod {
2695 Tip,
2697 InternalAudit,
2699 ManagementReview,
2701 ExternalAudit,
2703 AccountReconciliation,
2705 DocumentExamination,
2707 ByAccident,
2709 ItControls,
2711 Surveillance,
2713 Confession,
2715 LawEnforcement,
2717 Other,
2719}
2720
2721impl AcfeDetectionMethod {
2722 pub fn typical_detection_rate(&self) -> f64 {
2724 match self {
2725 AcfeDetectionMethod::Tip => 0.42,
2726 AcfeDetectionMethod::InternalAudit => 0.16,
2727 AcfeDetectionMethod::ManagementReview => 0.12,
2728 AcfeDetectionMethod::ExternalAudit => 0.04,
2729 AcfeDetectionMethod::AccountReconciliation => 0.05,
2730 AcfeDetectionMethod::DocumentExamination => 0.04,
2731 AcfeDetectionMethod::ByAccident => 0.06,
2732 AcfeDetectionMethod::ItControls => 0.03,
2733 AcfeDetectionMethod::Surveillance => 0.02,
2734 AcfeDetectionMethod::Confession => 0.02,
2735 AcfeDetectionMethod::LawEnforcement => 0.01,
2736 AcfeDetectionMethod::Other => 0.03,
2737 }
2738 }
2739
2740 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2742 &[
2743 AcfeDetectionMethod::Tip,
2744 AcfeDetectionMethod::InternalAudit,
2745 AcfeDetectionMethod::ManagementReview,
2746 AcfeDetectionMethod::ExternalAudit,
2747 AcfeDetectionMethod::AccountReconciliation,
2748 AcfeDetectionMethod::DocumentExamination,
2749 AcfeDetectionMethod::ByAccident,
2750 AcfeDetectionMethod::ItControls,
2751 AcfeDetectionMethod::Surveillance,
2752 AcfeDetectionMethod::Confession,
2753 AcfeDetectionMethod::LawEnforcement,
2754 AcfeDetectionMethod::Other,
2755 ]
2756 }
2757}
2758
2759#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2761pub enum PerpetratorDepartment {
2762 Accounting,
2764 Operations,
2766 Executive,
2768 Sales,
2770 CustomerService,
2772 Purchasing,
2774 It,
2776 HumanResources,
2778 Administrative,
2780 Warehouse,
2782 BoardOfDirectors,
2784 Other,
2786}
2787
2788impl PerpetratorDepartment {
2789 pub fn typical_occurrence_rate(&self) -> f64 {
2791 match self {
2792 PerpetratorDepartment::Accounting => 0.21,
2793 PerpetratorDepartment::Operations => 0.17,
2794 PerpetratorDepartment::Executive => 0.12,
2795 PerpetratorDepartment::Sales => 0.11,
2796 PerpetratorDepartment::CustomerService => 0.07,
2797 PerpetratorDepartment::Purchasing => 0.06,
2798 PerpetratorDepartment::It => 0.05,
2799 PerpetratorDepartment::HumanResources => 0.04,
2800 PerpetratorDepartment::Administrative => 0.04,
2801 PerpetratorDepartment::Warehouse => 0.03,
2802 PerpetratorDepartment::BoardOfDirectors => 0.02,
2803 PerpetratorDepartment::Other => 0.08,
2804 }
2805 }
2806
2807 pub fn typical_median_loss(&self) -> Decimal {
2809 match self {
2810 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
2811 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
2812 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
2813 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
2814 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
2815 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
2816 PerpetratorDepartment::It => Decimal::new(100_000, 0),
2817 _ => Decimal::new(80_000, 0),
2818 }
2819 }
2820}
2821
2822#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2824pub enum PerpetratorLevel {
2825 Employee,
2827 Manager,
2829 OwnerExecutive,
2831}
2832
2833impl PerpetratorLevel {
2834 pub fn typical_occurrence_rate(&self) -> f64 {
2836 match self {
2837 PerpetratorLevel::Employee => 0.42,
2838 PerpetratorLevel::Manager => 0.36,
2839 PerpetratorLevel::OwnerExecutive => 0.22,
2840 }
2841 }
2842
2843 pub fn typical_median_loss(&self) -> Decimal {
2845 match self {
2846 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
2847 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
2848 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
2849 }
2850 }
2851}
2852
2853#[derive(Debug, Clone, Serialize, Deserialize)]
2858pub struct AcfeCalibration {
2859 pub median_loss: Decimal,
2861 pub median_duration_months: u32,
2863 pub category_distribution: HashMap<String, f64>,
2865 pub detection_method_distribution: HashMap<String, f64>,
2867 pub department_distribution: HashMap<String, f64>,
2869 pub level_distribution: HashMap<String, f64>,
2871 pub avg_red_flags_per_case: f64,
2873 pub collusion_rate: f64,
2875}
2876
2877impl Default for AcfeCalibration {
2878 fn default() -> Self {
2879 let mut category_distribution = HashMap::new();
2880 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
2881 category_distribution.insert("corruption".to_string(), 0.33);
2882 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
2883
2884 let mut detection_method_distribution = HashMap::new();
2885 for method in AcfeDetectionMethod::all_variants() {
2886 detection_method_distribution.insert(
2887 format!("{:?}", method).to_lowercase(),
2888 method.typical_detection_rate(),
2889 );
2890 }
2891
2892 let mut department_distribution = HashMap::new();
2893 department_distribution.insert("accounting".to_string(), 0.21);
2894 department_distribution.insert("operations".to_string(), 0.17);
2895 department_distribution.insert("executive".to_string(), 0.12);
2896 department_distribution.insert("sales".to_string(), 0.11);
2897 department_distribution.insert("customer_service".to_string(), 0.07);
2898 department_distribution.insert("purchasing".to_string(), 0.06);
2899 department_distribution.insert("other".to_string(), 0.26);
2900
2901 let mut level_distribution = HashMap::new();
2902 level_distribution.insert("employee".to_string(), 0.42);
2903 level_distribution.insert("manager".to_string(), 0.36);
2904 level_distribution.insert("owner_executive".to_string(), 0.22);
2905
2906 Self {
2907 median_loss: Decimal::new(117_000, 0),
2908 median_duration_months: 12,
2909 category_distribution,
2910 detection_method_distribution,
2911 department_distribution,
2912 level_distribution,
2913 avg_red_flags_per_case: 2.8,
2914 collusion_rate: 0.50,
2915 }
2916 }
2917}
2918
2919impl AcfeCalibration {
2920 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
2922 Self {
2923 median_loss,
2924 median_duration_months,
2925 ..Self::default()
2926 }
2927 }
2928
2929 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
2931 category.typical_median_loss()
2932 }
2933
2934 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
2936 category.typical_detection_months()
2937 }
2938
2939 pub fn validate(&self) -> Result<(), String> {
2941 if self.median_loss <= Decimal::ZERO {
2942 return Err("Median loss must be positive".to_string());
2943 }
2944 if self.median_duration_months == 0 {
2945 return Err("Median duration must be at least 1 month".to_string());
2946 }
2947 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
2948 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
2949 }
2950 Ok(())
2951 }
2952}
2953
2954#[derive(Debug, Clone, Serialize, Deserialize)]
2959pub struct FraudTriangle {
2960 pub pressure: PressureType,
2962 pub opportunities: Vec<OpportunityFactor>,
2964 pub rationalization: Rationalization,
2966}
2967
2968impl FraudTriangle {
2969 pub fn new(
2971 pressure: PressureType,
2972 opportunities: Vec<OpportunityFactor>,
2973 rationalization: Rationalization,
2974 ) -> Self {
2975 Self {
2976 pressure,
2977 opportunities,
2978 rationalization,
2979 }
2980 }
2981
2982 pub fn risk_score(&self) -> f64 {
2984 let pressure_score = self.pressure.risk_weight();
2985 let opportunity_score: f64 = self
2986 .opportunities
2987 .iter()
2988 .map(|o| o.risk_weight())
2989 .sum::<f64>()
2990 / self.opportunities.len().max(1) as f64;
2991 let rationalization_score = self.rationalization.risk_weight();
2992
2993 (pressure_score + opportunity_score + rationalization_score) / 3.0
2994 }
2995}
2996
2997#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2999pub enum PressureType {
3000 PersonalFinancialDifficulties,
3003 FinancialTargets,
3005 MarketExpectations,
3007 CovenantCompliance,
3009 CreditRatingMaintenance,
3011 AcquisitionValuation,
3013
3014 JobSecurity,
3017 StatusMaintenance,
3019 GamblingAddiction,
3021 SubstanceAbuse,
3023 FamilyPressure,
3025 Greed,
3027}
3028
3029impl PressureType {
3030 pub fn risk_weight(&self) -> f64 {
3032 match self {
3033 PressureType::PersonalFinancialDifficulties => 0.80,
3034 PressureType::FinancialTargets => 0.75,
3035 PressureType::MarketExpectations => 0.70,
3036 PressureType::CovenantCompliance => 0.85,
3037 PressureType::CreditRatingMaintenance => 0.70,
3038 PressureType::AcquisitionValuation => 0.75,
3039 PressureType::JobSecurity => 0.65,
3040 PressureType::StatusMaintenance => 0.55,
3041 PressureType::GamblingAddiction => 0.90,
3042 PressureType::SubstanceAbuse => 0.85,
3043 PressureType::FamilyPressure => 0.60,
3044 PressureType::Greed => 0.70,
3045 }
3046 }
3047}
3048
3049#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3051pub enum OpportunityFactor {
3052 WeakInternalControls,
3054 LackOfSegregation,
3056 ManagementOverride,
3058 ComplexTransactions,
3060 RelatedPartyTransactions,
3062 PoorToneAtTop,
3064 InadequateSupervision,
3066 AssetAccess,
3068 PoorRecordKeeping,
3070 LackOfDiscipline,
3072 LackOfIndependentChecks,
3074}
3075
3076impl OpportunityFactor {
3077 pub fn risk_weight(&self) -> f64 {
3079 match self {
3080 OpportunityFactor::WeakInternalControls => 0.85,
3081 OpportunityFactor::LackOfSegregation => 0.80,
3082 OpportunityFactor::ManagementOverride => 0.90,
3083 OpportunityFactor::ComplexTransactions => 0.70,
3084 OpportunityFactor::RelatedPartyTransactions => 0.75,
3085 OpportunityFactor::PoorToneAtTop => 0.85,
3086 OpportunityFactor::InadequateSupervision => 0.75,
3087 OpportunityFactor::AssetAccess => 0.70,
3088 OpportunityFactor::PoorRecordKeeping => 0.65,
3089 OpportunityFactor::LackOfDiscipline => 0.60,
3090 OpportunityFactor::LackOfIndependentChecks => 0.75,
3091 }
3092 }
3093}
3094
3095#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3097pub enum Rationalization {
3098 TemporaryBorrowing,
3100 EveryoneDoesIt,
3102 ForTheCompanyGood,
3104 Entitlement,
3106 FollowingOrders,
3108 TheyWontMissIt,
3110 NeedItMore,
3112 NotReallyStealing,
3114 Underpaid,
3116 VictimlessCrime,
3118}
3119
3120impl Rationalization {
3121 pub fn risk_weight(&self) -> f64 {
3123 match self {
3124 Rationalization::Entitlement => 0.85,
3126 Rationalization::EveryoneDoesIt => 0.80,
3127 Rationalization::NotReallyStealing => 0.80,
3128 Rationalization::TheyWontMissIt => 0.75,
3129 Rationalization::Underpaid => 0.70,
3131 Rationalization::ForTheCompanyGood => 0.65,
3132 Rationalization::NeedItMore => 0.65,
3133 Rationalization::TemporaryBorrowing => 0.60,
3135 Rationalization::FollowingOrders => 0.55,
3136 Rationalization::VictimlessCrime => 0.60,
3137 }
3138 }
3139}
3140
3141#[derive(Debug, Clone, Serialize, Deserialize)]
3147pub enum NearMissPattern {
3148 NearDuplicate {
3150 date_difference_days: u32,
3152 similar_transaction_id: String,
3154 },
3155 ThresholdProximity {
3157 threshold: Decimal,
3159 proximity: f64,
3161 },
3162 UnusualLegitimate {
3164 pattern_type: LegitimatePatternType,
3166 justification: String,
3168 },
3169 CorrectedError {
3171 correction_lag_days: u32,
3173 correction_document_id: String,
3175 },
3176}
3177
3178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3180pub enum LegitimatePatternType {
3181 YearEndBonus,
3183 ContractPrepayment,
3185 SettlementPayment,
3187 InsuranceClaim,
3189 OneTimePayment,
3191 AssetDisposal,
3193 SeasonalInventory,
3195 PromotionalSpending,
3197}
3198
3199impl LegitimatePatternType {
3200 pub fn description(&self) -> &'static str {
3202 match self {
3203 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3204 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3205 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3206 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3207 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3208 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3209 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3210 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3211 }
3212 }
3213}
3214
3215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3217pub enum FalsePositiveTrigger {
3218 AmountNearThreshold,
3220 UnusualTiming,
3222 SimilarTransaction,
3224 NewCounterparty,
3226 UnusualAccountCombination,
3228 VolumeSpike,
3230 RoundAmount,
3232}
3233
3234#[derive(Debug, Clone, Serialize, Deserialize)]
3236pub struct NearMissLabel {
3237 pub document_id: String,
3239 pub pattern: NearMissPattern,
3241 pub suspicion_score: f64,
3243 pub false_positive_trigger: FalsePositiveTrigger,
3245 pub explanation: String,
3247}
3248
3249impl NearMissLabel {
3250 pub fn new(
3252 document_id: impl Into<String>,
3253 pattern: NearMissPattern,
3254 suspicion_score: f64,
3255 trigger: FalsePositiveTrigger,
3256 explanation: impl Into<String>,
3257 ) -> Self {
3258 Self {
3259 document_id: document_id.into(),
3260 pattern,
3261 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3262 false_positive_trigger: trigger,
3263 explanation: explanation.into(),
3264 }
3265 }
3266}
3267
3268#[derive(Debug, Clone, Serialize, Deserialize)]
3270pub struct AnomalyRateConfig {
3271 pub total_rate: f64,
3273 pub fraud_rate: f64,
3275 pub error_rate: f64,
3277 pub process_issue_rate: f64,
3279 pub statistical_rate: f64,
3281 pub relational_rate: f64,
3283}
3284
3285impl Default for AnomalyRateConfig {
3286 fn default() -> Self {
3287 Self {
3288 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, }
3295 }
3296}
3297
3298impl AnomalyRateConfig {
3299 pub fn validate(&self) -> Result<(), String> {
3301 let sum = self.fraud_rate
3302 + self.error_rate
3303 + self.process_issue_rate
3304 + self.statistical_rate
3305 + self.relational_rate;
3306
3307 if (sum - 1.0).abs() > 0.01 {
3308 return Err(format!(
3309 "Anomaly category rates must sum to 1.0, got {}",
3310 sum
3311 ));
3312 }
3313
3314 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3315 return Err(format!(
3316 "Total rate must be between 0.0 and 1.0, got {}",
3317 self.total_rate
3318 ));
3319 }
3320
3321 Ok(())
3322 }
3323}
3324
3325#[cfg(test)]
3326#[allow(clippy::unwrap_used)]
3327mod tests {
3328 use super::*;
3329 use rust_decimal_macros::dec;
3330
3331 #[test]
3332 fn test_anomaly_type_category() {
3333 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3334 assert_eq!(fraud.category(), "Fraud");
3335 assert!(fraud.is_intentional());
3336
3337 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3338 assert_eq!(error.category(), "Error");
3339 assert!(!error.is_intentional());
3340 }
3341
3342 #[test]
3343 fn test_labeled_anomaly() {
3344 let anomaly = LabeledAnomaly::new(
3345 "ANO001".to_string(),
3346 AnomalyType::Fraud(FraudType::SelfApproval),
3347 "JE001".to_string(),
3348 "JE".to_string(),
3349 "1000".to_string(),
3350 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3351 )
3352 .with_description("User approved their own expense report")
3353 .with_related_entity("USER001");
3354
3355 assert_eq!(anomaly.severity, 3);
3356 assert!(anomaly.is_injected);
3357 assert_eq!(anomaly.related_entities.len(), 1);
3358 }
3359
3360 #[test]
3361 fn test_labeled_anomaly_with_provenance() {
3362 let anomaly = LabeledAnomaly::new(
3363 "ANO001".to_string(),
3364 AnomalyType::Fraud(FraudType::SelfApproval),
3365 "JE001".to_string(),
3366 "JE".to_string(),
3367 "1000".to_string(),
3368 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3369 )
3370 .with_run_id("run-123")
3371 .with_generation_seed(42)
3372 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3373 .with_structured_strategy(InjectionStrategy::SelfApproval {
3374 user_id: "USER001".to_string(),
3375 })
3376 .with_scenario("scenario-001")
3377 .with_original_document_hash("abc123");
3378
3379 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3380 assert_eq!(anomaly.generation_seed, Some(42));
3381 assert!(anomaly.causal_reason.is_some());
3382 assert!(anomaly.structured_strategy.is_some());
3383 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3384 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3385
3386 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3388 }
3389
3390 #[test]
3391 fn test_labeled_anomaly_derivation_chain() {
3392 let parent = LabeledAnomaly::new(
3393 "ANO001".to_string(),
3394 AnomalyType::Fraud(FraudType::DuplicatePayment),
3395 "JE001".to_string(),
3396 "JE".to_string(),
3397 "1000".to_string(),
3398 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3399 );
3400
3401 let child = LabeledAnomaly::new(
3402 "ANO002".to_string(),
3403 AnomalyType::Error(ErrorType::DuplicateEntry),
3404 "JE002".to_string(),
3405 "JE".to_string(),
3406 "1000".to_string(),
3407 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3408 )
3409 .with_parent_anomaly(&parent.anomaly_id);
3410
3411 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3412 }
3413
3414 #[test]
3415 fn test_injection_strategy_description() {
3416 let strategy = InjectionStrategy::AmountManipulation {
3417 original: dec!(1000),
3418 factor: 2.5,
3419 };
3420 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3421 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3422
3423 let strategy = InjectionStrategy::ThresholdAvoidance {
3424 threshold: dec!(10000),
3425 adjusted_amount: dec!(9999),
3426 };
3427 assert_eq!(
3428 strategy.description(),
3429 "Amount adjusted to avoid 10000 threshold"
3430 );
3431
3432 let strategy = InjectionStrategy::DateShift {
3433 days_shifted: -5,
3434 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3435 };
3436 assert_eq!(strategy.description(), "Date backdated by 5 days");
3437
3438 let strategy = InjectionStrategy::DateShift {
3439 days_shifted: 3,
3440 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3441 };
3442 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3443 }
3444
3445 #[test]
3446 fn test_causal_reason_variants() {
3447 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3448 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3449 assert!((base_rate - 0.02).abs() < 0.001);
3450 }
3451
3452 let reason = AnomalyCausalReason::TemporalPattern {
3453 pattern_name: "year_end_spike".to_string(),
3454 };
3455 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3456 assert_eq!(pattern_name, "year_end_spike");
3457 }
3458
3459 let reason = AnomalyCausalReason::ScenarioStep {
3460 scenario_type: "kickback".to_string(),
3461 step_number: 3,
3462 };
3463 if let AnomalyCausalReason::ScenarioStep {
3464 scenario_type,
3465 step_number,
3466 } = reason
3467 {
3468 assert_eq!(scenario_type, "kickback");
3469 assert_eq!(step_number, 3);
3470 }
3471 }
3472
3473 #[test]
3474 fn test_feature_vector_length() {
3475 let anomaly = LabeledAnomaly::new(
3476 "ANO001".to_string(),
3477 AnomalyType::Fraud(FraudType::SelfApproval),
3478 "JE001".to_string(),
3479 "JE".to_string(),
3480 "1000".to_string(),
3481 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3482 );
3483
3484 let features = anomaly.to_features();
3485 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3486 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3487 }
3488
3489 #[test]
3490 fn test_feature_vector_with_provenance() {
3491 let anomaly = LabeledAnomaly::new(
3492 "ANO001".to_string(),
3493 AnomalyType::Fraud(FraudType::SelfApproval),
3494 "JE001".to_string(),
3495 "JE".to_string(),
3496 "1000".to_string(),
3497 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3498 )
3499 .with_scenario("scenario-001")
3500 .with_parent_anomaly("ANO000");
3501
3502 let features = anomaly.to_features();
3503
3504 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3508
3509 #[test]
3510 fn test_anomaly_summary() {
3511 let anomalies = vec![
3512 LabeledAnomaly::new(
3513 "ANO001".to_string(),
3514 AnomalyType::Fraud(FraudType::SelfApproval),
3515 "JE001".to_string(),
3516 "JE".to_string(),
3517 "1000".to_string(),
3518 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3519 ),
3520 LabeledAnomaly::new(
3521 "ANO002".to_string(),
3522 AnomalyType::Error(ErrorType::DuplicateEntry),
3523 "JE002".to_string(),
3524 "JE".to_string(),
3525 "1000".to_string(),
3526 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3527 ),
3528 ];
3529
3530 let summary = AnomalySummary::from_anomalies(&anomalies);
3531
3532 assert_eq!(summary.total_count, 2);
3533 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3534 assert_eq!(summary.by_category.get("Error"), Some(&1));
3535 }
3536
3537 #[test]
3538 fn test_rate_config_validation() {
3539 let config = AnomalyRateConfig::default();
3540 assert!(config.validate().is_ok());
3541
3542 let bad_config = AnomalyRateConfig {
3543 fraud_rate: 0.5,
3544 error_rate: 0.5,
3545 process_issue_rate: 0.5, ..Default::default()
3547 };
3548 assert!(bad_config.validate().is_err());
3549 }
3550
3551 #[test]
3552 fn test_injection_strategy_serialization() {
3553 let strategy = InjectionStrategy::SoDViolation {
3554 duty1: "CreatePO".to_string(),
3555 duty2: "ApprovePO".to_string(),
3556 violating_user: "USER001".to_string(),
3557 };
3558
3559 let json = serde_json::to_string(&strategy).unwrap();
3560 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3561
3562 assert_eq!(strategy, deserialized);
3563 }
3564
3565 #[test]
3566 fn test_labeled_anomaly_serialization_with_provenance() {
3567 let anomaly = LabeledAnomaly::new(
3568 "ANO001".to_string(),
3569 AnomalyType::Fraud(FraudType::SelfApproval),
3570 "JE001".to_string(),
3571 "JE".to_string(),
3572 "1000".to_string(),
3573 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3574 )
3575 .with_run_id("run-123")
3576 .with_generation_seed(42)
3577 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3578
3579 let json = serde_json::to_string(&anomaly).unwrap();
3580 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3581
3582 assert_eq!(anomaly.run_id, deserialized.run_id);
3583 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3584 }
3585
3586 #[test]
3591 fn test_anomaly_category_from_anomaly_type() {
3592 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3594 assert_eq!(
3595 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3596 AnomalyCategory::FictitiousVendor
3597 );
3598
3599 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3600 assert_eq!(
3601 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3602 AnomalyCategory::VendorKickback
3603 );
3604
3605 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3606 assert_eq!(
3607 AnomalyCategory::from_anomaly_type(&fraud_structured),
3608 AnomalyCategory::StructuredTransaction
3609 );
3610
3611 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3613 assert_eq!(
3614 AnomalyCategory::from_anomaly_type(&error_duplicate),
3615 AnomalyCategory::DuplicatePayment
3616 );
3617
3618 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3620 assert_eq!(
3621 AnomalyCategory::from_anomaly_type(&process_skip),
3622 AnomalyCategory::MissingApproval
3623 );
3624
3625 let relational_circular =
3627 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3628 assert_eq!(
3629 AnomalyCategory::from_anomaly_type(&relational_circular),
3630 AnomalyCategory::CircularFlow
3631 );
3632 }
3633
3634 #[test]
3635 fn test_anomaly_category_ordinal() {
3636 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3637 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3638 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3639 }
3640
3641 #[test]
3642 fn test_contributing_factor() {
3643 let factor = ContributingFactor::new(
3644 FactorType::AmountDeviation,
3645 15000.0,
3646 10000.0,
3647 true,
3648 0.5,
3649 "Amount exceeds threshold",
3650 );
3651
3652 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3653 assert_eq!(factor.value, 15000.0);
3654 assert_eq!(factor.threshold, 10000.0);
3655 assert!(factor.direction_greater);
3656
3657 let contribution = factor.contribution();
3659 assert!((contribution - 0.25).abs() < 0.01);
3660 }
3661
3662 #[test]
3663 fn test_contributing_factor_with_evidence() {
3664 let mut data = HashMap::new();
3665 data.insert("expected".to_string(), "10000".to_string());
3666 data.insert("actual".to_string(), "15000".to_string());
3667
3668 let factor = ContributingFactor::new(
3669 FactorType::AmountDeviation,
3670 15000.0,
3671 10000.0,
3672 true,
3673 0.5,
3674 "Amount deviation detected",
3675 )
3676 .with_evidence("transaction_history", data);
3677
3678 assert!(factor.evidence.is_some());
3679 let evidence = factor.evidence.unwrap();
3680 assert_eq!(evidence.source, "transaction_history");
3681 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3682 }
3683
3684 #[test]
3685 fn test_enhanced_anomaly_label() {
3686 let base = LabeledAnomaly::new(
3687 "ANO001".to_string(),
3688 AnomalyType::Fraud(FraudType::DuplicatePayment),
3689 "JE001".to_string(),
3690 "JE".to_string(),
3691 "1000".to_string(),
3692 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3693 );
3694
3695 let enhanced = EnhancedAnomalyLabel::from_base(base)
3696 .with_confidence(0.85)
3697 .with_severity(0.7)
3698 .with_factor(ContributingFactor::new(
3699 FactorType::DuplicateIndicator,
3700 1.0,
3701 0.5,
3702 true,
3703 0.4,
3704 "Duplicate payment detected",
3705 ))
3706 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3707
3708 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3709 assert_eq!(enhanced.enhanced_confidence, 0.85);
3710 assert_eq!(enhanced.enhanced_severity, 0.7);
3711 assert_eq!(enhanced.contributing_factors.len(), 1);
3712 assert_eq!(enhanced.secondary_categories.len(), 1);
3713 }
3714
3715 #[test]
3716 fn test_enhanced_anomaly_label_features() {
3717 let base = LabeledAnomaly::new(
3718 "ANO001".to_string(),
3719 AnomalyType::Fraud(FraudType::SelfApproval),
3720 "JE001".to_string(),
3721 "JE".to_string(),
3722 "1000".to_string(),
3723 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3724 );
3725
3726 let enhanced = EnhancedAnomalyLabel::from_base(base)
3727 .with_confidence(0.9)
3728 .with_severity(0.8)
3729 .with_factor(ContributingFactor::new(
3730 FactorType::ControlBypass,
3731 1.0,
3732 0.0,
3733 true,
3734 0.5,
3735 "Control bypass detected",
3736 ));
3737
3738 let features = enhanced.to_features();
3739
3740 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3742 assert_eq!(features.len(), 25);
3743
3744 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3750
3751 #[test]
3752 fn test_enhanced_anomaly_label_feature_names() {
3753 let names = EnhancedAnomalyLabel::feature_names();
3754 assert_eq!(names.len(), 25);
3755 assert!(names.contains(&"enhanced_confidence"));
3756 assert!(names.contains(&"enhanced_severity"));
3757 assert!(names.contains(&"has_control_bypass"));
3758 }
3759
3760 #[test]
3761 fn test_factor_type_names() {
3762 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3763 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3764 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3765 }
3766
3767 #[test]
3768 fn test_anomaly_category_serialization() {
3769 let category = AnomalyCategory::CircularFlow;
3770 let json = serde_json::to_string(&category).unwrap();
3771 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3772 assert_eq!(category, deserialized);
3773
3774 let custom = AnomalyCategory::Custom("custom_type".to_string());
3775 let json = serde_json::to_string(&custom).unwrap();
3776 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3777 assert_eq!(custom, deserialized);
3778 }
3779
3780 #[test]
3781 fn test_enhanced_label_secondary_category_dedup() {
3782 let base = LabeledAnomaly::new(
3783 "ANO001".to_string(),
3784 AnomalyType::Fraud(FraudType::DuplicatePayment),
3785 "JE001".to_string(),
3786 "JE".to_string(),
3787 "1000".to_string(),
3788 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3789 );
3790
3791 let enhanced = EnhancedAnomalyLabel::from_base(base)
3792 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3794 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3796 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3798
3799 assert_eq!(enhanced.secondary_categories.len(), 1);
3801 assert_eq!(
3802 enhanced.secondary_categories[0],
3803 AnomalyCategory::TimingAnomaly
3804 );
3805 }
3806
3807 #[test]
3812 fn test_revenue_recognition_fraud_types() {
3813 let fraud_types = [
3815 FraudType::ImproperRevenueRecognition,
3816 FraudType::ImproperPoAllocation,
3817 FraudType::VariableConsiderationManipulation,
3818 FraudType::ContractModificationMisstatement,
3819 ];
3820
3821 for fraud_type in fraud_types {
3822 let anomaly_type = AnomalyType::Fraud(fraud_type);
3823 assert_eq!(anomaly_type.category(), "Fraud");
3824 assert!(anomaly_type.is_intentional());
3825 assert!(anomaly_type.severity() >= 3);
3826 }
3827 }
3828
3829 #[test]
3830 fn test_lease_accounting_fraud_types() {
3831 let fraud_types = [
3833 FraudType::LeaseClassificationManipulation,
3834 FraudType::OffBalanceSheetLease,
3835 FraudType::LeaseLiabilityUnderstatement,
3836 FraudType::RouAssetMisstatement,
3837 ];
3838
3839 for fraud_type in fraud_types {
3840 let anomaly_type = AnomalyType::Fraud(fraud_type);
3841 assert_eq!(anomaly_type.category(), "Fraud");
3842 assert!(anomaly_type.is_intentional());
3843 assert!(anomaly_type.severity() >= 3);
3844 }
3845
3846 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3848 }
3849
3850 #[test]
3851 fn test_fair_value_fraud_types() {
3852 let fraud_types = [
3854 FraudType::FairValueHierarchyManipulation,
3855 FraudType::Level3InputManipulation,
3856 FraudType::ValuationTechniqueManipulation,
3857 ];
3858
3859 for fraud_type in fraud_types {
3860 let anomaly_type = AnomalyType::Fraud(fraud_type);
3861 assert_eq!(anomaly_type.category(), "Fraud");
3862 assert!(anomaly_type.is_intentional());
3863 assert!(anomaly_type.severity() >= 4);
3864 }
3865
3866 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3868 }
3869
3870 #[test]
3871 fn test_impairment_fraud_types() {
3872 let fraud_types = [
3874 FraudType::DelayedImpairment,
3875 FraudType::ImpairmentTestAvoidance,
3876 FraudType::CashFlowProjectionManipulation,
3877 FraudType::ImproperImpairmentReversal,
3878 ];
3879
3880 for fraud_type in fraud_types {
3881 let anomaly_type = AnomalyType::Fraud(fraud_type);
3882 assert_eq!(anomaly_type.category(), "Fraud");
3883 assert!(anomaly_type.is_intentional());
3884 assert!(anomaly_type.severity() >= 3);
3885 }
3886
3887 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3889 }
3890
3891 #[test]
3896 fn test_standards_error_types() {
3897 let error_types = [
3899 ErrorType::RevenueTimingError,
3900 ErrorType::PoAllocationError,
3901 ErrorType::LeaseClassificationError,
3902 ErrorType::LeaseCalculationError,
3903 ErrorType::FairValueError,
3904 ErrorType::ImpairmentCalculationError,
3905 ErrorType::DiscountRateError,
3906 ErrorType::FrameworkApplicationError,
3907 ];
3908
3909 for error_type in error_types {
3910 let anomaly_type = AnomalyType::Error(error_type);
3911 assert_eq!(anomaly_type.category(), "Error");
3912 assert!(!anomaly_type.is_intentional());
3913 assert!(anomaly_type.severity() >= 3);
3914 }
3915 }
3916
3917 #[test]
3918 fn test_framework_application_error() {
3919 let error_type = ErrorType::FrameworkApplicationError;
3921 assert_eq!(error_type.severity(), 4);
3922
3923 let anomaly = LabeledAnomaly::new(
3924 "ERR001".to_string(),
3925 AnomalyType::Error(error_type),
3926 "JE100".to_string(),
3927 "JE".to_string(),
3928 "1000".to_string(),
3929 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3930 )
3931 .with_description("LIFO inventory method used under IFRS (not permitted)")
3932 .with_metadata("framework", "IFRS")
3933 .with_metadata("standard_violated", "IAS 2");
3934
3935 assert_eq!(anomaly.anomaly_type.category(), "Error");
3936 assert_eq!(
3937 anomaly.metadata.get("standard_violated"),
3938 Some(&"IAS 2".to_string())
3939 );
3940 }
3941
3942 #[test]
3943 fn test_standards_anomaly_serialization() {
3944 let fraud_types = [
3946 FraudType::ImproperRevenueRecognition,
3947 FraudType::LeaseClassificationManipulation,
3948 FraudType::FairValueHierarchyManipulation,
3949 FraudType::DelayedImpairment,
3950 ];
3951
3952 for fraud_type in fraud_types {
3953 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
3954 let deserialized: FraudType =
3955 serde_json::from_str(&json).expect("Failed to deserialize");
3956 assert_eq!(fraud_type, deserialized);
3957 }
3958
3959 let error_types = [
3961 ErrorType::RevenueTimingError,
3962 ErrorType::LeaseCalculationError,
3963 ErrorType::FairValueError,
3964 ErrorType::FrameworkApplicationError,
3965 ];
3966
3967 for error_type in error_types {
3968 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
3969 let deserialized: ErrorType =
3970 serde_json::from_str(&json).expect("Failed to deserialize");
3971 assert_eq!(error_type, deserialized);
3972 }
3973 }
3974
3975 #[test]
3976 fn test_standards_labeled_anomaly() {
3977 let anomaly = LabeledAnomaly::new(
3979 "STD001".to_string(),
3980 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
3981 "CONTRACT-2024-001".to_string(),
3982 "Revenue".to_string(),
3983 "1000".to_string(),
3984 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
3985 )
3986 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
3987 .with_monetary_impact(dec!(500000))
3988 .with_metadata("standard", "ASC 606")
3989 .with_metadata("paragraph", "606-10-25-1")
3990 .with_metadata("contract_id", "C-2024-001")
3991 .with_related_entity("CONTRACT-2024-001")
3992 .with_related_entity("CUSTOMER-500");
3993
3994 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
3996 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
3997 assert_eq!(anomaly.related_entities.len(), 2);
3998 assert_eq!(
3999 anomaly.metadata.get("standard"),
4000 Some(&"ASC 606".to_string())
4001 );
4002 }
4003
4004 #[test]
4009 fn test_severity_level() {
4010 assert_eq!(SeverityLevel::Low.numeric(), 1);
4011 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4012
4013 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4014 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4015
4016 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4017 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4018
4019 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4020 }
4021
4022 #[test]
4023 fn test_anomaly_severity() {
4024 let severity =
4025 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4026
4027 assert_eq!(severity.level, SeverityLevel::High);
4028 assert!(severity.is_material);
4029 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4030
4031 let low_severity =
4033 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4034 assert!(!low_severity.is_material);
4035 }
4036
4037 #[test]
4038 fn test_detection_difficulty() {
4039 assert!(
4040 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4041 );
4042 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4043
4044 assert_eq!(
4045 AnomalyDetectionDifficulty::from_score(0.05),
4046 AnomalyDetectionDifficulty::Trivial
4047 );
4048 assert_eq!(
4049 AnomalyDetectionDifficulty::from_score(0.90),
4050 AnomalyDetectionDifficulty::Expert
4051 );
4052
4053 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4054 }
4055
4056 #[test]
4057 fn test_ground_truth_certainty() {
4058 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4059 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4060 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4061 }
4062
4063 #[test]
4064 fn test_detection_method() {
4065 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4066 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4067 }
4068
4069 #[test]
4070 fn test_extended_anomaly_label() {
4071 let base = LabeledAnomaly::new(
4072 "ANO001".to_string(),
4073 AnomalyType::Fraud(FraudType::FictitiousVendor),
4074 "JE001".to_string(),
4075 "JE".to_string(),
4076 "1000".to_string(),
4077 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4078 )
4079 .with_monetary_impact(dec!(100000));
4080
4081 let extended = ExtendedAnomalyLabel::from_base(base)
4082 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4083 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4084 .with_method(DetectionMethod::GraphBased)
4085 .with_method(DetectionMethod::ForensicAudit)
4086 .with_indicator("New vendor with no history")
4087 .with_indicator("Large first transaction")
4088 .with_certainty(GroundTruthCertainty::Definite)
4089 .with_entity("V001")
4090 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4091 .with_scheme("SCHEME001", 2);
4092
4093 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4094 assert_eq!(
4095 extended.detection_difficulty,
4096 AnomalyDetectionDifficulty::Hard
4097 );
4098 assert_eq!(extended.recommended_methods.len(), 3);
4100 assert_eq!(extended.key_indicators.len(), 2);
4101 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4102 assert_eq!(extended.scheme_stage, Some(2));
4103 }
4104
4105 #[test]
4106 fn test_extended_anomaly_label_features() {
4107 let base = LabeledAnomaly::new(
4108 "ANO001".to_string(),
4109 AnomalyType::Fraud(FraudType::SelfApproval),
4110 "JE001".to_string(),
4111 "JE".to_string(),
4112 "1000".to_string(),
4113 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4114 );
4115
4116 let extended =
4117 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4118
4119 let features = extended.to_features();
4120 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4121 assert_eq!(features.len(), 30);
4122
4123 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4126 }
4127
4128 #[test]
4129 fn test_extended_label_near_miss() {
4130 let base = LabeledAnomaly::new(
4131 "ANO001".to_string(),
4132 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4133 "JE001".to_string(),
4134 "JE".to_string(),
4135 "1000".to_string(),
4136 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4137 );
4138
4139 let extended = ExtendedAnomalyLabel::from_base(base)
4140 .as_near_miss("Year-end bonus payment, legitimately high");
4141
4142 assert!(extended.is_near_miss);
4143 assert!(extended.near_miss_explanation.is_some());
4144 }
4145
4146 #[test]
4147 fn test_scheme_type() {
4148 assert_eq!(
4149 SchemeType::GradualEmbezzlement.name(),
4150 "gradual_embezzlement"
4151 );
4152 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4153 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4154 }
4155
4156 #[test]
4157 fn test_concealment_technique() {
4158 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4159 assert!(
4160 ConcealmentTechnique::Collusion.difficulty_bonus()
4161 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4162 );
4163 }
4164
4165 #[test]
4166 fn test_near_miss_label() {
4167 let near_miss = NearMissLabel::new(
4168 "JE001",
4169 NearMissPattern::ThresholdProximity {
4170 threshold: dec!(10000),
4171 proximity: 0.95,
4172 },
4173 0.7,
4174 FalsePositiveTrigger::AmountNearThreshold,
4175 "Transaction is 95% of threshold but business justified",
4176 );
4177
4178 assert_eq!(near_miss.document_id, "JE001");
4179 assert_eq!(near_miss.suspicion_score, 0.7);
4180 assert_eq!(
4181 near_miss.false_positive_trigger,
4182 FalsePositiveTrigger::AmountNearThreshold
4183 );
4184 }
4185
4186 #[test]
4187 fn test_legitimate_pattern_type() {
4188 assert_eq!(
4189 LegitimatePatternType::YearEndBonus.description(),
4190 "Year-end bonus payment"
4191 );
4192 assert_eq!(
4193 LegitimatePatternType::InsuranceClaim.description(),
4194 "Insurance claim reimbursement"
4195 );
4196 }
4197
4198 #[test]
4199 fn test_severity_detection_difficulty_serialization() {
4200 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4201 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4202 let deserialized: AnomalySeverity =
4203 serde_json::from_str(&json).expect("Failed to deserialize");
4204 assert_eq!(severity.level, deserialized.level);
4205
4206 let difficulty = AnomalyDetectionDifficulty::Hard;
4207 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4208 let deserialized: AnomalyDetectionDifficulty =
4209 serde_json::from_str(&json).expect("Failed to deserialize");
4210 assert_eq!(difficulty, deserialized);
4211 }
4212
4213 #[test]
4218 fn test_acfe_fraud_category() {
4219 let asset = AcfeFraudCategory::AssetMisappropriation;
4220 assert_eq!(asset.name(), "asset_misappropriation");
4221 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4222 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4223 assert_eq!(asset.typical_detection_months(), 12);
4224
4225 let corruption = AcfeFraudCategory::Corruption;
4226 assert_eq!(corruption.name(), "corruption");
4227 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4228
4229 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4230 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4231 assert_eq!(fs_fraud.typical_detection_months(), 24);
4232 }
4233
4234 #[test]
4235 fn test_cash_fraud_scheme() {
4236 let shell = CashFraudScheme::ShellCompany;
4237 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4238 assert_eq!(shell.subcategory(), "billing_schemes");
4239 assert_eq!(shell.severity(), 5);
4240 assert_eq!(
4241 shell.detection_difficulty(),
4242 AnomalyDetectionDifficulty::Hard
4243 );
4244
4245 let ghost = CashFraudScheme::GhostEmployee;
4246 assert_eq!(ghost.subcategory(), "payroll_schemes");
4247 assert_eq!(ghost.severity(), 5);
4248
4249 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4251 }
4252
4253 #[test]
4254 fn test_asset_fraud_scheme() {
4255 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4256 assert_eq!(
4257 ip_theft.category(),
4258 AcfeFraudCategory::AssetMisappropriation
4259 );
4260 assert_eq!(ip_theft.subcategory(), "other_assets");
4261 assert_eq!(ip_theft.severity(), 5);
4262
4263 let inv_theft = AssetFraudScheme::InventoryTheft;
4264 assert_eq!(inv_theft.subcategory(), "inventory");
4265 assert_eq!(inv_theft.severity(), 4);
4266 }
4267
4268 #[test]
4269 fn test_corruption_scheme() {
4270 let kickback = CorruptionScheme::InvoiceKickback;
4271 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4272 assert_eq!(kickback.subcategory(), "bribery");
4273 assert_eq!(kickback.severity(), 5);
4274 assert_eq!(
4275 kickback.detection_difficulty(),
4276 AnomalyDetectionDifficulty::Expert
4277 );
4278
4279 let bid_rigging = CorruptionScheme::BidRigging;
4280 assert_eq!(bid_rigging.subcategory(), "bribery");
4281 assert_eq!(
4282 bid_rigging.detection_difficulty(),
4283 AnomalyDetectionDifficulty::Hard
4284 );
4285
4286 let purchasing = CorruptionScheme::PurchasingConflict;
4287 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4288
4289 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4291 }
4292
4293 #[test]
4294 fn test_financial_statement_scheme() {
4295 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4296 assert_eq!(
4297 fictitious.category(),
4298 AcfeFraudCategory::FinancialStatementFraud
4299 );
4300 assert_eq!(fictitious.subcategory(), "overstatement");
4301 assert_eq!(fictitious.severity(), 5);
4302 assert_eq!(
4303 fictitious.detection_difficulty(),
4304 AnomalyDetectionDifficulty::Expert
4305 );
4306
4307 let understated = FinancialStatementScheme::UnderstatedRevenues;
4308 assert_eq!(understated.subcategory(), "understatement");
4309
4310 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4312 }
4313
4314 #[test]
4315 fn test_acfe_scheme_unified() {
4316 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4317 assert_eq!(
4318 cash_scheme.category(),
4319 AcfeFraudCategory::AssetMisappropriation
4320 );
4321 assert_eq!(cash_scheme.severity(), 5);
4322
4323 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4324 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4325
4326 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4327 assert_eq!(
4328 fs_scheme.category(),
4329 AcfeFraudCategory::FinancialStatementFraud
4330 );
4331 }
4332
4333 #[test]
4334 fn test_acfe_detection_method() {
4335 let tip = AcfeDetectionMethod::Tip;
4336 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4337
4338 let internal_audit = AcfeDetectionMethod::InternalAudit;
4339 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4340
4341 let external_audit = AcfeDetectionMethod::ExternalAudit;
4342 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4343
4344 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4346 }
4347
4348 #[test]
4349 fn test_perpetrator_department() {
4350 let accounting = PerpetratorDepartment::Accounting;
4351 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4352 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4353
4354 let executive = PerpetratorDepartment::Executive;
4355 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4356 }
4357
4358 #[test]
4359 fn test_perpetrator_level() {
4360 let employee = PerpetratorLevel::Employee;
4361 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4362 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4363
4364 let exec = PerpetratorLevel::OwnerExecutive;
4365 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4366 }
4367
4368 #[test]
4369 fn test_acfe_calibration() {
4370 let cal = AcfeCalibration::default();
4371 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4372 assert_eq!(cal.median_duration_months, 12);
4373 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4374 assert!(cal.validate().is_ok());
4375
4376 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4378 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4379 assert_eq!(custom_cal.median_duration_months, 18);
4380
4381 let bad_cal = AcfeCalibration {
4383 collusion_rate: 1.5,
4384 ..Default::default()
4385 };
4386 assert!(bad_cal.validate().is_err());
4387 }
4388
4389 #[test]
4390 fn test_fraud_triangle() {
4391 let triangle = FraudTriangle::new(
4392 PressureType::FinancialTargets,
4393 vec![
4394 OpportunityFactor::WeakInternalControls,
4395 OpportunityFactor::ManagementOverride,
4396 ],
4397 Rationalization::ForTheCompanyGood,
4398 );
4399
4400 let risk = triangle.risk_score();
4402 assert!((0.0..=1.0).contains(&risk));
4403 assert!(risk > 0.5);
4405 }
4406
4407 #[test]
4408 fn test_pressure_types() {
4409 let financial = PressureType::FinancialTargets;
4410 assert!(financial.risk_weight() > 0.5);
4411
4412 let gambling = PressureType::GamblingAddiction;
4413 assert_eq!(gambling.risk_weight(), 0.90);
4414 }
4415
4416 #[test]
4417 fn test_opportunity_factors() {
4418 let override_factor = OpportunityFactor::ManagementOverride;
4419 assert_eq!(override_factor.risk_weight(), 0.90);
4420
4421 let weak_controls = OpportunityFactor::WeakInternalControls;
4422 assert!(weak_controls.risk_weight() > 0.8);
4423 }
4424
4425 #[test]
4426 fn test_rationalizations() {
4427 let entitlement = Rationalization::Entitlement;
4428 assert!(entitlement.risk_weight() > 0.8);
4429
4430 let borrowing = Rationalization::TemporaryBorrowing;
4431 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4432 }
4433
4434 #[test]
4435 fn test_acfe_scheme_serialization() {
4436 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4437 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4438 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4439 assert_eq!(scheme, deserialized);
4440
4441 let calibration = AcfeCalibration::default();
4442 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4443 let deserialized: AcfeCalibration =
4444 serde_json::from_str(&json).expect("Failed to deserialize");
4445 assert_eq!(calibration.median_loss, deserialized.median_loss);
4446 }
4447}