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)]
3326mod tests {
3327 use super::*;
3328 use rust_decimal_macros::dec;
3329
3330 #[test]
3331 fn test_anomaly_type_category() {
3332 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3333 assert_eq!(fraud.category(), "Fraud");
3334 assert!(fraud.is_intentional());
3335
3336 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3337 assert_eq!(error.category(), "Error");
3338 assert!(!error.is_intentional());
3339 }
3340
3341 #[test]
3342 fn test_labeled_anomaly() {
3343 let anomaly = LabeledAnomaly::new(
3344 "ANO001".to_string(),
3345 AnomalyType::Fraud(FraudType::SelfApproval),
3346 "JE001".to_string(),
3347 "JE".to_string(),
3348 "1000".to_string(),
3349 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3350 )
3351 .with_description("User approved their own expense report")
3352 .with_related_entity("USER001");
3353
3354 assert_eq!(anomaly.severity, 3);
3355 assert!(anomaly.is_injected);
3356 assert_eq!(anomaly.related_entities.len(), 1);
3357 }
3358
3359 #[test]
3360 fn test_labeled_anomaly_with_provenance() {
3361 let anomaly = LabeledAnomaly::new(
3362 "ANO001".to_string(),
3363 AnomalyType::Fraud(FraudType::SelfApproval),
3364 "JE001".to_string(),
3365 "JE".to_string(),
3366 "1000".to_string(),
3367 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3368 )
3369 .with_run_id("run-123")
3370 .with_generation_seed(42)
3371 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3372 .with_structured_strategy(InjectionStrategy::SelfApproval {
3373 user_id: "USER001".to_string(),
3374 })
3375 .with_scenario("scenario-001")
3376 .with_original_document_hash("abc123");
3377
3378 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3379 assert_eq!(anomaly.generation_seed, Some(42));
3380 assert!(anomaly.causal_reason.is_some());
3381 assert!(anomaly.structured_strategy.is_some());
3382 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3383 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3384
3385 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3387 }
3388
3389 #[test]
3390 fn test_labeled_anomaly_derivation_chain() {
3391 let parent = LabeledAnomaly::new(
3392 "ANO001".to_string(),
3393 AnomalyType::Fraud(FraudType::DuplicatePayment),
3394 "JE001".to_string(),
3395 "JE".to_string(),
3396 "1000".to_string(),
3397 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3398 );
3399
3400 let child = LabeledAnomaly::new(
3401 "ANO002".to_string(),
3402 AnomalyType::Error(ErrorType::DuplicateEntry),
3403 "JE002".to_string(),
3404 "JE".to_string(),
3405 "1000".to_string(),
3406 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3407 )
3408 .with_parent_anomaly(&parent.anomaly_id);
3409
3410 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3411 }
3412
3413 #[test]
3414 fn test_injection_strategy_description() {
3415 let strategy = InjectionStrategy::AmountManipulation {
3416 original: dec!(1000),
3417 factor: 2.5,
3418 };
3419 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3420 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3421
3422 let strategy = InjectionStrategy::ThresholdAvoidance {
3423 threshold: dec!(10000),
3424 adjusted_amount: dec!(9999),
3425 };
3426 assert_eq!(
3427 strategy.description(),
3428 "Amount adjusted to avoid 10000 threshold"
3429 );
3430
3431 let strategy = InjectionStrategy::DateShift {
3432 days_shifted: -5,
3433 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3434 };
3435 assert_eq!(strategy.description(), "Date backdated by 5 days");
3436
3437 let strategy = InjectionStrategy::DateShift {
3438 days_shifted: 3,
3439 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3440 };
3441 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3442 }
3443
3444 #[test]
3445 fn test_causal_reason_variants() {
3446 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3447 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3448 assert!((base_rate - 0.02).abs() < 0.001);
3449 }
3450
3451 let reason = AnomalyCausalReason::TemporalPattern {
3452 pattern_name: "year_end_spike".to_string(),
3453 };
3454 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3455 assert_eq!(pattern_name, "year_end_spike");
3456 }
3457
3458 let reason = AnomalyCausalReason::ScenarioStep {
3459 scenario_type: "kickback".to_string(),
3460 step_number: 3,
3461 };
3462 if let AnomalyCausalReason::ScenarioStep {
3463 scenario_type,
3464 step_number,
3465 } = reason
3466 {
3467 assert_eq!(scenario_type, "kickback");
3468 assert_eq!(step_number, 3);
3469 }
3470 }
3471
3472 #[test]
3473 fn test_feature_vector_length() {
3474 let anomaly = LabeledAnomaly::new(
3475 "ANO001".to_string(),
3476 AnomalyType::Fraud(FraudType::SelfApproval),
3477 "JE001".to_string(),
3478 "JE".to_string(),
3479 "1000".to_string(),
3480 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3481 );
3482
3483 let features = anomaly.to_features();
3484 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3485 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3486 }
3487
3488 #[test]
3489 fn test_feature_vector_with_provenance() {
3490 let anomaly = LabeledAnomaly::new(
3491 "ANO001".to_string(),
3492 AnomalyType::Fraud(FraudType::SelfApproval),
3493 "JE001".to_string(),
3494 "JE".to_string(),
3495 "1000".to_string(),
3496 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3497 )
3498 .with_scenario("scenario-001")
3499 .with_parent_anomaly("ANO000");
3500
3501 let features = anomaly.to_features();
3502
3503 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3507
3508 #[test]
3509 fn test_anomaly_summary() {
3510 let anomalies = vec![
3511 LabeledAnomaly::new(
3512 "ANO001".to_string(),
3513 AnomalyType::Fraud(FraudType::SelfApproval),
3514 "JE001".to_string(),
3515 "JE".to_string(),
3516 "1000".to_string(),
3517 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3518 ),
3519 LabeledAnomaly::new(
3520 "ANO002".to_string(),
3521 AnomalyType::Error(ErrorType::DuplicateEntry),
3522 "JE002".to_string(),
3523 "JE".to_string(),
3524 "1000".to_string(),
3525 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3526 ),
3527 ];
3528
3529 let summary = AnomalySummary::from_anomalies(&anomalies);
3530
3531 assert_eq!(summary.total_count, 2);
3532 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3533 assert_eq!(summary.by_category.get("Error"), Some(&1));
3534 }
3535
3536 #[test]
3537 fn test_rate_config_validation() {
3538 let config = AnomalyRateConfig::default();
3539 assert!(config.validate().is_ok());
3540
3541 let bad_config = AnomalyRateConfig {
3542 fraud_rate: 0.5,
3543 error_rate: 0.5,
3544 process_issue_rate: 0.5, ..Default::default()
3546 };
3547 assert!(bad_config.validate().is_err());
3548 }
3549
3550 #[test]
3551 fn test_injection_strategy_serialization() {
3552 let strategy = InjectionStrategy::SoDViolation {
3553 duty1: "CreatePO".to_string(),
3554 duty2: "ApprovePO".to_string(),
3555 violating_user: "USER001".to_string(),
3556 };
3557
3558 let json = serde_json::to_string(&strategy).unwrap();
3559 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3560
3561 assert_eq!(strategy, deserialized);
3562 }
3563
3564 #[test]
3565 fn test_labeled_anomaly_serialization_with_provenance() {
3566 let anomaly = LabeledAnomaly::new(
3567 "ANO001".to_string(),
3568 AnomalyType::Fraud(FraudType::SelfApproval),
3569 "JE001".to_string(),
3570 "JE".to_string(),
3571 "1000".to_string(),
3572 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3573 )
3574 .with_run_id("run-123")
3575 .with_generation_seed(42)
3576 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3577
3578 let json = serde_json::to_string(&anomaly).unwrap();
3579 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3580
3581 assert_eq!(anomaly.run_id, deserialized.run_id);
3582 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3583 }
3584
3585 #[test]
3590 fn test_anomaly_category_from_anomaly_type() {
3591 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3593 assert_eq!(
3594 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3595 AnomalyCategory::FictitiousVendor
3596 );
3597
3598 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3599 assert_eq!(
3600 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3601 AnomalyCategory::VendorKickback
3602 );
3603
3604 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3605 assert_eq!(
3606 AnomalyCategory::from_anomaly_type(&fraud_structured),
3607 AnomalyCategory::StructuredTransaction
3608 );
3609
3610 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3612 assert_eq!(
3613 AnomalyCategory::from_anomaly_type(&error_duplicate),
3614 AnomalyCategory::DuplicatePayment
3615 );
3616
3617 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3619 assert_eq!(
3620 AnomalyCategory::from_anomaly_type(&process_skip),
3621 AnomalyCategory::MissingApproval
3622 );
3623
3624 let relational_circular =
3626 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3627 assert_eq!(
3628 AnomalyCategory::from_anomaly_type(&relational_circular),
3629 AnomalyCategory::CircularFlow
3630 );
3631 }
3632
3633 #[test]
3634 fn test_anomaly_category_ordinal() {
3635 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3636 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3637 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3638 }
3639
3640 #[test]
3641 fn test_contributing_factor() {
3642 let factor = ContributingFactor::new(
3643 FactorType::AmountDeviation,
3644 15000.0,
3645 10000.0,
3646 true,
3647 0.5,
3648 "Amount exceeds threshold",
3649 );
3650
3651 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3652 assert_eq!(factor.value, 15000.0);
3653 assert_eq!(factor.threshold, 10000.0);
3654 assert!(factor.direction_greater);
3655
3656 let contribution = factor.contribution();
3658 assert!((contribution - 0.25).abs() < 0.01);
3659 }
3660
3661 #[test]
3662 fn test_contributing_factor_with_evidence() {
3663 let mut data = HashMap::new();
3664 data.insert("expected".to_string(), "10000".to_string());
3665 data.insert("actual".to_string(), "15000".to_string());
3666
3667 let factor = ContributingFactor::new(
3668 FactorType::AmountDeviation,
3669 15000.0,
3670 10000.0,
3671 true,
3672 0.5,
3673 "Amount deviation detected",
3674 )
3675 .with_evidence("transaction_history", data);
3676
3677 assert!(factor.evidence.is_some());
3678 let evidence = factor.evidence.unwrap();
3679 assert_eq!(evidence.source, "transaction_history");
3680 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3681 }
3682
3683 #[test]
3684 fn test_enhanced_anomaly_label() {
3685 let base = LabeledAnomaly::new(
3686 "ANO001".to_string(),
3687 AnomalyType::Fraud(FraudType::DuplicatePayment),
3688 "JE001".to_string(),
3689 "JE".to_string(),
3690 "1000".to_string(),
3691 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3692 );
3693
3694 let enhanced = EnhancedAnomalyLabel::from_base(base)
3695 .with_confidence(0.85)
3696 .with_severity(0.7)
3697 .with_factor(ContributingFactor::new(
3698 FactorType::DuplicateIndicator,
3699 1.0,
3700 0.5,
3701 true,
3702 0.4,
3703 "Duplicate payment detected",
3704 ))
3705 .with_secondary_category(AnomalyCategory::StructuredTransaction);
3706
3707 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
3708 assert_eq!(enhanced.enhanced_confidence, 0.85);
3709 assert_eq!(enhanced.enhanced_severity, 0.7);
3710 assert_eq!(enhanced.contributing_factors.len(), 1);
3711 assert_eq!(enhanced.secondary_categories.len(), 1);
3712 }
3713
3714 #[test]
3715 fn test_enhanced_anomaly_label_features() {
3716 let base = LabeledAnomaly::new(
3717 "ANO001".to_string(),
3718 AnomalyType::Fraud(FraudType::SelfApproval),
3719 "JE001".to_string(),
3720 "JE".to_string(),
3721 "1000".to_string(),
3722 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3723 );
3724
3725 let enhanced = EnhancedAnomalyLabel::from_base(base)
3726 .with_confidence(0.9)
3727 .with_severity(0.8)
3728 .with_factor(ContributingFactor::new(
3729 FactorType::ControlBypass,
3730 1.0,
3731 0.0,
3732 true,
3733 0.5,
3734 "Control bypass detected",
3735 ));
3736
3737 let features = enhanced.to_features();
3738
3739 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
3741 assert_eq!(features.len(), 25);
3742
3743 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
3749
3750 #[test]
3751 fn test_enhanced_anomaly_label_feature_names() {
3752 let names = EnhancedAnomalyLabel::feature_names();
3753 assert_eq!(names.len(), 25);
3754 assert!(names.contains(&"enhanced_confidence"));
3755 assert!(names.contains(&"enhanced_severity"));
3756 assert!(names.contains(&"has_control_bypass"));
3757 }
3758
3759 #[test]
3760 fn test_factor_type_names() {
3761 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
3762 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
3763 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
3764 }
3765
3766 #[test]
3767 fn test_anomaly_category_serialization() {
3768 let category = AnomalyCategory::CircularFlow;
3769 let json = serde_json::to_string(&category).unwrap();
3770 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3771 assert_eq!(category, deserialized);
3772
3773 let custom = AnomalyCategory::Custom("custom_type".to_string());
3774 let json = serde_json::to_string(&custom).unwrap();
3775 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
3776 assert_eq!(custom, deserialized);
3777 }
3778
3779 #[test]
3780 fn test_enhanced_label_secondary_category_dedup() {
3781 let base = LabeledAnomaly::new(
3782 "ANO001".to_string(),
3783 AnomalyType::Fraud(FraudType::DuplicatePayment),
3784 "JE001".to_string(),
3785 "JE".to_string(),
3786 "1000".to_string(),
3787 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3788 );
3789
3790 let enhanced = EnhancedAnomalyLabel::from_base(base)
3791 .with_secondary_category(AnomalyCategory::DuplicatePayment)
3793 .with_secondary_category(AnomalyCategory::TimingAnomaly)
3795 .with_secondary_category(AnomalyCategory::TimingAnomaly);
3797
3798 assert_eq!(enhanced.secondary_categories.len(), 1);
3800 assert_eq!(
3801 enhanced.secondary_categories[0],
3802 AnomalyCategory::TimingAnomaly
3803 );
3804 }
3805
3806 #[test]
3811 fn test_revenue_recognition_fraud_types() {
3812 let fraud_types = [
3814 FraudType::ImproperRevenueRecognition,
3815 FraudType::ImproperPoAllocation,
3816 FraudType::VariableConsiderationManipulation,
3817 FraudType::ContractModificationMisstatement,
3818 ];
3819
3820 for fraud_type in fraud_types {
3821 let anomaly_type = AnomalyType::Fraud(fraud_type);
3822 assert_eq!(anomaly_type.category(), "Fraud");
3823 assert!(anomaly_type.is_intentional());
3824 assert!(anomaly_type.severity() >= 3);
3825 }
3826 }
3827
3828 #[test]
3829 fn test_lease_accounting_fraud_types() {
3830 let fraud_types = [
3832 FraudType::LeaseClassificationManipulation,
3833 FraudType::OffBalanceSheetLease,
3834 FraudType::LeaseLiabilityUnderstatement,
3835 FraudType::RouAssetMisstatement,
3836 ];
3837
3838 for fraud_type in fraud_types {
3839 let anomaly_type = AnomalyType::Fraud(fraud_type);
3840 assert_eq!(anomaly_type.category(), "Fraud");
3841 assert!(anomaly_type.is_intentional());
3842 assert!(anomaly_type.severity() >= 3);
3843 }
3844
3845 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
3847 }
3848
3849 #[test]
3850 fn test_fair_value_fraud_types() {
3851 let fraud_types = [
3853 FraudType::FairValueHierarchyManipulation,
3854 FraudType::Level3InputManipulation,
3855 FraudType::ValuationTechniqueManipulation,
3856 ];
3857
3858 for fraud_type in fraud_types {
3859 let anomaly_type = AnomalyType::Fraud(fraud_type);
3860 assert_eq!(anomaly_type.category(), "Fraud");
3861 assert!(anomaly_type.is_intentional());
3862 assert!(anomaly_type.severity() >= 4);
3863 }
3864
3865 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
3867 }
3868
3869 #[test]
3870 fn test_impairment_fraud_types() {
3871 let fraud_types = [
3873 FraudType::DelayedImpairment,
3874 FraudType::ImpairmentTestAvoidance,
3875 FraudType::CashFlowProjectionManipulation,
3876 FraudType::ImproperImpairmentReversal,
3877 ];
3878
3879 for fraud_type in fraud_types {
3880 let anomaly_type = AnomalyType::Fraud(fraud_type);
3881 assert_eq!(anomaly_type.category(), "Fraud");
3882 assert!(anomaly_type.is_intentional());
3883 assert!(anomaly_type.severity() >= 3);
3884 }
3885
3886 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
3888 }
3889
3890 #[test]
3895 fn test_standards_error_types() {
3896 let error_types = [
3898 ErrorType::RevenueTimingError,
3899 ErrorType::PoAllocationError,
3900 ErrorType::LeaseClassificationError,
3901 ErrorType::LeaseCalculationError,
3902 ErrorType::FairValueError,
3903 ErrorType::ImpairmentCalculationError,
3904 ErrorType::DiscountRateError,
3905 ErrorType::FrameworkApplicationError,
3906 ];
3907
3908 for error_type in error_types {
3909 let anomaly_type = AnomalyType::Error(error_type);
3910 assert_eq!(anomaly_type.category(), "Error");
3911 assert!(!anomaly_type.is_intentional());
3912 assert!(anomaly_type.severity() >= 3);
3913 }
3914 }
3915
3916 #[test]
3917 fn test_framework_application_error() {
3918 let error_type = ErrorType::FrameworkApplicationError;
3920 assert_eq!(error_type.severity(), 4);
3921
3922 let anomaly = LabeledAnomaly::new(
3923 "ERR001".to_string(),
3924 AnomalyType::Error(error_type),
3925 "JE100".to_string(),
3926 "JE".to_string(),
3927 "1000".to_string(),
3928 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
3929 )
3930 .with_description("LIFO inventory method used under IFRS (not permitted)")
3931 .with_metadata("framework", "IFRS")
3932 .with_metadata("standard_violated", "IAS 2");
3933
3934 assert_eq!(anomaly.anomaly_type.category(), "Error");
3935 assert_eq!(
3936 anomaly.metadata.get("standard_violated"),
3937 Some(&"IAS 2".to_string())
3938 );
3939 }
3940
3941 #[test]
3942 fn test_standards_anomaly_serialization() {
3943 let fraud_types = [
3945 FraudType::ImproperRevenueRecognition,
3946 FraudType::LeaseClassificationManipulation,
3947 FraudType::FairValueHierarchyManipulation,
3948 FraudType::DelayedImpairment,
3949 ];
3950
3951 for fraud_type in fraud_types {
3952 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
3953 let deserialized: FraudType =
3954 serde_json::from_str(&json).expect("Failed to deserialize");
3955 assert_eq!(fraud_type, deserialized);
3956 }
3957
3958 let error_types = [
3960 ErrorType::RevenueTimingError,
3961 ErrorType::LeaseCalculationError,
3962 ErrorType::FairValueError,
3963 ErrorType::FrameworkApplicationError,
3964 ];
3965
3966 for error_type in error_types {
3967 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
3968 let deserialized: ErrorType =
3969 serde_json::from_str(&json).expect("Failed to deserialize");
3970 assert_eq!(error_type, deserialized);
3971 }
3972 }
3973
3974 #[test]
3975 fn test_standards_labeled_anomaly() {
3976 let anomaly = LabeledAnomaly::new(
3978 "STD001".to_string(),
3979 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
3980 "CONTRACT-2024-001".to_string(),
3981 "Revenue".to_string(),
3982 "1000".to_string(),
3983 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
3984 )
3985 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
3986 .with_monetary_impact(dec!(500000))
3987 .with_metadata("standard", "ASC 606")
3988 .with_metadata("paragraph", "606-10-25-1")
3989 .with_metadata("contract_id", "C-2024-001")
3990 .with_related_entity("CONTRACT-2024-001")
3991 .with_related_entity("CUSTOMER-500");
3992
3993 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
3995 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
3996 assert_eq!(anomaly.related_entities.len(), 2);
3997 assert_eq!(
3998 anomaly.metadata.get("standard"),
3999 Some(&"ASC 606".to_string())
4000 );
4001 }
4002
4003 #[test]
4008 fn test_severity_level() {
4009 assert_eq!(SeverityLevel::Low.numeric(), 1);
4010 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4011
4012 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4013 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4014
4015 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4016 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4017
4018 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4019 }
4020
4021 #[test]
4022 fn test_anomaly_severity() {
4023 let severity =
4024 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4025
4026 assert_eq!(severity.level, SeverityLevel::High);
4027 assert!(severity.is_material);
4028 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4029
4030 let low_severity =
4032 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4033 assert!(!low_severity.is_material);
4034 }
4035
4036 #[test]
4037 fn test_detection_difficulty() {
4038 assert!(
4039 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4040 );
4041 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4042
4043 assert_eq!(
4044 AnomalyDetectionDifficulty::from_score(0.05),
4045 AnomalyDetectionDifficulty::Trivial
4046 );
4047 assert_eq!(
4048 AnomalyDetectionDifficulty::from_score(0.90),
4049 AnomalyDetectionDifficulty::Expert
4050 );
4051
4052 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4053 }
4054
4055 #[test]
4056 fn test_ground_truth_certainty() {
4057 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4058 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4059 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4060 }
4061
4062 #[test]
4063 fn test_detection_method() {
4064 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4065 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4066 }
4067
4068 #[test]
4069 fn test_extended_anomaly_label() {
4070 let base = LabeledAnomaly::new(
4071 "ANO001".to_string(),
4072 AnomalyType::Fraud(FraudType::FictitiousVendor),
4073 "JE001".to_string(),
4074 "JE".to_string(),
4075 "1000".to_string(),
4076 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4077 )
4078 .with_monetary_impact(dec!(100000));
4079
4080 let extended = ExtendedAnomalyLabel::from_base(base)
4081 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4082 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4083 .with_method(DetectionMethod::GraphBased)
4084 .with_method(DetectionMethod::ForensicAudit)
4085 .with_indicator("New vendor with no history")
4086 .with_indicator("Large first transaction")
4087 .with_certainty(GroundTruthCertainty::Definite)
4088 .with_entity("V001")
4089 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4090 .with_scheme("SCHEME001", 2);
4091
4092 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4093 assert_eq!(
4094 extended.detection_difficulty,
4095 AnomalyDetectionDifficulty::Hard
4096 );
4097 assert_eq!(extended.recommended_methods.len(), 3);
4099 assert_eq!(extended.key_indicators.len(), 2);
4100 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4101 assert_eq!(extended.scheme_stage, Some(2));
4102 }
4103
4104 #[test]
4105 fn test_extended_anomaly_label_features() {
4106 let base = LabeledAnomaly::new(
4107 "ANO001".to_string(),
4108 AnomalyType::Fraud(FraudType::SelfApproval),
4109 "JE001".to_string(),
4110 "JE".to_string(),
4111 "1000".to_string(),
4112 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4113 );
4114
4115 let extended =
4116 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4117
4118 let features = extended.to_features();
4119 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4120 assert_eq!(features.len(), 30);
4121
4122 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4125 }
4126
4127 #[test]
4128 fn test_extended_label_near_miss() {
4129 let base = LabeledAnomaly::new(
4130 "ANO001".to_string(),
4131 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4132 "JE001".to_string(),
4133 "JE".to_string(),
4134 "1000".to_string(),
4135 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4136 );
4137
4138 let extended = ExtendedAnomalyLabel::from_base(base)
4139 .as_near_miss("Year-end bonus payment, legitimately high");
4140
4141 assert!(extended.is_near_miss);
4142 assert!(extended.near_miss_explanation.is_some());
4143 }
4144
4145 #[test]
4146 fn test_scheme_type() {
4147 assert_eq!(
4148 SchemeType::GradualEmbezzlement.name(),
4149 "gradual_embezzlement"
4150 );
4151 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4152 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4153 }
4154
4155 #[test]
4156 fn test_concealment_technique() {
4157 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4158 assert!(
4159 ConcealmentTechnique::Collusion.difficulty_bonus()
4160 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4161 );
4162 }
4163
4164 #[test]
4165 fn test_near_miss_label() {
4166 let near_miss = NearMissLabel::new(
4167 "JE001",
4168 NearMissPattern::ThresholdProximity {
4169 threshold: dec!(10000),
4170 proximity: 0.95,
4171 },
4172 0.7,
4173 FalsePositiveTrigger::AmountNearThreshold,
4174 "Transaction is 95% of threshold but business justified",
4175 );
4176
4177 assert_eq!(near_miss.document_id, "JE001");
4178 assert_eq!(near_miss.suspicion_score, 0.7);
4179 assert_eq!(
4180 near_miss.false_positive_trigger,
4181 FalsePositiveTrigger::AmountNearThreshold
4182 );
4183 }
4184
4185 #[test]
4186 fn test_legitimate_pattern_type() {
4187 assert_eq!(
4188 LegitimatePatternType::YearEndBonus.description(),
4189 "Year-end bonus payment"
4190 );
4191 assert_eq!(
4192 LegitimatePatternType::InsuranceClaim.description(),
4193 "Insurance claim reimbursement"
4194 );
4195 }
4196
4197 #[test]
4198 fn test_severity_detection_difficulty_serialization() {
4199 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4200 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4201 let deserialized: AnomalySeverity =
4202 serde_json::from_str(&json).expect("Failed to deserialize");
4203 assert_eq!(severity.level, deserialized.level);
4204
4205 let difficulty = AnomalyDetectionDifficulty::Hard;
4206 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4207 let deserialized: AnomalyDetectionDifficulty =
4208 serde_json::from_str(&json).expect("Failed to deserialize");
4209 assert_eq!(difficulty, deserialized);
4210 }
4211
4212 #[test]
4217 fn test_acfe_fraud_category() {
4218 let asset = AcfeFraudCategory::AssetMisappropriation;
4219 assert_eq!(asset.name(), "asset_misappropriation");
4220 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4221 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4222 assert_eq!(asset.typical_detection_months(), 12);
4223
4224 let corruption = AcfeFraudCategory::Corruption;
4225 assert_eq!(corruption.name(), "corruption");
4226 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4227
4228 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4229 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4230 assert_eq!(fs_fraud.typical_detection_months(), 24);
4231 }
4232
4233 #[test]
4234 fn test_cash_fraud_scheme() {
4235 let shell = CashFraudScheme::ShellCompany;
4236 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4237 assert_eq!(shell.subcategory(), "billing_schemes");
4238 assert_eq!(shell.severity(), 5);
4239 assert_eq!(
4240 shell.detection_difficulty(),
4241 AnomalyDetectionDifficulty::Hard
4242 );
4243
4244 let ghost = CashFraudScheme::GhostEmployee;
4245 assert_eq!(ghost.subcategory(), "payroll_schemes");
4246 assert_eq!(ghost.severity(), 5);
4247
4248 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4250 }
4251
4252 #[test]
4253 fn test_asset_fraud_scheme() {
4254 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4255 assert_eq!(
4256 ip_theft.category(),
4257 AcfeFraudCategory::AssetMisappropriation
4258 );
4259 assert_eq!(ip_theft.subcategory(), "other_assets");
4260 assert_eq!(ip_theft.severity(), 5);
4261
4262 let inv_theft = AssetFraudScheme::InventoryTheft;
4263 assert_eq!(inv_theft.subcategory(), "inventory");
4264 assert_eq!(inv_theft.severity(), 4);
4265 }
4266
4267 #[test]
4268 fn test_corruption_scheme() {
4269 let kickback = CorruptionScheme::InvoiceKickback;
4270 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4271 assert_eq!(kickback.subcategory(), "bribery");
4272 assert_eq!(kickback.severity(), 5);
4273 assert_eq!(
4274 kickback.detection_difficulty(),
4275 AnomalyDetectionDifficulty::Expert
4276 );
4277
4278 let bid_rigging = CorruptionScheme::BidRigging;
4279 assert_eq!(bid_rigging.subcategory(), "bribery");
4280 assert_eq!(
4281 bid_rigging.detection_difficulty(),
4282 AnomalyDetectionDifficulty::Hard
4283 );
4284
4285 let purchasing = CorruptionScheme::PurchasingConflict;
4286 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4287
4288 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4290 }
4291
4292 #[test]
4293 fn test_financial_statement_scheme() {
4294 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4295 assert_eq!(
4296 fictitious.category(),
4297 AcfeFraudCategory::FinancialStatementFraud
4298 );
4299 assert_eq!(fictitious.subcategory(), "overstatement");
4300 assert_eq!(fictitious.severity(), 5);
4301 assert_eq!(
4302 fictitious.detection_difficulty(),
4303 AnomalyDetectionDifficulty::Expert
4304 );
4305
4306 let understated = FinancialStatementScheme::UnderstatedRevenues;
4307 assert_eq!(understated.subcategory(), "understatement");
4308
4309 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4311 }
4312
4313 #[test]
4314 fn test_acfe_scheme_unified() {
4315 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4316 assert_eq!(
4317 cash_scheme.category(),
4318 AcfeFraudCategory::AssetMisappropriation
4319 );
4320 assert_eq!(cash_scheme.severity(), 5);
4321
4322 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4323 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4324
4325 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4326 assert_eq!(
4327 fs_scheme.category(),
4328 AcfeFraudCategory::FinancialStatementFraud
4329 );
4330 }
4331
4332 #[test]
4333 fn test_acfe_detection_method() {
4334 let tip = AcfeDetectionMethod::Tip;
4335 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4336
4337 let internal_audit = AcfeDetectionMethod::InternalAudit;
4338 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4339
4340 let external_audit = AcfeDetectionMethod::ExternalAudit;
4341 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4342
4343 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4345 }
4346
4347 #[test]
4348 fn test_perpetrator_department() {
4349 let accounting = PerpetratorDepartment::Accounting;
4350 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4351 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4352
4353 let executive = PerpetratorDepartment::Executive;
4354 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4355 }
4356
4357 #[test]
4358 fn test_perpetrator_level() {
4359 let employee = PerpetratorLevel::Employee;
4360 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4361 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4362
4363 let exec = PerpetratorLevel::OwnerExecutive;
4364 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4365 }
4366
4367 #[test]
4368 fn test_acfe_calibration() {
4369 let cal = AcfeCalibration::default();
4370 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4371 assert_eq!(cal.median_duration_months, 12);
4372 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4373 assert!(cal.validate().is_ok());
4374
4375 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4377 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4378 assert_eq!(custom_cal.median_duration_months, 18);
4379
4380 let bad_cal = AcfeCalibration {
4382 collusion_rate: 1.5,
4383 ..Default::default()
4384 };
4385 assert!(bad_cal.validate().is_err());
4386 }
4387
4388 #[test]
4389 fn test_fraud_triangle() {
4390 let triangle = FraudTriangle::new(
4391 PressureType::FinancialTargets,
4392 vec![
4393 OpportunityFactor::WeakInternalControls,
4394 OpportunityFactor::ManagementOverride,
4395 ],
4396 Rationalization::ForTheCompanyGood,
4397 );
4398
4399 let risk = triangle.risk_score();
4401 assert!((0.0..=1.0).contains(&risk));
4402 assert!(risk > 0.5);
4404 }
4405
4406 #[test]
4407 fn test_pressure_types() {
4408 let financial = PressureType::FinancialTargets;
4409 assert!(financial.risk_weight() > 0.5);
4410
4411 let gambling = PressureType::GamblingAddiction;
4412 assert_eq!(gambling.risk_weight(), 0.90);
4413 }
4414
4415 #[test]
4416 fn test_opportunity_factors() {
4417 let override_factor = OpportunityFactor::ManagementOverride;
4418 assert_eq!(override_factor.risk_weight(), 0.90);
4419
4420 let weak_controls = OpportunityFactor::WeakInternalControls;
4421 assert!(weak_controls.risk_weight() > 0.8);
4422 }
4423
4424 #[test]
4425 fn test_rationalizations() {
4426 let entitlement = Rationalization::Entitlement;
4427 assert!(entitlement.risk_weight() > 0.8);
4428
4429 let borrowing = Rationalization::TemporaryBorrowing;
4430 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4431 }
4432
4433 #[test]
4434 fn test_acfe_scheme_serialization() {
4435 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4436 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4437 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4438 assert_eq!(scheme, deserialized);
4439
4440 let calibration = AcfeCalibration::default();
4441 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4442 let deserialized: AcfeCalibration =
4443 serde_json::from_str(&json).expect("Failed to deserialize");
4444 assert_eq!(calibration.median_loss, deserialized.median_loss);
4445 }
4446}