1use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use rand_distr::{Distribution, LogNormal};
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12
13use super::AmountDistributionConfig;
14
15#[allow(clippy::approx_constant)]
19pub const BENFORD_PROBABILITIES: [f64; 9] = [
20 0.30103, 0.17609, 0.12494, 0.09691, 0.07918, 0.06695, 0.05799, 0.05115, 0.04576, ];
30
31#[allow(clippy::approx_constant)]
34pub const BENFORD_CDF: [f64; 9] = [
35 0.30103, 0.47712, 0.60206, 0.69897, 0.77815, 0.84510, 0.90309, 0.95424, 1.00000, ];
45
46#[allow(clippy::approx_constant)]
49pub const BENFORD_SECOND_DIGIT_PROBABILITIES: [f64; 10] = [
50 0.11968, 0.11389, 0.10882, 0.10433, 0.10031, 0.09668, 0.09337, 0.09035, 0.08757, 0.08500, ];
61
62pub const BENFORD_SECOND_DIGIT_CDF: [f64; 10] = [
64 0.11968, 0.23357, 0.34239, 0.44672, 0.54703, 0.64371, 0.73708, 0.82743, 0.91500, 1.00000,
65];
66
67pub fn benford_first_two_probability(d1: u8, d2: u8) -> f64 {
70 if !(1..=9).contains(&d1) || d2 > 9 {
71 return 0.0;
72 }
73 let n = (d1 as f64) * 10.0 + (d2 as f64);
74 (1.0 + 1.0 / n).log10()
75}
76
77pub fn benford_first_two_probabilities() -> [f64; 90] {
79 let mut probs = [0.0; 90];
80 for d1 in 1..=9 {
81 for d2 in 0..=9 {
82 let idx = (d1 - 1) * 10 + d2;
83 probs[idx as usize] = benford_first_two_probability(d1, d2);
84 }
85 }
86 probs
87}
88
89pub const ANTI_BENFORD_PROBABILITIES: [f64; 9] = [
92 0.05, 0.05, 0.05, 0.10, 0.25, 0.10, 0.20, 0.05, 0.15, ];
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum FraudAmountPattern {
107 #[default]
109 Normal,
110 StatisticallyImprobable,
113 ObviousRoundNumbers,
116 ThresholdAdjacent,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ThresholdConfig {
124 pub thresholds: Vec<f64>,
126 pub min_below_pct: f64,
128 pub max_below_pct: f64,
130}
131
132impl Default for ThresholdConfig {
133 fn default() -> Self {
134 Self {
135 thresholds: vec![1000.0, 5000.0, 10000.0, 25000.0, 50000.0, 100000.0],
136 min_below_pct: 0.01,
137 max_below_pct: 0.15,
138 }
139 }
140}
141
142pub struct BenfordSampler {
159 rng: ChaCha8Rng,
160 config: AmountDistributionConfig,
161 lognormal: LogNormal<f64>,
166}
167
168impl BenfordSampler {
169 pub fn new(seed: u64, config: AmountDistributionConfig) -> Self {
171 let lognormal = LogNormal::new(config.lognormal_mu, config.lognormal_sigma)
172 .expect("Invalid log-normal parameters in BenfordSampler");
173 Self {
174 rng: ChaCha8Rng::seed_from_u64(seed),
175 config,
176 lognormal,
177 }
178 }
179
180 fn sample_benford_first_digit(&mut self) -> u8 {
182 let p: f64 = self.rng.random();
183 for (i, &cumulative) in BENFORD_CDF.iter().enumerate() {
184 if p < cumulative {
185 return (i + 1) as u8;
186 }
187 }
188 9
189 }
190
191 fn sample_anti_benford_first_digit(&mut self) -> u8 {
193 let p: f64 = self.rng.random();
194 let mut cumulative = 0.0;
195 for (i, &prob) in ANTI_BENFORD_PROBABILITIES.iter().enumerate() {
196 cumulative += prob;
197 if p < cumulative {
198 return (i + 1) as u8;
199 }
200 }
201 9
202 }
203
204 pub fn sample(&mut self) -> Decimal {
206 let first_digit = self.sample_benford_first_digit();
207 self.sample_with_first_digit(first_digit)
208 }
209
210 pub fn sample_with_first_digit(&mut self, first_digit: u8) -> Decimal {
222 let first_digit = first_digit.clamp(1, 9);
223
224 let raw_amount = self
228 .lognormal
229 .sample(&mut self.rng)
230 .clamp(self.config.min_amount, self.config.max_amount);
231 let magnitude = raw_amount.log10().floor() as i32;
232 let base = 10_f64.powi(magnitude);
233
234 let remaining: f64 = self.rng.random();
236
237 let mantissa = first_digit as f64 + remaining;
239 let mut amount = mantissa * base;
240
241 amount = amount.clamp(self.config.min_amount, self.config.max_amount);
243
244 let p: f64 = self.rng.random();
246 if p < self.config.round_number_probability {
247 amount = (amount / 100.0).round() * 100.0;
249 } else if p < self.config.round_number_probability + self.config.nice_number_probability {
250 amount = (amount / 5.0).round() * 5.0;
252 }
253
254 let decimal_multiplier = 10_f64.powi(self.config.decimal_places as i32);
256 amount = (amount * decimal_multiplier).round() / decimal_multiplier;
257
258 amount = amount.max(self.config.min_amount);
260
261 Decimal::from_f64_retain(amount).unwrap_or(Decimal::ONE)
262 }
263
264 pub fn reset(&mut self, seed: u64) {
266 self.rng = ChaCha8Rng::seed_from_u64(seed);
267 }
268}
269
270pub struct FraudAmountGenerator {
272 rng: ChaCha8Rng,
273 benford_sampler: BenfordSampler,
274 threshold_config: ThresholdConfig,
275 config: AmountDistributionConfig,
276}
277
278impl FraudAmountGenerator {
279 pub fn new(
281 seed: u64,
282 config: AmountDistributionConfig,
283 threshold_config: ThresholdConfig,
284 ) -> Self {
285 Self {
286 rng: ChaCha8Rng::seed_from_u64(seed),
287 benford_sampler: BenfordSampler::new(seed + 1, config.clone()),
288 threshold_config,
289 config,
290 }
291 }
292
293 pub fn sample(&mut self, pattern: FraudAmountPattern) -> Decimal {
295 match pattern {
296 FraudAmountPattern::Normal => self.benford_sampler.sample(),
297 FraudAmountPattern::StatisticallyImprobable => self.sample_anti_benford(),
298 FraudAmountPattern::ObviousRoundNumbers => self.sample_obvious_round(),
299 FraudAmountPattern::ThresholdAdjacent => self.sample_threshold_adjacent(),
300 }
301 }
302
303 fn sample_anti_benford(&mut self) -> Decimal {
305 let first_digit = self.benford_sampler.sample_anti_benford_first_digit();
306 self.benford_sampler.sample_with_first_digit(first_digit)
307 }
308
309 fn sample_obvious_round(&mut self) -> Decimal {
311 let pattern_choice = self.rng.random_range(0..5);
312
313 let amount = match pattern_choice {
314 0 => {
316 let multiplier = self.rng.random_range(1..100);
317 multiplier as f64 * 1000.0
318 }
319 1 => {
321 let base = self.rng.random_range(1..10) as f64 * 10000.0;
322 base - 0.01
323 }
324 2 => {
326 let multiplier = self.rng.random_range(1..20);
327 multiplier as f64 * 10000.0
328 }
329 3 => {
331 let multiplier = self.rng.random_range(1..40);
332 multiplier as f64 * 5000.0
333 }
334 _ => {
336 let base = self.rng.random_range(1..100) as f64 * 1000.0;
337 base - 0.01
338 }
339 };
340
341 let clamped = amount.clamp(self.config.min_amount, self.config.max_amount);
343 Decimal::from_f64_retain(clamped).unwrap_or(Decimal::ONE)
344 }
345
346 fn sample_threshold_adjacent(&mut self) -> Decimal {
348 let threshold = if self.threshold_config.thresholds.is_empty() {
350 10000.0
351 } else {
352 *self
353 .threshold_config
354 .thresholds
355 .choose(&mut self.rng)
356 .unwrap_or(&10000.0)
357 };
358
359 let pct_below = self
361 .rng
362 .random_range(self.threshold_config.min_below_pct..self.threshold_config.max_below_pct);
363 let base_amount = threshold * (1.0 - pct_below);
364
365 let noise_factor = 1.0 + self.rng.random_range(-0.005..0.005);
367 let amount = base_amount * noise_factor;
368
369 let rounded = (amount * 100.0).round() / 100.0;
371
372 let final_amount = rounded.min(threshold - 0.01);
374 let clamped = final_amount.clamp(self.config.min_amount, self.config.max_amount);
375
376 Decimal::from_f64_retain(clamped).unwrap_or(Decimal::ONE)
377 }
378
379 pub fn reset(&mut self, seed: u64) {
381 self.rng = ChaCha8Rng::seed_from_u64(seed);
382 self.benford_sampler.reset(seed + 1);
383 }
384}
385
386pub fn get_first_digit(amount: Decimal) -> Option<u8> {
388 let s = amount.to_string();
389 s.chars()
390 .find(|c| c.is_ascii_digit() && *c != '0')
391 .and_then(|c| c.to_digit(10))
392 .map(|d| d as u8)
393}
394
395pub fn get_first_two_digits(amount: Decimal) -> Option<(u8, u8)> {
397 let s = amount.abs().to_string();
398 let mut first_found = false;
399 let mut first_digit = 0u8;
400
401 for c in s.chars() {
402 if c.is_ascii_digit() {
403 let d = c
404 .to_digit(10)
405 .expect("digit char confirmed by is_ascii_digit") as u8;
406 if !first_found && d != 0 {
407 first_digit = d;
408 first_found = true;
409 } else if first_found && c != '.' {
410 return Some((first_digit, d));
411 }
412 }
413 }
414 None
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, Default)]
419pub struct EnhancedBenfordConfig {
420 pub amount_config: AmountDistributionConfig,
422 #[serde(default)]
424 pub second_digit_compliance: bool,
425 #[serde(default)]
427 pub first_two_digit_compliance: bool,
428}
429
430pub struct EnhancedBenfordSampler {
436 rng: ChaCha8Rng,
437 config: EnhancedBenfordConfig,
438 first_two_cdf: [f64; 90],
440 lognormal: LogNormal<f64>,
442}
443
444impl EnhancedBenfordSampler {
445 pub fn new(seed: u64, config: EnhancedBenfordConfig) -> Self {
447 let probs = benford_first_two_probabilities();
449 let mut first_two_cdf = [0.0; 90];
450 let mut cumulative = 0.0;
451 for i in 0..90 {
452 cumulative += probs[i];
453 first_two_cdf[i] = cumulative;
454 }
455
456 let lognormal = LogNormal::new(
457 config.amount_config.lognormal_mu,
458 config.amount_config.lognormal_sigma,
459 )
460 .expect("Invalid log-normal parameters in EnhancedBenfordSampler");
461
462 Self {
463 rng: ChaCha8Rng::seed_from_u64(seed),
464 config,
465 first_two_cdf,
466 lognormal,
467 }
468 }
469
470 fn sample_first_two_digits(&mut self) -> (u8, u8) {
472 let p: f64 = self.rng.random();
473 for (i, &cdf) in self.first_two_cdf.iter().enumerate() {
474 if p < cdf {
475 let d1 = (i / 10 + 1) as u8;
476 let d2 = (i % 10) as u8;
477 return (d1, d2);
478 }
479 }
480 (9, 9)
481 }
482
483 fn sample_second_digit(&mut self) -> u8 {
485 let p: f64 = self.rng.random();
486 for (i, &cdf) in BENFORD_SECOND_DIGIT_CDF.iter().enumerate() {
487 if p < cdf {
488 return i as u8;
489 }
490 }
491 9
492 }
493
494 fn sample_first_digit(&mut self) -> u8 {
496 let p: f64 = self.rng.random();
497 for (i, &cdf) in BENFORD_CDF.iter().enumerate() {
498 if p < cdf {
499 return (i + 1) as u8;
500 }
501 }
502 9
503 }
504
505 pub fn sample(&mut self) -> Decimal {
507 let (first_digit, second_digit) = if self.config.first_two_digit_compliance {
508 self.sample_first_two_digits()
509 } else if self.config.second_digit_compliance {
510 (self.sample_first_digit(), self.sample_second_digit())
511 } else {
512 (
513 self.sample_first_digit(),
514 self.rng.random_range(0..10) as u8,
515 )
516 };
517
518 self.sample_with_digits(first_digit, second_digit)
519 }
520
521 fn sample_with_digits(&mut self, first_digit: u8, second_digit: u8) -> Decimal {
526 let first_digit = first_digit.clamp(1, 9);
527 let second_digit = second_digit.clamp(0, 9);
528
529 let raw_amount = self.lognormal.sample(&mut self.rng).clamp(
531 self.config.amount_config.min_amount,
532 self.config.amount_config.max_amount,
533 );
534 let magnitude = raw_amount.log10().floor() as i32;
535 let base = 10_f64.powi(magnitude - 1); let remaining: f64 = self.rng.random();
539
540 let mantissa = (first_digit as f64) * 10.0 + (second_digit as f64) + remaining;
542 let mut amount = mantissa * base;
543
544 amount = amount.clamp(
546 self.config.amount_config.min_amount,
547 self.config.amount_config.max_amount,
548 );
549
550 let decimal_multiplier = 10_f64.powi(self.config.amount_config.decimal_places as i32);
552 amount = (amount * decimal_multiplier).round() / decimal_multiplier;
553
554 Decimal::from_f64_retain(amount).unwrap_or(Decimal::ONE)
555 }
556
557 pub fn reset(&mut self, seed: u64) {
559 self.rng = ChaCha8Rng::seed_from_u64(seed);
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
565#[serde(rename_all = "snake_case")]
566#[derive(Default)]
567pub enum BenfordDeviationType {
568 #[default]
570 RoundNumberBias,
571 ThresholdClustering,
573 UniformFirstDigit,
575 DigitBias { digit: u8 },
577 TrailingZeros,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct BenfordDeviationConfig {
584 pub deviation_type: BenfordDeviationType,
586 #[serde(default = "default_intensity")]
588 pub intensity: f64,
589 pub amount_config: AmountDistributionConfig,
591 #[serde(default = "default_thresholds")]
593 pub thresholds: Vec<f64>,
594}
595
596fn default_intensity() -> f64 {
597 0.5
598}
599
600fn default_thresholds() -> Vec<f64> {
601 vec![1000.0, 5000.0, 10000.0, 25000.0, 50000.0, 100000.0]
602}
603
604impl Default for BenfordDeviationConfig {
605 fn default() -> Self {
606 Self {
607 deviation_type: BenfordDeviationType::RoundNumberBias,
608 intensity: 0.5,
609 amount_config: AmountDistributionConfig::default(),
610 thresholds: default_thresholds(),
611 }
612 }
613}
614
615pub struct BenfordDeviationSampler {
618 rng: ChaCha8Rng,
619 config: BenfordDeviationConfig,
620 benford_sampler: BenfordSampler,
621}
622
623impl BenfordDeviationSampler {
624 pub fn new(seed: u64, config: BenfordDeviationConfig) -> Self {
626 Self {
627 rng: ChaCha8Rng::seed_from_u64(seed),
628 benford_sampler: BenfordSampler::new(seed + 100, config.amount_config.clone()),
629 config,
630 }
631 }
632
633 pub fn sample(&mut self) -> Decimal {
635 let p: f64 = self.rng.random();
637 if p > self.config.intensity {
638 return self.benford_sampler.sample();
639 }
640
641 match self.config.deviation_type {
643 BenfordDeviationType::RoundNumberBias => self.sample_round_bias(),
644 BenfordDeviationType::ThresholdClustering => self.sample_threshold_cluster(),
645 BenfordDeviationType::UniformFirstDigit => self.sample_uniform_first_digit(),
646 BenfordDeviationType::DigitBias { digit } => self.sample_digit_bias(digit),
647 BenfordDeviationType::TrailingZeros => self.sample_trailing_zeros(),
648 }
649 }
650
651 fn sample_round_bias(&mut self) -> Decimal {
653 let first_digit = if self.rng.random_bool(0.6) {
655 if self.rng.random_bool(0.7) {
656 1
657 } else {
658 5
659 }
660 } else {
661 self.rng.random_range(1..=9)
662 };
663
664 let _second_digit = if self.rng.random_bool(0.5) {
666 if self.rng.random_bool(0.6) {
667 0
668 } else {
669 5
670 }
671 } else {
672 self.rng.random_range(0..=9)
673 };
674
675 self.benford_sampler.sample_with_first_digit(first_digit)
676 }
677
678 fn sample_threshold_cluster(&mut self) -> Decimal {
680 let threshold = self
681 .config
682 .thresholds
683 .choose(&mut self.rng)
684 .copied()
685 .unwrap_or(10000.0);
686
687 let pct_below = self.rng.random_range(0.01..0.15);
689 let amount = threshold * (1.0 - pct_below);
690
691 let noise = 1.0 + self.rng.random_range(-0.005..0.005);
693 let final_amount = (amount * noise * 100.0).round() / 100.0;
694
695 Decimal::from_f64_retain(final_amount.clamp(
696 self.config.amount_config.min_amount,
697 self.config.amount_config.max_amount,
698 ))
699 .unwrap_or(Decimal::ONE)
700 }
701
702 fn sample_uniform_first_digit(&mut self) -> Decimal {
704 let first_digit = self.rng.random_range(1..=9);
705 self.benford_sampler.sample_with_first_digit(first_digit)
706 }
707
708 fn sample_digit_bias(&mut self, target_digit: u8) -> Decimal {
710 let digit = target_digit.clamp(1, 9);
711 let first_digit = if self.rng.random_bool(0.7) {
713 digit
714 } else {
715 self.rng.random_range(1..=9)
716 };
717 self.benford_sampler.sample_with_first_digit(first_digit)
718 }
719
720 fn sample_trailing_zeros(&mut self) -> Decimal {
722 let amount = self.benford_sampler.sample();
723 let amount_f64: f64 = amount.to_string().parse().unwrap_or(0.0);
724
725 let rounded = amount_f64.round();
727 Decimal::from_f64_retain(rounded.clamp(
728 self.config.amount_config.min_amount,
729 self.config.amount_config.max_amount,
730 ))
731 .unwrap_or(Decimal::ONE)
732 }
733
734 pub fn reset(&mut self, seed: u64) {
736 self.rng = ChaCha8Rng::seed_from_u64(seed);
737 self.benford_sampler.reset(seed + 100);
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn test_benford_probabilities_sum_to_one() {
747 let sum: f64 = BENFORD_PROBABILITIES.iter().sum();
748 assert!(
749 (sum - 1.0).abs() < 0.001,
750 "Benford probabilities sum to {}, expected 1.0",
751 sum
752 );
753 }
754
755 #[test]
756 fn test_benford_cdf_ends_at_one() {
757 assert!(
758 (BENFORD_CDF[8] - 1.0).abs() < 0.0001,
759 "CDF should end at 1.0"
760 );
761 }
762
763 #[test]
764 fn test_anti_benford_probabilities_sum_to_one() {
765 let sum: f64 = ANTI_BENFORD_PROBABILITIES.iter().sum();
766 assert!(
767 (sum - 1.0).abs() < 0.001,
768 "Anti-Benford probabilities sum to {}, expected 1.0",
769 sum
770 );
771 }
772
773 #[test]
774 fn test_benford_sampler_determinism() {
775 let config = AmountDistributionConfig::default();
776 let mut sampler1 = BenfordSampler::new(42, config.clone());
777 let mut sampler2 = BenfordSampler::new(42, config);
778
779 for _ in 0..100 {
780 assert_eq!(sampler1.sample(), sampler2.sample());
781 }
782 }
783
784 #[test]
785 fn test_benford_first_digit_distribution() {
786 let config = AmountDistributionConfig::default();
787 let mut sampler = BenfordSampler::new(12345, config);
788
789 let mut digit_counts = [0u32; 9];
790 let iterations = 10_000;
791
792 for _ in 0..iterations {
793 let amount = sampler.sample();
794 if let Some(digit) = get_first_digit(amount) {
795 if (1..=9).contains(&digit) {
796 digit_counts[(digit - 1) as usize] += 1;
797 }
798 }
799 }
800
801 let digit_1_pct = digit_counts[0] as f64 / iterations as f64;
803 assert!(
804 digit_1_pct > 0.15 && digit_1_pct < 0.50,
805 "Digit 1 should be ~30%, got {:.1}%",
806 digit_1_pct * 100.0
807 );
808
809 let digit_9_pct = digit_counts[8] as f64 / iterations as f64;
811 assert!(
812 digit_9_pct > 0.02 && digit_9_pct < 0.10,
813 "Digit 9 should be ~5%, got {:.1}%",
814 digit_9_pct * 100.0
815 );
816 }
817
818 #[test]
819 fn test_threshold_adjacent_below_threshold() {
820 let config = AmountDistributionConfig::default();
821 let threshold_config = ThresholdConfig {
822 thresholds: vec![10000.0],
823 min_below_pct: 0.01,
824 max_below_pct: 0.15,
825 };
826 let mut gen = FraudAmountGenerator::new(42, config, threshold_config);
827
828 for _ in 0..100 {
829 let amount = gen.sample(FraudAmountPattern::ThresholdAdjacent);
830 let f = amount.to_string().parse::<f64>().unwrap();
831 assert!(f < 10000.0, "Amount {} should be below threshold 10000", f);
832 assert!(
834 f >= 8400.0,
835 "Amount {} should be approximately within 15% of threshold",
836 f
837 );
838 }
839 }
840
841 #[test]
842 fn test_obvious_round_numbers() {
843 let config = AmountDistributionConfig::default();
844 let threshold_config = ThresholdConfig::default();
845 let mut gen = FraudAmountGenerator::new(42, config, threshold_config);
846
847 for _ in 0..100 {
848 let amount = gen.sample(FraudAmountPattern::ObviousRoundNumbers);
849 let f = amount.to_string().parse::<f64>().unwrap();
850
851 let is_round = f % 1000.0 == 0.0 || f % 5000.0 == 0.0;
853 let is_just_under = (f + 0.01) % 1000.0 < 0.02 || (f + 0.01) % 10000.0 < 0.02;
854
855 assert!(
856 is_round || is_just_under || f > 0.0,
857 "Amount {} should be a suspicious round number",
858 f
859 );
860 }
861 }
862
863 #[test]
864 fn test_get_first_digit() {
865 assert_eq!(get_first_digit(Decimal::from(123)), Some(1));
866 assert_eq!(get_first_digit(Decimal::from(999)), Some(9));
867 assert_eq!(get_first_digit(Decimal::from(50000)), Some(5));
868 assert_eq!(
869 get_first_digit(Decimal::from_str_exact("0.00123").unwrap()),
870 Some(1)
871 );
872 }
873
874 #[test]
875 fn test_second_digit_probabilities_sum_to_one() {
876 let sum: f64 = BENFORD_SECOND_DIGIT_PROBABILITIES.iter().sum();
877 assert!(
878 (sum - 1.0).abs() < 0.001,
879 "Second digit probabilities sum to {}, expected 1.0",
880 sum
881 );
882 }
883
884 #[test]
885 fn test_first_two_probability() {
886 let p10 = benford_first_two_probability(1, 0);
888 assert!((p10 - 0.0414).abs() < 0.001);
889
890 let p99 = benford_first_two_probability(9, 9);
892 assert!((p99 - 0.00436).abs() < 0.0001);
893
894 let probs = benford_first_two_probabilities();
896 let sum: f64 = probs.iter().sum();
897 assert!((sum - 1.0).abs() < 0.001);
898 }
899
900 #[test]
901 fn test_get_first_two_digits() {
902 assert_eq!(get_first_two_digits(Decimal::from(123)), Some((1, 2)));
903 assert_eq!(get_first_two_digits(Decimal::from(999)), Some((9, 9)));
904 assert_eq!(get_first_two_digits(Decimal::from(50000)), Some((5, 0)));
905 assert_eq!(
906 get_first_two_digits(Decimal::from_str_exact("0.00123").unwrap()),
907 Some((1, 2))
908 );
909 }
910
911 #[test]
912 fn test_enhanced_benford_sampler() {
913 let config = EnhancedBenfordConfig {
914 amount_config: AmountDistributionConfig::default(),
915 second_digit_compliance: true,
916 first_two_digit_compliance: false,
917 };
918 let mut sampler = EnhancedBenfordSampler::new(42, config);
919
920 let mut digit_counts = [0u32; 10];
921 for _ in 0..10000 {
922 let amount = sampler.sample();
923 if let Some((_, d2)) = get_first_two_digits(amount) {
924 digit_counts[d2 as usize] += 1;
925 }
926 }
927
928 let total_valid = digit_counts.iter().sum::<u32>();
932 assert!(
933 total_valid > 9000,
934 "Most samples should have valid first two digits"
935 );
936
937 let max_count = *digit_counts.iter().max().unwrap();
939 let _min_count = *digit_counts.iter().min().unwrap();
940 assert!(
941 max_count < total_valid / 2,
942 "Second digits should have some variety, max count: {}",
943 max_count
944 );
945 }
946
947 #[test]
948 fn test_benford_deviation_sampler() {
949 let config = BenfordDeviationConfig {
950 deviation_type: BenfordDeviationType::ThresholdClustering,
951 intensity: 1.0,
952 amount_config: AmountDistributionConfig::default(),
953 thresholds: vec![10000.0],
954 };
955 let mut sampler = BenfordDeviationSampler::new(42, config);
956
957 for _ in 0..100 {
958 let amount = sampler.sample();
959 let f: f64 = amount.to_string().parse().unwrap();
960 assert!(f < 10000.0, "Amount {} should be below 10000", f);
962 assert!(f > 8000.0, "Amount {} should be near threshold 10000", f);
964 }
965 }
966
967 #[test]
968 fn test_benford_deviation_round_bias() {
969 let config = BenfordDeviationConfig {
970 deviation_type: BenfordDeviationType::RoundNumberBias,
971 intensity: 1.0,
972 amount_config: AmountDistributionConfig::default(),
973 thresholds: vec![],
974 };
975 let mut sampler = BenfordDeviationSampler::new(42, config);
976
977 let mut digit_counts = [0u32; 9];
978 for _ in 0..1000 {
979 let amount = sampler.sample();
980 if let Some(d) = get_first_digit(amount) {
981 if (1..=9).contains(&d) {
982 digit_counts[(d - 1) as usize] += 1;
983 }
984 }
985 }
986
987 let d1_pct = digit_counts[0] as f64 / 1000.0;
989 let d5_pct = digit_counts[4] as f64 / 1000.0;
990
991 assert!(d1_pct > 0.35 || d5_pct > 0.10);
993 }
994}