datasynth_core/models/
anomaly.rs

1//! Anomaly types and labels for synthetic data generation.
2//!
3//! This module provides comprehensive anomaly classification for:
4//! - Fraud detection training
5//! - Error detection systems
6//! - Process compliance monitoring
7//! - Statistical anomaly detection
8//! - Graph-based anomaly detection
9
10use chrono::{NaiveDate, NaiveDateTime};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15/// Primary anomaly classification.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum AnomalyType {
18    /// Fraudulent activity.
19    Fraud(FraudType),
20    /// Data entry or processing error.
21    Error(ErrorType),
22    /// Process or control issue.
23    ProcessIssue(ProcessIssueType),
24    /// Statistical anomaly.
25    Statistical(StatisticalAnomalyType),
26    /// Relational/graph anomaly.
27    Relational(RelationalAnomalyType),
28    /// Custom anomaly type.
29    Custom(String),
30}
31
32impl AnomalyType {
33    /// Returns the category name.
34    pub fn category(&self) -> &'static str {
35        match self {
36            AnomalyType::Fraud(_) => "Fraud",
37            AnomalyType::Error(_) => "Error",
38            AnomalyType::ProcessIssue(_) => "ProcessIssue",
39            AnomalyType::Statistical(_) => "Statistical",
40            AnomalyType::Relational(_) => "Relational",
41            AnomalyType::Custom(_) => "Custom",
42        }
43    }
44
45    /// Returns the specific type name.
46    pub fn type_name(&self) -> String {
47        match self {
48            AnomalyType::Fraud(t) => format!("{:?}", t),
49            AnomalyType::Error(t) => format!("{:?}", t),
50            AnomalyType::ProcessIssue(t) => format!("{:?}", t),
51            AnomalyType::Statistical(t) => format!("{:?}", t),
52            AnomalyType::Relational(t) => format!("{:?}", t),
53            AnomalyType::Custom(s) => s.clone(),
54        }
55    }
56
57    /// Returns the severity level (1-5, 5 being most severe).
58    pub fn severity(&self) -> u8 {
59        match self {
60            AnomalyType::Fraud(t) => t.severity(),
61            AnomalyType::Error(t) => t.severity(),
62            AnomalyType::ProcessIssue(t) => t.severity(),
63            AnomalyType::Statistical(t) => t.severity(),
64            AnomalyType::Relational(t) => t.severity(),
65            AnomalyType::Custom(_) => 3,
66        }
67    }
68
69    /// Returns whether this anomaly is typically intentional.
70    pub fn is_intentional(&self) -> bool {
71        matches!(self, AnomalyType::Fraud(_))
72    }
73}
74
75/// Fraud types for detection training.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub enum FraudType {
78    // Journal Entry Fraud
79    /// Fictitious journal entry with no business purpose.
80    FictitiousEntry,
81    /// Fictitious transaction (alias for FictitiousEntry).
82    FictitiousTransaction,
83    /// Round-dollar amounts suggesting manual manipulation.
84    RoundDollarManipulation,
85    /// Entry posted just below approval threshold.
86    JustBelowThreshold,
87    /// Revenue recognition manipulation.
88    RevenueManipulation,
89    /// Expense capitalization fraud.
90    ImproperCapitalization,
91    /// Improperly capitalizing expenses as assets.
92    ExpenseCapitalization,
93    /// Cookie jar reserves manipulation.
94    ReserveManipulation,
95    /// Round-tripping funds through suspense/clearing accounts.
96    SuspenseAccountAbuse,
97    /// Splitting transactions to stay below approval thresholds.
98    SplitTransaction,
99    /// Unusual timing (weekend, holiday, after-hours postings).
100    TimingAnomaly,
101    /// Posting to unauthorized accounts.
102    UnauthorizedAccess,
103
104    // Approval Fraud
105    /// User approving their own request.
106    SelfApproval,
107    /// Approval beyond authorized limit.
108    ExceededApprovalLimit,
109    /// Segregation of duties violation.
110    SegregationOfDutiesViolation,
111    /// Approval by unauthorized user.
112    UnauthorizedApproval,
113    /// Collusion between approver and requester.
114    CollusiveApproval,
115
116    // Vendor/Payment Fraud
117    /// Fictitious vendor.
118    FictitiousVendor,
119    /// Duplicate payment to vendor.
120    DuplicatePayment,
121    /// Payment to shell company.
122    ShellCompanyPayment,
123    /// Kickback scheme.
124    Kickback,
125    /// Kickback scheme (alias).
126    KickbackScheme,
127    /// Invoice manipulation.
128    InvoiceManipulation,
129
130    // Asset Fraud
131    /// Misappropriation of assets.
132    AssetMisappropriation,
133    /// Inventory theft.
134    InventoryTheft,
135    /// Ghost employee.
136    GhostEmployee,
137
138    // Financial Statement Fraud
139    /// Premature revenue recognition.
140    PrematureRevenue,
141    /// Understated liabilities.
142    UnderstatedLiabilities,
143    /// Overstated assets.
144    OverstatedAssets,
145    /// Channel stuffing.
146    ChannelStuffing,
147}
148
149impl FraudType {
150    /// Returns severity level (1-5).
151    pub fn severity(&self) -> u8 {
152        match self {
153            FraudType::RoundDollarManipulation => 2,
154            FraudType::JustBelowThreshold => 3,
155            FraudType::SelfApproval => 3,
156            FraudType::ExceededApprovalLimit => 3,
157            FraudType::DuplicatePayment => 3,
158            FraudType::FictitiousEntry => 4,
159            FraudType::RevenueManipulation => 5,
160            FraudType::FictitiousVendor => 5,
161            FraudType::ShellCompanyPayment => 5,
162            FraudType::AssetMisappropriation => 5,
163            FraudType::SegregationOfDutiesViolation => 4,
164            FraudType::CollusiveApproval => 5,
165            _ => 4,
166        }
167    }
168}
169
170/// Error types for error detection.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172pub enum ErrorType {
173    // Data Entry Errors
174    /// Duplicate document entry.
175    DuplicateEntry,
176    /// Reversed debit/credit amounts.
177    ReversedAmount,
178    /// Transposed digits in amount.
179    TransposedDigits,
180    /// Wrong decimal placement.
181    DecimalError,
182    /// Missing required field.
183    MissingField,
184    /// Invalid account code.
185    InvalidAccount,
186
187    // Timing Errors
188    /// Posted to wrong period.
189    WrongPeriod,
190    /// Backdated entry.
191    BackdatedEntry,
192    /// Future-dated entry.
193    FutureDatedEntry,
194    /// Cutoff error.
195    CutoffError,
196
197    // Classification Errors
198    /// Wrong account classification.
199    MisclassifiedAccount,
200    /// Wrong cost center.
201    WrongCostCenter,
202    /// Wrong company code.
203    WrongCompanyCode,
204
205    // Calculation Errors
206    /// Unbalanced journal entry.
207    UnbalancedEntry,
208    /// Rounding error.
209    RoundingError,
210    /// Currency conversion error.
211    CurrencyError,
212    /// Tax calculation error.
213    TaxCalculationError,
214}
215
216impl ErrorType {
217    /// Returns severity level (1-5).
218    pub fn severity(&self) -> u8 {
219        match self {
220            ErrorType::RoundingError => 1,
221            ErrorType::MissingField => 2,
222            ErrorType::TransposedDigits => 2,
223            ErrorType::DecimalError => 3,
224            ErrorType::DuplicateEntry => 3,
225            ErrorType::ReversedAmount => 3,
226            ErrorType::WrongPeriod => 4,
227            ErrorType::UnbalancedEntry => 5,
228            ErrorType::CurrencyError => 4,
229            _ => 3,
230        }
231    }
232}
233
234/// Process issue types.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
236pub enum ProcessIssueType {
237    // Approval Issues
238    /// Approval skipped entirely.
239    SkippedApproval,
240    /// Late approval (after posting).
241    LateApproval,
242    /// Missing supporting documentation.
243    MissingDocumentation,
244    /// Incomplete approval chain.
245    IncompleteApprovalChain,
246
247    // Timing Issues
248    /// Late posting.
249    LatePosting,
250    /// Posting outside business hours.
251    AfterHoursPosting,
252    /// Weekend/holiday posting.
253    WeekendPosting,
254    /// Rushed period-end posting.
255    RushedPeriodEnd,
256
257    // Control Issues
258    /// Manual override of system control.
259    ManualOverride,
260    /// Unusual user access pattern.
261    UnusualAccess,
262    /// System bypass.
263    SystemBypass,
264    /// Batch processing anomaly.
265    BatchAnomaly,
266
267    // Documentation Issues
268    /// Vague or missing description.
269    VagueDescription,
270    /// Changed after posting.
271    PostFactoChange,
272    /// Incomplete audit trail.
273    IncompleteAuditTrail,
274}
275
276impl ProcessIssueType {
277    /// Returns severity level (1-5).
278    pub fn severity(&self) -> u8 {
279        match self {
280            ProcessIssueType::VagueDescription => 1,
281            ProcessIssueType::LatePosting => 2,
282            ProcessIssueType::AfterHoursPosting => 2,
283            ProcessIssueType::WeekendPosting => 2,
284            ProcessIssueType::SkippedApproval => 4,
285            ProcessIssueType::ManualOverride => 4,
286            ProcessIssueType::SystemBypass => 5,
287            ProcessIssueType::IncompleteAuditTrail => 4,
288            _ => 3,
289        }
290    }
291}
292
293/// Statistical anomaly types.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
295pub enum StatisticalAnomalyType {
296    // Amount Anomalies
297    /// Amount significantly above normal.
298    UnusuallyHighAmount,
299    /// Amount significantly below normal.
300    UnusuallyLowAmount,
301    /// Violates Benford's Law distribution.
302    BenfordViolation,
303    /// Exact duplicate amount (suspicious).
304    ExactDuplicateAmount,
305    /// Repeating pattern in amounts.
306    RepeatingAmount,
307
308    // Frequency Anomalies
309    /// Unusual transaction frequency.
310    UnusualFrequency,
311    /// Burst of transactions.
312    TransactionBurst,
313    /// Unusual time of day.
314    UnusualTiming,
315
316    // Trend Anomalies
317    /// Break in historical trend.
318    TrendBreak,
319    /// Sudden level shift.
320    LevelShift,
321    /// Seasonal pattern violation.
322    SeasonalAnomaly,
323
324    // Distribution Anomalies
325    /// Outlier in distribution.
326    StatisticalOutlier,
327    /// Change in variance.
328    VarianceChange,
329    /// Distribution shift.
330    DistributionShift,
331}
332
333impl StatisticalAnomalyType {
334    /// Returns severity level (1-5).
335    pub fn severity(&self) -> u8 {
336        match self {
337            StatisticalAnomalyType::UnusualTiming => 1,
338            StatisticalAnomalyType::UnusualFrequency => 2,
339            StatisticalAnomalyType::BenfordViolation => 2,
340            StatisticalAnomalyType::UnusuallyHighAmount => 3,
341            StatisticalAnomalyType::TrendBreak => 3,
342            StatisticalAnomalyType::TransactionBurst => 4,
343            StatisticalAnomalyType::ExactDuplicateAmount => 3,
344            _ => 3,
345        }
346    }
347}
348
349/// Relational/graph anomaly types.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
351pub enum RelationalAnomalyType {
352    // Transaction Pattern Anomalies
353    /// Circular transaction pattern.
354    CircularTransaction,
355    /// Unusual account combination.
356    UnusualAccountPair,
357    /// New trading partner.
358    NewCounterparty,
359    /// Dormant account suddenly active.
360    DormantAccountActivity,
361
362    // Network Anomalies
363    /// Unusual network centrality.
364    CentralityAnomaly,
365    /// Isolated transaction cluster.
366    IsolatedCluster,
367    /// Bridge node anomaly.
368    BridgeNodeAnomaly,
369    /// Community structure change.
370    CommunityAnomaly,
371
372    // Relationship Anomalies
373    /// Missing expected relationship.
374    MissingRelationship,
375    /// Unexpected relationship.
376    UnexpectedRelationship,
377    /// Relationship strength change.
378    RelationshipStrengthChange,
379
380    // Intercompany Anomalies
381    /// Unmatched intercompany transaction.
382    UnmatchedIntercompany,
383    /// Circular intercompany flow.
384    CircularIntercompany,
385    /// Transfer pricing anomaly.
386    TransferPricingAnomaly,
387}
388
389impl RelationalAnomalyType {
390    /// Returns severity level (1-5).
391    pub fn severity(&self) -> u8 {
392        match self {
393            RelationalAnomalyType::NewCounterparty => 1,
394            RelationalAnomalyType::DormantAccountActivity => 2,
395            RelationalAnomalyType::UnusualAccountPair => 2,
396            RelationalAnomalyType::CircularTransaction => 4,
397            RelationalAnomalyType::CircularIntercompany => 4,
398            RelationalAnomalyType::TransferPricingAnomaly => 4,
399            RelationalAnomalyType::UnmatchedIntercompany => 3,
400            _ => 3,
401        }
402    }
403}
404
405/// A labeled anomaly for supervised learning.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct LabeledAnomaly {
408    /// Unique anomaly identifier.
409    pub anomaly_id: String,
410    /// Type of anomaly.
411    pub anomaly_type: AnomalyType,
412    /// Document or entity that contains the anomaly.
413    pub document_id: String,
414    /// Document type (JE, PO, Invoice, etc.).
415    pub document_type: String,
416    /// Company code.
417    pub company_code: String,
418    /// Date the anomaly occurred.
419    pub anomaly_date: NaiveDate,
420    /// Timestamp when detected/injected.
421    pub detection_timestamp: NaiveDateTime,
422    /// Confidence score (0.0 - 1.0) for injected anomalies.
423    pub confidence: f64,
424    /// Severity (1-5).
425    pub severity: u8,
426    /// Description of the anomaly.
427    pub description: String,
428    /// Related entities (user IDs, account codes, etc.).
429    pub related_entities: Vec<String>,
430    /// Monetary impact if applicable.
431    pub monetary_impact: Option<Decimal>,
432    /// Additional metadata.
433    pub metadata: HashMap<String, String>,
434    /// Whether this was injected (true) or naturally occurring (false).
435    pub is_injected: bool,
436    /// Injection strategy used (if injected).
437    pub injection_strategy: Option<String>,
438    /// Cluster ID if part of an anomaly cluster.
439    pub cluster_id: Option<String>,
440}
441
442impl LabeledAnomaly {
443    /// Creates a new labeled anomaly.
444    pub fn new(
445        anomaly_id: String,
446        anomaly_type: AnomalyType,
447        document_id: String,
448        document_type: String,
449        company_code: String,
450        anomaly_date: NaiveDate,
451    ) -> Self {
452        let severity = anomaly_type.severity();
453        let description = format!(
454            "{} - {} in document {}",
455            anomaly_type.category(),
456            anomaly_type.type_name(),
457            document_id
458        );
459
460        Self {
461            anomaly_id,
462            anomaly_type,
463            document_id,
464            document_type,
465            company_code,
466            anomaly_date,
467            detection_timestamp: chrono::Local::now().naive_local(),
468            confidence: 1.0,
469            severity,
470            description,
471            related_entities: Vec::new(),
472            monetary_impact: None,
473            metadata: HashMap::new(),
474            is_injected: true,
475            injection_strategy: None,
476            cluster_id: None,
477        }
478    }
479
480    /// Sets the description.
481    pub fn with_description(mut self, description: &str) -> Self {
482        self.description = description.to_string();
483        self
484    }
485
486    /// Sets the monetary impact.
487    pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
488        self.monetary_impact = Some(impact);
489        self
490    }
491
492    /// Adds a related entity.
493    pub fn with_related_entity(mut self, entity: &str) -> Self {
494        self.related_entities.push(entity.to_string());
495        self
496    }
497
498    /// Adds metadata.
499    pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
500        self.metadata.insert(key.to_string(), value.to_string());
501        self
502    }
503
504    /// Sets the injection strategy.
505    pub fn with_injection_strategy(mut self, strategy: &str) -> Self {
506        self.injection_strategy = Some(strategy.to_string());
507        self
508    }
509
510    /// Sets the cluster ID.
511    pub fn with_cluster(mut self, cluster_id: &str) -> Self {
512        self.cluster_id = Some(cluster_id.to_string());
513        self
514    }
515
516    /// Converts to a feature vector for ML.
517    pub fn to_features(&self) -> Vec<f64> {
518        let mut features = Vec::new();
519
520        // Category one-hot encoding
521        let categories = [
522            "Fraud",
523            "Error",
524            "ProcessIssue",
525            "Statistical",
526            "Relational",
527            "Custom",
528        ];
529        for cat in &categories {
530            features.push(if self.anomaly_type.category() == *cat {
531                1.0
532            } else {
533                0.0
534            });
535        }
536
537        // Severity (normalized)
538        features.push(self.severity as f64 / 5.0);
539
540        // Confidence
541        features.push(self.confidence);
542
543        // Has monetary impact
544        features.push(if self.monetary_impact.is_some() {
545            1.0
546        } else {
547            0.0
548        });
549
550        // Monetary impact (log-scaled)
551        if let Some(impact) = self.monetary_impact {
552            let impact_f64: f64 = impact.try_into().unwrap_or(0.0);
553            features.push((impact_f64.abs() + 1.0).ln());
554        } else {
555            features.push(0.0);
556        }
557
558        // Is intentional
559        features.push(if self.anomaly_type.is_intentional() {
560            1.0
561        } else {
562            0.0
563        });
564
565        // Number of related entities
566        features.push(self.related_entities.len() as f64);
567
568        // Is part of cluster
569        features.push(if self.cluster_id.is_some() { 1.0 } else { 0.0 });
570
571        features
572    }
573}
574
575/// Summary of anomalies for reporting.
576#[derive(Debug, Clone, Default, Serialize, Deserialize)]
577pub struct AnomalySummary {
578    /// Total anomaly count.
579    pub total_count: usize,
580    /// Count by category.
581    pub by_category: HashMap<String, usize>,
582    /// Count by specific type.
583    pub by_type: HashMap<String, usize>,
584    /// Count by severity.
585    pub by_severity: HashMap<u8, usize>,
586    /// Count by company.
587    pub by_company: HashMap<String, usize>,
588    /// Total monetary impact.
589    pub total_monetary_impact: Decimal,
590    /// Date range.
591    pub date_range: Option<(NaiveDate, NaiveDate)>,
592    /// Number of clusters.
593    pub cluster_count: usize,
594}
595
596impl AnomalySummary {
597    /// Creates a summary from a list of anomalies.
598    pub fn from_anomalies(anomalies: &[LabeledAnomaly]) -> Self {
599        let mut summary = AnomalySummary {
600            total_count: anomalies.len(),
601            ..Default::default()
602        };
603
604        let mut min_date: Option<NaiveDate> = None;
605        let mut max_date: Option<NaiveDate> = None;
606        let mut clusters = std::collections::HashSet::new();
607
608        for anomaly in anomalies {
609            // By category
610            *summary
611                .by_category
612                .entry(anomaly.anomaly_type.category().to_string())
613                .or_insert(0) += 1;
614
615            // By type
616            *summary
617                .by_type
618                .entry(anomaly.anomaly_type.type_name())
619                .or_insert(0) += 1;
620
621            // By severity
622            *summary.by_severity.entry(anomaly.severity).or_insert(0) += 1;
623
624            // By company
625            *summary
626                .by_company
627                .entry(anomaly.company_code.clone())
628                .or_insert(0) += 1;
629
630            // Monetary impact
631            if let Some(impact) = anomaly.monetary_impact {
632                summary.total_monetary_impact += impact;
633            }
634
635            // Date range
636            match min_date {
637                None => min_date = Some(anomaly.anomaly_date),
638                Some(d) if anomaly.anomaly_date < d => min_date = Some(anomaly.anomaly_date),
639                _ => {}
640            }
641            match max_date {
642                None => max_date = Some(anomaly.anomaly_date),
643                Some(d) if anomaly.anomaly_date > d => max_date = Some(anomaly.anomaly_date),
644                _ => {}
645            }
646
647            // Clusters
648            if let Some(cluster_id) = &anomaly.cluster_id {
649                clusters.insert(cluster_id.clone());
650            }
651        }
652
653        summary.date_range = min_date.zip(max_date);
654        summary.cluster_count = clusters.len();
655
656        summary
657    }
658}
659
660/// Configuration for anomaly rates.
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct AnomalyRateConfig {
663    /// Overall anomaly rate (0.0 - 1.0).
664    pub total_rate: f64,
665    /// Fraud rate as proportion of anomalies.
666    pub fraud_rate: f64,
667    /// Error rate as proportion of anomalies.
668    pub error_rate: f64,
669    /// Process issue rate as proportion of anomalies.
670    pub process_issue_rate: f64,
671    /// Statistical anomaly rate as proportion of anomalies.
672    pub statistical_rate: f64,
673    /// Relational anomaly rate as proportion of anomalies.
674    pub relational_rate: f64,
675}
676
677impl Default for AnomalyRateConfig {
678    fn default() -> Self {
679        Self {
680            total_rate: 0.02,         // 2% of transactions are anomalous
681            fraud_rate: 0.25,         // 25% of anomalies are fraud
682            error_rate: 0.35,         // 35% of anomalies are errors
683            process_issue_rate: 0.20, // 20% are process issues
684            statistical_rate: 0.15,   // 15% are statistical
685            relational_rate: 0.05,    // 5% are relational
686        }
687    }
688}
689
690impl AnomalyRateConfig {
691    /// Validates that rates sum to approximately 1.0.
692    pub fn validate(&self) -> Result<(), String> {
693        let sum = self.fraud_rate
694            + self.error_rate
695            + self.process_issue_rate
696            + self.statistical_rate
697            + self.relational_rate;
698
699        if (sum - 1.0).abs() > 0.01 {
700            return Err(format!(
701                "Anomaly category rates must sum to 1.0, got {}",
702                sum
703            ));
704        }
705
706        if self.total_rate < 0.0 || self.total_rate > 1.0 {
707            return Err(format!(
708                "Total rate must be between 0.0 and 1.0, got {}",
709                self.total_rate
710            ));
711        }
712
713        Ok(())
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn test_anomaly_type_category() {
723        let fraud = AnomalyType::Fraud(FraudType::SelfApproval);
724        assert_eq!(fraud.category(), "Fraud");
725        assert!(fraud.is_intentional());
726
727        let error = AnomalyType::Error(ErrorType::DuplicateEntry);
728        assert_eq!(error.category(), "Error");
729        assert!(!error.is_intentional());
730    }
731
732    #[test]
733    fn test_labeled_anomaly() {
734        let anomaly = LabeledAnomaly::new(
735            "ANO001".to_string(),
736            AnomalyType::Fraud(FraudType::SelfApproval),
737            "JE001".to_string(),
738            "JE".to_string(),
739            "1000".to_string(),
740            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
741        )
742        .with_description("User approved their own expense report")
743        .with_related_entity("USER001");
744
745        assert_eq!(anomaly.severity, 3);
746        assert!(anomaly.is_injected);
747        assert_eq!(anomaly.related_entities.len(), 1);
748    }
749
750    #[test]
751    fn test_anomaly_summary() {
752        let anomalies = vec![
753            LabeledAnomaly::new(
754                "ANO001".to_string(),
755                AnomalyType::Fraud(FraudType::SelfApproval),
756                "JE001".to_string(),
757                "JE".to_string(),
758                "1000".to_string(),
759                NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
760            ),
761            LabeledAnomaly::new(
762                "ANO002".to_string(),
763                AnomalyType::Error(ErrorType::DuplicateEntry),
764                "JE002".to_string(),
765                "JE".to_string(),
766                "1000".to_string(),
767                NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
768            ),
769        ];
770
771        let summary = AnomalySummary::from_anomalies(&anomalies);
772
773        assert_eq!(summary.total_count, 2);
774        assert_eq!(summary.by_category.get("Fraud"), Some(&1));
775        assert_eq!(summary.by_category.get("Error"), Some(&1));
776    }
777
778    #[test]
779    fn test_rate_config_validation() {
780        let config = AnomalyRateConfig::default();
781        assert!(config.validate().is_ok());
782
783        let bad_config = AnomalyRateConfig {
784            fraud_rate: 0.5,
785            error_rate: 0.5,
786            process_issue_rate: 0.5, // Sum > 1.0
787            ..Default::default()
788        };
789        assert!(bad_config.validate().is_err());
790    }
791}