1use chrono::{NaiveDate, NaiveDateTime};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum AnomalyCausalReason {
20 RandomRate {
22 base_rate: f64,
24 },
25 TemporalPattern {
27 pattern_name: String,
29 },
30 EntityTargeting {
32 target_type: String,
34 target_id: String,
36 },
37 ClusterMembership {
39 cluster_id: String,
41 },
42 ScenarioStep {
44 scenario_type: String,
46 step_number: u32,
48 },
49 DataQualityProfile {
51 profile: String,
53 },
54 MLTrainingBalance {
56 target_class: String,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub enum InjectionStrategy {
67 AmountManipulation {
69 original: Decimal,
71 factor: f64,
73 },
74 ThresholdAvoidance {
76 threshold: Decimal,
78 adjusted_amount: Decimal,
80 },
81 DateShift {
83 days_shifted: i32,
85 original_date: NaiveDate,
87 },
88 SelfApproval {
90 user_id: String,
92 },
93 SoDViolation {
95 duty1: String,
97 duty2: String,
99 violating_user: String,
101 },
102 ExactDuplicate {
104 original_doc_id: String,
106 },
107 NearDuplicate {
109 original_doc_id: String,
111 varied_fields: Vec<String>,
113 },
114 CircularFlow {
116 entity_chain: Vec<String>,
118 },
119 SplitTransaction {
121 original_amount: Decimal,
123 split_count: u32,
125 split_doc_ids: Vec<String>,
127 },
128 RoundNumbering {
130 original_amount: Decimal,
132 rounded_amount: Decimal,
134 },
135 TimingManipulation {
137 timing_type: String,
139 original_time: Option<NaiveDateTime>,
141 },
142 AccountMisclassification {
144 correct_account: String,
146 incorrect_account: String,
148 },
149 MissingField {
151 field_name: String,
153 },
154 Custom {
156 name: String,
158 parameters: HashMap<String, String>,
160 },
161}
162
163impl InjectionStrategy {
164 pub fn description(&self) -> String {
166 match self {
167 InjectionStrategy::AmountManipulation { factor, .. } => {
168 format!("Amount multiplied by {factor:.2}")
169 }
170 InjectionStrategy::ThresholdAvoidance { threshold, .. } => {
171 format!("Amount adjusted to avoid {threshold} threshold")
172 }
173 InjectionStrategy::DateShift { days_shifted, .. } => {
174 if *days_shifted < 0 {
175 format!("Date backdated by {} days", days_shifted.abs())
176 } else {
177 format!("Date forward-dated by {days_shifted} days")
178 }
179 }
180 InjectionStrategy::SelfApproval { user_id } => {
181 format!("Self-approval by user {user_id}")
182 }
183 InjectionStrategy::SoDViolation { duty1, duty2, .. } => {
184 format!("SoD violation: {duty1} and {duty2}")
185 }
186 InjectionStrategy::ExactDuplicate { original_doc_id } => {
187 format!("Exact duplicate of {original_doc_id}")
188 }
189 InjectionStrategy::NearDuplicate {
190 original_doc_id,
191 varied_fields,
192 } => {
193 format!("Near-duplicate of {original_doc_id} (varied: {varied_fields:?})")
194 }
195 InjectionStrategy::CircularFlow { entity_chain } => {
196 format!("Circular flow through {} entities", entity_chain.len())
197 }
198 InjectionStrategy::SplitTransaction { split_count, .. } => {
199 format!("Split into {split_count} transactions")
200 }
201 InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
202 InjectionStrategy::TimingManipulation { timing_type, .. } => {
203 format!("Timing manipulation: {timing_type}")
204 }
205 InjectionStrategy::AccountMisclassification {
206 correct_account,
207 incorrect_account,
208 } => {
209 format!("Misclassified from {correct_account} to {incorrect_account}")
210 }
211 InjectionStrategy::MissingField { field_name } => {
212 format!("Missing required field: {field_name}")
213 }
214 InjectionStrategy::Custom { name, .. } => format!("Custom: {name}"),
215 }
216 }
217
218 pub fn strategy_type(&self) -> &'static str {
220 match self {
221 InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
222 InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
223 InjectionStrategy::DateShift { .. } => "DateShift",
224 InjectionStrategy::SelfApproval { .. } => "SelfApproval",
225 InjectionStrategy::SoDViolation { .. } => "SoDViolation",
226 InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
227 InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
228 InjectionStrategy::CircularFlow { .. } => "CircularFlow",
229 InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
230 InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
231 InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
232 InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
233 InjectionStrategy::MissingField { .. } => "MissingField",
234 InjectionStrategy::Custom { .. } => "Custom",
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
241pub enum AnomalyType {
242 Fraud(FraudType),
244 Error(ErrorType),
246 ProcessIssue(ProcessIssueType),
248 Statistical(StatisticalAnomalyType),
250 Relational(RelationalAnomalyType),
252 Custom(String),
254}
255
256impl AnomalyType {
257 pub fn category(&self) -> &'static str {
259 match self {
260 AnomalyType::Fraud(_) => "Fraud",
261 AnomalyType::Error(_) => "Error",
262 AnomalyType::ProcessIssue(_) => "ProcessIssue",
263 AnomalyType::Statistical(_) => "Statistical",
264 AnomalyType::Relational(_) => "Relational",
265 AnomalyType::Custom(_) => "Custom",
266 }
267 }
268
269 pub fn type_name(&self) -> String {
271 match self {
272 AnomalyType::Fraud(t) => format!("{t:?}"),
273 AnomalyType::Error(t) => format!("{t:?}"),
274 AnomalyType::ProcessIssue(t) => format!("{t:?}"),
275 AnomalyType::Statistical(t) => format!("{t:?}"),
276 AnomalyType::Relational(t) => format!("{t:?}"),
277 AnomalyType::Custom(s) => s.clone(),
278 }
279 }
280
281 pub fn severity(&self) -> u8 {
283 match self {
284 AnomalyType::Fraud(t) => t.severity(),
285 AnomalyType::Error(t) => t.severity(),
286 AnomalyType::ProcessIssue(t) => t.severity(),
287 AnomalyType::Statistical(t) => t.severity(),
288 AnomalyType::Relational(t) => t.severity(),
289 AnomalyType::Custom(_) => 3,
290 }
291 }
292
293 pub fn is_intentional(&self) -> bool {
295 matches!(self, AnomalyType::Fraud(_))
296 }
297
298 pub fn observability_class(&self) -> ObservabilityClass {
301 match self {
302 AnomalyType::Fraud(t) => t.observability_class(),
303 AnomalyType::Error(t) => t.observability_class(),
304 AnomalyType::ProcessIssue(t) => t.observability_class(),
305 AnomalyType::Statistical(t) => t.observability_class(),
306 AnomalyType::Relational(t) => t.observability_class(),
307 AnomalyType::Custom(_) => ObservabilityClass::PerJeDensity,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
320#[serde(rename_all = "snake_case")]
321pub enum ObservabilityClass {
322 #[default]
326 PerJeDensity,
327 RelationalGraph,
331 Temporal,
335 MemoryOnly,
339}
340
341impl ObservabilityClass {
342 pub fn as_str(&self) -> &'static str {
344 match self {
345 ObservabilityClass::PerJeDensity => "per_je_density",
346 ObservabilityClass::RelationalGraph => "relational_graph",
347 ObservabilityClass::Temporal => "temporal",
348 ObservabilityClass::MemoryOnly => "memory_only",
349 }
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
355pub enum FraudType {
356 FictitiousEntry,
359 FictitiousTransaction,
361 RoundDollarManipulation,
363 JustBelowThreshold,
365 RevenueManipulation,
367 ImproperCapitalization,
369 ExpenseCapitalization,
371 ReserveManipulation,
373 SuspenseAccountAbuse,
375 SplitTransaction,
377 TimingAnomaly,
379 UnauthorizedAccess,
381
382 SelfApproval,
385 ExceededApprovalLimit,
387 SegregationOfDutiesViolation,
389 UnauthorizedApproval,
391 CollusiveApproval,
393
394 FictitiousVendor,
397 DuplicatePayment,
399 ShellCompanyPayment,
401 Kickback,
403 KickbackScheme,
405 UnauthorizedDiscount,
407 RoundTripping,
410 InvoiceManipulation,
412
413 AssetMisappropriation,
416 InventoryTheft,
418 GhostEmployee,
420
421 PrematureRevenue,
424 UnderstatedLiabilities,
426 OverstatedAssets,
428 ChannelStuffing,
430
431 ImproperRevenueRecognition,
434 ImproperPoAllocation,
436 VariableConsiderationManipulation,
438 ContractModificationMisstatement,
440
441 LeaseClassificationManipulation,
444 OffBalanceSheetLease,
446 LeaseLiabilityUnderstatement,
448 RouAssetMisstatement,
450
451 FairValueHierarchyManipulation,
454 Level3InputManipulation,
456 ValuationTechniqueManipulation,
458
459 DelayedImpairment,
462 ImpairmentTestAvoidance,
464 CashFlowProjectionManipulation,
466 ImproperImpairmentReversal,
468
469 BidRigging,
472 PhantomVendorContract,
474 SplitContractThreshold,
476 ConflictOfInterestSourcing,
478
479 GhostEmployeePayroll,
482 PayrollInflation,
484 DuplicateExpenseReport,
486 FictitiousExpense,
488 SplitExpenseToAvoidApproval,
490
491 RevenueTimingManipulation,
494 QuotePriceOverride,
496}
497
498impl FraudType {
499 pub fn severity(&self) -> u8 {
501 match self {
502 FraudType::RoundDollarManipulation => 2,
503 FraudType::JustBelowThreshold => 3,
504 FraudType::SelfApproval => 3,
505 FraudType::ExceededApprovalLimit => 3,
506 FraudType::DuplicatePayment => 3,
507 FraudType::FictitiousEntry => 4,
508 FraudType::RevenueManipulation => 5,
509 FraudType::FictitiousVendor => 5,
510 FraudType::ShellCompanyPayment => 5,
511 FraudType::AssetMisappropriation => 5,
512 FraudType::SegregationOfDutiesViolation => 4,
513 FraudType::CollusiveApproval => 5,
514 FraudType::ImproperRevenueRecognition => 5,
516 FraudType::ImproperPoAllocation => 4,
517 FraudType::VariableConsiderationManipulation => 4,
518 FraudType::ContractModificationMisstatement => 3,
519 FraudType::LeaseClassificationManipulation => 4,
521 FraudType::OffBalanceSheetLease => 5,
522 FraudType::LeaseLiabilityUnderstatement => 4,
523 FraudType::RouAssetMisstatement => 3,
524 FraudType::FairValueHierarchyManipulation => 4,
526 FraudType::Level3InputManipulation => 5,
527 FraudType::ValuationTechniqueManipulation => 4,
528 FraudType::DelayedImpairment => 4,
530 FraudType::ImpairmentTestAvoidance => 4,
531 FraudType::CashFlowProjectionManipulation => 5,
532 FraudType::ImproperImpairmentReversal => 3,
533 _ => 4,
534 }
535 }
536
537 pub fn observability_class(&self) -> ObservabilityClass {
539 use ObservabilityClass::*;
540 match self {
541 FraudType::RoundTripping
543 | FraudType::SuspenseAccountAbuse
544 | FraudType::ShellCompanyPayment
545 | FraudType::Kickback
546 | FraudType::KickbackScheme
547 | FraudType::CollusiveApproval
548 | FraudType::FictitiousVendor
549 | FraudType::PhantomVendorContract
550 | FraudType::BidRigging
551 | FraudType::ConflictOfInterestSourcing => RelationalGraph,
552 FraudType::DuplicatePayment
554 | FraudType::DuplicateExpenseReport
555 | FraudType::GhostEmployee
556 | FraudType::GhostEmployeePayroll => MemoryOnly,
557 FraudType::PrematureRevenue
559 | FraudType::ChannelStuffing
560 | FraudType::RevenueTimingManipulation
561 | FraudType::DelayedImpairment
562 | FraudType::ImpairmentTestAvoidance => Temporal,
563 _ => PerJeDensity,
566 }
567 }
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
572pub enum ErrorType {
573 DuplicateEntry,
576 ReversedAmount,
578 TransposedDigits,
580 DecimalError,
582 MissingField,
584 InvalidAccount,
586
587 WrongPeriod,
590 BackdatedEntry,
592 FutureDatedEntry,
594 CutoffError,
596
597 MisclassifiedAccount,
600 WrongCostCenter,
602 WrongCompanyCode,
604
605 UnbalancedEntry,
608 RoundingError,
610 CurrencyError,
612 TaxCalculationError,
614
615 RevenueTimingError,
618 PoAllocationError,
620 LeaseClassificationError,
622 LeaseCalculationError,
624 FairValueError,
626 ImpairmentCalculationError,
628 DiscountRateError,
630 FrameworkApplicationError,
632}
633
634impl ErrorType {
635 pub fn severity(&self) -> u8 {
637 match self {
638 ErrorType::RoundingError => 1,
639 ErrorType::MissingField => 2,
640 ErrorType::TransposedDigits => 2,
641 ErrorType::DecimalError => 3,
642 ErrorType::DuplicateEntry => 3,
643 ErrorType::ReversedAmount => 3,
644 ErrorType::WrongPeriod => 4,
645 ErrorType::UnbalancedEntry => 5,
646 ErrorType::CurrencyError => 4,
647 ErrorType::RevenueTimingError => 4,
649 ErrorType::PoAllocationError => 3,
650 ErrorType::LeaseClassificationError => 3,
651 ErrorType::LeaseCalculationError => 3,
652 ErrorType::FairValueError => 4,
653 ErrorType::ImpairmentCalculationError => 4,
654 ErrorType::DiscountRateError => 3,
655 ErrorType::FrameworkApplicationError => 4,
656 _ => 3,
657 }
658 }
659
660 pub fn observability_class(&self) -> ObservabilityClass {
662 use ObservabilityClass::*;
663 match self {
664 ErrorType::WrongPeriod
666 | ErrorType::BackdatedEntry
667 | ErrorType::FutureDatedEntry
668 | ErrorType::CutoffError => Temporal,
669 ErrorType::DuplicateEntry => MemoryOnly,
671 _ => PerJeDensity,
673 }
674 }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
679pub enum ProcessIssueType {
680 SkippedApproval,
683 LateApproval,
685 MissingDocumentation,
687 IncompleteApprovalChain,
689
690 LatePosting,
693 AfterHoursPosting,
695 WeekendPosting,
697 RushedPeriodEnd,
699 PostClosePosting,
703
704 ManualOverride,
707 UnusualAccess,
709 SystemBypass,
711 BatchAnomaly,
713
714 VagueDescription,
717 PostFactoChange,
719 IncompleteAuditTrail,
721
722 MaverickSpend,
725 ExpiredContractPurchase,
727 ContractPriceOverride,
729 SingleBidAward,
731 QualificationBypass,
733
734 ExpiredQuoteConversion,
737}
738
739impl ProcessIssueType {
740 pub fn severity(&self) -> u8 {
742 match self {
743 ProcessIssueType::VagueDescription => 1,
744 ProcessIssueType::LatePosting => 2,
745 ProcessIssueType::AfterHoursPosting => 2,
746 ProcessIssueType::WeekendPosting => 2,
747 ProcessIssueType::PostClosePosting => 4,
748 ProcessIssueType::SkippedApproval => 4,
749 ProcessIssueType::ManualOverride => 4,
750 ProcessIssueType::SystemBypass => 5,
751 ProcessIssueType::IncompleteAuditTrail => 4,
752 _ => 3,
753 }
754 }
755
756 pub fn observability_class(&self) -> ObservabilityClass {
758 use ObservabilityClass::*;
759 match self {
760 ProcessIssueType::LatePosting
762 | ProcessIssueType::LateApproval
763 | ProcessIssueType::RushedPeriodEnd => Temporal,
764 _ => PerJeDensity,
767 }
768 }
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
773pub enum StatisticalAnomalyType {
774 UnusuallyHighAmount,
777 UnusuallyLowAmount,
779 BenfordViolation,
781 ExactDuplicateAmount,
783 RepeatingAmount,
785
786 UnusualFrequency,
789 TransactionBurst,
791 UnusualTiming,
793
794 TrendBreak,
797 LevelShift,
799 SeasonalAnomaly,
801
802 StatisticalOutlier,
805 VarianceChange,
807 DistributionShift,
809
810 SlaBreachPattern,
813 UnusedContract,
815
816 OvertimeAnomaly,
819
820 ConsolidationOutlier,
830}
831
832impl StatisticalAnomalyType {
833 pub fn severity(&self) -> u8 {
835 match self {
836 StatisticalAnomalyType::UnusualTiming => 1,
837 StatisticalAnomalyType::UnusualFrequency => 2,
838 StatisticalAnomalyType::BenfordViolation => 2,
839 StatisticalAnomalyType::UnusuallyHighAmount => 3,
840 StatisticalAnomalyType::TrendBreak => 3,
841 StatisticalAnomalyType::TransactionBurst => 4,
842 StatisticalAnomalyType::ExactDuplicateAmount => 3,
843 StatisticalAnomalyType::ConsolidationOutlier => 4,
847 _ => 3,
848 }
849 }
850
851 pub fn observability_class(&self) -> ObservabilityClass {
853 use ObservabilityClass::*;
854 match self {
855 StatisticalAnomalyType::UnusualFrequency
857 | StatisticalAnomalyType::TransactionBurst
858 | StatisticalAnomalyType::TrendBreak
859 | StatisticalAnomalyType::LevelShift
860 | StatisticalAnomalyType::SeasonalAnomaly
861 | StatisticalAnomalyType::VarianceChange
862 | StatisticalAnomalyType::DistributionShift
863 | StatisticalAnomalyType::RepeatingAmount
864 | StatisticalAnomalyType::SlaBreachPattern
865 | StatisticalAnomalyType::OvertimeAnomaly => Temporal,
866 StatisticalAnomalyType::ExactDuplicateAmount
868 | StatisticalAnomalyType::UnusedContract => MemoryOnly,
869 StatisticalAnomalyType::ConsolidationOutlier => RelationalGraph,
871 _ => PerJeDensity,
873 }
874 }
875}
876
877#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
879pub enum RelationalAnomalyType {
880 CircularTransaction,
883 UnusualAccountPair,
885 NewCounterparty,
887 DormantAccountActivity,
889
890 CentralityAnomaly,
893 IsolatedCluster,
895 BridgeNodeAnomaly,
897 CommunityAnomaly,
899
900 MissingRelationship,
903 UnexpectedRelationship,
905 RelationshipStrengthChange,
907
908 UnmatchedIntercompany,
911 CircularIntercompany,
913 TransferPricingAnomaly,
915
916 SourceConditionalRarity,
922}
923
924impl RelationalAnomalyType {
925 pub fn severity(&self) -> u8 {
927 match self {
928 RelationalAnomalyType::NewCounterparty => 1,
929 RelationalAnomalyType::DormantAccountActivity => 2,
930 RelationalAnomalyType::UnusualAccountPair => 2,
931 RelationalAnomalyType::CircularTransaction => 4,
932 RelationalAnomalyType::CircularIntercompany => 4,
933 RelationalAnomalyType::TransferPricingAnomaly => 4,
934 RelationalAnomalyType::UnmatchedIntercompany => 3,
935 RelationalAnomalyType::SourceConditionalRarity => 2,
936 _ => 3,
937 }
938 }
939
940 pub fn observability_class(&self) -> ObservabilityClass {
943 ObservabilityClass::RelationalGraph
944 }
945}
946
947#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct LabeledAnomaly {
950 pub anomaly_id: String,
952 pub anomaly_type: AnomalyType,
954 pub document_id: String,
956 pub document_type: String,
958 pub company_code: String,
960 pub anomaly_date: NaiveDate,
962 #[serde(with = "crate::serde_timestamp::naive")]
964 pub detection_timestamp: NaiveDateTime,
965 pub confidence: f64,
967 pub severity: u8,
969 #[serde(default)]
974 pub observability: ObservabilityClass,
975 pub description: String,
977 pub related_entities: Vec<String>,
979 pub monetary_impact: Option<Decimal>,
981 pub metadata: HashMap<String, String>,
983 pub is_injected: bool,
985 pub injection_strategy: Option<String>,
987 pub cluster_id: Option<String>,
989
990 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub original_document_hash: Option<String>,
997
998 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub causal_reason: Option<AnomalyCausalReason>,
1002
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1006 pub structured_strategy: Option<InjectionStrategy>,
1007
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub parent_anomaly_id: Option<String>,
1012
1013 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1015 pub child_anomaly_ids: Vec<String>,
1016
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub scenario_id: Option<String>,
1020
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub run_id: Option<String>,
1025
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub generation_seed: Option<u64>,
1030}
1031
1032impl LabeledAnomaly {
1033 pub fn new(
1035 anomaly_id: String,
1036 anomaly_type: AnomalyType,
1037 document_id: String,
1038 document_type: String,
1039 company_code: String,
1040 anomaly_date: NaiveDate,
1041 ) -> Self {
1042 let severity = anomaly_type.severity();
1043 let observability = anomaly_type.observability_class();
1044 let description = format!(
1045 "{} - {} in document {}",
1046 anomaly_type.category(),
1047 anomaly_type.type_name(),
1048 document_id
1049 );
1050
1051 Self {
1052 anomaly_id,
1053 anomaly_type,
1054 document_id,
1055 document_type,
1056 company_code,
1057 anomaly_date,
1058 detection_timestamp: chrono::Local::now().naive_local(),
1059 confidence: 1.0,
1060 severity,
1061 observability,
1062 description,
1063 related_entities: Vec::new(),
1064 monetary_impact: None,
1065 metadata: HashMap::new(),
1066 is_injected: true,
1067 injection_strategy: None,
1068 cluster_id: None,
1069 original_document_hash: None,
1071 causal_reason: None,
1072 structured_strategy: None,
1073 parent_anomaly_id: None,
1074 child_anomaly_ids: Vec::new(),
1075 scenario_id: None,
1076 run_id: None,
1077 generation_seed: None,
1078 }
1079 }
1080
1081 pub fn with_description(mut self, description: &str) -> Self {
1083 self.description = description.to_string();
1084 self
1085 }
1086
1087 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
1089 self.monetary_impact = Some(impact);
1090 self
1091 }
1092
1093 pub fn with_related_entity(mut self, entity: &str) -> Self {
1095 self.related_entities.push(entity.to_string());
1096 self
1097 }
1098
1099 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
1101 self.metadata.insert(key.to_string(), value.to_string());
1102 self
1103 }
1104
1105 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
1107 self.injection_strategy = Some(strategy.to_string());
1108 self
1109 }
1110
1111 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
1113 self.cluster_id = Some(cluster_id.to_string());
1114 self
1115 }
1116
1117 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
1123 self.original_document_hash = Some(hash.to_string());
1124 self
1125 }
1126
1127 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
1129 self.causal_reason = Some(reason);
1130 self
1131 }
1132
1133 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
1135 self.injection_strategy = Some(strategy.strategy_type().to_string());
1137 self.structured_strategy = Some(strategy);
1138 self
1139 }
1140
1141 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
1143 self.parent_anomaly_id = Some(parent_id.to_string());
1144 self
1145 }
1146
1147 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
1149 self.child_anomaly_ids.push(child_id.to_string());
1150 self
1151 }
1152
1153 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
1155 self.scenario_id = Some(scenario_id.to_string());
1156 self
1157 }
1158
1159 pub fn with_run_id(mut self, run_id: &str) -> Self {
1161 self.run_id = Some(run_id.to_string());
1162 self
1163 }
1164
1165 pub fn with_generation_seed(mut self, seed: u64) -> Self {
1167 self.generation_seed = Some(seed);
1168 self
1169 }
1170
1171 pub fn with_provenance(
1173 mut self,
1174 run_id: Option<&str>,
1175 seed: Option<u64>,
1176 causal_reason: Option<AnomalyCausalReason>,
1177 ) -> Self {
1178 if let Some(id) = run_id {
1179 self.run_id = Some(id.to_string());
1180 }
1181 self.generation_seed = seed;
1182 self.causal_reason = causal_reason;
1183 self
1184 }
1185
1186 pub fn to_features(&self) -> Vec<f64> {
1200 let mut features = Vec::new();
1201
1202 let categories = [
1204 "Fraud",
1205 "Error",
1206 "ProcessIssue",
1207 "Statistical",
1208 "Relational",
1209 "Custom",
1210 ];
1211 for cat in &categories {
1212 features.push(if self.anomaly_type.category() == *cat {
1213 1.0
1214 } else {
1215 0.0
1216 });
1217 }
1218
1219 features.push(self.severity as f64 / 5.0);
1221
1222 features.push(self.confidence);
1224
1225 features.push(if self.monetary_impact.is_some() {
1227 1.0
1228 } else {
1229 0.0
1230 });
1231
1232 if let Some(impact) = self.monetary_impact {
1234 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
1235 features.push((impact_f64.abs() + 1.0).ln());
1236 } else {
1237 features.push(0.0);
1238 }
1239
1240 features.push(if self.anomaly_type.is_intentional() {
1242 1.0
1243 } else {
1244 0.0
1245 });
1246
1247 features.push(self.related_entities.len() as f64);
1249
1250 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
1252
1253 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
1256
1257 features.push(if self.parent_anomaly_id.is_some() {
1259 1.0
1260 } else {
1261 0.0
1262 });
1263
1264 features
1265 }
1266
1267 pub fn feature_count() -> usize {
1269 15 }
1271
1272 pub fn feature_names() -> Vec<&'static str> {
1274 vec![
1275 "category_fraud",
1276 "category_error",
1277 "category_process_issue",
1278 "category_statistical",
1279 "category_relational",
1280 "category_custom",
1281 "severity_normalized",
1282 "confidence",
1283 "has_monetary_impact",
1284 "monetary_impact_log",
1285 "is_intentional",
1286 "related_entity_count",
1287 "is_clustered",
1288 "is_scenario_part",
1289 "is_derived",
1290 ]
1291 }
1292}
1293
1294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1296pub struct AnomalySummary {
1297 pub total_count: usize,
1299 pub by_category: HashMap<String, usize>,
1301 pub by_type: HashMap<String, usize>,
1303 pub by_severity: HashMap<u8, usize>,
1305 pub by_company: HashMap<String, usize>,
1307 pub total_monetary_impact: Decimal,
1309 pub date_range: Option<(NaiveDate, NaiveDate)>,
1311 pub cluster_count: usize,
1313}
1314
1315impl AnomalySummary {
1316 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
1318 let mut summary = AnomalySummary {
1319 total_count: anomalies.len(),
1320 ..Default::default()
1321 };
1322
1323 let mut min_date: Option<NaiveDate> = None;
1324 let mut max_date: Option<NaiveDate> = None;
1325 let mut clusters = std::collections::HashSet::new();
1326
1327 for anomaly in anomalies {
1328 *summary
1330 .by_category
1331 .entry(anomaly.anomaly_type.category().to_string())
1332 .or_insert(0) += 1;
1333
1334 *summary
1336 .by_type
1337 .entry(anomaly.anomaly_type.type_name())
1338 .or_insert(0) += 1;
1339
1340 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1342
1343 *summary
1345 .by_company
1346 .entry(anomaly.company_code.clone())
1347 .or_insert(0) += 1;
1348
1349 if let Some(impact) = anomaly.monetary_impact {
1351 summary.total_monetary_impact += impact;
1352 }
1353
1354 match min_date {
1356 None => min_date = Some(anomaly.anomaly_date),
1357 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1358 _ => {}
1359 }
1360 match max_date {
1361 None => max_date = Some(anomaly.anomaly_date),
1362 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1363 _ => {}
1364 }
1365
1366 if let Some(cluster_id) = &anomaly.cluster_id {
1368 clusters.insert(cluster_id.clone());
1369 }
1370 }
1371
1372 summary.date_range = min_date.zip(max_date);
1373 summary.cluster_count = clusters.len();
1374
1375 summary
1376 }
1377}
1378
1379#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1388pub enum AnomalyCategory {
1389 FictitiousVendor,
1392 VendorKickback,
1394 RelatedPartyVendor,
1396
1397 DuplicatePayment,
1400 UnauthorizedTransaction,
1402 StructuredTransaction,
1404
1405 CircularFlow,
1408 BehavioralAnomaly,
1410 TimingAnomaly,
1412
1413 JournalAnomaly,
1416 ManualOverride,
1418 MissingApproval,
1420
1421 StatisticalOutlier,
1424 DistributionAnomaly,
1426
1427 Custom(String),
1430}
1431
1432impl AnomalyCategory {
1433 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1435 match anomaly_type {
1436 AnomalyType::Fraud(fraud_type) => match fraud_type {
1437 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1438 AnomalyCategory::FictitiousVendor
1439 }
1440 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1441 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1442 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1443 AnomalyCategory::StructuredTransaction
1444 }
1445 FraudType::SelfApproval
1446 | FraudType::UnauthorizedApproval
1447 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1448 FraudType::TimingAnomaly
1449 | FraudType::RoundDollarManipulation
1450 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1451 _ => AnomalyCategory::BehavioralAnomaly,
1452 },
1453 AnomalyType::Error(error_type) => match error_type {
1454 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1455 ErrorType::WrongPeriod
1456 | ErrorType::BackdatedEntry
1457 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1458 _ => AnomalyCategory::JournalAnomaly,
1459 },
1460 AnomalyType::ProcessIssue(process_type) => match process_type {
1461 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1462 AnomalyCategory::MissingApproval
1463 }
1464 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1465 AnomalyCategory::ManualOverride
1466 }
1467 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1468 AnomalyCategory::TimingAnomaly
1469 }
1470 _ => AnomalyCategory::BehavioralAnomaly,
1471 },
1472 AnomalyType::Statistical(stat_type) => match stat_type {
1473 StatisticalAnomalyType::BenfordViolation
1474 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1475 _ => AnomalyCategory::StatisticalOutlier,
1476 },
1477 AnomalyType::Relational(rel_type) => match rel_type {
1478 RelationalAnomalyType::CircularTransaction
1479 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1480 _ => AnomalyCategory::BehavioralAnomaly,
1481 },
1482 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1483 }
1484 }
1485
1486 pub fn name(&self) -> &str {
1488 match self {
1489 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1490 AnomalyCategory::VendorKickback => "vendor_kickback",
1491 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1492 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1493 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1494 AnomalyCategory::StructuredTransaction => "structured_transaction",
1495 AnomalyCategory::CircularFlow => "circular_flow",
1496 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1497 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1498 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1499 AnomalyCategory::ManualOverride => "manual_override",
1500 AnomalyCategory::MissingApproval => "missing_approval",
1501 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1502 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1503 AnomalyCategory::Custom(s) => s.as_str(),
1504 }
1505 }
1506
1507 pub fn ordinal(&self) -> u8 {
1509 match self {
1510 AnomalyCategory::FictitiousVendor => 0,
1511 AnomalyCategory::VendorKickback => 1,
1512 AnomalyCategory::RelatedPartyVendor => 2,
1513 AnomalyCategory::DuplicatePayment => 3,
1514 AnomalyCategory::UnauthorizedTransaction => 4,
1515 AnomalyCategory::StructuredTransaction => 5,
1516 AnomalyCategory::CircularFlow => 6,
1517 AnomalyCategory::BehavioralAnomaly => 7,
1518 AnomalyCategory::TimingAnomaly => 8,
1519 AnomalyCategory::JournalAnomaly => 9,
1520 AnomalyCategory::ManualOverride => 10,
1521 AnomalyCategory::MissingApproval => 11,
1522 AnomalyCategory::StatisticalOutlier => 12,
1523 AnomalyCategory::DistributionAnomaly => 13,
1524 AnomalyCategory::Custom(_) => 14,
1525 }
1526 }
1527
1528 pub fn category_count() -> usize {
1530 15 }
1532}
1533
1534#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1536pub enum FactorType {
1537 AmountDeviation,
1539 ThresholdProximity,
1541 TimingAnomaly,
1543 EntityRisk,
1545 PatternMatch,
1547 FrequencyDeviation,
1549 RelationshipAnomaly,
1551 ControlBypass,
1553 BenfordViolation,
1555 DuplicateIndicator,
1557 ApprovalChainIssue,
1559 DocumentationGap,
1561 Custom,
1563}
1564
1565impl FactorType {
1566 pub fn name(&self) -> &'static str {
1568 match self {
1569 FactorType::AmountDeviation => "amount_deviation",
1570 FactorType::ThresholdProximity => "threshold_proximity",
1571 FactorType::TimingAnomaly => "timing_anomaly",
1572 FactorType::EntityRisk => "entity_risk",
1573 FactorType::PatternMatch => "pattern_match",
1574 FactorType::FrequencyDeviation => "frequency_deviation",
1575 FactorType::RelationshipAnomaly => "relationship_anomaly",
1576 FactorType::ControlBypass => "control_bypass",
1577 FactorType::BenfordViolation => "benford_violation",
1578 FactorType::DuplicateIndicator => "duplicate_indicator",
1579 FactorType::ApprovalChainIssue => "approval_chain_issue",
1580 FactorType::DocumentationGap => "documentation_gap",
1581 FactorType::Custom => "custom",
1582 }
1583 }
1584}
1585
1586#[derive(Debug, Clone, Serialize, Deserialize)]
1588pub struct FactorEvidence {
1589 pub source: String,
1591 pub data: HashMap<String, String>,
1593}
1594
1595#[derive(Debug, Clone, Serialize, Deserialize)]
1597pub struct ContributingFactor {
1598 pub factor_type: FactorType,
1600 pub value: f64,
1602 pub threshold: f64,
1604 pub direction_greater: bool,
1606 pub weight: f64,
1608 pub description: String,
1610 pub evidence: Option<FactorEvidence>,
1612}
1613
1614impl ContributingFactor {
1615 pub fn new(
1617 factor_type: FactorType,
1618 value: f64,
1619 threshold: f64,
1620 direction_greater: bool,
1621 weight: f64,
1622 description: &str,
1623 ) -> Self {
1624 Self {
1625 factor_type,
1626 value,
1627 threshold,
1628 direction_greater,
1629 weight,
1630 description: description.to_string(),
1631 evidence: None,
1632 }
1633 }
1634
1635 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1637 self.evidence = Some(FactorEvidence {
1638 source: source.to_string(),
1639 data,
1640 });
1641 self
1642 }
1643
1644 pub fn contribution(&self) -> f64 {
1646 let deviation = if self.direction_greater {
1647 (self.value - self.threshold).max(0.0)
1648 } else {
1649 (self.threshold - self.value).max(0.0)
1650 };
1651
1652 let relative_deviation = if self.threshold.abs() > 0.001 {
1654 deviation / self.threshold.abs()
1655 } else {
1656 deviation
1657 };
1658
1659 (relative_deviation * self.weight).min(1.0)
1661 }
1662}
1663
1664#[derive(Debug, Clone, Serialize, Deserialize)]
1666pub struct EnhancedAnomalyLabel {
1667 pub base: LabeledAnomaly,
1669 pub category: AnomalyCategory,
1671 pub enhanced_confidence: f64,
1673 pub enhanced_severity: f64,
1675 pub contributing_factors: Vec<ContributingFactor>,
1677 pub secondary_categories: Vec<AnomalyCategory>,
1679}
1680
1681impl EnhancedAnomalyLabel {
1682 pub fn from_base(base: LabeledAnomaly) -> Self {
1684 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1685 let enhanced_confidence = base.confidence;
1686 let enhanced_severity = base.severity as f64 / 5.0;
1687
1688 Self {
1689 base,
1690 category,
1691 enhanced_confidence,
1692 enhanced_severity,
1693 contributing_factors: Vec::new(),
1694 secondary_categories: Vec::new(),
1695 }
1696 }
1697
1698 pub fn with_confidence(mut self, confidence: f64) -> Self {
1700 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1701 self
1702 }
1703
1704 pub fn with_severity(mut self, severity: f64) -> Self {
1706 self.enhanced_severity = severity.clamp(0.0, 1.0);
1707 self
1708 }
1709
1710 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1712 self.contributing_factors.push(factor);
1713 self
1714 }
1715
1716 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1718 if !self.secondary_categories.contains(&category) && category != self.category {
1719 self.secondary_categories.push(category);
1720 }
1721 self
1722 }
1723
1724 pub fn to_features(&self) -> Vec<f64> {
1728 let mut features = self.base.to_features();
1729
1730 features.push(self.enhanced_confidence);
1732 features.push(self.enhanced_severity);
1733 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1734 features.push(self.secondary_categories.len() as f64);
1735 features.push(self.contributing_factors.len() as f64);
1736
1737 let max_weight = self
1739 .contributing_factors
1740 .iter()
1741 .map(|f| f.weight)
1742 .fold(0.0, f64::max);
1743 features.push(max_weight);
1744
1745 let has_control_bypass = self
1747 .contributing_factors
1748 .iter()
1749 .any(|f| f.factor_type == FactorType::ControlBypass);
1750 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1751
1752 let has_amount_deviation = self
1753 .contributing_factors
1754 .iter()
1755 .any(|f| f.factor_type == FactorType::AmountDeviation);
1756 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1757
1758 let has_timing = self
1759 .contributing_factors
1760 .iter()
1761 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1762 features.push(if has_timing { 1.0 } else { 0.0 });
1763
1764 let has_pattern_match = self
1765 .contributing_factors
1766 .iter()
1767 .any(|f| f.factor_type == FactorType::PatternMatch);
1768 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1769
1770 features
1771 }
1772
1773 pub fn feature_count() -> usize {
1775 25 }
1777
1778 pub fn feature_names() -> Vec<&'static str> {
1780 let mut names = LabeledAnomaly::feature_names();
1781 names.extend(vec![
1782 "enhanced_confidence",
1783 "enhanced_severity",
1784 "category_ordinal",
1785 "secondary_category_count",
1786 "contributing_factor_count",
1787 "max_factor_weight",
1788 "has_control_bypass",
1789 "has_amount_deviation",
1790 "has_timing_factor",
1791 "has_pattern_match",
1792 ]);
1793 names
1794 }
1795}
1796
1797#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1803pub enum SeverityLevel {
1804 Low,
1806 #[default]
1808 Medium,
1809 High,
1811 Critical,
1813}
1814
1815impl SeverityLevel {
1816 pub fn numeric(&self) -> u8 {
1818 match self {
1819 SeverityLevel::Low => 1,
1820 SeverityLevel::Medium => 2,
1821 SeverityLevel::High => 3,
1822 SeverityLevel::Critical => 4,
1823 }
1824 }
1825
1826 pub fn from_numeric(value: u8) -> Self {
1828 match value {
1829 1 => SeverityLevel::Low,
1830 2 => SeverityLevel::Medium,
1831 3 => SeverityLevel::High,
1832 _ => SeverityLevel::Critical,
1833 }
1834 }
1835
1836 pub fn from_score(score: f64) -> Self {
1838 match score {
1839 s if s < 0.25 => SeverityLevel::Low,
1840 s if s < 0.50 => SeverityLevel::Medium,
1841 s if s < 0.75 => SeverityLevel::High,
1842 _ => SeverityLevel::Critical,
1843 }
1844 }
1845
1846 pub fn to_score(&self) -> f64 {
1848 match self {
1849 SeverityLevel::Low => 0.125,
1850 SeverityLevel::Medium => 0.375,
1851 SeverityLevel::High => 0.625,
1852 SeverityLevel::Critical => 0.875,
1853 }
1854 }
1855}
1856
1857#[derive(Debug, Clone, Serialize, Deserialize)]
1859pub struct AnomalySeverity {
1860 pub level: SeverityLevel,
1862 pub score: f64,
1864 pub financial_impact: Decimal,
1866 pub is_material: bool,
1868 #[serde(default, skip_serializing_if = "Option::is_none")]
1870 pub materiality_threshold: Option<Decimal>,
1871}
1872
1873impl AnomalySeverity {
1874 pub fn new(level: SeverityLevel, financial_impact: Decimal) -> Self {
1876 Self {
1877 level,
1878 score: level.to_score(),
1879 financial_impact,
1880 is_material: false,
1881 materiality_threshold: None,
1882 }
1883 }
1884
1885 pub fn from_score(score: f64, financial_impact: Decimal) -> Self {
1887 Self {
1888 level: SeverityLevel::from_score(score),
1889 score: score.clamp(0.0, 1.0),
1890 financial_impact,
1891 is_material: false,
1892 materiality_threshold: None,
1893 }
1894 }
1895
1896 pub fn with_materiality(mut self, threshold: Decimal) -> Self {
1898 self.materiality_threshold = Some(threshold);
1899 self.is_material = self.financial_impact.abs() >= threshold;
1900 self
1901 }
1902}
1903
1904impl Default for AnomalySeverity {
1905 fn default() -> Self {
1906 Self {
1907 level: SeverityLevel::Medium,
1908 score: 0.5,
1909 financial_impact: Decimal::ZERO,
1910 is_material: false,
1911 materiality_threshold: None,
1912 }
1913 }
1914}
1915
1916#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1924pub enum AnomalyDetectionDifficulty {
1925 Trivial,
1927 Easy,
1929 #[default]
1931 Moderate,
1932 Hard,
1934 Expert,
1936}
1937
1938impl AnomalyDetectionDifficulty {
1939 pub fn expected_detection_rate(&self) -> f64 {
1941 match self {
1942 AnomalyDetectionDifficulty::Trivial => 0.99,
1943 AnomalyDetectionDifficulty::Easy => 0.90,
1944 AnomalyDetectionDifficulty::Moderate => 0.70,
1945 AnomalyDetectionDifficulty::Hard => 0.40,
1946 AnomalyDetectionDifficulty::Expert => 0.15,
1947 }
1948 }
1949
1950 pub fn difficulty_score(&self) -> f64 {
1952 match self {
1953 AnomalyDetectionDifficulty::Trivial => 0.05,
1954 AnomalyDetectionDifficulty::Easy => 0.25,
1955 AnomalyDetectionDifficulty::Moderate => 0.50,
1956 AnomalyDetectionDifficulty::Hard => 0.75,
1957 AnomalyDetectionDifficulty::Expert => 0.95,
1958 }
1959 }
1960
1961 pub fn from_score(score: f64) -> Self {
1963 match score {
1964 s if s < 0.15 => AnomalyDetectionDifficulty::Trivial,
1965 s if s < 0.35 => AnomalyDetectionDifficulty::Easy,
1966 s if s < 0.55 => AnomalyDetectionDifficulty::Moderate,
1967 s if s < 0.75 => AnomalyDetectionDifficulty::Hard,
1968 _ => AnomalyDetectionDifficulty::Expert,
1969 }
1970 }
1971
1972 pub fn name(&self) -> &'static str {
1974 match self {
1975 AnomalyDetectionDifficulty::Trivial => "trivial",
1976 AnomalyDetectionDifficulty::Easy => "easy",
1977 AnomalyDetectionDifficulty::Moderate => "moderate",
1978 AnomalyDetectionDifficulty::Hard => "hard",
1979 AnomalyDetectionDifficulty::Expert => "expert",
1980 }
1981 }
1982}
1983
1984#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1988pub enum GroundTruthCertainty {
1989 #[default]
1991 Definite,
1992 Probable,
1994 Possible,
1996}
1997
1998impl GroundTruthCertainty {
1999 pub fn certainty_score(&self) -> f64 {
2001 match self {
2002 GroundTruthCertainty::Definite => 1.0,
2003 GroundTruthCertainty::Probable => 0.8,
2004 GroundTruthCertainty::Possible => 0.5,
2005 }
2006 }
2007
2008 pub fn name(&self) -> &'static str {
2010 match self {
2011 GroundTruthCertainty::Definite => "definite",
2012 GroundTruthCertainty::Probable => "probable",
2013 GroundTruthCertainty::Possible => "possible",
2014 }
2015 }
2016}
2017
2018#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2022pub enum DetectionMethod {
2023 RuleBased,
2025 Statistical,
2027 MachineLearning,
2029 GraphBased,
2031 ForensicAudit,
2033 Hybrid,
2035}
2036
2037impl DetectionMethod {
2038 pub fn name(&self) -> &'static str {
2040 match self {
2041 DetectionMethod::RuleBased => "rule_based",
2042 DetectionMethod::Statistical => "statistical",
2043 DetectionMethod::MachineLearning => "machine_learning",
2044 DetectionMethod::GraphBased => "graph_based",
2045 DetectionMethod::ForensicAudit => "forensic_audit",
2046 DetectionMethod::Hybrid => "hybrid",
2047 }
2048 }
2049
2050 pub fn description(&self) -> &'static str {
2052 match self {
2053 DetectionMethod::RuleBased => "Simple threshold and filter rules",
2054 DetectionMethod::Statistical => "Statistical distribution analysis",
2055 DetectionMethod::MachineLearning => "ML classification models",
2056 DetectionMethod::GraphBased => "Network and relationship analysis",
2057 DetectionMethod::ForensicAudit => "Manual forensic procedures",
2058 DetectionMethod::Hybrid => "Combined multi-method approach",
2059 }
2060 }
2061}
2062
2063#[derive(Debug, Clone, Serialize, Deserialize)]
2068pub struct ExtendedAnomalyLabel {
2069 pub base: LabeledAnomaly,
2071 pub category: AnomalyCategory,
2073 pub severity: AnomalySeverity,
2075 pub detection_difficulty: AnomalyDetectionDifficulty,
2077 pub recommended_methods: Vec<DetectionMethod>,
2079 pub key_indicators: Vec<String>,
2081 pub ground_truth_certainty: GroundTruthCertainty,
2083 pub contributing_factors: Vec<ContributingFactor>,
2085 pub related_entity_ids: Vec<String>,
2087 pub secondary_categories: Vec<AnomalyCategory>,
2089 #[serde(default, skip_serializing_if = "Option::is_none")]
2091 pub scheme_id: Option<String>,
2092 #[serde(default, skip_serializing_if = "Option::is_none")]
2094 pub scheme_stage: Option<u32>,
2095 #[serde(default)]
2097 pub is_near_miss: bool,
2098 #[serde(default, skip_serializing_if = "Option::is_none")]
2100 pub near_miss_explanation: Option<String>,
2101}
2102
2103impl ExtendedAnomalyLabel {
2104 pub fn from_base(base: LabeledAnomaly) -> Self {
2106 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
2107 let severity = AnomalySeverity {
2108 level: SeverityLevel::from_numeric(base.severity),
2109 score: base.severity as f64 / 5.0,
2110 financial_impact: base.monetary_impact.unwrap_or(Decimal::ZERO),
2111 is_material: false,
2112 materiality_threshold: None,
2113 };
2114
2115 Self {
2116 base,
2117 category,
2118 severity,
2119 detection_difficulty: AnomalyDetectionDifficulty::Moderate,
2120 recommended_methods: vec![DetectionMethod::RuleBased],
2121 key_indicators: Vec::new(),
2122 ground_truth_certainty: GroundTruthCertainty::Definite,
2123 contributing_factors: Vec::new(),
2124 related_entity_ids: Vec::new(),
2125 secondary_categories: Vec::new(),
2126 scheme_id: None,
2127 scheme_stage: None,
2128 is_near_miss: false,
2129 near_miss_explanation: None,
2130 }
2131 }
2132
2133 pub fn with_severity(mut self, severity: AnomalySeverity) -> Self {
2135 self.severity = severity;
2136 self
2137 }
2138
2139 pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
2141 self.detection_difficulty = difficulty;
2142 self
2143 }
2144
2145 pub fn with_method(mut self, method: DetectionMethod) -> Self {
2147 if !self.recommended_methods.contains(&method) {
2148 self.recommended_methods.push(method);
2149 }
2150 self
2151 }
2152
2153 pub fn with_methods(mut self, methods: Vec<DetectionMethod>) -> Self {
2155 self.recommended_methods = methods;
2156 self
2157 }
2158
2159 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
2161 self.key_indicators.push(indicator.into());
2162 self
2163 }
2164
2165 pub fn with_certainty(mut self, certainty: GroundTruthCertainty) -> Self {
2167 self.ground_truth_certainty = certainty;
2168 self
2169 }
2170
2171 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
2173 self.contributing_factors.push(factor);
2174 self
2175 }
2176
2177 pub fn with_entity(mut self, entity_id: impl Into<String>) -> Self {
2179 self.related_entity_ids.push(entity_id.into());
2180 self
2181 }
2182
2183 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
2185 if category != self.category && !self.secondary_categories.contains(&category) {
2186 self.secondary_categories.push(category);
2187 }
2188 self
2189 }
2190
2191 pub fn with_scheme(mut self, scheme_id: impl Into<String>, stage: u32) -> Self {
2193 self.scheme_id = Some(scheme_id.into());
2194 self.scheme_stage = Some(stage);
2195 self
2196 }
2197
2198 pub fn as_near_miss(mut self, explanation: impl Into<String>) -> Self {
2200 self.is_near_miss = true;
2201 self.near_miss_explanation = Some(explanation.into());
2202 self
2203 }
2204
2205 pub fn to_features(&self) -> Vec<f64> {
2209 let mut features = self.base.to_features();
2210
2211 features.push(self.severity.score);
2213 features.push(self.severity.level.to_score());
2214 features.push(if self.severity.is_material { 1.0 } else { 0.0 });
2215 features.push(self.detection_difficulty.difficulty_score());
2216 features.push(self.detection_difficulty.expected_detection_rate());
2217 features.push(self.ground_truth_certainty.certainty_score());
2218 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
2219 features.push(self.secondary_categories.len() as f64);
2220 features.push(self.contributing_factors.len() as f64);
2221 features.push(self.key_indicators.len() as f64);
2222 features.push(self.recommended_methods.len() as f64);
2223 features.push(self.related_entity_ids.len() as f64);
2224 features.push(if self.scheme_id.is_some() { 1.0 } else { 0.0 });
2225 features.push(self.scheme_stage.unwrap_or(0) as f64);
2226 features.push(if self.is_near_miss { 1.0 } else { 0.0 });
2227
2228 features
2229 }
2230
2231 pub fn feature_count() -> usize {
2233 30 }
2235
2236 pub fn feature_names() -> Vec<&'static str> {
2238 let mut names = LabeledAnomaly::feature_names();
2239 names.extend(vec![
2240 "severity_score",
2241 "severity_level_score",
2242 "is_material",
2243 "difficulty_score",
2244 "expected_detection_rate",
2245 "ground_truth_certainty",
2246 "category_ordinal",
2247 "secondary_category_count",
2248 "contributing_factor_count",
2249 "key_indicator_count",
2250 "recommended_method_count",
2251 "related_entity_count",
2252 "is_part_of_scheme",
2253 "scheme_stage",
2254 "is_near_miss",
2255 ]);
2256 names
2257 }
2258}
2259
2260#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2266pub enum SchemeType {
2267 GradualEmbezzlement,
2269 RevenueManipulation,
2271 VendorKickback,
2273 RoundTripping,
2275 GhostEmployee,
2277 ExpenseReimbursement,
2279 InventoryTheft,
2281 Custom,
2283}
2284
2285impl SchemeType {
2286 pub fn name(&self) -> &'static str {
2288 match self {
2289 SchemeType::GradualEmbezzlement => "gradual_embezzlement",
2290 SchemeType::RevenueManipulation => "revenue_manipulation",
2291 SchemeType::VendorKickback => "vendor_kickback",
2292 SchemeType::RoundTripping => "round_tripping",
2293 SchemeType::GhostEmployee => "ghost_employee",
2294 SchemeType::ExpenseReimbursement => "expense_reimbursement",
2295 SchemeType::InventoryTheft => "inventory_theft",
2296 SchemeType::Custom => "custom",
2297 }
2298 }
2299
2300 pub fn typical_stages(&self) -> u32 {
2302 match self {
2303 SchemeType::GradualEmbezzlement => 4, SchemeType::RevenueManipulation => 4, SchemeType::VendorKickback => 4, SchemeType::RoundTripping => 3, SchemeType::GhostEmployee => 3, SchemeType::ExpenseReimbursement => 3, SchemeType::InventoryTheft => 3, SchemeType::Custom => 4,
2311 }
2312 }
2313}
2314
2315#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2317pub enum SchemeDetectionStatus {
2318 #[default]
2320 Undetected,
2321 UnderInvestigation,
2323 PartiallyDetected,
2325 FullyDetected,
2327}
2328
2329#[derive(Debug, Clone, Serialize, Deserialize)]
2331pub struct SchemeTransactionRef {
2332 pub document_id: String,
2334 pub date: chrono::NaiveDate,
2336 pub amount: Decimal,
2338 pub stage: u32,
2340 #[serde(default, skip_serializing_if = "Option::is_none")]
2342 pub anomaly_id: Option<String>,
2343}
2344
2345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2347pub enum ConcealmentTechnique {
2348 DocumentManipulation,
2350 ApprovalCircumvention,
2352 TimingExploitation,
2354 TransactionSplitting,
2356 AccountMisclassification,
2358 Collusion,
2360 DataAlteration,
2362 FalseDocumentation,
2364}
2365
2366impl ConcealmentTechnique {
2367 pub fn difficulty_bonus(&self) -> f64 {
2369 match self {
2370 ConcealmentTechnique::DocumentManipulation => 0.20,
2371 ConcealmentTechnique::ApprovalCircumvention => 0.15,
2372 ConcealmentTechnique::TimingExploitation => 0.10,
2373 ConcealmentTechnique::TransactionSplitting => 0.15,
2374 ConcealmentTechnique::AccountMisclassification => 0.10,
2375 ConcealmentTechnique::Collusion => 0.25,
2376 ConcealmentTechnique::DataAlteration => 0.20,
2377 ConcealmentTechnique::FalseDocumentation => 0.15,
2378 }
2379 }
2380}
2381
2382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
2399pub enum AcfeFraudCategory {
2400 #[default]
2403 AssetMisappropriation,
2404 Corruption,
2407 FinancialStatementFraud,
2410}
2411
2412impl AcfeFraudCategory {
2413 pub fn name(&self) -> &'static str {
2415 match self {
2416 AcfeFraudCategory::AssetMisappropriation => "asset_misappropriation",
2417 AcfeFraudCategory::Corruption => "corruption",
2418 AcfeFraudCategory::FinancialStatementFraud => "financial_statement_fraud",
2419 }
2420 }
2421
2422 pub fn typical_occurrence_rate(&self) -> f64 {
2424 match self {
2425 AcfeFraudCategory::AssetMisappropriation => 0.86,
2426 AcfeFraudCategory::Corruption => 0.33,
2427 AcfeFraudCategory::FinancialStatementFraud => 0.10,
2428 }
2429 }
2430
2431 pub fn typical_median_loss(&self) -> Decimal {
2433 match self {
2434 AcfeFraudCategory::AssetMisappropriation => Decimal::new(100_000, 0),
2435 AcfeFraudCategory::Corruption => Decimal::new(150_000, 0),
2436 AcfeFraudCategory::FinancialStatementFraud => Decimal::new(954_000, 0),
2437 }
2438 }
2439
2440 pub fn typical_detection_months(&self) -> u32 {
2442 match self {
2443 AcfeFraudCategory::AssetMisappropriation => 12,
2444 AcfeFraudCategory::Corruption => 18,
2445 AcfeFraudCategory::FinancialStatementFraud => 24,
2446 }
2447 }
2448}
2449
2450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2457pub enum CashFraudScheme {
2458 Larceny,
2461 Skimming,
2463
2464 SalesSkimming,
2467 ReceivablesSkimming,
2469 RefundSchemes,
2471
2472 ShellCompany,
2475 NonAccompliceVendor,
2477 PersonalPurchases,
2479
2480 GhostEmployee,
2483 FalsifiedWages,
2485 CommissionSchemes,
2487
2488 MischaracterizedExpenses,
2491 OverstatedExpenses,
2493 FictitiousExpenses,
2495
2496 ForgedMaker,
2499 ForgedEndorsement,
2501 AlteredPayee,
2503 AuthorizedMaker,
2505
2506 FalseVoids,
2509 FalseRefunds,
2511}
2512
2513impl CashFraudScheme {
2514 pub fn category(&self) -> AcfeFraudCategory {
2516 AcfeFraudCategory::AssetMisappropriation
2517 }
2518
2519 pub fn subcategory(&self) -> &'static str {
2521 match self {
2522 CashFraudScheme::Larceny | CashFraudScheme::Skimming => "theft_of_cash_on_hand",
2523 CashFraudScheme::SalesSkimming
2524 | CashFraudScheme::ReceivablesSkimming
2525 | CashFraudScheme::RefundSchemes => "theft_of_cash_receipts",
2526 CashFraudScheme::ShellCompany
2527 | CashFraudScheme::NonAccompliceVendor
2528 | CashFraudScheme::PersonalPurchases => "billing_schemes",
2529 CashFraudScheme::GhostEmployee
2530 | CashFraudScheme::FalsifiedWages
2531 | CashFraudScheme::CommissionSchemes => "payroll_schemes",
2532 CashFraudScheme::MischaracterizedExpenses
2533 | CashFraudScheme::OverstatedExpenses
2534 | CashFraudScheme::FictitiousExpenses => "expense_reimbursement",
2535 CashFraudScheme::ForgedMaker
2536 | CashFraudScheme::ForgedEndorsement
2537 | CashFraudScheme::AlteredPayee
2538 | CashFraudScheme::AuthorizedMaker => "check_tampering",
2539 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => "register_schemes",
2540 }
2541 }
2542
2543 pub fn severity(&self) -> u8 {
2545 match self {
2546 CashFraudScheme::FalseVoids
2548 | CashFraudScheme::FalseRefunds
2549 | CashFraudScheme::MischaracterizedExpenses => 3,
2550 CashFraudScheme::OverstatedExpenses
2552 | CashFraudScheme::Skimming
2553 | CashFraudScheme::Larceny
2554 | CashFraudScheme::PersonalPurchases
2555 | CashFraudScheme::FalsifiedWages => 4,
2556 CashFraudScheme::ShellCompany
2558 | CashFraudScheme::GhostEmployee
2559 | CashFraudScheme::FictitiousExpenses
2560 | CashFraudScheme::ForgedMaker
2561 | CashFraudScheme::AuthorizedMaker => 5,
2562 _ => 4,
2563 }
2564 }
2565
2566 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2568 match self {
2569 CashFraudScheme::FalseVoids | CashFraudScheme::FalseRefunds => {
2571 AnomalyDetectionDifficulty::Easy
2572 }
2573 CashFraudScheme::Larceny | CashFraudScheme::OverstatedExpenses => {
2575 AnomalyDetectionDifficulty::Moderate
2576 }
2577 CashFraudScheme::Skimming
2579 | CashFraudScheme::ShellCompany
2580 | CashFraudScheme::GhostEmployee => AnomalyDetectionDifficulty::Hard,
2581 CashFraudScheme::SalesSkimming | CashFraudScheme::ReceivablesSkimming => {
2583 AnomalyDetectionDifficulty::Expert
2584 }
2585 _ => AnomalyDetectionDifficulty::Moderate,
2586 }
2587 }
2588
2589 pub fn all_variants() -> &'static [CashFraudScheme] {
2591 &[
2592 CashFraudScheme::Larceny,
2593 CashFraudScheme::Skimming,
2594 CashFraudScheme::SalesSkimming,
2595 CashFraudScheme::ReceivablesSkimming,
2596 CashFraudScheme::RefundSchemes,
2597 CashFraudScheme::ShellCompany,
2598 CashFraudScheme::NonAccompliceVendor,
2599 CashFraudScheme::PersonalPurchases,
2600 CashFraudScheme::GhostEmployee,
2601 CashFraudScheme::FalsifiedWages,
2602 CashFraudScheme::CommissionSchemes,
2603 CashFraudScheme::MischaracterizedExpenses,
2604 CashFraudScheme::OverstatedExpenses,
2605 CashFraudScheme::FictitiousExpenses,
2606 CashFraudScheme::ForgedMaker,
2607 CashFraudScheme::ForgedEndorsement,
2608 CashFraudScheme::AlteredPayee,
2609 CashFraudScheme::AuthorizedMaker,
2610 CashFraudScheme::FalseVoids,
2611 CashFraudScheme::FalseRefunds,
2612 ]
2613 }
2614}
2615
2616#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2618pub enum AssetFraudScheme {
2619 InventoryMisuse,
2622 InventoryTheft,
2624 InventoryPurchasingScheme,
2626 InventoryReceivingScheme,
2628
2629 EquipmentMisuse,
2632 EquipmentTheft,
2634 IntellectualPropertyTheft,
2636 TimeTheft,
2638}
2639
2640impl AssetFraudScheme {
2641 pub fn category(&self) -> AcfeFraudCategory {
2643 AcfeFraudCategory::AssetMisappropriation
2644 }
2645
2646 pub fn subcategory(&self) -> &'static str {
2648 match self {
2649 AssetFraudScheme::InventoryMisuse
2650 | AssetFraudScheme::InventoryTheft
2651 | AssetFraudScheme::InventoryPurchasingScheme
2652 | AssetFraudScheme::InventoryReceivingScheme => "inventory",
2653 _ => "other_assets",
2654 }
2655 }
2656
2657 pub fn severity(&self) -> u8 {
2659 match self {
2660 AssetFraudScheme::TimeTheft | AssetFraudScheme::EquipmentMisuse => 2,
2661 AssetFraudScheme::InventoryMisuse | AssetFraudScheme::EquipmentTheft => 3,
2662 AssetFraudScheme::InventoryTheft
2663 | AssetFraudScheme::InventoryPurchasingScheme
2664 | AssetFraudScheme::InventoryReceivingScheme => 4,
2665 AssetFraudScheme::IntellectualPropertyTheft => 5,
2666 }
2667 }
2668}
2669
2670#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2675pub enum CorruptionScheme {
2676 PurchasingConflict,
2679 SalesConflict,
2681 OutsideBusinessInterest,
2683 NepotismConflict,
2685
2686 InvoiceKickback,
2689 BidRigging,
2691 CashBribery,
2693 PublicOfficial,
2695
2696 IllegalGratuity,
2699
2700 EconomicExtortion,
2703}
2704
2705impl CorruptionScheme {
2706 pub fn category(&self) -> AcfeFraudCategory {
2708 AcfeFraudCategory::Corruption
2709 }
2710
2711 pub fn subcategory(&self) -> &'static str {
2713 match self {
2714 CorruptionScheme::PurchasingConflict
2715 | CorruptionScheme::SalesConflict
2716 | CorruptionScheme::OutsideBusinessInterest
2717 | CorruptionScheme::NepotismConflict => "conflicts_of_interest",
2718 CorruptionScheme::InvoiceKickback
2719 | CorruptionScheme::BidRigging
2720 | CorruptionScheme::CashBribery
2721 | CorruptionScheme::PublicOfficial => "bribery",
2722 CorruptionScheme::IllegalGratuity => "illegal_gratuities",
2723 CorruptionScheme::EconomicExtortion => "economic_extortion",
2724 }
2725 }
2726
2727 pub fn severity(&self) -> u8 {
2729 match self {
2730 CorruptionScheme::NepotismConflict => 3,
2732 CorruptionScheme::PurchasingConflict
2734 | CorruptionScheme::SalesConflict
2735 | CorruptionScheme::OutsideBusinessInterest
2736 | CorruptionScheme::IllegalGratuity => 4,
2737 CorruptionScheme::InvoiceKickback
2739 | CorruptionScheme::BidRigging
2740 | CorruptionScheme::CashBribery
2741 | CorruptionScheme::EconomicExtortion => 5,
2742 CorruptionScheme::PublicOfficial => 5,
2744 }
2745 }
2746
2747 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2749 match self {
2750 CorruptionScheme::NepotismConflict | CorruptionScheme::OutsideBusinessInterest => {
2752 AnomalyDetectionDifficulty::Moderate
2753 }
2754 CorruptionScheme::PurchasingConflict
2756 | CorruptionScheme::SalesConflict
2757 | CorruptionScheme::BidRigging => AnomalyDetectionDifficulty::Hard,
2758 CorruptionScheme::InvoiceKickback
2760 | CorruptionScheme::CashBribery
2761 | CorruptionScheme::PublicOfficial
2762 | CorruptionScheme::IllegalGratuity
2763 | CorruptionScheme::EconomicExtortion => AnomalyDetectionDifficulty::Expert,
2764 }
2765 }
2766
2767 pub fn all_variants() -> &'static [CorruptionScheme] {
2769 &[
2770 CorruptionScheme::PurchasingConflict,
2771 CorruptionScheme::SalesConflict,
2772 CorruptionScheme::OutsideBusinessInterest,
2773 CorruptionScheme::NepotismConflict,
2774 CorruptionScheme::InvoiceKickback,
2775 CorruptionScheme::BidRigging,
2776 CorruptionScheme::CashBribery,
2777 CorruptionScheme::PublicOfficial,
2778 CorruptionScheme::IllegalGratuity,
2779 CorruptionScheme::EconomicExtortion,
2780 ]
2781 }
2782}
2783
2784#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2789pub enum FinancialStatementScheme {
2790 PrematureRevenue,
2793 DelayedExpenses,
2795 FictitiousRevenues,
2797 ConcealedLiabilities,
2799 ImproperAssetValuations,
2801 ImproperDisclosures,
2803 ChannelStuffing,
2805 BillAndHold,
2807 ImproperCapitalization,
2809
2810 UnderstatedRevenues,
2813 OverstatedExpenses,
2815 OverstatedLiabilities,
2817 ImproperAssetWritedowns,
2819}
2820
2821impl FinancialStatementScheme {
2822 pub fn category(&self) -> AcfeFraudCategory {
2824 AcfeFraudCategory::FinancialStatementFraud
2825 }
2826
2827 pub fn subcategory(&self) -> &'static str {
2829 match self {
2830 FinancialStatementScheme::UnderstatedRevenues
2831 | FinancialStatementScheme::OverstatedExpenses
2832 | FinancialStatementScheme::OverstatedLiabilities
2833 | FinancialStatementScheme::ImproperAssetWritedowns => "understatement",
2834 _ => "overstatement",
2835 }
2836 }
2837
2838 pub fn severity(&self) -> u8 {
2840 5
2842 }
2843
2844 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2846 match self {
2847 FinancialStatementScheme::ChannelStuffing
2849 | FinancialStatementScheme::DelayedExpenses => AnomalyDetectionDifficulty::Moderate,
2850 FinancialStatementScheme::PrematureRevenue
2852 | FinancialStatementScheme::ImproperCapitalization
2853 | FinancialStatementScheme::ImproperAssetWritedowns => AnomalyDetectionDifficulty::Hard,
2854 FinancialStatementScheme::FictitiousRevenues
2856 | FinancialStatementScheme::ConcealedLiabilities
2857 | FinancialStatementScheme::ImproperAssetValuations
2858 | FinancialStatementScheme::ImproperDisclosures
2859 | FinancialStatementScheme::BillAndHold => AnomalyDetectionDifficulty::Expert,
2860 _ => AnomalyDetectionDifficulty::Hard,
2861 }
2862 }
2863
2864 pub fn all_variants() -> &'static [FinancialStatementScheme] {
2866 &[
2867 FinancialStatementScheme::PrematureRevenue,
2868 FinancialStatementScheme::DelayedExpenses,
2869 FinancialStatementScheme::FictitiousRevenues,
2870 FinancialStatementScheme::ConcealedLiabilities,
2871 FinancialStatementScheme::ImproperAssetValuations,
2872 FinancialStatementScheme::ImproperDisclosures,
2873 FinancialStatementScheme::ChannelStuffing,
2874 FinancialStatementScheme::BillAndHold,
2875 FinancialStatementScheme::ImproperCapitalization,
2876 FinancialStatementScheme::UnderstatedRevenues,
2877 FinancialStatementScheme::OverstatedExpenses,
2878 FinancialStatementScheme::OverstatedLiabilities,
2879 FinancialStatementScheme::ImproperAssetWritedowns,
2880 ]
2881 }
2882}
2883
2884#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2886pub enum AcfeScheme {
2887 Cash(CashFraudScheme),
2889 Asset(AssetFraudScheme),
2891 Corruption(CorruptionScheme),
2893 FinancialStatement(FinancialStatementScheme),
2895}
2896
2897impl AcfeScheme {
2898 pub fn category(&self) -> AcfeFraudCategory {
2900 match self {
2901 AcfeScheme::Cash(s) => s.category(),
2902 AcfeScheme::Asset(s) => s.category(),
2903 AcfeScheme::Corruption(s) => s.category(),
2904 AcfeScheme::FinancialStatement(s) => s.category(),
2905 }
2906 }
2907
2908 pub fn severity(&self) -> u8 {
2910 match self {
2911 AcfeScheme::Cash(s) => s.severity(),
2912 AcfeScheme::Asset(s) => s.severity(),
2913 AcfeScheme::Corruption(s) => s.severity(),
2914 AcfeScheme::FinancialStatement(s) => s.severity(),
2915 }
2916 }
2917
2918 pub fn detection_difficulty(&self) -> AnomalyDetectionDifficulty {
2920 match self {
2921 AcfeScheme::Cash(s) => s.detection_difficulty(),
2922 AcfeScheme::Asset(_) => AnomalyDetectionDifficulty::Moderate,
2923 AcfeScheme::Corruption(s) => s.detection_difficulty(),
2924 AcfeScheme::FinancialStatement(s) => s.detection_difficulty(),
2925 }
2926 }
2927}
2928
2929#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2931pub enum AcfeDetectionMethod {
2932 Tip,
2934 InternalAudit,
2936 ManagementReview,
2938 ExternalAudit,
2940 AccountReconciliation,
2942 DocumentExamination,
2944 ByAccident,
2946 ItControls,
2948 Surveillance,
2950 Confession,
2952 LawEnforcement,
2954 Other,
2956}
2957
2958impl AcfeDetectionMethod {
2959 pub fn typical_detection_rate(&self) -> f64 {
2961 match self {
2962 AcfeDetectionMethod::Tip => 0.42,
2963 AcfeDetectionMethod::InternalAudit => 0.16,
2964 AcfeDetectionMethod::ManagementReview => 0.12,
2965 AcfeDetectionMethod::ExternalAudit => 0.04,
2966 AcfeDetectionMethod::AccountReconciliation => 0.05,
2967 AcfeDetectionMethod::DocumentExamination => 0.04,
2968 AcfeDetectionMethod::ByAccident => 0.06,
2969 AcfeDetectionMethod::ItControls => 0.03,
2970 AcfeDetectionMethod::Surveillance => 0.02,
2971 AcfeDetectionMethod::Confession => 0.02,
2972 AcfeDetectionMethod::LawEnforcement => 0.01,
2973 AcfeDetectionMethod::Other => 0.03,
2974 }
2975 }
2976
2977 pub fn all_variants() -> &'static [AcfeDetectionMethod] {
2979 &[
2980 AcfeDetectionMethod::Tip,
2981 AcfeDetectionMethod::InternalAudit,
2982 AcfeDetectionMethod::ManagementReview,
2983 AcfeDetectionMethod::ExternalAudit,
2984 AcfeDetectionMethod::AccountReconciliation,
2985 AcfeDetectionMethod::DocumentExamination,
2986 AcfeDetectionMethod::ByAccident,
2987 AcfeDetectionMethod::ItControls,
2988 AcfeDetectionMethod::Surveillance,
2989 AcfeDetectionMethod::Confession,
2990 AcfeDetectionMethod::LawEnforcement,
2991 AcfeDetectionMethod::Other,
2992 ]
2993 }
2994}
2995
2996#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2998pub enum PerpetratorDepartment {
2999 Accounting,
3001 Operations,
3003 Executive,
3005 Sales,
3007 CustomerService,
3009 Purchasing,
3011 It,
3013 HumanResources,
3015 Administrative,
3017 Warehouse,
3019 BoardOfDirectors,
3021 Other,
3023}
3024
3025impl PerpetratorDepartment {
3026 pub fn typical_occurrence_rate(&self) -> f64 {
3028 match self {
3029 PerpetratorDepartment::Accounting => 0.21,
3030 PerpetratorDepartment::Operations => 0.17,
3031 PerpetratorDepartment::Executive => 0.12,
3032 PerpetratorDepartment::Sales => 0.11,
3033 PerpetratorDepartment::CustomerService => 0.07,
3034 PerpetratorDepartment::Purchasing => 0.06,
3035 PerpetratorDepartment::It => 0.05,
3036 PerpetratorDepartment::HumanResources => 0.04,
3037 PerpetratorDepartment::Administrative => 0.04,
3038 PerpetratorDepartment::Warehouse => 0.03,
3039 PerpetratorDepartment::BoardOfDirectors => 0.02,
3040 PerpetratorDepartment::Other => 0.08,
3041 }
3042 }
3043
3044 pub fn typical_median_loss(&self) -> Decimal {
3046 match self {
3047 PerpetratorDepartment::Executive => Decimal::new(600_000, 0),
3048 PerpetratorDepartment::BoardOfDirectors => Decimal::new(500_000, 0),
3049 PerpetratorDepartment::Sales => Decimal::new(150_000, 0),
3050 PerpetratorDepartment::Accounting => Decimal::new(130_000, 0),
3051 PerpetratorDepartment::Purchasing => Decimal::new(120_000, 0),
3052 PerpetratorDepartment::Operations => Decimal::new(100_000, 0),
3053 PerpetratorDepartment::It => Decimal::new(100_000, 0),
3054 _ => Decimal::new(80_000, 0),
3055 }
3056 }
3057}
3058
3059#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3061pub enum PerpetratorLevel {
3062 Employee,
3064 Manager,
3066 OwnerExecutive,
3068}
3069
3070impl PerpetratorLevel {
3071 pub fn typical_occurrence_rate(&self) -> f64 {
3073 match self {
3074 PerpetratorLevel::Employee => 0.42,
3075 PerpetratorLevel::Manager => 0.36,
3076 PerpetratorLevel::OwnerExecutive => 0.22,
3077 }
3078 }
3079
3080 pub fn typical_median_loss(&self) -> Decimal {
3082 match self {
3083 PerpetratorLevel::Employee => Decimal::new(50_000, 0),
3084 PerpetratorLevel::Manager => Decimal::new(125_000, 0),
3085 PerpetratorLevel::OwnerExecutive => Decimal::new(337_000, 0),
3086 }
3087 }
3088}
3089
3090#[derive(Debug, Clone, Serialize, Deserialize)]
3095pub struct AcfeCalibration {
3096 pub median_loss: Decimal,
3098 pub median_duration_months: u32,
3100 pub category_distribution: HashMap<String, f64>,
3102 pub detection_method_distribution: HashMap<String, f64>,
3104 pub department_distribution: HashMap<String, f64>,
3106 pub level_distribution: HashMap<String, f64>,
3108 pub avg_red_flags_per_case: f64,
3110 pub collusion_rate: f64,
3112}
3113
3114impl Default for AcfeCalibration {
3115 fn default() -> Self {
3116 let mut category_distribution = HashMap::new();
3117 category_distribution.insert("asset_misappropriation".to_string(), 0.86);
3118 category_distribution.insert("corruption".to_string(), 0.33);
3119 category_distribution.insert("financial_statement_fraud".to_string(), 0.10);
3120
3121 let mut detection_method_distribution = HashMap::new();
3122 for method in AcfeDetectionMethod::all_variants() {
3123 detection_method_distribution.insert(
3124 format!("{method:?}").to_lowercase(),
3125 method.typical_detection_rate(),
3126 );
3127 }
3128
3129 let mut department_distribution = HashMap::new();
3130 department_distribution.insert("accounting".to_string(), 0.21);
3131 department_distribution.insert("operations".to_string(), 0.17);
3132 department_distribution.insert("executive".to_string(), 0.12);
3133 department_distribution.insert("sales".to_string(), 0.11);
3134 department_distribution.insert("customer_service".to_string(), 0.07);
3135 department_distribution.insert("purchasing".to_string(), 0.06);
3136 department_distribution.insert("other".to_string(), 0.26);
3137
3138 let mut level_distribution = HashMap::new();
3139 level_distribution.insert("employee".to_string(), 0.42);
3140 level_distribution.insert("manager".to_string(), 0.36);
3141 level_distribution.insert("owner_executive".to_string(), 0.22);
3142
3143 Self {
3144 median_loss: Decimal::new(117_000, 0),
3145 median_duration_months: 12,
3146 category_distribution,
3147 detection_method_distribution,
3148 department_distribution,
3149 level_distribution,
3150 avg_red_flags_per_case: 2.8,
3151 collusion_rate: 0.50,
3152 }
3153 }
3154}
3155
3156impl AcfeCalibration {
3157 pub fn new(median_loss: Decimal, median_duration_months: u32) -> Self {
3159 Self {
3160 median_loss,
3161 median_duration_months,
3162 ..Self::default()
3163 }
3164 }
3165
3166 pub fn median_loss_for_category(&self, category: AcfeFraudCategory) -> Decimal {
3168 category.typical_median_loss()
3169 }
3170
3171 pub fn median_duration_for_category(&self, category: AcfeFraudCategory) -> u32 {
3173 category.typical_detection_months()
3174 }
3175
3176 pub fn validate(&self) -> Result<(), String> {
3178 if self.median_loss <= Decimal::ZERO {
3179 return Err("Median loss must be positive".to_string());
3180 }
3181 if self.median_duration_months == 0 {
3182 return Err("Median duration must be at least 1 month".to_string());
3183 }
3184 if self.collusion_rate < 0.0 || self.collusion_rate > 1.0 {
3185 return Err("Collusion rate must be between 0.0 and 1.0".to_string());
3186 }
3187 Ok(())
3188 }
3189}
3190
3191#[derive(Debug, Clone, Serialize, Deserialize)]
3196pub struct FraudTriangle {
3197 pub pressure: PressureType,
3199 pub opportunities: Vec<OpportunityFactor>,
3201 pub rationalization: Rationalization,
3203}
3204
3205impl FraudTriangle {
3206 pub fn new(
3208 pressure: PressureType,
3209 opportunities: Vec<OpportunityFactor>,
3210 rationalization: Rationalization,
3211 ) -> Self {
3212 Self {
3213 pressure,
3214 opportunities,
3215 rationalization,
3216 }
3217 }
3218
3219 pub fn risk_score(&self) -> f64 {
3221 let pressure_score = self.pressure.risk_weight();
3222 let opportunity_score: f64 = self
3223 .opportunities
3224 .iter()
3225 .map(OpportunityFactor::risk_weight)
3226 .sum::<f64>()
3227 / self.opportunities.len().max(1) as f64;
3228 let rationalization_score = self.rationalization.risk_weight();
3229
3230 (pressure_score + opportunity_score + rationalization_score) / 3.0
3231 }
3232}
3233
3234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3236pub enum PressureType {
3237 PersonalFinancialDifficulties,
3240 FinancialTargets,
3242 MarketExpectations,
3244 CovenantCompliance,
3246 CreditRatingMaintenance,
3248 AcquisitionValuation,
3250
3251 JobSecurity,
3254 StatusMaintenance,
3256 GamblingAddiction,
3258 SubstanceAbuse,
3260 FamilyPressure,
3262 Greed,
3264}
3265
3266impl PressureType {
3267 pub fn risk_weight(&self) -> f64 {
3269 match self {
3270 PressureType::PersonalFinancialDifficulties => 0.80,
3271 PressureType::FinancialTargets => 0.75,
3272 PressureType::MarketExpectations => 0.70,
3273 PressureType::CovenantCompliance => 0.85,
3274 PressureType::CreditRatingMaintenance => 0.70,
3275 PressureType::AcquisitionValuation => 0.75,
3276 PressureType::JobSecurity => 0.65,
3277 PressureType::StatusMaintenance => 0.55,
3278 PressureType::GamblingAddiction => 0.90,
3279 PressureType::SubstanceAbuse => 0.85,
3280 PressureType::FamilyPressure => 0.60,
3281 PressureType::Greed => 0.70,
3282 }
3283 }
3284}
3285
3286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3288pub enum OpportunityFactor {
3289 WeakInternalControls,
3291 LackOfSegregation,
3293 ManagementOverride,
3295 ComplexTransactions,
3297 RelatedPartyTransactions,
3299 PoorToneAtTop,
3301 InadequateSupervision,
3303 AssetAccess,
3305 PoorRecordKeeping,
3307 LackOfDiscipline,
3309 LackOfIndependentChecks,
3311}
3312
3313impl OpportunityFactor {
3314 pub fn risk_weight(&self) -> f64 {
3316 match self {
3317 OpportunityFactor::WeakInternalControls => 0.85,
3318 OpportunityFactor::LackOfSegregation => 0.80,
3319 OpportunityFactor::ManagementOverride => 0.90,
3320 OpportunityFactor::ComplexTransactions => 0.70,
3321 OpportunityFactor::RelatedPartyTransactions => 0.75,
3322 OpportunityFactor::PoorToneAtTop => 0.85,
3323 OpportunityFactor::InadequateSupervision => 0.75,
3324 OpportunityFactor::AssetAccess => 0.70,
3325 OpportunityFactor::PoorRecordKeeping => 0.65,
3326 OpportunityFactor::LackOfDiscipline => 0.60,
3327 OpportunityFactor::LackOfIndependentChecks => 0.75,
3328 }
3329 }
3330}
3331
3332#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3334pub enum Rationalization {
3335 TemporaryBorrowing,
3337 EveryoneDoesIt,
3339 ForTheCompanyGood,
3341 Entitlement,
3343 FollowingOrders,
3345 TheyWontMissIt,
3347 NeedItMore,
3349 NotReallyStealing,
3351 Underpaid,
3353 VictimlessCrime,
3355}
3356
3357impl Rationalization {
3358 pub fn risk_weight(&self) -> f64 {
3360 match self {
3361 Rationalization::Entitlement => 0.85,
3363 Rationalization::EveryoneDoesIt => 0.80,
3364 Rationalization::NotReallyStealing => 0.80,
3365 Rationalization::TheyWontMissIt => 0.75,
3366 Rationalization::Underpaid => 0.70,
3368 Rationalization::ForTheCompanyGood => 0.65,
3369 Rationalization::NeedItMore => 0.65,
3370 Rationalization::TemporaryBorrowing => 0.60,
3372 Rationalization::FollowingOrders => 0.55,
3373 Rationalization::VictimlessCrime => 0.60,
3374 }
3375 }
3376}
3377
3378#[derive(Debug, Clone, Serialize, Deserialize)]
3384pub enum NearMissPattern {
3385 NearDuplicate {
3387 date_difference_days: u32,
3389 similar_transaction_id: String,
3391 },
3392 ThresholdProximity {
3394 threshold: Decimal,
3396 proximity: f64,
3398 },
3399 UnusualLegitimate {
3401 pattern_type: LegitimatePatternType,
3403 justification: String,
3405 },
3406 CorrectedError {
3408 correction_lag_days: u32,
3410 correction_document_id: String,
3412 },
3413}
3414
3415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3417pub enum LegitimatePatternType {
3418 YearEndBonus,
3420 ContractPrepayment,
3422 SettlementPayment,
3424 InsuranceClaim,
3426 OneTimePayment,
3428 AssetDisposal,
3430 SeasonalInventory,
3432 PromotionalSpending,
3434}
3435
3436impl LegitimatePatternType {
3437 pub fn description(&self) -> &'static str {
3439 match self {
3440 LegitimatePatternType::YearEndBonus => "Year-end bonus payment",
3441 LegitimatePatternType::ContractPrepayment => "Contract prepayment per terms",
3442 LegitimatePatternType::SettlementPayment => "Legal settlement payment",
3443 LegitimatePatternType::InsuranceClaim => "Insurance claim reimbursement",
3444 LegitimatePatternType::OneTimePayment => "One-time vendor payment",
3445 LegitimatePatternType::AssetDisposal => "Fixed asset disposal",
3446 LegitimatePatternType::SeasonalInventory => "Seasonal inventory buildup",
3447 LegitimatePatternType::PromotionalSpending => "Promotional campaign spending",
3448 }
3449 }
3450}
3451
3452#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
3454pub enum FalsePositiveTrigger {
3455 AmountNearThreshold,
3457 UnusualTiming,
3459 SimilarTransaction,
3461 NewCounterparty,
3463 UnusualAccountCombination,
3465 VolumeSpike,
3467 RoundAmount,
3469}
3470
3471#[derive(Debug, Clone, Serialize, Deserialize)]
3473pub struct NearMissLabel {
3474 pub document_id: String,
3476 pub pattern: NearMissPattern,
3478 pub suspicion_score: f64,
3480 pub false_positive_trigger: FalsePositiveTrigger,
3482 pub explanation: String,
3484}
3485
3486impl NearMissLabel {
3487 pub fn new(
3489 document_id: impl Into<String>,
3490 pattern: NearMissPattern,
3491 suspicion_score: f64,
3492 trigger: FalsePositiveTrigger,
3493 explanation: impl Into<String>,
3494 ) -> Self {
3495 Self {
3496 document_id: document_id.into(),
3497 pattern,
3498 suspicion_score: suspicion_score.clamp(0.0, 1.0),
3499 false_positive_trigger: trigger,
3500 explanation: explanation.into(),
3501 }
3502 }
3503}
3504
3505#[derive(Debug, Clone, Serialize, Deserialize)]
3507pub struct AnomalyRateConfig {
3508 pub total_rate: f64,
3510 pub fraud_rate: f64,
3512 pub error_rate: f64,
3514 pub process_issue_rate: f64,
3516 pub statistical_rate: f64,
3518 pub relational_rate: f64,
3520}
3521
3522impl Default for AnomalyRateConfig {
3523 fn default() -> Self {
3524 Self {
3525 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, }
3532 }
3533}
3534
3535impl AnomalyRateConfig {
3536 pub fn validate(&self) -> Result<(), String> {
3538 let sum = self.fraud_rate
3539 + self.error_rate
3540 + self.process_issue_rate
3541 + self.statistical_rate
3542 + self.relational_rate;
3543
3544 if (sum - 1.0).abs() > 0.01 {
3545 return Err(format!("Anomaly category rates must sum to 1.0, got {sum}"));
3546 }
3547
3548 if self.total_rate < 0.0 || self.total_rate > 1.0 {
3549 return Err(format!(
3550 "Total rate must be between 0.0 and 1.0, got {}",
3551 self.total_rate
3552 ));
3553 }
3554
3555 Ok(())
3556 }
3557}
3558
3559#[cfg(test)]
3560mod tests {
3561 use super::*;
3562 use rust_decimal_macros::dec;
3563
3564 #[test]
3565 fn observability_class_maps_families_to_arms() {
3566 use ObservabilityClass::*;
3567 assert_eq!(
3569 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction)
3570 .observability_class(),
3571 RelationalGraph
3572 );
3573 assert_eq!(
3574 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity)
3575 .observability_class(),
3576 RelationalGraph
3577 );
3578 assert_eq!(
3580 AnomalyType::Fraud(FraudType::RoundTripping).observability_class(),
3581 RelationalGraph
3582 );
3583 assert_eq!(
3584 AnomalyType::Fraud(FraudType::DuplicatePayment).observability_class(),
3585 MemoryOnly
3586 );
3587 assert_eq!(
3588 AnomalyType::Fraud(FraudType::PrematureRevenue).observability_class(),
3589 Temporal
3590 );
3591 assert_eq!(
3593 AnomalyType::Fraud(FraudType::RoundDollarManipulation).observability_class(),
3594 PerJeDensity
3595 );
3596 assert_eq!(
3598 AnomalyType::Error(ErrorType::WrongPeriod).observability_class(),
3599 Temporal
3600 );
3601 assert_eq!(
3602 AnomalyType::Error(ErrorType::DuplicateEntry).observability_class(),
3603 MemoryOnly
3604 );
3605 assert_eq!(
3607 AnomalyType::Statistical(StatisticalAnomalyType::TransactionBurst)
3608 .observability_class(),
3609 Temporal
3610 );
3611 assert_eq!(
3612 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation)
3613 .observability_class(),
3614 PerJeDensity
3615 );
3616 }
3617
3618 #[test]
3619 fn labeled_anomaly_new_populates_observability_and_serializes_snake_case() {
3620 let a = LabeledAnomaly::new(
3621 "A1".to_string(),
3622 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction),
3623 "JE1".to_string(),
3624 "JE".to_string(),
3625 "1000".to_string(),
3626 NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
3627 );
3628 assert_eq!(a.observability, ObservabilityClass::RelationalGraph);
3630 let json = serde_json::to_string(&a).expect("serialize");
3632 assert!(json.contains("\"observability\":\"relational_graph\""));
3633 assert_eq!(
3634 ObservabilityClass::RelationalGraph.as_str(),
3635 "relational_graph"
3636 );
3637 let back: LabeledAnomaly = serde_json::from_str(&json).expect("deserialize");
3638 assert_eq!(back.observability, ObservabilityClass::RelationalGraph);
3639 let legacy = json.replacen(",\"observability\":\"relational_graph\"", "", 1);
3641 let parsed: LabeledAnomaly = serde_json::from_str(&legacy).expect("legacy deserialize");
3642 assert_eq!(parsed.observability, ObservabilityClass::PerJeDensity);
3643 }
3644
3645 #[test]
3646 fn test_anomaly_type_category() {
3647 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
3648 assert_eq!(fraud.category(), "Fraud");
3649 assert!(fraud.is_intentional());
3650
3651 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
3652 assert_eq!(error.category(), "Error");
3653 assert!(!error.is_intentional());
3654 }
3655
3656 #[test]
3657 fn test_labeled_anomaly() {
3658 let anomaly = LabeledAnomaly::new(
3659 "ANO001".to_string(),
3660 AnomalyType::Fraud(FraudType::SelfApproval),
3661 "JE001".to_string(),
3662 "JE".to_string(),
3663 "1000".to_string(),
3664 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3665 )
3666 .with_description("User approved their own expense report")
3667 .with_related_entity("USER001");
3668
3669 assert_eq!(anomaly.severity, 3);
3670 assert!(anomaly.is_injected);
3671 assert_eq!(anomaly.related_entities.len(), 1);
3672 }
3673
3674 #[test]
3675 fn test_labeled_anomaly_with_provenance() {
3676 let anomaly = LabeledAnomaly::new(
3677 "ANO001".to_string(),
3678 AnomalyType::Fraud(FraudType::SelfApproval),
3679 "JE001".to_string(),
3680 "JE".to_string(),
3681 "1000".to_string(),
3682 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3683 )
3684 .with_run_id("run-123")
3685 .with_generation_seed(42)
3686 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
3687 .with_structured_strategy(InjectionStrategy::SelfApproval {
3688 user_id: "USER001".to_string(),
3689 })
3690 .with_scenario("scenario-001")
3691 .with_original_document_hash("abc123");
3692
3693 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
3694 assert_eq!(anomaly.generation_seed, Some(42));
3695 assert!(anomaly.causal_reason.is_some());
3696 assert!(anomaly.structured_strategy.is_some());
3697 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
3698 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
3699
3700 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
3702 }
3703
3704 #[test]
3705 fn test_labeled_anomaly_derivation_chain() {
3706 let parent = LabeledAnomaly::new(
3707 "ANO001".to_string(),
3708 AnomalyType::Fraud(FraudType::DuplicatePayment),
3709 "JE001".to_string(),
3710 "JE".to_string(),
3711 "1000".to_string(),
3712 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3713 );
3714
3715 let child = LabeledAnomaly::new(
3716 "ANO002".to_string(),
3717 AnomalyType::Error(ErrorType::DuplicateEntry),
3718 "JE002".to_string(),
3719 "JE".to_string(),
3720 "1000".to_string(),
3721 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3722 )
3723 .with_parent_anomaly(&parent.anomaly_id);
3724
3725 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
3726 }
3727
3728 #[test]
3729 fn test_injection_strategy_description() {
3730 let strategy = InjectionStrategy::AmountManipulation {
3731 original: dec!(1000),
3732 factor: 2.5,
3733 };
3734 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
3735 assert_eq!(strategy.strategy_type(), "AmountManipulation");
3736
3737 let strategy = InjectionStrategy::ThresholdAvoidance {
3738 threshold: dec!(10000),
3739 adjusted_amount: dec!(9999),
3740 };
3741 assert_eq!(
3742 strategy.description(),
3743 "Amount adjusted to avoid 10000 threshold"
3744 );
3745
3746 let strategy = InjectionStrategy::DateShift {
3747 days_shifted: -5,
3748 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3749 };
3750 assert_eq!(strategy.description(), "Date backdated by 5 days");
3751
3752 let strategy = InjectionStrategy::DateShift {
3753 days_shifted: 3,
3754 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3755 };
3756 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
3757 }
3758
3759 #[test]
3760 fn test_causal_reason_variants() {
3761 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
3762 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
3763 assert!((base_rate - 0.02).abs() < 0.001);
3764 }
3765
3766 let reason = AnomalyCausalReason::TemporalPattern {
3767 pattern_name: "year_end_spike".to_string(),
3768 };
3769 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
3770 assert_eq!(pattern_name, "year_end_spike");
3771 }
3772
3773 let reason = AnomalyCausalReason::ScenarioStep {
3774 scenario_type: "kickback".to_string(),
3775 step_number: 3,
3776 };
3777 if let AnomalyCausalReason::ScenarioStep {
3778 scenario_type,
3779 step_number,
3780 } = reason
3781 {
3782 assert_eq!(scenario_type, "kickback");
3783 assert_eq!(step_number, 3);
3784 }
3785 }
3786
3787 #[test]
3788 fn test_feature_vector_length() {
3789 let anomaly = LabeledAnomaly::new(
3790 "ANO001".to_string(),
3791 AnomalyType::Fraud(FraudType::SelfApproval),
3792 "JE001".to_string(),
3793 "JE".to_string(),
3794 "1000".to_string(),
3795 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3796 );
3797
3798 let features = anomaly.to_features();
3799 assert_eq!(features.len(), LabeledAnomaly::feature_count());
3800 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
3801 }
3802
3803 #[test]
3804 fn test_feature_vector_with_provenance() {
3805 let anomaly = LabeledAnomaly::new(
3806 "ANO001".to_string(),
3807 AnomalyType::Fraud(FraudType::SelfApproval),
3808 "JE001".to_string(),
3809 "JE".to_string(),
3810 "1000".to_string(),
3811 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3812 )
3813 .with_scenario("scenario-001")
3814 .with_parent_anomaly("ANO000");
3815
3816 let features = anomaly.to_features();
3817
3818 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
3822
3823 #[test]
3824 fn test_anomaly_summary() {
3825 let anomalies = vec![
3826 LabeledAnomaly::new(
3827 "ANO001".to_string(),
3828 AnomalyType::Fraud(FraudType::SelfApproval),
3829 "JE001".to_string(),
3830 "JE".to_string(),
3831 "1000".to_string(),
3832 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3833 ),
3834 LabeledAnomaly::new(
3835 "ANO002".to_string(),
3836 AnomalyType::Error(ErrorType::DuplicateEntry),
3837 "JE002".to_string(),
3838 "JE".to_string(),
3839 "1000".to_string(),
3840 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
3841 ),
3842 ];
3843
3844 let summary = AnomalySummary::from_anomalies(&anomalies);
3845
3846 assert_eq!(summary.total_count, 2);
3847 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
3848 assert_eq!(summary.by_category.get("Error"), Some(&1));
3849 }
3850
3851 #[test]
3852 fn test_rate_config_validation() {
3853 let config = AnomalyRateConfig::default();
3854 assert!(config.validate().is_ok());
3855
3856 let bad_config = AnomalyRateConfig {
3857 fraud_rate: 0.5,
3858 error_rate: 0.5,
3859 process_issue_rate: 0.5, ..Default::default()
3861 };
3862 assert!(bad_config.validate().is_err());
3863 }
3864
3865 #[test]
3866 fn test_injection_strategy_serialization() {
3867 let strategy = InjectionStrategy::SoDViolation {
3868 duty1: "CreatePO".to_string(),
3869 duty2: "ApprovePO".to_string(),
3870 violating_user: "USER001".to_string(),
3871 };
3872
3873 let json = serde_json::to_string(&strategy).unwrap();
3874 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
3875
3876 assert_eq!(strategy, deserialized);
3877 }
3878
3879 #[test]
3880 fn test_labeled_anomaly_serialization_with_provenance() {
3881 let anomaly = LabeledAnomaly::new(
3882 "ANO001".to_string(),
3883 AnomalyType::Fraud(FraudType::SelfApproval),
3884 "JE001".to_string(),
3885 "JE".to_string(),
3886 "1000".to_string(),
3887 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3888 )
3889 .with_run_id("run-123")
3890 .with_generation_seed(42)
3891 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
3892
3893 let json = serde_json::to_string(&anomaly).unwrap();
3894 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
3895
3896 assert_eq!(anomaly.run_id, deserialized.run_id);
3897 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
3898 }
3899
3900 #[test]
3905 fn test_anomaly_category_from_anomaly_type() {
3906 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
3908 assert_eq!(
3909 AnomalyCategory::from_anomaly_type(&fraud_vendor),
3910 AnomalyCategory::FictitiousVendor
3911 );
3912
3913 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
3914 assert_eq!(
3915 AnomalyCategory::from_anomaly_type(&fraud_kickback),
3916 AnomalyCategory::VendorKickback
3917 );
3918
3919 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
3920 assert_eq!(
3921 AnomalyCategory::from_anomaly_type(&fraud_structured),
3922 AnomalyCategory::StructuredTransaction
3923 );
3924
3925 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
3927 assert_eq!(
3928 AnomalyCategory::from_anomaly_type(&error_duplicate),
3929 AnomalyCategory::DuplicatePayment
3930 );
3931
3932 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
3934 assert_eq!(
3935 AnomalyCategory::from_anomaly_type(&process_skip),
3936 AnomalyCategory::MissingApproval
3937 );
3938
3939 let relational_circular =
3941 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
3942 assert_eq!(
3943 AnomalyCategory::from_anomaly_type(&relational_circular),
3944 AnomalyCategory::CircularFlow
3945 );
3946 }
3947
3948 #[test]
3949 fn test_anomaly_category_ordinal() {
3950 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
3951 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
3952 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
3953 }
3954
3955 #[test]
3956 fn test_contributing_factor() {
3957 let factor = ContributingFactor::new(
3958 FactorType::AmountDeviation,
3959 15000.0,
3960 10000.0,
3961 true,
3962 0.5,
3963 "Amount exceeds threshold",
3964 );
3965
3966 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
3967 assert_eq!(factor.value, 15000.0);
3968 assert_eq!(factor.threshold, 10000.0);
3969 assert!(factor.direction_greater);
3970
3971 let contribution = factor.contribution();
3973 assert!((contribution - 0.25).abs() < 0.01);
3974 }
3975
3976 #[test]
3977 fn test_contributing_factor_with_evidence() {
3978 let mut data = HashMap::new();
3979 data.insert("expected".to_string(), "10000".to_string());
3980 data.insert("actual".to_string(), "15000".to_string());
3981
3982 let factor = ContributingFactor::new(
3983 FactorType::AmountDeviation,
3984 15000.0,
3985 10000.0,
3986 true,
3987 0.5,
3988 "Amount deviation detected",
3989 )
3990 .with_evidence("transaction_history", data);
3991
3992 assert!(factor.evidence.is_some());
3993 let evidence = factor.evidence.unwrap();
3994 assert_eq!(evidence.source, "transaction_history");
3995 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
3996 }
3997
3998 #[test]
3999 fn test_enhanced_anomaly_label() {
4000 let base = LabeledAnomaly::new(
4001 "ANO001".to_string(),
4002 AnomalyType::Fraud(FraudType::DuplicatePayment),
4003 "JE001".to_string(),
4004 "JE".to_string(),
4005 "1000".to_string(),
4006 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
4007 );
4008
4009 let enhanced = EnhancedAnomalyLabel::from_base(base)
4010 .with_confidence(0.85)
4011 .with_severity(0.7)
4012 .with_factor(ContributingFactor::new(
4013 FactorType::DuplicateIndicator,
4014 1.0,
4015 0.5,
4016 true,
4017 0.4,
4018 "Duplicate payment detected",
4019 ))
4020 .with_secondary_category(AnomalyCategory::StructuredTransaction);
4021
4022 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
4023 assert_eq!(enhanced.enhanced_confidence, 0.85);
4024 assert_eq!(enhanced.enhanced_severity, 0.7);
4025 assert_eq!(enhanced.contributing_factors.len(), 1);
4026 assert_eq!(enhanced.secondary_categories.len(), 1);
4027 }
4028
4029 #[test]
4030 fn test_enhanced_anomaly_label_features() {
4031 let base = LabeledAnomaly::new(
4032 "ANO001".to_string(),
4033 AnomalyType::Fraud(FraudType::SelfApproval),
4034 "JE001".to_string(),
4035 "JE".to_string(),
4036 "1000".to_string(),
4037 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
4038 );
4039
4040 let enhanced = EnhancedAnomalyLabel::from_base(base)
4041 .with_confidence(0.9)
4042 .with_severity(0.8)
4043 .with_factor(ContributingFactor::new(
4044 FactorType::ControlBypass,
4045 1.0,
4046 0.0,
4047 true,
4048 0.5,
4049 "Control bypass detected",
4050 ));
4051
4052 let features = enhanced.to_features();
4053
4054 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
4056 assert_eq!(features.len(), 25);
4057
4058 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
4064
4065 #[test]
4066 fn test_enhanced_anomaly_label_feature_names() {
4067 let names = EnhancedAnomalyLabel::feature_names();
4068 assert_eq!(names.len(), 25);
4069 assert!(names.contains(&"enhanced_confidence"));
4070 assert!(names.contains(&"enhanced_severity"));
4071 assert!(names.contains(&"has_control_bypass"));
4072 }
4073
4074 #[test]
4075 fn test_factor_type_names() {
4076 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
4077 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
4078 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
4079 }
4080
4081 #[test]
4082 fn test_anomaly_category_serialization() {
4083 let category = AnomalyCategory::CircularFlow;
4084 let json = serde_json::to_string(&category).unwrap();
4085 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
4086 assert_eq!(category, deserialized);
4087
4088 let custom = AnomalyCategory::Custom("custom_type".to_string());
4089 let json = serde_json::to_string(&custom).unwrap();
4090 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
4091 assert_eq!(custom, deserialized);
4092 }
4093
4094 #[test]
4095 fn test_enhanced_label_secondary_category_dedup() {
4096 let base = LabeledAnomaly::new(
4097 "ANO001".to_string(),
4098 AnomalyType::Fraud(FraudType::DuplicatePayment),
4099 "JE001".to_string(),
4100 "JE".to_string(),
4101 "1000".to_string(),
4102 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
4103 );
4104
4105 let enhanced = EnhancedAnomalyLabel::from_base(base)
4106 .with_secondary_category(AnomalyCategory::DuplicatePayment)
4108 .with_secondary_category(AnomalyCategory::TimingAnomaly)
4110 .with_secondary_category(AnomalyCategory::TimingAnomaly);
4112
4113 assert_eq!(enhanced.secondary_categories.len(), 1);
4115 assert_eq!(
4116 enhanced.secondary_categories[0],
4117 AnomalyCategory::TimingAnomaly
4118 );
4119 }
4120
4121 #[test]
4126 fn test_revenue_recognition_fraud_types() {
4127 let fraud_types = [
4129 FraudType::ImproperRevenueRecognition,
4130 FraudType::ImproperPoAllocation,
4131 FraudType::VariableConsiderationManipulation,
4132 FraudType::ContractModificationMisstatement,
4133 ];
4134
4135 for fraud_type in fraud_types {
4136 let anomaly_type = AnomalyType::Fraud(fraud_type);
4137 assert_eq!(anomaly_type.category(), "Fraud");
4138 assert!(anomaly_type.is_intentional());
4139 assert!(anomaly_type.severity() >= 3);
4140 }
4141 }
4142
4143 #[test]
4144 fn test_lease_accounting_fraud_types() {
4145 let fraud_types = [
4147 FraudType::LeaseClassificationManipulation,
4148 FraudType::OffBalanceSheetLease,
4149 FraudType::LeaseLiabilityUnderstatement,
4150 FraudType::RouAssetMisstatement,
4151 ];
4152
4153 for fraud_type in fraud_types {
4154 let anomaly_type = AnomalyType::Fraud(fraud_type);
4155 assert_eq!(anomaly_type.category(), "Fraud");
4156 assert!(anomaly_type.is_intentional());
4157 assert!(anomaly_type.severity() >= 3);
4158 }
4159
4160 assert_eq!(FraudType::OffBalanceSheetLease.severity(), 5);
4162 }
4163
4164 #[test]
4165 fn test_fair_value_fraud_types() {
4166 let fraud_types = [
4168 FraudType::FairValueHierarchyManipulation,
4169 FraudType::Level3InputManipulation,
4170 FraudType::ValuationTechniqueManipulation,
4171 ];
4172
4173 for fraud_type in fraud_types {
4174 let anomaly_type = AnomalyType::Fraud(fraud_type);
4175 assert_eq!(anomaly_type.category(), "Fraud");
4176 assert!(anomaly_type.is_intentional());
4177 assert!(anomaly_type.severity() >= 4);
4178 }
4179
4180 assert_eq!(FraudType::Level3InputManipulation.severity(), 5);
4182 }
4183
4184 #[test]
4185 fn test_impairment_fraud_types() {
4186 let fraud_types = [
4188 FraudType::DelayedImpairment,
4189 FraudType::ImpairmentTestAvoidance,
4190 FraudType::CashFlowProjectionManipulation,
4191 FraudType::ImproperImpairmentReversal,
4192 ];
4193
4194 for fraud_type in fraud_types {
4195 let anomaly_type = AnomalyType::Fraud(fraud_type);
4196 assert_eq!(anomaly_type.category(), "Fraud");
4197 assert!(anomaly_type.is_intentional());
4198 assert!(anomaly_type.severity() >= 3);
4199 }
4200
4201 assert_eq!(FraudType::CashFlowProjectionManipulation.severity(), 5);
4203 }
4204
4205 #[test]
4210 fn test_standards_error_types() {
4211 let error_types = [
4213 ErrorType::RevenueTimingError,
4214 ErrorType::PoAllocationError,
4215 ErrorType::LeaseClassificationError,
4216 ErrorType::LeaseCalculationError,
4217 ErrorType::FairValueError,
4218 ErrorType::ImpairmentCalculationError,
4219 ErrorType::DiscountRateError,
4220 ErrorType::FrameworkApplicationError,
4221 ];
4222
4223 for error_type in error_types {
4224 let anomaly_type = AnomalyType::Error(error_type);
4225 assert_eq!(anomaly_type.category(), "Error");
4226 assert!(!anomaly_type.is_intentional());
4227 assert!(anomaly_type.severity() >= 3);
4228 }
4229 }
4230
4231 #[test]
4232 fn test_framework_application_error() {
4233 let error_type = ErrorType::FrameworkApplicationError;
4235 assert_eq!(error_type.severity(), 4);
4236
4237 let anomaly = LabeledAnomaly::new(
4238 "ERR001".to_string(),
4239 AnomalyType::Error(error_type),
4240 "JE100".to_string(),
4241 "JE".to_string(),
4242 "1000".to_string(),
4243 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
4244 )
4245 .with_description("LIFO inventory method used under IFRS (not permitted)")
4246 .with_metadata("framework", "IFRS")
4247 .with_metadata("standard_violated", "IAS 2");
4248
4249 assert_eq!(anomaly.anomaly_type.category(), "Error");
4250 assert_eq!(
4251 anomaly.metadata.get("standard_violated"),
4252 Some(&"IAS 2".to_string())
4253 );
4254 }
4255
4256 #[test]
4257 fn test_standards_anomaly_serialization() {
4258 let fraud_types = [
4260 FraudType::ImproperRevenueRecognition,
4261 FraudType::LeaseClassificationManipulation,
4262 FraudType::FairValueHierarchyManipulation,
4263 FraudType::DelayedImpairment,
4264 ];
4265
4266 for fraud_type in fraud_types {
4267 let json = serde_json::to_string(&fraud_type).expect("Failed to serialize");
4268 let deserialized: FraudType =
4269 serde_json::from_str(&json).expect("Failed to deserialize");
4270 assert_eq!(fraud_type, deserialized);
4271 }
4272
4273 let error_types = [
4275 ErrorType::RevenueTimingError,
4276 ErrorType::LeaseCalculationError,
4277 ErrorType::FairValueError,
4278 ErrorType::FrameworkApplicationError,
4279 ];
4280
4281 for error_type in error_types {
4282 let json = serde_json::to_string(&error_type).expect("Failed to serialize");
4283 let deserialized: ErrorType =
4284 serde_json::from_str(&json).expect("Failed to deserialize");
4285 assert_eq!(error_type, deserialized);
4286 }
4287 }
4288
4289 #[test]
4290 fn test_standards_labeled_anomaly() {
4291 let anomaly = LabeledAnomaly::new(
4293 "STD001".to_string(),
4294 AnomalyType::Fraud(FraudType::ImproperRevenueRecognition),
4295 "CONTRACT-2024-001".to_string(),
4296 "Revenue".to_string(),
4297 "1000".to_string(),
4298 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
4299 )
4300 .with_description("Revenue recognized before performance obligation satisfied (ASC 606)")
4301 .with_monetary_impact(dec!(500000))
4302 .with_metadata("standard", "ASC 606")
4303 .with_metadata("paragraph", "606-10-25-1")
4304 .with_metadata("contract_id", "C-2024-001")
4305 .with_related_entity("CONTRACT-2024-001")
4306 .with_related_entity("CUSTOMER-500");
4307
4308 assert_eq!(anomaly.severity, 5); assert!(anomaly.is_injected);
4310 assert_eq!(anomaly.monetary_impact, Some(dec!(500000)));
4311 assert_eq!(anomaly.related_entities.len(), 2);
4312 assert_eq!(
4313 anomaly.metadata.get("standard"),
4314 Some(&"ASC 606".to_string())
4315 );
4316 }
4317
4318 #[test]
4323 fn test_severity_level() {
4324 assert_eq!(SeverityLevel::Low.numeric(), 1);
4325 assert_eq!(SeverityLevel::Critical.numeric(), 4);
4326
4327 assert_eq!(SeverityLevel::from_numeric(1), SeverityLevel::Low);
4328 assert_eq!(SeverityLevel::from_numeric(4), SeverityLevel::Critical);
4329
4330 assert_eq!(SeverityLevel::from_score(0.1), SeverityLevel::Low);
4331 assert_eq!(SeverityLevel::from_score(0.9), SeverityLevel::Critical);
4332
4333 assert!((SeverityLevel::Medium.to_score() - 0.375).abs() < 0.01);
4334 }
4335
4336 #[test]
4337 fn test_anomaly_severity() {
4338 let severity =
4339 AnomalySeverity::new(SeverityLevel::High, dec!(50000)).with_materiality(dec!(10000));
4340
4341 assert_eq!(severity.level, SeverityLevel::High);
4342 assert!(severity.is_material);
4343 assert_eq!(severity.materiality_threshold, Some(dec!(10000)));
4344
4345 let low_severity =
4347 AnomalySeverity::new(SeverityLevel::Low, dec!(5000)).with_materiality(dec!(10000));
4348 assert!(!low_severity.is_material);
4349 }
4350
4351 #[test]
4352 fn test_detection_difficulty() {
4353 assert!(
4354 (AnomalyDetectionDifficulty::Trivial.expected_detection_rate() - 0.99).abs() < 0.01
4355 );
4356 assert!((AnomalyDetectionDifficulty::Expert.expected_detection_rate() - 0.15).abs() < 0.01);
4357
4358 assert_eq!(
4359 AnomalyDetectionDifficulty::from_score(0.05),
4360 AnomalyDetectionDifficulty::Trivial
4361 );
4362 assert_eq!(
4363 AnomalyDetectionDifficulty::from_score(0.90),
4364 AnomalyDetectionDifficulty::Expert
4365 );
4366
4367 assert_eq!(AnomalyDetectionDifficulty::Moderate.name(), "moderate");
4368 }
4369
4370 #[test]
4371 fn test_ground_truth_certainty() {
4372 assert_eq!(GroundTruthCertainty::Definite.certainty_score(), 1.0);
4373 assert_eq!(GroundTruthCertainty::Probable.certainty_score(), 0.8);
4374 assert_eq!(GroundTruthCertainty::Possible.certainty_score(), 0.5);
4375 }
4376
4377 #[test]
4378 fn test_detection_method() {
4379 assert_eq!(DetectionMethod::RuleBased.name(), "rule_based");
4380 assert_eq!(DetectionMethod::MachineLearning.name(), "machine_learning");
4381 }
4382
4383 #[test]
4384 fn test_extended_anomaly_label() {
4385 let base = LabeledAnomaly::new(
4386 "ANO001".to_string(),
4387 AnomalyType::Fraud(FraudType::FictitiousVendor),
4388 "JE001".to_string(),
4389 "JE".to_string(),
4390 "1000".to_string(),
4391 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4392 )
4393 .with_monetary_impact(dec!(100000));
4394
4395 let extended = ExtendedAnomalyLabel::from_base(base)
4396 .with_severity(AnomalySeverity::new(SeverityLevel::Critical, dec!(100000)))
4397 .with_difficulty(AnomalyDetectionDifficulty::Hard)
4398 .with_method(DetectionMethod::GraphBased)
4399 .with_method(DetectionMethod::ForensicAudit)
4400 .with_indicator("New vendor with no history")
4401 .with_indicator("Large first transaction")
4402 .with_certainty(GroundTruthCertainty::Definite)
4403 .with_entity("V001")
4404 .with_secondary_category(AnomalyCategory::BehavioralAnomaly)
4405 .with_scheme("SCHEME001", 2);
4406
4407 assert_eq!(extended.severity.level, SeverityLevel::Critical);
4408 assert_eq!(
4409 extended.detection_difficulty,
4410 AnomalyDetectionDifficulty::Hard
4411 );
4412 assert_eq!(extended.recommended_methods.len(), 3);
4414 assert_eq!(extended.key_indicators.len(), 2);
4415 assert_eq!(extended.scheme_id, Some("SCHEME001".to_string()));
4416 assert_eq!(extended.scheme_stage, Some(2));
4417 }
4418
4419 #[test]
4420 fn test_extended_anomaly_label_features() {
4421 let base = LabeledAnomaly::new(
4422 "ANO001".to_string(),
4423 AnomalyType::Fraud(FraudType::SelfApproval),
4424 "JE001".to_string(),
4425 "JE".to_string(),
4426 "1000".to_string(),
4427 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4428 );
4429
4430 let extended =
4431 ExtendedAnomalyLabel::from_base(base).with_difficulty(AnomalyDetectionDifficulty::Hard);
4432
4433 let features = extended.to_features();
4434 assert_eq!(features.len(), ExtendedAnomalyLabel::feature_count());
4435 assert_eq!(features.len(), 30);
4436
4437 let difficulty_idx = 18; assert!((features[difficulty_idx] - 0.75).abs() < 0.01);
4440 }
4441
4442 #[test]
4443 fn test_extended_label_near_miss() {
4444 let base = LabeledAnomaly::new(
4445 "ANO001".to_string(),
4446 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
4447 "JE001".to_string(),
4448 "JE".to_string(),
4449 "1000".to_string(),
4450 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4451 );
4452
4453 let extended = ExtendedAnomalyLabel::from_base(base)
4454 .as_near_miss("Year-end bonus payment, legitimately high");
4455
4456 assert!(extended.is_near_miss);
4457 assert!(extended.near_miss_explanation.is_some());
4458 }
4459
4460 #[test]
4461 fn test_scheme_type() {
4462 assert_eq!(
4463 SchemeType::GradualEmbezzlement.name(),
4464 "gradual_embezzlement"
4465 );
4466 assert_eq!(SchemeType::GradualEmbezzlement.typical_stages(), 4);
4467 assert_eq!(SchemeType::VendorKickback.typical_stages(), 4);
4468 }
4469
4470 #[test]
4471 fn test_concealment_technique() {
4472 assert!(ConcealmentTechnique::Collusion.difficulty_bonus() > 0.0);
4473 assert!(
4474 ConcealmentTechnique::Collusion.difficulty_bonus()
4475 > ConcealmentTechnique::TimingExploitation.difficulty_bonus()
4476 );
4477 }
4478
4479 #[test]
4480 fn test_near_miss_label() {
4481 let near_miss = NearMissLabel::new(
4482 "JE001",
4483 NearMissPattern::ThresholdProximity {
4484 threshold: dec!(10000),
4485 proximity: 0.95,
4486 },
4487 0.7,
4488 FalsePositiveTrigger::AmountNearThreshold,
4489 "Transaction is 95% of threshold but business justified",
4490 );
4491
4492 assert_eq!(near_miss.document_id, "JE001");
4493 assert_eq!(near_miss.suspicion_score, 0.7);
4494 assert_eq!(
4495 near_miss.false_positive_trigger,
4496 FalsePositiveTrigger::AmountNearThreshold
4497 );
4498 }
4499
4500 #[test]
4501 fn test_legitimate_pattern_type() {
4502 assert_eq!(
4503 LegitimatePatternType::YearEndBonus.description(),
4504 "Year-end bonus payment"
4505 );
4506 assert_eq!(
4507 LegitimatePatternType::InsuranceClaim.description(),
4508 "Insurance claim reimbursement"
4509 );
4510 }
4511
4512 #[test]
4513 fn test_severity_detection_difficulty_serialization() {
4514 let severity = AnomalySeverity::new(SeverityLevel::High, dec!(50000));
4515 let json = serde_json::to_string(&severity).expect("Failed to serialize");
4516 let deserialized: AnomalySeverity =
4517 serde_json::from_str(&json).expect("Failed to deserialize");
4518 assert_eq!(severity.level, deserialized.level);
4519
4520 let difficulty = AnomalyDetectionDifficulty::Hard;
4521 let json = serde_json::to_string(&difficulty).expect("Failed to serialize");
4522 let deserialized: AnomalyDetectionDifficulty =
4523 serde_json::from_str(&json).expect("Failed to deserialize");
4524 assert_eq!(difficulty, deserialized);
4525 }
4526
4527 #[test]
4532 fn test_acfe_fraud_category() {
4533 let asset = AcfeFraudCategory::AssetMisappropriation;
4534 assert_eq!(asset.name(), "asset_misappropriation");
4535 assert!((asset.typical_occurrence_rate() - 0.86).abs() < 0.01);
4536 assert_eq!(asset.typical_median_loss(), Decimal::new(100_000, 0));
4537 assert_eq!(asset.typical_detection_months(), 12);
4538
4539 let corruption = AcfeFraudCategory::Corruption;
4540 assert_eq!(corruption.name(), "corruption");
4541 assert!((corruption.typical_occurrence_rate() - 0.33).abs() < 0.01);
4542
4543 let fs_fraud = AcfeFraudCategory::FinancialStatementFraud;
4544 assert_eq!(fs_fraud.typical_median_loss(), Decimal::new(954_000, 0));
4545 assert_eq!(fs_fraud.typical_detection_months(), 24);
4546 }
4547
4548 #[test]
4549 fn test_cash_fraud_scheme() {
4550 let shell = CashFraudScheme::ShellCompany;
4551 assert_eq!(shell.category(), AcfeFraudCategory::AssetMisappropriation);
4552 assert_eq!(shell.subcategory(), "billing_schemes");
4553 assert_eq!(shell.severity(), 5);
4554 assert_eq!(
4555 shell.detection_difficulty(),
4556 AnomalyDetectionDifficulty::Hard
4557 );
4558
4559 let ghost = CashFraudScheme::GhostEmployee;
4560 assert_eq!(ghost.subcategory(), "payroll_schemes");
4561 assert_eq!(ghost.severity(), 5);
4562
4563 assert_eq!(CashFraudScheme::all_variants().len(), 20);
4565 }
4566
4567 #[test]
4568 fn test_asset_fraud_scheme() {
4569 let ip_theft = AssetFraudScheme::IntellectualPropertyTheft;
4570 assert_eq!(
4571 ip_theft.category(),
4572 AcfeFraudCategory::AssetMisappropriation
4573 );
4574 assert_eq!(ip_theft.subcategory(), "other_assets");
4575 assert_eq!(ip_theft.severity(), 5);
4576
4577 let inv_theft = AssetFraudScheme::InventoryTheft;
4578 assert_eq!(inv_theft.subcategory(), "inventory");
4579 assert_eq!(inv_theft.severity(), 4);
4580 }
4581
4582 #[test]
4583 fn test_corruption_scheme() {
4584 let kickback = CorruptionScheme::InvoiceKickback;
4585 assert_eq!(kickback.category(), AcfeFraudCategory::Corruption);
4586 assert_eq!(kickback.subcategory(), "bribery");
4587 assert_eq!(kickback.severity(), 5);
4588 assert_eq!(
4589 kickback.detection_difficulty(),
4590 AnomalyDetectionDifficulty::Expert
4591 );
4592
4593 let bid_rigging = CorruptionScheme::BidRigging;
4594 assert_eq!(bid_rigging.subcategory(), "bribery");
4595 assert_eq!(
4596 bid_rigging.detection_difficulty(),
4597 AnomalyDetectionDifficulty::Hard
4598 );
4599
4600 let purchasing = CorruptionScheme::PurchasingConflict;
4601 assert_eq!(purchasing.subcategory(), "conflicts_of_interest");
4602
4603 assert_eq!(CorruptionScheme::all_variants().len(), 10);
4605 }
4606
4607 #[test]
4608 fn test_financial_statement_scheme() {
4609 let fictitious = FinancialStatementScheme::FictitiousRevenues;
4610 assert_eq!(
4611 fictitious.category(),
4612 AcfeFraudCategory::FinancialStatementFraud
4613 );
4614 assert_eq!(fictitious.subcategory(), "overstatement");
4615 assert_eq!(fictitious.severity(), 5);
4616 assert_eq!(
4617 fictitious.detection_difficulty(),
4618 AnomalyDetectionDifficulty::Expert
4619 );
4620
4621 let understated = FinancialStatementScheme::UnderstatedRevenues;
4622 assert_eq!(understated.subcategory(), "understatement");
4623
4624 assert_eq!(FinancialStatementScheme::all_variants().len(), 13);
4626 }
4627
4628 #[test]
4629 fn test_acfe_scheme_unified() {
4630 let cash_scheme = AcfeScheme::Cash(CashFraudScheme::ShellCompany);
4631 assert_eq!(
4632 cash_scheme.category(),
4633 AcfeFraudCategory::AssetMisappropriation
4634 );
4635 assert_eq!(cash_scheme.severity(), 5);
4636
4637 let corruption_scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4638 assert_eq!(corruption_scheme.category(), AcfeFraudCategory::Corruption);
4639
4640 let fs_scheme = AcfeScheme::FinancialStatement(FinancialStatementScheme::PrematureRevenue);
4641 assert_eq!(
4642 fs_scheme.category(),
4643 AcfeFraudCategory::FinancialStatementFraud
4644 );
4645 }
4646
4647 #[test]
4648 fn test_acfe_detection_method() {
4649 let tip = AcfeDetectionMethod::Tip;
4650 assert!((tip.typical_detection_rate() - 0.42).abs() < 0.01);
4651
4652 let internal_audit = AcfeDetectionMethod::InternalAudit;
4653 assert!((internal_audit.typical_detection_rate() - 0.16).abs() < 0.01);
4654
4655 let external_audit = AcfeDetectionMethod::ExternalAudit;
4656 assert!((external_audit.typical_detection_rate() - 0.04).abs() < 0.01);
4657
4658 assert_eq!(AcfeDetectionMethod::all_variants().len(), 12);
4660 }
4661
4662 #[test]
4663 fn test_perpetrator_department() {
4664 let accounting = PerpetratorDepartment::Accounting;
4665 assert!((accounting.typical_occurrence_rate() - 0.21).abs() < 0.01);
4666 assert_eq!(accounting.typical_median_loss(), Decimal::new(130_000, 0));
4667
4668 let executive = PerpetratorDepartment::Executive;
4669 assert_eq!(executive.typical_median_loss(), Decimal::new(600_000, 0));
4670 }
4671
4672 #[test]
4673 fn test_perpetrator_level() {
4674 let employee = PerpetratorLevel::Employee;
4675 assert!((employee.typical_occurrence_rate() - 0.42).abs() < 0.01);
4676 assert_eq!(employee.typical_median_loss(), Decimal::new(50_000, 0));
4677
4678 let exec = PerpetratorLevel::OwnerExecutive;
4679 assert_eq!(exec.typical_median_loss(), Decimal::new(337_000, 0));
4680 }
4681
4682 #[test]
4683 fn test_acfe_calibration() {
4684 let cal = AcfeCalibration::default();
4685 assert_eq!(cal.median_loss, Decimal::new(117_000, 0));
4686 assert_eq!(cal.median_duration_months, 12);
4687 assert!((cal.collusion_rate - 0.50).abs() < 0.01);
4688 assert!(cal.validate().is_ok());
4689
4690 let custom_cal = AcfeCalibration::new(Decimal::new(200_000, 0), 18);
4692 assert_eq!(custom_cal.median_loss, Decimal::new(200_000, 0));
4693 assert_eq!(custom_cal.median_duration_months, 18);
4694
4695 let bad_cal = AcfeCalibration {
4697 collusion_rate: 1.5,
4698 ..Default::default()
4699 };
4700 assert!(bad_cal.validate().is_err());
4701 }
4702
4703 #[test]
4704 fn test_fraud_triangle() {
4705 let triangle = FraudTriangle::new(
4706 PressureType::FinancialTargets,
4707 vec![
4708 OpportunityFactor::WeakInternalControls,
4709 OpportunityFactor::ManagementOverride,
4710 ],
4711 Rationalization::ForTheCompanyGood,
4712 );
4713
4714 let risk = triangle.risk_score();
4716 assert!((0.0..=1.0).contains(&risk));
4717 assert!(risk > 0.5);
4719 }
4720
4721 #[test]
4722 fn test_pressure_types() {
4723 let financial = PressureType::FinancialTargets;
4724 assert!(financial.risk_weight() > 0.5);
4725
4726 let gambling = PressureType::GamblingAddiction;
4727 assert_eq!(gambling.risk_weight(), 0.90);
4728 }
4729
4730 #[test]
4731 fn test_opportunity_factors() {
4732 let override_factor = OpportunityFactor::ManagementOverride;
4733 assert_eq!(override_factor.risk_weight(), 0.90);
4734
4735 let weak_controls = OpportunityFactor::WeakInternalControls;
4736 assert!(weak_controls.risk_weight() > 0.8);
4737 }
4738
4739 #[test]
4740 fn test_rationalizations() {
4741 let entitlement = Rationalization::Entitlement;
4742 assert!(entitlement.risk_weight() > 0.8);
4743
4744 let borrowing = Rationalization::TemporaryBorrowing;
4745 assert!(borrowing.risk_weight() < entitlement.risk_weight());
4746 }
4747
4748 #[test]
4749 fn test_acfe_scheme_serialization() {
4750 let scheme = AcfeScheme::Corruption(CorruptionScheme::BidRigging);
4751 let json = serde_json::to_string(&scheme).expect("Failed to serialize");
4752 let deserialized: AcfeScheme = serde_json::from_str(&json).expect("Failed to deserialize");
4753 assert_eq!(scheme, deserialized);
4754
4755 let calibration = AcfeCalibration::default();
4756 let json = serde_json::to_string(&calibration).expect("Failed to serialize");
4757 let deserialized: AcfeCalibration =
4758 serde_json::from_str(&json).expect("Failed to deserialize");
4759 assert_eq!(calibration.median_loss, deserialized.median_loss);
4760 }
4761}