Skip to main content

datasynth_generators/anomaly/
strategies.rs

1//! Injection strategies for anomaly generation.
2//!
3//! Strategies determine how anomalies are applied to existing data.
4
5use chrono::Datelike;
6use rand::{Rng, RngExt};
7use rust_decimal::Decimal;
8
9use datasynth_core::accounts::dormant_accounts;
10use datasynth_core::models::{
11    AnomalyType, ControlStatus, ErrorType, FraudType, JournalEntry, ProcessIssueType,
12    RelationalAnomalyType, StatisticalAnomalyType,
13};
14use datasynth_core::uuid_factory::DeterministicUuidFactory;
15
16/// Base trait for injection strategies.
17pub trait InjectionStrategy {
18    /// Name of the strategy.
19    fn name(&self) -> &'static str;
20
21    /// Whether this strategy can be applied to the given entry.
22    fn can_apply(&self, entry: &JournalEntry) -> bool;
23
24    /// Applies the strategy to modify an entry.
25    fn apply<R: Rng>(
26        &self,
27        entry: &mut JournalEntry,
28        anomaly_type: &AnomalyType,
29        rng: &mut R,
30    ) -> InjectionResult;
31}
32
33/// Result of an injection attempt.
34#[derive(Debug, Clone)]
35pub struct InjectionResult {
36    /// Whether the injection was successful.
37    pub success: bool,
38    /// Description of what was modified.
39    pub description: String,
40    /// Monetary impact of the anomaly.
41    pub monetary_impact: Option<Decimal>,
42    /// Related entity IDs.
43    pub related_entities: Vec<String>,
44    /// Additional metadata.
45    pub metadata: Vec<(String, String)>,
46}
47
48impl InjectionResult {
49    /// Creates a successful result.
50    pub fn success(description: &str) -> Self {
51        Self {
52            success: true,
53            description: description.to_string(),
54            monetary_impact: None,
55            related_entities: Vec::new(),
56            metadata: Vec::new(),
57        }
58    }
59
60    /// Creates a failed result.
61    pub fn failure(reason: &str) -> Self {
62        Self {
63            success: false,
64            description: reason.to_string(),
65            monetary_impact: None,
66            related_entities: Vec::new(),
67            metadata: Vec::new(),
68        }
69    }
70
71    /// Adds monetary impact.
72    pub fn with_impact(mut self, impact: Decimal) -> Self {
73        self.monetary_impact = Some(impact);
74        self
75    }
76
77    /// Adds a related entity.
78    pub fn with_entity(mut self, entity: &str) -> Self {
79        self.related_entities.push(entity.to_string());
80        self
81    }
82
83    /// Adds metadata.
84    pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
85        self.metadata.push((key.to_string(), value.to_string()));
86        self
87    }
88}
89
90/// Strategy for modifying amounts.
91pub struct AmountModificationStrategy {
92    /// Minimum multiplier for amount changes.
93    pub min_multiplier: f64,
94    /// Maximum multiplier for amount changes.
95    pub max_multiplier: f64,
96    /// Whether to use round numbers.
97    pub prefer_round_numbers: bool,
98    /// Whether to rebalance the entry after modification.
99    /// If true, a corresponding line will be adjusted to maintain balance.
100    /// If false, the entry will become unbalanced (for intentional fraud detection).
101    pub rebalance_entry: bool,
102}
103
104impl Default for AmountModificationStrategy {
105    fn default() -> Self {
106        Self {
107            min_multiplier: 2.0,
108            max_multiplier: 10.0,
109            prefer_round_numbers: false,
110            rebalance_entry: true, // Default to maintaining balance
111        }
112    }
113}
114
115impl InjectionStrategy for AmountModificationStrategy {
116    fn name(&self) -> &'static str {
117        "AmountModification"
118    }
119
120    fn can_apply(&self, entry: &JournalEntry) -> bool {
121        // v5.31 C1 Phase 7+: skip IC injector JEs. Modifying the
122        // amount on either leg would create a seller/buyer mismatch
123        // that propagates as a silent consolidation error
124        // (eliminations net to the wrong notional). IC amounts are
125        // deterministic by manifest contract.
126        !entry.lines.is_empty() && entry.header.ic_pair_id.is_none()
127    }
128
129    fn apply<R: Rng>(
130        &self,
131        entry: &mut JournalEntry,
132        anomaly_type: &AnomalyType,
133        rng: &mut R,
134    ) -> InjectionResult {
135        if entry.lines.is_empty() {
136            return InjectionResult::failure("No lines to modify");
137        }
138
139        let line_idx = rng.random_range(0..entry.lines.len());
140        let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
141        let original_amount = if is_debit {
142            entry.lines[line_idx].debit_amount
143        } else {
144            entry.lines[line_idx].credit_amount
145        };
146
147        let multiplier = rng.random_range(self.min_multiplier..self.max_multiplier);
148        let mut new_amount =
149            original_amount * Decimal::from_f64_retain(multiplier).unwrap_or(Decimal::ONE);
150
151        // Round to nice number if preferred
152        if self.prefer_round_numbers {
153            let abs_amount = new_amount.abs();
154            let magnitude = if abs_amount >= Decimal::ONE {
155                let digits = abs_amount
156                    .to_string()
157                    .split('.')
158                    .next()
159                    .map(|s| s.trim_start_matches('-').len())
160                    .unwrap_or(1);
161                (digits as i32 - 1).max(0)
162            } else {
163                0
164            };
165            let round_factor = Decimal::new(10_i64.pow(magnitude as u32), 0);
166            new_amount = (new_amount / round_factor).round() * round_factor;
167        }
168
169        let impact = new_amount - original_amount;
170        let account_code = entry.lines[line_idx].account_code.clone();
171
172        // Apply the modification
173        if is_debit {
174            entry.lines[line_idx].debit_amount = new_amount;
175        } else {
176            entry.lines[line_idx].credit_amount = new_amount;
177        }
178
179        // Rebalance the entry if configured to do so
180        if self.rebalance_entry {
181            // Find a line on the opposite side to adjust
182            let balancing_idx = entry.lines.iter().position(|l| {
183                if is_debit {
184                    l.credit_amount > Decimal::ZERO
185                } else {
186                    l.debit_amount > Decimal::ZERO
187                }
188            });
189
190            if let Some(bal_idx) = balancing_idx {
191                // Adjust the balancing line by the same impact
192                if is_debit {
193                    entry.lines[bal_idx].credit_amount += impact;
194                } else {
195                    entry.lines[bal_idx].debit_amount += impact;
196                }
197            }
198        }
199
200        match anomaly_type {
201            AnomalyType::Fraud(FraudType::RoundDollarManipulation) => {
202                InjectionResult::success(&format!(
203                    "Modified amount from {} to {} (round dollar){}",
204                    original_amount,
205                    new_amount,
206                    if self.rebalance_entry {
207                        " [rebalanced]"
208                    } else {
209                        " [UNBALANCED]"
210                    }
211                ))
212                .with_impact(impact)
213                .with_entity(&account_code)
214            }
215            AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount) => {
216                InjectionResult::success(&format!(
217                    "Inflated amount by {:.1}x to {}{}",
218                    multiplier,
219                    new_amount,
220                    if self.rebalance_entry {
221                        " [rebalanced]"
222                    } else {
223                        " [UNBALANCED]"
224                    }
225                ))
226                .with_impact(impact)
227                .with_metadata("multiplier", &format!("{multiplier:.2}"))
228            }
229            _ => InjectionResult::success(&format!(
230                "Modified amount to {}{}",
231                new_amount,
232                if self.rebalance_entry {
233                    " [rebalanced]"
234                } else {
235                    " [UNBALANCED]"
236                }
237            ))
238            .with_impact(impact),
239        }
240    }
241}
242
243/// Strategy for modifying dates.
244pub struct DateModificationStrategy {
245    /// Maximum days to backdate.
246    pub max_backdate_days: i64,
247    /// Maximum days to future-date.
248    pub max_future_days: i64,
249    /// Whether to cross period boundaries.
250    pub cross_period_boundary: bool,
251}
252
253impl Default for DateModificationStrategy {
254    fn default() -> Self {
255        Self {
256            max_backdate_days: 30,
257            max_future_days: 7,
258            cross_period_boundary: true,
259        }
260    }
261}
262
263impl InjectionStrategy for DateModificationStrategy {
264    fn name(&self) -> &'static str {
265        "DateModification"
266    }
267
268    fn can_apply(&self, _entry: &JournalEntry) -> bool {
269        true
270    }
271
272    fn apply<R: Rng>(
273        &self,
274        entry: &mut JournalEntry,
275        anomaly_type: &AnomalyType,
276        rng: &mut R,
277    ) -> InjectionResult {
278        let original_date = entry.header.posting_date;
279
280        let (days_offset, description) = match anomaly_type {
281            AnomalyType::Error(ErrorType::BackdatedEntry) => {
282                let days = rng.random_range(1..=self.max_backdate_days);
283                (-days, format!("Backdated by {days} days"))
284            }
285            AnomalyType::Error(ErrorType::FutureDatedEntry) => {
286                let days = rng.random_range(1..=self.max_future_days);
287                (days, format!("Future-dated by {days} days"))
288            }
289            AnomalyType::Error(ErrorType::WrongPeriod) => {
290                // Move to previous or next month
291                let direction: i64 = if rng.random_bool(0.5) { -1 } else { 1 };
292                let days = direction * 32; // Ensure crossing month boundary
293                (days, "Posted to wrong period".to_string())
294            }
295            AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
296                let days = rng.random_range(5..=15);
297                entry.header.document_date = entry.header.posting_date; // Document date stays same
298                entry.header.posting_date = original_date + chrono::Duration::days(days);
299                return InjectionResult::success(&format!(
300                    "Late posting: {days} days after transaction"
301                ))
302                .with_metadata("delay_days", &days.to_string());
303            }
304            _ => (0, "Date unchanged".to_string()),
305        };
306
307        if days_offset != 0 {
308            entry.header.posting_date = original_date + chrono::Duration::days(days_offset);
309        }
310
311        InjectionResult::success(&description)
312            .with_metadata("original_date", &original_date.to_string())
313            .with_metadata("new_date", &entry.header.posting_date.to_string())
314    }
315}
316
317/// Strategy for document duplication.
318pub struct DuplicationStrategy {
319    /// Whether to modify amounts slightly.
320    pub vary_amounts: bool,
321    /// Amount variance factor.
322    pub amount_variance: f64,
323    /// Whether to change document numbers.
324    pub change_doc_number: bool,
325}
326
327impl Default for DuplicationStrategy {
328    fn default() -> Self {
329        Self {
330            vary_amounts: false,
331            amount_variance: 0.01,
332            change_doc_number: true,
333        }
334    }
335}
336
337impl DuplicationStrategy {
338    /// Creates a duplicate of the entry.
339    pub fn duplicate<R: Rng>(
340        &self,
341        entry: &JournalEntry,
342        rng: &mut R,
343        uuid_factory: &DeterministicUuidFactory,
344    ) -> JournalEntry {
345        let mut duplicate = entry.clone();
346
347        if self.change_doc_number {
348            // Generate a new UUID for the duplicate
349            duplicate.header.document_id = uuid_factory.next();
350            // Update line items to reference the new document ID
351            for line in &mut duplicate.lines {
352                line.document_id = duplicate.header.document_id;
353            }
354        }
355
356        if self.vary_amounts {
357            for line in &mut duplicate.lines {
358                let variance = 1.0 + rng.random_range(-self.amount_variance..self.amount_variance);
359                let variance_dec = Decimal::from_f64_retain(variance).unwrap_or(Decimal::ONE);
360
361                if line.debit_amount > Decimal::ZERO {
362                    line.debit_amount = (line.debit_amount * variance_dec).round_dp(2);
363                }
364                if line.credit_amount > Decimal::ZERO {
365                    line.credit_amount = (line.credit_amount * variance_dec).round_dp(2);
366                }
367            }
368        }
369
370        duplicate
371    }
372}
373
374/// Strategy for approval-related anomalies.
375pub struct ApprovalAnomalyStrategy {
376    /// Approval threshold to target.
377    pub approval_threshold: Decimal,
378    /// Buffer below threshold.
379    pub threshold_buffer: Decimal,
380}
381
382impl Default for ApprovalAnomalyStrategy {
383    fn default() -> Self {
384        Self {
385            approval_threshold: Decimal::new(10000, 0),
386            threshold_buffer: Decimal::new(100, 0),
387        }
388    }
389}
390
391impl InjectionStrategy for ApprovalAnomalyStrategy {
392    fn name(&self) -> &'static str {
393        "ApprovalAnomaly"
394    }
395
396    fn can_apply(&self, entry: &JournalEntry) -> bool {
397        entry.total_debit() > Decimal::ZERO
398    }
399
400    fn apply<R: Rng>(
401        &self,
402        entry: &mut JournalEntry,
403        anomaly_type: &AnomalyType,
404        rng: &mut R,
405    ) -> InjectionResult {
406        match anomaly_type {
407            AnomalyType::Fraud(FraudType::JustBelowThreshold) => {
408                // Set total to just below threshold
409                let target = self.approval_threshold
410                    - self.threshold_buffer
411                    - Decimal::new(rng.random_range(1..50), 0);
412
413                let current_total = entry.total_debit();
414                if current_total == Decimal::ZERO {
415                    return InjectionResult::failure("Cannot scale zero amount");
416                }
417
418                let scale = target / current_total;
419                for line in &mut entry.lines {
420                    line.debit_amount = (line.debit_amount * scale).round_dp(2);
421                    line.credit_amount = (line.credit_amount * scale).round_dp(2);
422                }
423
424                InjectionResult::success(&format!(
425                    "Adjusted total to {} (just below threshold {})",
426                    entry.total_debit(),
427                    self.approval_threshold
428                ))
429                .with_metadata("threshold", &self.approval_threshold.to_string())
430            }
431            AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
432                // Set total to exceed threshold
433                let target = self.approval_threshold * Decimal::new(15, 1); // 1.5x threshold
434
435                let current_total = entry.total_debit();
436                if current_total == Decimal::ZERO {
437                    return InjectionResult::failure("Cannot scale zero amount");
438                }
439
440                let scale = target / current_total;
441                for line in &mut entry.lines {
442                    line.debit_amount = (line.debit_amount * scale).round_dp(2);
443                    line.credit_amount = (line.credit_amount * scale).round_dp(2);
444                }
445
446                InjectionResult::success(&format!(
447                    "Exceeded approval limit: {} vs limit {}",
448                    entry.total_debit(),
449                    self.approval_threshold
450                ))
451                .with_impact(entry.total_debit() - self.approval_threshold)
452            }
453            _ => InjectionResult::failure("Unsupported anomaly type for this strategy"),
454        }
455    }
456}
457
458/// Strategy for description/text anomalies.
459pub struct DescriptionAnomalyStrategy {
460    /// Vague descriptions to use.
461    pub vague_descriptions: Vec<String>,
462}
463
464impl Default for DescriptionAnomalyStrategy {
465    fn default() -> Self {
466        Self {
467            vague_descriptions: vec![
468                "Misc".to_string(),
469                "Adjustment".to_string(),
470                "Correction".to_string(),
471                "Various".to_string(),
472                "Other".to_string(),
473                "TBD".to_string(),
474                "See attachment".to_string(),
475                "As discussed".to_string(),
476                "Per management".to_string(),
477                ".".to_string(),
478                "xxx".to_string(),
479                "test".to_string(),
480            ],
481        }
482    }
483}
484
485impl InjectionStrategy for DescriptionAnomalyStrategy {
486    fn name(&self) -> &'static str {
487        "DescriptionAnomaly"
488    }
489
490    fn can_apply(&self, _entry: &JournalEntry) -> bool {
491        true
492    }
493
494    fn apply<R: Rng>(
495        &self,
496        entry: &mut JournalEntry,
497        _anomaly_type: &AnomalyType,
498        rng: &mut R,
499    ) -> InjectionResult {
500        let original = entry.description().unwrap_or("").to_string();
501        let vague = &self.vague_descriptions[rng.random_range(0..self.vague_descriptions.len())];
502        entry.set_description(vague.clone());
503
504        InjectionResult::success(&format!(
505            "Changed description from '{original}' to '{vague}'"
506        ))
507        .with_metadata("original_description", &original)
508    }
509}
510
511/// Strategy for Benford's Law violations.
512pub struct BenfordViolationStrategy {
513    /// Target first digits (rarely occurring).
514    pub target_digits: Vec<u32>,
515    /// Whether to rebalance the entry after modification.
516    pub rebalance_entry: bool,
517}
518
519impl Default for BenfordViolationStrategy {
520    fn default() -> Self {
521        Self {
522            target_digits: vec![5, 6, 7, 8, 9], // Less common first digits
523            rebalance_entry: true,              // Default to maintaining balance
524        }
525    }
526}
527
528impl InjectionStrategy for BenfordViolationStrategy {
529    fn name(&self) -> &'static str {
530        "BenfordViolation"
531    }
532
533    fn can_apply(&self, entry: &JournalEntry) -> bool {
534        !entry.lines.is_empty()
535    }
536
537    fn apply<R: Rng>(
538        &self,
539        entry: &mut JournalEntry,
540        _anomaly_type: &AnomalyType,
541        rng: &mut R,
542    ) -> InjectionResult {
543        if entry.lines.is_empty() {
544            return InjectionResult::failure("No lines to modify");
545        }
546
547        let line_idx = rng.random_range(0..entry.lines.len());
548        let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
549        let original_amount = if is_debit {
550            entry.lines[line_idx].debit_amount
551        } else {
552            entry.lines[line_idx].credit_amount
553        };
554
555        // Get target first digit
556        let target_digit = self.target_digits[rng.random_range(0..self.target_digits.len())];
557
558        // Compute the order of magnitude of the integer part of the
559        // original amount.  Using the full Decimal string and stripping
560        // the decimal point would conflate the integer part with the
561        // scale (a `530079.625888901324346344432` opening balance has
562        // 28 digits in its full string but only 6 in its integer part),
563        // and previously caused `safe_magnitude` to clamp to 18 — the
564        // resulting `base = 10^18` then produced 5–9 quintillion
565        // amounts on routine inputs.  See issue #185.
566        //
567        // f64 has plenty of precision for log10 of amounts that fit in
568        // a Decimal scaled-i128, and falls back to 0 for anything
569        // pathological.
570        let abs_amount: f64 = original_amount.abs().try_into().unwrap_or(0.0);
571        let magnitude = if abs_amount >= 1.0 {
572            abs_amount.log10().floor() as i32
573        } else {
574            0
575        };
576        // Cap at 12 (≈ \$1 trillion).  Anti-Benford fraud amounts on
577        // realistic enterprise data should not be quintillion-scale,
578        // and `i64::pow` overflows above 18 anyway.
579        let safe_magnitude = magnitude.clamp(0, 12) as u32;
580
581        let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
582        let new_amount = base * Decimal::new(target_digit as i64, 0)
583            + Decimal::new(rng.random_range(0..10_i64.pow(safe_magnitude)), 0);
584
585        let impact = new_amount - original_amount;
586
587        // Apply the modification
588        if is_debit {
589            entry.lines[line_idx].debit_amount = new_amount;
590        } else {
591            entry.lines[line_idx].credit_amount = new_amount;
592        }
593
594        // Rebalance the entry if configured to do so
595        if self.rebalance_entry {
596            // Find a line on the opposite side to adjust
597            let balancing_idx = entry.lines.iter().position(|l| {
598                if is_debit {
599                    l.credit_amount > Decimal::ZERO
600                } else {
601                    l.debit_amount > Decimal::ZERO
602                }
603            });
604
605            if let Some(bal_idx) = balancing_idx {
606                // Adjust the balancing line by the same impact
607                if is_debit {
608                    entry.lines[bal_idx].credit_amount += impact;
609                } else {
610                    entry.lines[bal_idx].debit_amount += impact;
611                }
612            }
613        }
614
615        let first_digit = target_digit;
616        let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
617
618        InjectionResult::success(&format!(
619            "Created Benford violation: first digit {} (expected probability {:.1}%){}",
620            first_digit,
621            benford_prob * 100.0,
622            if self.rebalance_entry {
623                " [rebalanced]"
624            } else {
625                " [UNBALANCED]"
626            }
627        ))
628        .with_impact(impact)
629        .with_metadata("first_digit", &first_digit.to_string())
630        .with_metadata("benford_probability", &format!("{benford_prob:.4}"))
631    }
632}
633
634/// Strategy for split transactions (structuring to avoid thresholds).
635pub struct SplitTransactionStrategy {
636    /// Threshold above which transactions are split.
637    pub split_threshold: Decimal,
638    /// Number of splits to create.
639    pub min_splits: usize,
640    pub max_splits: usize,
641    /// Buffer below threshold.
642    pub threshold_buffer: Decimal,
643}
644
645impl Default for SplitTransactionStrategy {
646    fn default() -> Self {
647        Self {
648            split_threshold: Decimal::new(10000, 0),
649            min_splits: 2,
650            max_splits: 5,
651            threshold_buffer: Decimal::new(500, 0),
652        }
653    }
654}
655
656impl InjectionStrategy for SplitTransactionStrategy {
657    fn name(&self) -> &'static str {
658        "SplitTransaction"
659    }
660
661    fn can_apply(&self, entry: &JournalEntry) -> bool {
662        // v5.31 C1 Phase 7+: skip IC injector JEs. Splitting / scaling
663        // the IC amount would create a seller/buyer notional mismatch
664        // (the partner-side JE on the other entity is untouched).
665        // IC amounts are deterministic by manifest contract.
666        entry.total_debit() > self.split_threshold && entry.header.ic_pair_id.is_none()
667    }
668
669    fn apply<R: Rng>(
670        &self,
671        entry: &mut JournalEntry,
672        _anomaly_type: &AnomalyType,
673        rng: &mut R,
674    ) -> InjectionResult {
675        let total = entry.total_debit();
676        if total <= self.split_threshold || total.is_zero() {
677            return InjectionResult::failure("Amount below split threshold");
678        }
679
680        let num_splits = rng.random_range(self.min_splits..=self.max_splits);
681        let target_per_split = self.split_threshold
682            - self.threshold_buffer
683            - Decimal::new(rng.random_range(1..100), 0);
684
685        // Scale down all lines to fit first split
686        let scale = target_per_split / total;
687        for line in &mut entry.lines {
688            line.debit_amount = (line.debit_amount * scale).round_dp(2);
689            line.credit_amount = (line.credit_amount * scale).round_dp(2);
690        }
691
692        InjectionResult::success(&format!(
693            "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
694            total, num_splits, target_per_split, self.split_threshold
695        ))
696        .with_impact(total)
697        .with_metadata("original_amount", &total.to_string())
698        .with_metadata("num_splits", &num_splits.to_string())
699        .with_metadata("threshold", &self.split_threshold.to_string())
700    }
701}
702
703/// Strategy for skipped approval anomalies.
704pub struct SkippedApprovalStrategy {
705    /// Threshold above which approval is required.
706    pub approval_threshold: Decimal,
707}
708
709impl Default for SkippedApprovalStrategy {
710    fn default() -> Self {
711        Self {
712            approval_threshold: Decimal::new(5000, 0),
713        }
714    }
715}
716
717impl InjectionStrategy for SkippedApprovalStrategy {
718    fn name(&self) -> &'static str {
719        "SkippedApproval"
720    }
721
722    fn can_apply(&self, entry: &JournalEntry) -> bool {
723        // Can only skip approval on entries above threshold
724        entry.total_debit() > self.approval_threshold
725    }
726
727    fn apply<R: Rng>(
728        &self,
729        entry: &mut JournalEntry,
730        _anomaly_type: &AnomalyType,
731        _rng: &mut R,
732    ) -> InjectionResult {
733        let amount = entry.total_debit();
734        if amount <= self.approval_threshold {
735            return InjectionResult::failure("Amount below approval threshold");
736        }
737
738        // Mark control status as exception (simulates skipped approval)
739        entry.header.control_status = ControlStatus::Exception;
740        entry.header.sod_violation = true;
741
742        InjectionResult::success(&format!(
743            "Skipped required approval for ${} entry (threshold: ${})",
744            amount, self.approval_threshold
745        ))
746        .with_impact(amount)
747        .with_metadata("threshold", &self.approval_threshold.to_string())
748    }
749}
750
751/// Strategy for weekend/holiday posting anomalies.
752pub struct WeekendPostingStrategy;
753
754impl Default for WeekendPostingStrategy {
755    fn default() -> Self {
756        Self
757    }
758}
759
760impl InjectionStrategy for WeekendPostingStrategy {
761    fn name(&self) -> &'static str {
762        "WeekendPosting"
763    }
764
765    fn can_apply(&self, _entry: &JournalEntry) -> bool {
766        true
767    }
768
769    fn apply<R: Rng>(
770        &self,
771        entry: &mut JournalEntry,
772        _anomaly_type: &AnomalyType,
773        rng: &mut R,
774    ) -> InjectionResult {
775        use chrono::Weekday;
776
777        let original_date = entry.header.posting_date;
778        let weekday = original_date.weekday();
779
780        // Find days until next weekend
781        let days_to_weekend = match weekday {
782            Weekday::Mon => 5,
783            Weekday::Tue => 4,
784            Weekday::Wed => 3,
785            Weekday::Thu => 2,
786            Weekday::Fri => 1,
787            Weekday::Sat => 0,
788            Weekday::Sun => 0,
789        };
790
791        // Move to Saturday or Sunday
792        let weekend_day = if rng.random_bool(0.6) {
793            days_to_weekend
794        } else {
795            days_to_weekend + 1
796        };
797        let new_date = original_date + chrono::Duration::days(weekend_day as i64);
798
799        entry.header.posting_date = new_date;
800
801        InjectionResult::success(&format!(
802            "Moved posting from {} ({:?}) to {} ({:?})",
803            original_date,
804            weekday,
805            new_date,
806            new_date.weekday()
807        ))
808        .with_metadata("original_date", &original_date.to_string())
809        .with_metadata("new_date", &new_date.to_string())
810    }
811}
812
813/// Strategy for reversed amount errors.
814pub struct ReversedAmountStrategy;
815
816impl Default for ReversedAmountStrategy {
817    fn default() -> Self {
818        Self
819    }
820}
821
822impl InjectionStrategy for ReversedAmountStrategy {
823    fn name(&self) -> &'static str {
824        "ReversedAmount"
825    }
826
827    fn can_apply(&self, entry: &JournalEntry) -> bool {
828        // v5.31 C1 Phase 7: skip IC injector JEs. The IC elimination
829        // contract requires exactly one debit line at the seller's DR
830        // account; swapping debit↔credit on either line violates that
831        // contract and breaks the consolidated FS bundle. IC-pair
832        // amounts are deterministic by manifest, so picking them as
833        // anomaly targets adds no realism but does add correctness
834        // bugs at downstream consolidation time.
835        entry.lines.len() >= 2 && entry.header.ic_pair_id.is_none()
836    }
837
838    fn apply<R: Rng>(
839        &self,
840        entry: &mut JournalEntry,
841        _anomaly_type: &AnomalyType,
842        rng: &mut R,
843    ) -> InjectionResult {
844        if entry.lines.len() < 2 {
845            return InjectionResult::failure("Need at least 2 lines to reverse");
846        }
847
848        // Pick a random line and swap its debit/credit
849        let line_idx = rng.random_range(0..entry.lines.len());
850        let line = &mut entry.lines[line_idx];
851
852        let original_debit = line.debit_amount;
853        let original_credit = line.credit_amount;
854
855        line.debit_amount = original_credit;
856        line.credit_amount = original_debit;
857
858        let impact = original_debit.max(original_credit);
859
860        InjectionResult::success(&format!(
861            "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
862            line_idx + 1,
863            original_debit,
864            line.credit_amount,
865            original_credit,
866            line.debit_amount
867        ))
868        .with_impact(impact * Decimal::new(2, 0)) // Double impact due to both sides being wrong
869        .with_metadata("line_number", &(line_idx + 1).to_string())
870    }
871}
872
873/// Strategy for transposed digits errors.
874pub struct TransposedDigitsStrategy;
875
876impl Default for TransposedDigitsStrategy {
877    fn default() -> Self {
878        Self
879    }
880}
881
882impl InjectionStrategy for TransposedDigitsStrategy {
883    fn name(&self) -> &'static str {
884        "TransposedDigits"
885    }
886
887    fn can_apply(&self, entry: &JournalEntry) -> bool {
888        // v5.31 C1 Phase 7+: skip IC injector JEs. Transposing digits
889        // on the amount would create a seller/buyer notional mismatch
890        // (e.g. 12 345 → 13 245). IC amounts are deterministic by
891        // manifest contract.
892        if entry.header.ic_pair_id.is_some() {
893            return false;
894        }
895        // Need at least one line with amount >= 10 (two digits to transpose)
896        entry.lines.iter().any(|l| {
897            let amount = if l.debit_amount > Decimal::ZERO {
898                l.debit_amount
899            } else {
900                l.credit_amount
901            };
902            amount >= Decimal::new(10, 0)
903        })
904    }
905
906    fn apply<R: Rng>(
907        &self,
908        entry: &mut JournalEntry,
909        _anomaly_type: &AnomalyType,
910        rng: &mut R,
911    ) -> InjectionResult {
912        // Find lines with transposable amounts
913        let valid_lines: Vec<usize> = entry
914            .lines
915            .iter()
916            .enumerate()
917            .filter(|(_, l)| {
918                let amount = if l.debit_amount > Decimal::ZERO {
919                    l.debit_amount
920                } else {
921                    l.credit_amount
922                };
923                amount >= Decimal::new(10, 0)
924            })
925            .map(|(i, _)| i)
926            .collect();
927
928        if valid_lines.is_empty() {
929            return InjectionResult::failure("No lines with transposable amounts");
930        }
931
932        let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
933        let line = &mut entry.lines[line_idx];
934
935        let is_debit = line.debit_amount > Decimal::ZERO;
936        let original_amount = if is_debit {
937            line.debit_amount
938        } else {
939            line.credit_amount
940        };
941
942        // Transpose two adjacent digits
943        let amount_str = original_amount.to_string().replace('.', "");
944        let chars: Vec<char> = amount_str.chars().collect();
945
946        if chars.len() < 2 {
947            return InjectionResult::failure("Amount too small to transpose");
948        }
949
950        // Pick a position to transpose (not the decimal point)
951        let pos = rng.random_range(0..chars.len() - 1);
952        let mut new_chars = chars.clone();
953        new_chars.swap(pos, pos + 1);
954
955        let new_str: String = new_chars.into_iter().collect();
956        let new_amount = new_str.parse::<i64>().unwrap_or(0);
957        let scale = original_amount.scale();
958        let new_decimal = Decimal::new(new_amount, scale);
959
960        let impact = (new_decimal - original_amount).abs();
961
962        if is_debit {
963            line.debit_amount = new_decimal;
964        } else {
965            line.credit_amount = new_decimal;
966        }
967
968        InjectionResult::success(&format!(
969            "Transposed digits: {} → {} (positions {} and {})",
970            original_amount,
971            new_decimal,
972            pos + 1,
973            pos + 2
974        ))
975        .with_impact(impact)
976        .with_metadata("original_amount", &original_amount.to_string())
977        .with_metadata("new_amount", &new_decimal.to_string())
978    }
979}
980
981/// Strategy for dormant account activity.
982pub struct DormantAccountStrategy {
983    /// List of dormant account codes to use.
984    pub dormant_accounts: Vec<String>,
985}
986
987impl Default for DormantAccountStrategy {
988    fn default() -> Self {
989        // Sourced from `datasynth_core::accounts::dormant_accounts` so the
990        // anomaly target list stays in sync with the COA's blocked-account
991        // seeds — every account this strategy can post to is also seeded
992        // (with `is_blocked = true`) by `seed_canonical_accounts`, which
993        // preserves the v5.5.1 COA-coverage invariant even when anomaly
994        // injection is enabled.
995        Self {
996            dormant_accounts: vec![
997                dormant_accounts::LEGACY_SUSPENSE.to_string(),
998                dormant_accounts::LEGACY_CLEARING.to_string(),
999                dormant_accounts::OBSOLETE.to_string(),
1000                dormant_accounts::TEST_ACCOUNT.to_string(),
1001            ],
1002        }
1003    }
1004}
1005
1006impl InjectionStrategy for DormantAccountStrategy {
1007    fn name(&self) -> &'static str {
1008        "DormantAccountActivity"
1009    }
1010
1011    fn can_apply(&self, entry: &JournalEntry) -> bool {
1012        !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
1013    }
1014
1015    fn apply<R: Rng>(
1016        &self,
1017        entry: &mut JournalEntry,
1018        _anomaly_type: &AnomalyType,
1019        rng: &mut R,
1020    ) -> InjectionResult {
1021        if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
1022            return InjectionResult::failure("No lines or dormant accounts");
1023        }
1024
1025        let line_idx = rng.random_range(0..entry.lines.len());
1026        let line = &mut entry.lines[line_idx];
1027
1028        let original_account = line.gl_account.clone();
1029        let dormant_account =
1030            &self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
1031
1032        line.gl_account = dormant_account.clone();
1033        line.account_code = dormant_account.clone();
1034
1035        let amount = if line.debit_amount > Decimal::ZERO {
1036            line.debit_amount
1037        } else {
1038            line.credit_amount
1039        };
1040
1041        InjectionResult::success(&format!(
1042            "Changed account from {original_account} to dormant account {dormant_account}"
1043        ))
1044        .with_impact(amount)
1045        .with_entity(dormant_account)
1046        .with_metadata("original_account", &original_account)
1047    }
1048}
1049
1050/// Collection of all available strategies.
1051#[derive(Default)]
1052pub struct StrategyCollection {
1053    pub amount_modification: AmountModificationStrategy,
1054    pub date_modification: DateModificationStrategy,
1055    pub duplication: DuplicationStrategy,
1056    pub approval_anomaly: ApprovalAnomalyStrategy,
1057    pub description_anomaly: DescriptionAnomalyStrategy,
1058    pub benford_violation: BenfordViolationStrategy,
1059    pub split_transaction: SplitTransactionStrategy,
1060    pub skipped_approval: SkippedApprovalStrategy,
1061    pub weekend_posting: WeekendPostingStrategy,
1062    pub reversed_amount: ReversedAmountStrategy,
1063    pub transposed_digits: TransposedDigitsStrategy,
1064    pub dormant_account: DormantAccountStrategy,
1065}
1066
1067impl StrategyCollection {
1068    /// Checks if the strategy can be applied to an entry.
1069    pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1070        match anomaly_type {
1071            // Amount-based strategies
1072            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1073            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1074            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1075                self.amount_modification.can_apply(entry)
1076            }
1077            // Date-based strategies
1078            AnomalyType::Error(ErrorType::BackdatedEntry)
1079            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1080            | AnomalyType::Error(ErrorType::WrongPeriod)
1081            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1082                self.date_modification.can_apply(entry)
1083            }
1084            // Approval threshold strategies
1085            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1086            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1087                self.approval_anomaly.can_apply(entry)
1088            }
1089            // Description strategies
1090            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1091                self.description_anomaly.can_apply(entry)
1092            }
1093            // Benford's Law strategies
1094            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1095                self.benford_violation.can_apply(entry)
1096            }
1097            // Split transaction (structuring)
1098            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1099                self.split_transaction.can_apply(entry)
1100            }
1101            // Skipped approval
1102            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1103                self.skipped_approval.can_apply(entry)
1104            }
1105            // Weekend posting
1106            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1107            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1108                self.weekend_posting.can_apply(entry)
1109            }
1110            // Reversed amount
1111            AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1112            // Transposed digits
1113            AnomalyType::Error(ErrorType::TransposedDigits) => {
1114                self.transposed_digits.can_apply(entry)
1115            }
1116            // Dormant account
1117            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1118                self.dormant_account.can_apply(entry)
1119            }
1120            // Default fallback
1121            _ => self.amount_modification.can_apply(entry),
1122        }
1123    }
1124
1125    /// Applies the appropriate strategy for an anomaly type.
1126    pub fn apply_strategy<R: Rng>(
1127        &self,
1128        entry: &mut JournalEntry,
1129        anomaly_type: &AnomalyType,
1130        rng: &mut R,
1131    ) -> InjectionResult {
1132        match anomaly_type {
1133            // Amount-based strategies
1134            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1135            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1136            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1137                self.amount_modification.apply(entry, anomaly_type, rng)
1138            }
1139            // Date-based strategies
1140            AnomalyType::Error(ErrorType::BackdatedEntry)
1141            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1142            | AnomalyType::Error(ErrorType::WrongPeriod)
1143            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1144                self.date_modification.apply(entry, anomaly_type, rng)
1145            }
1146            // Approval threshold strategies
1147            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1148            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1149                self.approval_anomaly.apply(entry, anomaly_type, rng)
1150            }
1151            // Description strategies
1152            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1153                self.description_anomaly.apply(entry, anomaly_type, rng)
1154            }
1155            // Benford's Law strategies
1156            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1157                self.benford_violation.apply(entry, anomaly_type, rng)
1158            }
1159            // Split transaction (structuring)
1160            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1161                self.split_transaction.apply(entry, anomaly_type, rng)
1162            }
1163            // Skipped approval
1164            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1165                self.skipped_approval.apply(entry, anomaly_type, rng)
1166            }
1167            // Weekend posting
1168            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1169            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1170                self.weekend_posting.apply(entry, anomaly_type, rng)
1171            }
1172            // Reversed amount
1173            AnomalyType::Error(ErrorType::ReversedAmount) => {
1174                self.reversed_amount.apply(entry, anomaly_type, rng)
1175            }
1176            // Transposed digits
1177            AnomalyType::Error(ErrorType::TransposedDigits) => {
1178                self.transposed_digits.apply(entry, anomaly_type, rng)
1179            }
1180            // Dormant account
1181            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1182                self.dormant_account.apply(entry, anomaly_type, rng)
1183            }
1184            // Default fallback
1185            _ => self.amount_modification.apply(entry, anomaly_type, rng),
1186        }
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193    use chrono::NaiveDate;
1194    use datasynth_core::models::JournalEntryLine;
1195    use rand::SeedableRng;
1196    use rand_chacha::ChaCha8Rng;
1197    use rust_decimal_macros::dec;
1198
1199    fn create_test_entry() -> JournalEntry {
1200        let mut entry = JournalEntry::new_simple(
1201            "JE001".to_string(),
1202            "1000".to_string(),
1203            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1204            "Test Entry".to_string(),
1205        );
1206
1207        entry.add_line(JournalEntryLine {
1208            line_number: 1,
1209            gl_account: "5000".to_string(),
1210            debit_amount: dec!(1000),
1211            ..Default::default()
1212        });
1213
1214        entry.add_line(JournalEntryLine {
1215            line_number: 2,
1216            gl_account: "1000".to_string(),
1217            credit_amount: dec!(1000),
1218            ..Default::default()
1219        });
1220
1221        entry
1222    }
1223
1224    /// Helper: build a 2-line IC injector JE (1 DR + 1 CR at amount).
1225    /// Mirrors `datasynth_group::shard::ic_je_injector::build_je_for_plan`
1226    /// at the shape level so contract violations show up the same way.
1227    fn create_test_ic_entry() -> JournalEntry {
1228        use datasynth_core::models::IcPairId;
1229        let mut entry = create_test_entry();
1230        entry.header.ic_pair_id = Some(IcPairId::from_bytes([0xAB; 32]));
1231        entry
1232    }
1233
1234    /// v5.31 C1 Phase 7+ contract test: IC injector JEs MUST be skipped
1235    /// by every contract-violating mutation strategy. Each gate is one
1236    /// `entry.header.ic_pair_id.is_none()` line in `can_apply`; this
1237    /// test pins the behaviour so a future refactor can't quietly
1238    /// regress it.
1239    #[test]
1240    fn ic_je_is_skipped_by_contract_violating_strategies() {
1241        let ic_entry = create_test_ic_entry();
1242        let normal_entry = create_test_entry();
1243
1244        // ReversedAmount (the Phase 7 root cause).
1245        assert!(
1246            !ReversedAmountStrategy.can_apply(&ic_entry),
1247            "ReversedAmountStrategy must skip IC JEs (debit/credit swap breaks elim contract)"
1248        );
1249        assert!(ReversedAmountStrategy.can_apply(&normal_entry));
1250
1251        // AmountModification — creates seller/buyer amount mismatch.
1252        assert!(
1253            !AmountModificationStrategy::default().can_apply(&ic_entry),
1254            "AmountModificationStrategy must skip IC JEs"
1255        );
1256        assert!(AmountModificationStrategy::default().can_apply(&normal_entry));
1257
1258        // SplitTransaction — scales amounts down on one side only.
1259        // Test on an entry above the split threshold.
1260        let mut big_ic = create_test_ic_entry();
1261        big_ic.lines[0].debit_amount = dec!(20000);
1262        big_ic.lines[1].credit_amount = dec!(20000);
1263        assert!(
1264            !SplitTransactionStrategy::default().can_apply(&big_ic),
1265            "SplitTransactionStrategy must skip IC JEs (silent seller/buyer notional drift)"
1266        );
1267        // And confirm it still applies on a similarly-sized non-IC entry.
1268        let mut big_normal = create_test_entry();
1269        big_normal.lines[0].debit_amount = dec!(20000);
1270        big_normal.lines[1].credit_amount = dec!(20000);
1271        assert!(SplitTransactionStrategy::default().can_apply(&big_normal));
1272
1273        // TransposedDigits — modifies amount digits on one side only.
1274        assert!(
1275            !TransposedDigitsStrategy.can_apply(&ic_entry),
1276            "TransposedDigitsStrategy must skip IC JEs"
1277        );
1278        assert!(TransposedDigitsStrategy.can_apply(&normal_entry));
1279    }
1280
1281    #[test]
1282    fn test_amount_modification() {
1283        let strategy = AmountModificationStrategy::default();
1284        let mut entry = create_test_entry();
1285        let mut rng = ChaCha8Rng::seed_from_u64(42);
1286
1287        let result = strategy.apply(
1288            &mut entry,
1289            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1290            &mut rng,
1291        );
1292
1293        assert!(result.success);
1294        assert!(result.monetary_impact.is_some());
1295    }
1296
1297    #[test]
1298    fn test_amount_modification_rebalanced() {
1299        let strategy = AmountModificationStrategy {
1300            rebalance_entry: true,
1301            ..Default::default()
1302        };
1303        let mut entry = create_test_entry();
1304        let mut rng = ChaCha8Rng::seed_from_u64(42);
1305
1306        // Entry should start balanced
1307        assert!(entry.is_balanced());
1308
1309        let result = strategy.apply(
1310            &mut entry,
1311            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1312            &mut rng,
1313        );
1314
1315        assert!(result.success);
1316        // Entry should remain balanced after rebalancing
1317        assert!(
1318            entry.is_balanced(),
1319            "Entry should remain balanced after amount modification with rebalancing"
1320        );
1321    }
1322
1323    #[test]
1324    fn test_amount_modification_unbalanced_fraud() {
1325        let strategy = AmountModificationStrategy {
1326            rebalance_entry: false, // Intentionally create unbalanced entry for fraud detection
1327            ..Default::default()
1328        };
1329        let mut entry = create_test_entry();
1330        let mut rng = ChaCha8Rng::seed_from_u64(42);
1331
1332        // Entry should start balanced
1333        assert!(entry.is_balanced());
1334
1335        let result = strategy.apply(
1336            &mut entry,
1337            &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1338            &mut rng,
1339        );
1340
1341        assert!(result.success);
1342        // Entry should be unbalanced when rebalance is disabled
1343        assert!(
1344            !entry.is_balanced(),
1345            "Entry should be unbalanced when rebalance_entry is false"
1346        );
1347    }
1348
1349    #[test]
1350    fn test_benford_violation_rebalanced() {
1351        let strategy = BenfordViolationStrategy {
1352            rebalance_entry: true,
1353            ..Default::default()
1354        };
1355        let mut entry = create_test_entry();
1356        let mut rng = ChaCha8Rng::seed_from_u64(42);
1357
1358        // Entry should start balanced
1359        assert!(entry.is_balanced());
1360
1361        let result = strategy.apply(
1362            &mut entry,
1363            &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1364            &mut rng,
1365        );
1366
1367        assert!(result.success);
1368        // Entry should remain balanced after rebalancing
1369        assert!(
1370            entry.is_balanced(),
1371            "Entry should remain balanced after Benford violation with rebalancing"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_date_modification() {
1377        let strategy = DateModificationStrategy::default();
1378        let mut entry = create_test_entry();
1379        let original_date = entry.header.posting_date;
1380        let mut rng = ChaCha8Rng::seed_from_u64(42);
1381
1382        let result = strategy.apply(
1383            &mut entry,
1384            &AnomalyType::Error(ErrorType::BackdatedEntry),
1385            &mut rng,
1386        );
1387
1388        assert!(result.success);
1389        assert!(entry.header.posting_date < original_date);
1390    }
1391
1392    #[test]
1393    fn test_description_anomaly() {
1394        let strategy = DescriptionAnomalyStrategy::default();
1395        let mut entry = create_test_entry();
1396        let mut rng = ChaCha8Rng::seed_from_u64(42);
1397
1398        let result = strategy.apply(
1399            &mut entry,
1400            &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1401            &mut rng,
1402        );
1403
1404        assert!(result.success);
1405        let desc = entry.description().unwrap_or("").to_string();
1406        assert!(strategy.vague_descriptions.contains(&desc));
1407    }
1408}