1use chrono::{NaiveDate, NaiveDateTime};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum AnomalyCausalReason {
20 RandomRate {
22 base_rate: f64,
24 },
25 TemporalPattern {
27 pattern_name: String,
29 },
30 EntityTargeting {
32 target_type: String,
34 target_id: String,
36 },
37 ClusterMembership {
39 cluster_id: String,
41 },
42 ScenarioStep {
44 scenario_type: String,
46 step_number: u32,
48 },
49 DataQualityProfile {
51 profile: String,
53 },
54 MLTrainingBalance {
56 target_class: String,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub enum InjectionStrategy {
67 AmountManipulation {
69 original: Decimal,
71 factor: f64,
73 },
74 ThresholdAvoidance {
76 threshold: Decimal,
78 adjusted_amount: Decimal,
80 },
81 DateShift {
83 days_shifted: i32,
85 original_date: NaiveDate,
87 },
88 SelfApproval {
90 user_id: String,
92 },
93 SoDViolation {
95 duty1: String,
97 duty2: String,
99 violating_user: String,
101 },
102 ExactDuplicate {
104 original_doc_id: String,
106 },
107 NearDuplicate {
109 original_doc_id: String,
111 varied_fields: Vec<String>,
113 },
114 CircularFlow {
116 entity_chain: Vec<String>,
118 },
119 SplitTransaction {
121 original_amount: Decimal,
123 split_count: u32,
125 split_doc_ids: Vec<String>,
127 },
128 RoundNumbering {
130 original_amount: Decimal,
132 rounded_amount: Decimal,
134 },
135 TimingManipulation {
137 timing_type: String,
139 original_time: Option<NaiveDateTime>,
141 },
142 AccountMisclassification {
144 correct_account: String,
146 incorrect_account: String,
148 },
149 MissingField {
151 field_name: String,
153 },
154 Custom {
156 name: String,
158 parameters: HashMap<String, String>,
160 },
161}
162
163impl InjectionStrategy {
164 pub fn description(&self) -> String {
166 match self {
167 InjectionStrategy::AmountManipulation { factor, .. } => {
168 format!("Amount multiplied by {:.2}", factor)
169 }
170 InjectionStrategy::ThresholdAvoidance { threshold, .. } => {
171 format!("Amount adjusted to avoid {} threshold", threshold)
172 }
173 InjectionStrategy::DateShift { days_shifted, .. } => {
174 if *days_shifted < 0 {
175 format!("Date backdated by {} days", days_shifted.abs())
176 } else {
177 format!("Date forward-dated by {} days", days_shifted)
178 }
179 }
180 InjectionStrategy::SelfApproval { user_id } => {
181 format!("Self-approval by user {}", user_id)
182 }
183 InjectionStrategy::SoDViolation { duty1, duty2, .. } => {
184 format!("SoD violation: {} and {}", duty1, duty2)
185 }
186 InjectionStrategy::ExactDuplicate { original_doc_id } => {
187 format!("Exact duplicate of {}", original_doc_id)
188 }
189 InjectionStrategy::NearDuplicate {
190 original_doc_id,
191 varied_fields,
192 } => {
193 format!(
194 "Near-duplicate of {} (varied: {:?})",
195 original_doc_id, varied_fields
196 )
197 }
198 InjectionStrategy::CircularFlow { entity_chain } => {
199 format!("Circular flow through {} entities", entity_chain.len())
200 }
201 InjectionStrategy::SplitTransaction { split_count, .. } => {
202 format!("Split into {} transactions", split_count)
203 }
204 InjectionStrategy::RoundNumbering { .. } => "Amount rounded to even number".to_string(),
205 InjectionStrategy::TimingManipulation { timing_type, .. } => {
206 format!("Timing manipulation: {}", timing_type)
207 }
208 InjectionStrategy::AccountMisclassification {
209 correct_account,
210 incorrect_account,
211 } => {
212 format!(
213 "Misclassified from {} to {}",
214 correct_account, incorrect_account
215 )
216 }
217 InjectionStrategy::MissingField { field_name } => {
218 format!("Missing required field: {}", field_name)
219 }
220 InjectionStrategy::Custom { name, .. } => format!("Custom: {}", name),
221 }
222 }
223
224 pub fn strategy_type(&self) -> &'static str {
226 match self {
227 InjectionStrategy::AmountManipulation { .. } => "AmountManipulation",
228 InjectionStrategy::ThresholdAvoidance { .. } => "ThresholdAvoidance",
229 InjectionStrategy::DateShift { .. } => "DateShift",
230 InjectionStrategy::SelfApproval { .. } => "SelfApproval",
231 InjectionStrategy::SoDViolation { .. } => "SoDViolation",
232 InjectionStrategy::ExactDuplicate { .. } => "ExactDuplicate",
233 InjectionStrategy::NearDuplicate { .. } => "NearDuplicate",
234 InjectionStrategy::CircularFlow { .. } => "CircularFlow",
235 InjectionStrategy::SplitTransaction { .. } => "SplitTransaction",
236 InjectionStrategy::RoundNumbering { .. } => "RoundNumbering",
237 InjectionStrategy::TimingManipulation { .. } => "TimingManipulation",
238 InjectionStrategy::AccountMisclassification { .. } => "AccountMisclassification",
239 InjectionStrategy::MissingField { .. } => "MissingField",
240 InjectionStrategy::Custom { .. } => "Custom",
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
247pub enum AnomalyType {
248 Fraud(FraudType),
250 Error(ErrorType),
252 ProcessIssue(ProcessIssueType),
254 Statistical(StatisticalAnomalyType),
256 Relational(RelationalAnomalyType),
258 Custom(String),
260}
261
262impl AnomalyType {
263 pub fn category(&self) -> &'static str {
265 match self {
266 AnomalyType::Fraud(_) => "Fraud",
267 AnomalyType::Error(_) => "Error",
268 AnomalyType::ProcessIssue(_) => "ProcessIssue",
269 AnomalyType::Statistical(_) => "Statistical",
270 AnomalyType::Relational(_) => "Relational",
271 AnomalyType::Custom(_) => "Custom",
272 }
273 }
274
275 pub fn type_name(&self) -> String {
277 match self {
278 AnomalyType::Fraud(t) => format!("{:?}", t),
279 AnomalyType::Error(t) => format!("{:?}", t),
280 AnomalyType::ProcessIssue(t) => format!("{:?}", t),
281 AnomalyType::Statistical(t) => format!("{:?}", t),
282 AnomalyType::Relational(t) => format!("{:?}", t),
283 AnomalyType::Custom(s) => s.clone(),
284 }
285 }
286
287 pub fn severity(&self) -> u8 {
289 match self {
290 AnomalyType::Fraud(t) => t.severity(),
291 AnomalyType::Error(t) => t.severity(),
292 AnomalyType::ProcessIssue(t) => t.severity(),
293 AnomalyType::Statistical(t) => t.severity(),
294 AnomalyType::Relational(t) => t.severity(),
295 AnomalyType::Custom(_) => 3,
296 }
297 }
298
299 pub fn is_intentional(&self) -> bool {
301 matches!(self, AnomalyType::Fraud(_))
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
307pub enum FraudType {
308 FictitiousEntry,
311 FictitiousTransaction,
313 RoundDollarManipulation,
315 JustBelowThreshold,
317 RevenueManipulation,
319 ImproperCapitalization,
321 ExpenseCapitalization,
323 ReserveManipulation,
325 SuspenseAccountAbuse,
327 SplitTransaction,
329 TimingAnomaly,
331 UnauthorizedAccess,
333
334 SelfApproval,
337 ExceededApprovalLimit,
339 SegregationOfDutiesViolation,
341 UnauthorizedApproval,
343 CollusiveApproval,
345
346 FictitiousVendor,
349 DuplicatePayment,
351 ShellCompanyPayment,
353 Kickback,
355 KickbackScheme,
357 InvoiceManipulation,
359
360 AssetMisappropriation,
363 InventoryTheft,
365 GhostEmployee,
367
368 PrematureRevenue,
371 UnderstatedLiabilities,
373 OverstatedAssets,
375 ChannelStuffing,
377}
378
379impl FraudType {
380 pub fn severity(&self) -> u8 {
382 match self {
383 FraudType::RoundDollarManipulation => 2,
384 FraudType::JustBelowThreshold => 3,
385 FraudType::SelfApproval => 3,
386 FraudType::ExceededApprovalLimit => 3,
387 FraudType::DuplicatePayment => 3,
388 FraudType::FictitiousEntry => 4,
389 FraudType::RevenueManipulation => 5,
390 FraudType::FictitiousVendor => 5,
391 FraudType::ShellCompanyPayment => 5,
392 FraudType::AssetMisappropriation => 5,
393 FraudType::SegregationOfDutiesViolation => 4,
394 FraudType::CollusiveApproval => 5,
395 _ => 4,
396 }
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
402pub enum ErrorType {
403 DuplicateEntry,
406 ReversedAmount,
408 TransposedDigits,
410 DecimalError,
412 MissingField,
414 InvalidAccount,
416
417 WrongPeriod,
420 BackdatedEntry,
422 FutureDatedEntry,
424 CutoffError,
426
427 MisclassifiedAccount,
430 WrongCostCenter,
432 WrongCompanyCode,
434
435 UnbalancedEntry,
438 RoundingError,
440 CurrencyError,
442 TaxCalculationError,
444}
445
446impl ErrorType {
447 pub fn severity(&self) -> u8 {
449 match self {
450 ErrorType::RoundingError => 1,
451 ErrorType::MissingField => 2,
452 ErrorType::TransposedDigits => 2,
453 ErrorType::DecimalError => 3,
454 ErrorType::DuplicateEntry => 3,
455 ErrorType::ReversedAmount => 3,
456 ErrorType::WrongPeriod => 4,
457 ErrorType::UnbalancedEntry => 5,
458 ErrorType::CurrencyError => 4,
459 _ => 3,
460 }
461 }
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
466pub enum ProcessIssueType {
467 SkippedApproval,
470 LateApproval,
472 MissingDocumentation,
474 IncompleteApprovalChain,
476
477 LatePosting,
480 AfterHoursPosting,
482 WeekendPosting,
484 RushedPeriodEnd,
486
487 ManualOverride,
490 UnusualAccess,
492 SystemBypass,
494 BatchAnomaly,
496
497 VagueDescription,
500 PostFactoChange,
502 IncompleteAuditTrail,
504}
505
506impl ProcessIssueType {
507 pub fn severity(&self) -> u8 {
509 match self {
510 ProcessIssueType::VagueDescription => 1,
511 ProcessIssueType::LatePosting => 2,
512 ProcessIssueType::AfterHoursPosting => 2,
513 ProcessIssueType::WeekendPosting => 2,
514 ProcessIssueType::SkippedApproval => 4,
515 ProcessIssueType::ManualOverride => 4,
516 ProcessIssueType::SystemBypass => 5,
517 ProcessIssueType::IncompleteAuditTrail => 4,
518 _ => 3,
519 }
520 }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
525pub enum StatisticalAnomalyType {
526 UnusuallyHighAmount,
529 UnusuallyLowAmount,
531 BenfordViolation,
533 ExactDuplicateAmount,
535 RepeatingAmount,
537
538 UnusualFrequency,
541 TransactionBurst,
543 UnusualTiming,
545
546 TrendBreak,
549 LevelShift,
551 SeasonalAnomaly,
553
554 StatisticalOutlier,
557 VarianceChange,
559 DistributionShift,
561}
562
563impl StatisticalAnomalyType {
564 pub fn severity(&self) -> u8 {
566 match self {
567 StatisticalAnomalyType::UnusualTiming => 1,
568 StatisticalAnomalyType::UnusualFrequency => 2,
569 StatisticalAnomalyType::BenfordViolation => 2,
570 StatisticalAnomalyType::UnusuallyHighAmount => 3,
571 StatisticalAnomalyType::TrendBreak => 3,
572 StatisticalAnomalyType::TransactionBurst => 4,
573 StatisticalAnomalyType::ExactDuplicateAmount => 3,
574 _ => 3,
575 }
576 }
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
581pub enum RelationalAnomalyType {
582 CircularTransaction,
585 UnusualAccountPair,
587 NewCounterparty,
589 DormantAccountActivity,
591
592 CentralityAnomaly,
595 IsolatedCluster,
597 BridgeNodeAnomaly,
599 CommunityAnomaly,
601
602 MissingRelationship,
605 UnexpectedRelationship,
607 RelationshipStrengthChange,
609
610 UnmatchedIntercompany,
613 CircularIntercompany,
615 TransferPricingAnomaly,
617}
618
619impl RelationalAnomalyType {
620 pub fn severity(&self) -> u8 {
622 match self {
623 RelationalAnomalyType::NewCounterparty => 1,
624 RelationalAnomalyType::DormantAccountActivity => 2,
625 RelationalAnomalyType::UnusualAccountPair => 2,
626 RelationalAnomalyType::CircularTransaction => 4,
627 RelationalAnomalyType::CircularIntercompany => 4,
628 RelationalAnomalyType::TransferPricingAnomaly => 4,
629 RelationalAnomalyType::UnmatchedIntercompany => 3,
630 _ => 3,
631 }
632 }
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct LabeledAnomaly {
638 pub anomaly_id: String,
640 pub anomaly_type: AnomalyType,
642 pub document_id: String,
644 pub document_type: String,
646 pub company_code: String,
648 pub anomaly_date: NaiveDate,
650 pub detection_timestamp: NaiveDateTime,
652 pub confidence: f64,
654 pub severity: u8,
656 pub description: String,
658 pub related_entities: Vec<String>,
660 pub monetary_impact: Option<Decimal>,
662 pub metadata: HashMap<String, String>,
664 pub is_injected: bool,
666 pub injection_strategy: Option<String>,
668 pub cluster_id: Option<String>,
670
671 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub original_document_hash: Option<String>,
678
679 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub causal_reason: Option<AnomalyCausalReason>,
683
684 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub structured_strategy: Option<InjectionStrategy>,
688
689 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub parent_anomaly_id: Option<String>,
693
694 #[serde(default, skip_serializing_if = "Vec::is_empty")]
696 pub child_anomaly_ids: Vec<String>,
697
698 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub scenario_id: Option<String>,
701
702 #[serde(default, skip_serializing_if = "Option::is_none")]
705 pub run_id: Option<String>,
706
707 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub generation_seed: Option<u64>,
711}
712
713impl LabeledAnomaly {
714 pub fn new(
716 anomaly_id: String,
717 anomaly_type: AnomalyType,
718 document_id: String,
719 document_type: String,
720 company_code: String,
721 anomaly_date: NaiveDate,
722 ) -> Self {
723 let severity = anomaly_type.severity();
724 let description = format!(
725 "{} - {} in document {}",
726 anomaly_type.category(),
727 anomaly_type.type_name(),
728 document_id
729 );
730
731 Self {
732 anomaly_id,
733 anomaly_type,
734 document_id,
735 document_type,
736 company_code,
737 anomaly_date,
738 detection_timestamp: chrono::Local::now().naive_local(),
739 confidence: 1.0,
740 severity,
741 description,
742 related_entities: Vec::new(),
743 monetary_impact: None,
744 metadata: HashMap::new(),
745 is_injected: true,
746 injection_strategy: None,
747 cluster_id: None,
748 original_document_hash: None,
750 causal_reason: None,
751 structured_strategy: None,
752 parent_anomaly_id: None,
753 child_anomaly_ids: Vec::new(),
754 scenario_id: None,
755 run_id: None,
756 generation_seed: None,
757 }
758 }
759
760 pub fn with_description(mut self, description: &str) -> Self {
762 self.description = description.to_string();
763 self
764 }
765
766 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
768 self.monetary_impact = Some(impact);
769 self
770 }
771
772 pub fn with_related_entity(mut self, entity: &str) -> Self {
774 self.related_entities.push(entity.to_string());
775 self
776 }
777
778 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
780 self.metadata.insert(key.to_string(), value.to_string());
781 self
782 }
783
784 pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
786 self.injection_strategy = Some(strategy.to_string());
787 self
788 }
789
790 pub fn with_cluster(mut self, cluster_id: &str) -> Self {
792 self.cluster_id = Some(cluster_id.to_string());
793 self
794 }
795
796 pub fn with_original_document_hash(mut self, hash: &str) -> Self {
802 self.original_document_hash = Some(hash.to_string());
803 self
804 }
805
806 pub fn with_causal_reason(mut self, reason: AnomalyCausalReason) -> Self {
808 self.causal_reason = Some(reason);
809 self
810 }
811
812 pub fn with_structured_strategy(mut self, strategy: InjectionStrategy) -> Self {
814 self.injection_strategy = Some(strategy.strategy_type().to_string());
816 self.structured_strategy = Some(strategy);
817 self
818 }
819
820 pub fn with_parent_anomaly(mut self, parent_id: &str) -> Self {
822 self.parent_anomaly_id = Some(parent_id.to_string());
823 self
824 }
825
826 pub fn with_child_anomaly(mut self, child_id: &str) -> Self {
828 self.child_anomaly_ids.push(child_id.to_string());
829 self
830 }
831
832 pub fn with_scenario(mut self, scenario_id: &str) -> Self {
834 self.scenario_id = Some(scenario_id.to_string());
835 self
836 }
837
838 pub fn with_run_id(mut self, run_id: &str) -> Self {
840 self.run_id = Some(run_id.to_string());
841 self
842 }
843
844 pub fn with_generation_seed(mut self, seed: u64) -> Self {
846 self.generation_seed = Some(seed);
847 self
848 }
849
850 pub fn with_provenance(
852 mut self,
853 run_id: Option<&str>,
854 seed: Option<u64>,
855 causal_reason: Option<AnomalyCausalReason>,
856 ) -> Self {
857 if let Some(id) = run_id {
858 self.run_id = Some(id.to_string());
859 }
860 self.generation_seed = seed;
861 self.causal_reason = causal_reason;
862 self
863 }
864
865 pub fn to_features(&self) -> Vec<f64> {
879 let mut features = Vec::new();
880
881 let categories = [
883 "Fraud",
884 "Error",
885 "ProcessIssue",
886 "Statistical",
887 "Relational",
888 "Custom",
889 ];
890 for cat in &categories {
891 features.push(if self.anomaly_type.category() == *cat {
892 1.0
893 } else {
894 0.0
895 });
896 }
897
898 features.push(self.severity as f64 / 5.0);
900
901 features.push(self.confidence);
903
904 features.push(if self.monetary_impact.is_some() {
906 1.0
907 } else {
908 0.0
909 });
910
911 if let Some(impact) = self.monetary_impact {
913 let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
914 features.push((impact_f64.abs() + 1.0).ln());
915 } else {
916 features.push(0.0);
917 }
918
919 features.push(if self.anomaly_type.is_intentional() {
921 1.0
922 } else {
923 0.0
924 });
925
926 features.push(self.related_entities.len() as f64);
928
929 features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
931
932 features.push(if self.scenario_id.is_some() { 1.0 } else { 0.0 });
935
936 features.push(if self.parent_anomaly_id.is_some() {
938 1.0
939 } else {
940 0.0
941 });
942
943 features
944 }
945
946 pub fn feature_count() -> usize {
948 15 }
950
951 pub fn feature_names() -> Vec<&'static str> {
953 vec![
954 "category_fraud",
955 "category_error",
956 "category_process_issue",
957 "category_statistical",
958 "category_relational",
959 "category_custom",
960 "severity_normalized",
961 "confidence",
962 "has_monetary_impact",
963 "monetary_impact_log",
964 "is_intentional",
965 "related_entity_count",
966 "is_clustered",
967 "is_scenario_part",
968 "is_derived",
969 ]
970 }
971}
972
973#[derive(Debug, Clone, Default, Serialize, Deserialize)]
975pub struct AnomalySummary {
976 pub total_count: usize,
978 pub by_category: HashMap<String, usize>,
980 pub by_type: HashMap<String, usize>,
982 pub by_severity: HashMap<u8, usize>,
984 pub by_company: HashMap<String, usize>,
986 pub total_monetary_impact: Decimal,
988 pub date_range: Option<(NaiveDate, NaiveDate)>,
990 pub cluster_count: usize,
992}
993
994impl AnomalySummary {
995 pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
997 let mut summary = AnomalySummary {
998 total_count: anomalies.len(),
999 ..Default::default()
1000 };
1001
1002 let mut min_date: Option<NaiveDate> = None;
1003 let mut max_date: Option<NaiveDate> = None;
1004 let mut clusters = std::collections::HashSet::new();
1005
1006 for anomaly in anomalies {
1007 *summary
1009 .by_category
1010 .entry(anomaly.anomaly_type.category().to_string())
1011 .or_insert(0) += 1;
1012
1013 *summary
1015 .by_type
1016 .entry(anomaly.anomaly_type.type_name())
1017 .or_insert(0) += 1;
1018
1019 *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
1021
1022 *summary
1024 .by_company
1025 .entry(anomaly.company_code.clone())
1026 .or_insert(0) += 1;
1027
1028 if let Some(impact) = anomaly.monetary_impact {
1030 summary.total_monetary_impact += impact;
1031 }
1032
1033 match min_date {
1035 None => min_date = Some(anomaly.anomaly_date),
1036 Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
1037 _ => {}
1038 }
1039 match max_date {
1040 None => max_date = Some(anomaly.anomaly_date),
1041 Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
1042 _ => {}
1043 }
1044
1045 if let Some(cluster_id) = &anomaly.cluster_id {
1047 clusters.insert(cluster_id.clone());
1048 }
1049 }
1050
1051 summary.date_range = min_date.zip(max_date);
1052 summary.cluster_count = clusters.len();
1053
1054 summary
1055 }
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1067pub enum AnomalyCategory {
1068 FictitiousVendor,
1071 VendorKickback,
1073 RelatedPartyVendor,
1075
1076 DuplicatePayment,
1079 UnauthorizedTransaction,
1081 StructuredTransaction,
1083
1084 CircularFlow,
1087 BehavioralAnomaly,
1089 TimingAnomaly,
1091
1092 JournalAnomaly,
1095 ManualOverride,
1097 MissingApproval,
1099
1100 StatisticalOutlier,
1103 DistributionAnomaly,
1105
1106 Custom(String),
1109}
1110
1111impl AnomalyCategory {
1112 pub fn from_anomaly_type(anomaly_type: &AnomalyType) -> Self {
1114 match anomaly_type {
1115 AnomalyType::Fraud(fraud_type) => match fraud_type {
1116 FraudType::FictitiousVendor | FraudType::ShellCompanyPayment => {
1117 AnomalyCategory::FictitiousVendor
1118 }
1119 FraudType::Kickback | FraudType::KickbackScheme => AnomalyCategory::VendorKickback,
1120 FraudType::DuplicatePayment => AnomalyCategory::DuplicatePayment,
1121 FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
1122 AnomalyCategory::StructuredTransaction
1123 }
1124 FraudType::SelfApproval
1125 | FraudType::UnauthorizedApproval
1126 | FraudType::CollusiveApproval => AnomalyCategory::UnauthorizedTransaction,
1127 FraudType::TimingAnomaly
1128 | FraudType::RoundDollarManipulation
1129 | FraudType::SuspenseAccountAbuse => AnomalyCategory::JournalAnomaly,
1130 _ => AnomalyCategory::BehavioralAnomaly,
1131 },
1132 AnomalyType::Error(error_type) => match error_type {
1133 ErrorType::DuplicateEntry => AnomalyCategory::DuplicatePayment,
1134 ErrorType::WrongPeriod
1135 | ErrorType::BackdatedEntry
1136 | ErrorType::FutureDatedEntry => AnomalyCategory::TimingAnomaly,
1137 _ => AnomalyCategory::JournalAnomaly,
1138 },
1139 AnomalyType::ProcessIssue(process_type) => match process_type {
1140 ProcessIssueType::SkippedApproval | ProcessIssueType::IncompleteApprovalChain => {
1141 AnomalyCategory::MissingApproval
1142 }
1143 ProcessIssueType::ManualOverride | ProcessIssueType::SystemBypass => {
1144 AnomalyCategory::ManualOverride
1145 }
1146 ProcessIssueType::AfterHoursPosting | ProcessIssueType::WeekendPosting => {
1147 AnomalyCategory::TimingAnomaly
1148 }
1149 _ => AnomalyCategory::BehavioralAnomaly,
1150 },
1151 AnomalyType::Statistical(stat_type) => match stat_type {
1152 StatisticalAnomalyType::BenfordViolation
1153 | StatisticalAnomalyType::DistributionShift => AnomalyCategory::DistributionAnomaly,
1154 _ => AnomalyCategory::StatisticalOutlier,
1155 },
1156 AnomalyType::Relational(rel_type) => match rel_type {
1157 RelationalAnomalyType::CircularTransaction
1158 | RelationalAnomalyType::CircularIntercompany => AnomalyCategory::CircularFlow,
1159 _ => AnomalyCategory::BehavioralAnomaly,
1160 },
1161 AnomalyType::Custom(s) => AnomalyCategory::Custom(s.clone()),
1162 }
1163 }
1164
1165 pub fn name(&self) -> &str {
1167 match self {
1168 AnomalyCategory::FictitiousVendor => "fictitious_vendor",
1169 AnomalyCategory::VendorKickback => "vendor_kickback",
1170 AnomalyCategory::RelatedPartyVendor => "related_party_vendor",
1171 AnomalyCategory::DuplicatePayment => "duplicate_payment",
1172 AnomalyCategory::UnauthorizedTransaction => "unauthorized_transaction",
1173 AnomalyCategory::StructuredTransaction => "structured_transaction",
1174 AnomalyCategory::CircularFlow => "circular_flow",
1175 AnomalyCategory::BehavioralAnomaly => "behavioral_anomaly",
1176 AnomalyCategory::TimingAnomaly => "timing_anomaly",
1177 AnomalyCategory::JournalAnomaly => "journal_anomaly",
1178 AnomalyCategory::ManualOverride => "manual_override",
1179 AnomalyCategory::MissingApproval => "missing_approval",
1180 AnomalyCategory::StatisticalOutlier => "statistical_outlier",
1181 AnomalyCategory::DistributionAnomaly => "distribution_anomaly",
1182 AnomalyCategory::Custom(s) => s.as_str(),
1183 }
1184 }
1185
1186 pub fn ordinal(&self) -> u8 {
1188 match self {
1189 AnomalyCategory::FictitiousVendor => 0,
1190 AnomalyCategory::VendorKickback => 1,
1191 AnomalyCategory::RelatedPartyVendor => 2,
1192 AnomalyCategory::DuplicatePayment => 3,
1193 AnomalyCategory::UnauthorizedTransaction => 4,
1194 AnomalyCategory::StructuredTransaction => 5,
1195 AnomalyCategory::CircularFlow => 6,
1196 AnomalyCategory::BehavioralAnomaly => 7,
1197 AnomalyCategory::TimingAnomaly => 8,
1198 AnomalyCategory::JournalAnomaly => 9,
1199 AnomalyCategory::ManualOverride => 10,
1200 AnomalyCategory::MissingApproval => 11,
1201 AnomalyCategory::StatisticalOutlier => 12,
1202 AnomalyCategory::DistributionAnomaly => 13,
1203 AnomalyCategory::Custom(_) => 14,
1204 }
1205 }
1206
1207 pub fn category_count() -> usize {
1209 15 }
1211}
1212
1213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1215pub enum FactorType {
1216 AmountDeviation,
1218 ThresholdProximity,
1220 TimingAnomaly,
1222 EntityRisk,
1224 PatternMatch,
1226 FrequencyDeviation,
1228 RelationshipAnomaly,
1230 ControlBypass,
1232 BenfordViolation,
1234 DuplicateIndicator,
1236 ApprovalChainIssue,
1238 DocumentationGap,
1240 Custom,
1242}
1243
1244impl FactorType {
1245 pub fn name(&self) -> &'static str {
1247 match self {
1248 FactorType::AmountDeviation => "amount_deviation",
1249 FactorType::ThresholdProximity => "threshold_proximity",
1250 FactorType::TimingAnomaly => "timing_anomaly",
1251 FactorType::EntityRisk => "entity_risk",
1252 FactorType::PatternMatch => "pattern_match",
1253 FactorType::FrequencyDeviation => "frequency_deviation",
1254 FactorType::RelationshipAnomaly => "relationship_anomaly",
1255 FactorType::ControlBypass => "control_bypass",
1256 FactorType::BenfordViolation => "benford_violation",
1257 FactorType::DuplicateIndicator => "duplicate_indicator",
1258 FactorType::ApprovalChainIssue => "approval_chain_issue",
1259 FactorType::DocumentationGap => "documentation_gap",
1260 FactorType::Custom => "custom",
1261 }
1262 }
1263}
1264
1265#[derive(Debug, Clone, Serialize, Deserialize)]
1267pub struct FactorEvidence {
1268 pub source: String,
1270 pub data: HashMap<String, String>,
1272}
1273
1274#[derive(Debug, Clone, Serialize, Deserialize)]
1276pub struct ContributingFactor {
1277 pub factor_type: FactorType,
1279 pub value: f64,
1281 pub threshold: f64,
1283 pub direction_greater: bool,
1285 pub weight: f64,
1287 pub description: String,
1289 pub evidence: Option<FactorEvidence>,
1291}
1292
1293impl ContributingFactor {
1294 pub fn new(
1296 factor_type: FactorType,
1297 value: f64,
1298 threshold: f64,
1299 direction_greater: bool,
1300 weight: f64,
1301 description: &str,
1302 ) -> Self {
1303 Self {
1304 factor_type,
1305 value,
1306 threshold,
1307 direction_greater,
1308 weight,
1309 description: description.to_string(),
1310 evidence: None,
1311 }
1312 }
1313
1314 pub fn with_evidence(mut self, source: &str, data: HashMap<String, String>) -> Self {
1316 self.evidence = Some(FactorEvidence {
1317 source: source.to_string(),
1318 data,
1319 });
1320 self
1321 }
1322
1323 pub fn contribution(&self) -> f64 {
1325 let deviation = if self.direction_greater {
1326 (self.value - self.threshold).max(0.0)
1327 } else {
1328 (self.threshold - self.value).max(0.0)
1329 };
1330
1331 let relative_deviation = if self.threshold.abs() > 0.001 {
1333 deviation / self.threshold.abs()
1334 } else {
1335 deviation
1336 };
1337
1338 (relative_deviation * self.weight).min(1.0)
1340 }
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1345pub struct EnhancedAnomalyLabel {
1346 pub base: LabeledAnomaly,
1348 pub category: AnomalyCategory,
1350 pub enhanced_confidence: f64,
1352 pub enhanced_severity: f64,
1354 pub contributing_factors: Vec<ContributingFactor>,
1356 pub secondary_categories: Vec<AnomalyCategory>,
1358}
1359
1360impl EnhancedAnomalyLabel {
1361 pub fn from_base(base: LabeledAnomaly) -> Self {
1363 let category = AnomalyCategory::from_anomaly_type(&base.anomaly_type);
1364 let enhanced_confidence = base.confidence;
1365 let enhanced_severity = base.severity as f64 / 5.0;
1366
1367 Self {
1368 base,
1369 category,
1370 enhanced_confidence,
1371 enhanced_severity,
1372 contributing_factors: Vec::new(),
1373 secondary_categories: Vec::new(),
1374 }
1375 }
1376
1377 pub fn with_confidence(mut self, confidence: f64) -> Self {
1379 self.enhanced_confidence = confidence.clamp(0.0, 1.0);
1380 self
1381 }
1382
1383 pub fn with_severity(mut self, severity: f64) -> Self {
1385 self.enhanced_severity = severity.clamp(0.0, 1.0);
1386 self
1387 }
1388
1389 pub fn with_factor(mut self, factor: ContributingFactor) -> Self {
1391 self.contributing_factors.push(factor);
1392 self
1393 }
1394
1395 pub fn with_secondary_category(mut self, category: AnomalyCategory) -> Self {
1397 if !self.secondary_categories.contains(&category) && category != self.category {
1398 self.secondary_categories.push(category);
1399 }
1400 self
1401 }
1402
1403 pub fn to_features(&self) -> Vec<f64> {
1407 let mut features = self.base.to_features();
1408
1409 features.push(self.enhanced_confidence);
1411 features.push(self.enhanced_severity);
1412 features.push(self.category.ordinal() as f64 / AnomalyCategory::category_count() as f64);
1413 features.push(self.secondary_categories.len() as f64);
1414 features.push(self.contributing_factors.len() as f64);
1415
1416 let max_weight = self
1418 .contributing_factors
1419 .iter()
1420 .map(|f| f.weight)
1421 .fold(0.0, f64::max);
1422 features.push(max_weight);
1423
1424 let has_control_bypass = self
1426 .contributing_factors
1427 .iter()
1428 .any(|f| f.factor_type == FactorType::ControlBypass);
1429 features.push(if has_control_bypass { 1.0 } else { 0.0 });
1430
1431 let has_amount_deviation = self
1432 .contributing_factors
1433 .iter()
1434 .any(|f| f.factor_type == FactorType::AmountDeviation);
1435 features.push(if has_amount_deviation { 1.0 } else { 0.0 });
1436
1437 let has_timing = self
1438 .contributing_factors
1439 .iter()
1440 .any(|f| f.factor_type == FactorType::TimingAnomaly);
1441 features.push(if has_timing { 1.0 } else { 0.0 });
1442
1443 let has_pattern_match = self
1444 .contributing_factors
1445 .iter()
1446 .any(|f| f.factor_type == FactorType::PatternMatch);
1447 features.push(if has_pattern_match { 1.0 } else { 0.0 });
1448
1449 features
1450 }
1451
1452 pub fn feature_count() -> usize {
1454 25 }
1456
1457 pub fn feature_names() -> Vec<&'static str> {
1459 let mut names = LabeledAnomaly::feature_names();
1460 names.extend(vec![
1461 "enhanced_confidence",
1462 "enhanced_severity",
1463 "category_ordinal",
1464 "secondary_category_count",
1465 "contributing_factor_count",
1466 "max_factor_weight",
1467 "has_control_bypass",
1468 "has_amount_deviation",
1469 "has_timing_factor",
1470 "has_pattern_match",
1471 ]);
1472 names
1473 }
1474}
1475
1476#[derive(Debug, Clone, Serialize, Deserialize)]
1478pub struct AnomalyRateConfig {
1479 pub total_rate: f64,
1481 pub fraud_rate: f64,
1483 pub error_rate: f64,
1485 pub process_issue_rate: f64,
1487 pub statistical_rate: f64,
1489 pub relational_rate: f64,
1491}
1492
1493impl Default for AnomalyRateConfig {
1494 fn default() -> Self {
1495 Self {
1496 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, }
1503 }
1504}
1505
1506impl AnomalyRateConfig {
1507 pub fn validate(&self) -> Result<(), String> {
1509 let sum = self.fraud_rate
1510 + self.error_rate
1511 + self.process_issue_rate
1512 + self.statistical_rate
1513 + self.relational_rate;
1514
1515 if (sum - 1.0).abs() > 0.01 {
1516 return Err(format!(
1517 "Anomaly category rates must sum to 1.0, got {}",
1518 sum
1519 ));
1520 }
1521
1522 if self.total_rate < 0.0 || self.total_rate > 1.0 {
1523 return Err(format!(
1524 "Total rate must be between 0.0 and 1.0, got {}",
1525 self.total_rate
1526 ));
1527 }
1528
1529 Ok(())
1530 }
1531}
1532
1533#[cfg(test)]
1534mod tests {
1535 use super::*;
1536 use rust_decimal_macros::dec;
1537
1538 #[test]
1539 fn test_anomaly_type_category() {
1540 let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
1541 assert_eq!(fraud.category(), "Fraud");
1542 assert!(fraud.is_intentional());
1543
1544 let error = AnomalyType::Error(ErrorType::DuplicateEntry);
1545 assert_eq!(error.category(), "Error");
1546 assert!(!error.is_intentional());
1547 }
1548
1549 #[test]
1550 fn test_labeled_anomaly() {
1551 let anomaly = LabeledAnomaly::new(
1552 "ANO001".to_string(),
1553 AnomalyType::Fraud(FraudType::SelfApproval),
1554 "JE001".to_string(),
1555 "JE".to_string(),
1556 "1000".to_string(),
1557 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1558 )
1559 .with_description("User approved their own expense report")
1560 .with_related_entity("USER001");
1561
1562 assert_eq!(anomaly.severity, 3);
1563 assert!(anomaly.is_injected);
1564 assert_eq!(anomaly.related_entities.len(), 1);
1565 }
1566
1567 #[test]
1568 fn test_labeled_anomaly_with_provenance() {
1569 let anomaly = LabeledAnomaly::new(
1570 "ANO001".to_string(),
1571 AnomalyType::Fraud(FraudType::SelfApproval),
1572 "JE001".to_string(),
1573 "JE".to_string(),
1574 "1000".to_string(),
1575 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1576 )
1577 .with_run_id("run-123")
1578 .with_generation_seed(42)
1579 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 })
1580 .with_structured_strategy(InjectionStrategy::SelfApproval {
1581 user_id: "USER001".to_string(),
1582 })
1583 .with_scenario("scenario-001")
1584 .with_original_document_hash("abc123");
1585
1586 assert_eq!(anomaly.run_id, Some("run-123".to_string()));
1587 assert_eq!(anomaly.generation_seed, Some(42));
1588 assert!(anomaly.causal_reason.is_some());
1589 assert!(anomaly.structured_strategy.is_some());
1590 assert_eq!(anomaly.scenario_id, Some("scenario-001".to_string()));
1591 assert_eq!(anomaly.original_document_hash, Some("abc123".to_string()));
1592
1593 assert_eq!(anomaly.injection_strategy, Some("SelfApproval".to_string()));
1595 }
1596
1597 #[test]
1598 fn test_labeled_anomaly_derivation_chain() {
1599 let parent = LabeledAnomaly::new(
1600 "ANO001".to_string(),
1601 AnomalyType::Fraud(FraudType::DuplicatePayment),
1602 "JE001".to_string(),
1603 "JE".to_string(),
1604 "1000".to_string(),
1605 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1606 );
1607
1608 let child = LabeledAnomaly::new(
1609 "ANO002".to_string(),
1610 AnomalyType::Error(ErrorType::DuplicateEntry),
1611 "JE002".to_string(),
1612 "JE".to_string(),
1613 "1000".to_string(),
1614 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1615 )
1616 .with_parent_anomaly(&parent.anomaly_id);
1617
1618 assert_eq!(child.parent_anomaly_id, Some("ANO001".to_string()));
1619 }
1620
1621 #[test]
1622 fn test_injection_strategy_description() {
1623 let strategy = InjectionStrategy::AmountManipulation {
1624 original: dec!(1000),
1625 factor: 2.5,
1626 };
1627 assert_eq!(strategy.description(), "Amount multiplied by 2.50");
1628 assert_eq!(strategy.strategy_type(), "AmountManipulation");
1629
1630 let strategy = InjectionStrategy::ThresholdAvoidance {
1631 threshold: dec!(10000),
1632 adjusted_amount: dec!(9999),
1633 };
1634 assert_eq!(
1635 strategy.description(),
1636 "Amount adjusted to avoid 10000 threshold"
1637 );
1638
1639 let strategy = InjectionStrategy::DateShift {
1640 days_shifted: -5,
1641 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1642 };
1643 assert_eq!(strategy.description(), "Date backdated by 5 days");
1644
1645 let strategy = InjectionStrategy::DateShift {
1646 days_shifted: 3,
1647 original_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1648 };
1649 assert_eq!(strategy.description(), "Date forward-dated by 3 days");
1650 }
1651
1652 #[test]
1653 fn test_causal_reason_variants() {
1654 let reason = AnomalyCausalReason::RandomRate { base_rate: 0.02 };
1655 if let AnomalyCausalReason::RandomRate { base_rate } = reason {
1656 assert!((base_rate - 0.02).abs() < 0.001);
1657 }
1658
1659 let reason = AnomalyCausalReason::TemporalPattern {
1660 pattern_name: "year_end_spike".to_string(),
1661 };
1662 if let AnomalyCausalReason::TemporalPattern { pattern_name } = reason {
1663 assert_eq!(pattern_name, "year_end_spike");
1664 }
1665
1666 let reason = AnomalyCausalReason::ScenarioStep {
1667 scenario_type: "kickback".to_string(),
1668 step_number: 3,
1669 };
1670 if let AnomalyCausalReason::ScenarioStep {
1671 scenario_type,
1672 step_number,
1673 } = reason
1674 {
1675 assert_eq!(scenario_type, "kickback");
1676 assert_eq!(step_number, 3);
1677 }
1678 }
1679
1680 #[test]
1681 fn test_feature_vector_length() {
1682 let anomaly = LabeledAnomaly::new(
1683 "ANO001".to_string(),
1684 AnomalyType::Fraud(FraudType::SelfApproval),
1685 "JE001".to_string(),
1686 "JE".to_string(),
1687 "1000".to_string(),
1688 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1689 );
1690
1691 let features = anomaly.to_features();
1692 assert_eq!(features.len(), LabeledAnomaly::feature_count());
1693 assert_eq!(features.len(), LabeledAnomaly::feature_names().len());
1694 }
1695
1696 #[test]
1697 fn test_feature_vector_with_provenance() {
1698 let anomaly = LabeledAnomaly::new(
1699 "ANO001".to_string(),
1700 AnomalyType::Fraud(FraudType::SelfApproval),
1701 "JE001".to_string(),
1702 "JE".to_string(),
1703 "1000".to_string(),
1704 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1705 )
1706 .with_scenario("scenario-001")
1707 .with_parent_anomaly("ANO000");
1708
1709 let features = anomaly.to_features();
1710
1711 assert_eq!(features[features.len() - 2], 1.0); assert_eq!(features[features.len() - 1], 1.0); }
1715
1716 #[test]
1717 fn test_anomaly_summary() {
1718 let anomalies = vec![
1719 LabeledAnomaly::new(
1720 "ANO001".to_string(),
1721 AnomalyType::Fraud(FraudType::SelfApproval),
1722 "JE001".to_string(),
1723 "JE".to_string(),
1724 "1000".to_string(),
1725 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1726 ),
1727 LabeledAnomaly::new(
1728 "ANO002".to_string(),
1729 AnomalyType::Error(ErrorType::DuplicateEntry),
1730 "JE002".to_string(),
1731 "JE".to_string(),
1732 "1000".to_string(),
1733 NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
1734 ),
1735 ];
1736
1737 let summary = AnomalySummary::from_anomalies(&anomalies);
1738
1739 assert_eq!(summary.total_count, 2);
1740 assert_eq!(summary.by_category.get("Fraud"), Some(&1));
1741 assert_eq!(summary.by_category.get("Error"), Some(&1));
1742 }
1743
1744 #[test]
1745 fn test_rate_config_validation() {
1746 let config = AnomalyRateConfig::default();
1747 assert!(config.validate().is_ok());
1748
1749 let bad_config = AnomalyRateConfig {
1750 fraud_rate: 0.5,
1751 error_rate: 0.5,
1752 process_issue_rate: 0.5, ..Default::default()
1754 };
1755 assert!(bad_config.validate().is_err());
1756 }
1757
1758 #[test]
1759 fn test_injection_strategy_serialization() {
1760 let strategy = InjectionStrategy::SoDViolation {
1761 duty1: "CreatePO".to_string(),
1762 duty2: "ApprovePO".to_string(),
1763 violating_user: "USER001".to_string(),
1764 };
1765
1766 let json = serde_json::to_string(&strategy).unwrap();
1767 let deserialized: InjectionStrategy = serde_json::from_str(&json).unwrap();
1768
1769 assert_eq!(strategy, deserialized);
1770 }
1771
1772 #[test]
1773 fn test_labeled_anomaly_serialization_with_provenance() {
1774 let anomaly = LabeledAnomaly::new(
1775 "ANO001".to_string(),
1776 AnomalyType::Fraud(FraudType::SelfApproval),
1777 "JE001".to_string(),
1778 "JE".to_string(),
1779 "1000".to_string(),
1780 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1781 )
1782 .with_run_id("run-123")
1783 .with_generation_seed(42)
1784 .with_causal_reason(AnomalyCausalReason::RandomRate { base_rate: 0.02 });
1785
1786 let json = serde_json::to_string(&anomaly).unwrap();
1787 let deserialized: LabeledAnomaly = serde_json::from_str(&json).unwrap();
1788
1789 assert_eq!(anomaly.run_id, deserialized.run_id);
1790 assert_eq!(anomaly.generation_seed, deserialized.generation_seed);
1791 }
1792
1793 #[test]
1798 fn test_anomaly_category_from_anomaly_type() {
1799 let fraud_vendor = AnomalyType::Fraud(FraudType::FictitiousVendor);
1801 assert_eq!(
1802 AnomalyCategory::from_anomaly_type(&fraud_vendor),
1803 AnomalyCategory::FictitiousVendor
1804 );
1805
1806 let fraud_kickback = AnomalyType::Fraud(FraudType::KickbackScheme);
1807 assert_eq!(
1808 AnomalyCategory::from_anomaly_type(&fraud_kickback),
1809 AnomalyCategory::VendorKickback
1810 );
1811
1812 let fraud_structured = AnomalyType::Fraud(FraudType::SplitTransaction);
1813 assert_eq!(
1814 AnomalyCategory::from_anomaly_type(&fraud_structured),
1815 AnomalyCategory::StructuredTransaction
1816 );
1817
1818 let error_duplicate = AnomalyType::Error(ErrorType::DuplicateEntry);
1820 assert_eq!(
1821 AnomalyCategory::from_anomaly_type(&error_duplicate),
1822 AnomalyCategory::DuplicatePayment
1823 );
1824
1825 let process_skip = AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval);
1827 assert_eq!(
1828 AnomalyCategory::from_anomaly_type(&process_skip),
1829 AnomalyCategory::MissingApproval
1830 );
1831
1832 let relational_circular =
1834 AnomalyType::Relational(RelationalAnomalyType::CircularTransaction);
1835 assert_eq!(
1836 AnomalyCategory::from_anomaly_type(&relational_circular),
1837 AnomalyCategory::CircularFlow
1838 );
1839 }
1840
1841 #[test]
1842 fn test_anomaly_category_ordinal() {
1843 assert_eq!(AnomalyCategory::FictitiousVendor.ordinal(), 0);
1844 assert_eq!(AnomalyCategory::VendorKickback.ordinal(), 1);
1845 assert_eq!(AnomalyCategory::Custom("test".to_string()).ordinal(), 14);
1846 }
1847
1848 #[test]
1849 fn test_contributing_factor() {
1850 let factor = ContributingFactor::new(
1851 FactorType::AmountDeviation,
1852 15000.0,
1853 10000.0,
1854 true,
1855 0.5,
1856 "Amount exceeds threshold",
1857 );
1858
1859 assert_eq!(factor.factor_type, FactorType::AmountDeviation);
1860 assert_eq!(factor.value, 15000.0);
1861 assert_eq!(factor.threshold, 10000.0);
1862 assert!(factor.direction_greater);
1863
1864 let contribution = factor.contribution();
1866 assert!((contribution - 0.25).abs() < 0.01);
1867 }
1868
1869 #[test]
1870 fn test_contributing_factor_with_evidence() {
1871 let mut data = HashMap::new();
1872 data.insert("expected".to_string(), "10000".to_string());
1873 data.insert("actual".to_string(), "15000".to_string());
1874
1875 let factor = ContributingFactor::new(
1876 FactorType::AmountDeviation,
1877 15000.0,
1878 10000.0,
1879 true,
1880 0.5,
1881 "Amount deviation detected",
1882 )
1883 .with_evidence("transaction_history", data);
1884
1885 assert!(factor.evidence.is_some());
1886 let evidence = factor.evidence.unwrap();
1887 assert_eq!(evidence.source, "transaction_history");
1888 assert_eq!(evidence.data.get("expected"), Some(&"10000".to_string()));
1889 }
1890
1891 #[test]
1892 fn test_enhanced_anomaly_label() {
1893 let base = LabeledAnomaly::new(
1894 "ANO001".to_string(),
1895 AnomalyType::Fraud(FraudType::DuplicatePayment),
1896 "JE001".to_string(),
1897 "JE".to_string(),
1898 "1000".to_string(),
1899 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1900 );
1901
1902 let enhanced = EnhancedAnomalyLabel::from_base(base)
1903 .with_confidence(0.85)
1904 .with_severity(0.7)
1905 .with_factor(ContributingFactor::new(
1906 FactorType::DuplicateIndicator,
1907 1.0,
1908 0.5,
1909 true,
1910 0.4,
1911 "Duplicate payment detected",
1912 ))
1913 .with_secondary_category(AnomalyCategory::StructuredTransaction);
1914
1915 assert_eq!(enhanced.category, AnomalyCategory::DuplicatePayment);
1916 assert_eq!(enhanced.enhanced_confidence, 0.85);
1917 assert_eq!(enhanced.enhanced_severity, 0.7);
1918 assert_eq!(enhanced.contributing_factors.len(), 1);
1919 assert_eq!(enhanced.secondary_categories.len(), 1);
1920 }
1921
1922 #[test]
1923 fn test_enhanced_anomaly_label_features() {
1924 let base = LabeledAnomaly::new(
1925 "ANO001".to_string(),
1926 AnomalyType::Fraud(FraudType::SelfApproval),
1927 "JE001".to_string(),
1928 "JE".to_string(),
1929 "1000".to_string(),
1930 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1931 );
1932
1933 let enhanced = EnhancedAnomalyLabel::from_base(base)
1934 .with_confidence(0.9)
1935 .with_severity(0.8)
1936 .with_factor(ContributingFactor::new(
1937 FactorType::ControlBypass,
1938 1.0,
1939 0.0,
1940 true,
1941 0.5,
1942 "Control bypass detected",
1943 ));
1944
1945 let features = enhanced.to_features();
1946
1947 assert_eq!(features.len(), EnhancedAnomalyLabel::feature_count());
1949 assert_eq!(features.len(), 25);
1950
1951 assert_eq!(features[15], 0.9); assert_eq!(features[21], 1.0); }
1957
1958 #[test]
1959 fn test_enhanced_anomaly_label_feature_names() {
1960 let names = EnhancedAnomalyLabel::feature_names();
1961 assert_eq!(names.len(), 25);
1962 assert!(names.contains(&"enhanced_confidence"));
1963 assert!(names.contains(&"enhanced_severity"));
1964 assert!(names.contains(&"has_control_bypass"));
1965 }
1966
1967 #[test]
1968 fn test_factor_type_names() {
1969 assert_eq!(FactorType::AmountDeviation.name(), "amount_deviation");
1970 assert_eq!(FactorType::ThresholdProximity.name(), "threshold_proximity");
1971 assert_eq!(FactorType::ControlBypass.name(), "control_bypass");
1972 }
1973
1974 #[test]
1975 fn test_anomaly_category_serialization() {
1976 let category = AnomalyCategory::CircularFlow;
1977 let json = serde_json::to_string(&category).unwrap();
1978 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
1979 assert_eq!(category, deserialized);
1980
1981 let custom = AnomalyCategory::Custom("custom_type".to_string());
1982 let json = serde_json::to_string(&custom).unwrap();
1983 let deserialized: AnomalyCategory = serde_json::from_str(&json).unwrap();
1984 assert_eq!(custom, deserialized);
1985 }
1986
1987 #[test]
1988 fn test_enhanced_label_secondary_category_dedup() {
1989 let base = LabeledAnomaly::new(
1990 "ANO001".to_string(),
1991 AnomalyType::Fraud(FraudType::DuplicatePayment),
1992 "JE001".to_string(),
1993 "JE".to_string(),
1994 "1000".to_string(),
1995 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1996 );
1997
1998 let enhanced = EnhancedAnomalyLabel::from_base(base)
1999 .with_secondary_category(AnomalyCategory::DuplicatePayment)
2001 .with_secondary_category(AnomalyCategory::TimingAnomaly)
2003 .with_secondary_category(AnomalyCategory::TimingAnomaly);
2005
2006 assert_eq!(enhanced.secondary_categories.len(), 1);
2008 assert_eq!(
2009 enhanced.secondary_categories[0],
2010 AnomalyCategory::TimingAnomaly
2011 );
2012 }
2013}