1use datasynth_core::models::JournalEntry;
4use rust_decimal::Decimal;
5
6#[macro_export]
8macro_rules! assert_balanced {
9 ($entry:expr) => {{
10 let entry = &$entry;
11 let total_debits: rust_decimal::Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
12 let total_credits: rust_decimal::Decimal =
13 entry.lines.iter().map(|l| l.credit_amount).sum();
14 assert_eq!(
15 total_debits, total_credits,
16 "Journal entry is not balanced: debits={}, credits={}",
17 total_debits, total_credits
18 );
19 }};
20}
21
22#[macro_export]
24macro_rules! assert_all_balanced {
25 ($entries:expr) => {{
26 for (i, entry) in $entries.iter().enumerate() {
27 let total_debits: rust_decimal::Decimal =
28 entry.lines.iter().map(|l| l.debit_amount).sum();
29 let total_credits: rust_decimal::Decimal =
30 entry.lines.iter().map(|l| l.credit_amount).sum();
31 assert_eq!(
32 total_debits, total_credits,
33 "Journal entry {} is not balanced: debits={}, credits={}",
34 i, total_debits, total_credits
35 );
36 }
37 }};
38}
39
40#[macro_export]
43macro_rules! assert_benford_compliant {
44 ($amounts:expr, $tolerance:expr) => {{
45 let amounts = &$amounts;
46 let expected = [0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046];
47 let mut counts = [0u64; 9];
48 let mut total = 0u64;
49
50 for amount in amounts.iter() {
51 if *amount > rust_decimal::Decimal::ZERO {
52 let first_digit = amount
53 .to_string()
54 .chars()
55 .find(|c| c.is_ascii_digit() && *c != '0')
56 .map(|c| c.to_digit(10).unwrap() as usize);
57
58 if let Some(d) = first_digit {
59 if d >= 1 && d <= 9 {
60 counts[d - 1] += 1;
61 total += 1;
62 }
63 }
64 }
65 }
66
67 if total > 0 {
68 for (i, (count, exp)) in counts.iter().zip(expected.iter()).enumerate() {
69 let observed = *count as f64 / total as f64;
70 let diff = (observed - exp).abs();
71 assert!(
72 diff < $tolerance,
73 "Benford's Law violation for digit {}: observed={:.4}, expected={:.4}, diff={:.4}",
74 i + 1,
75 observed,
76 exp,
77 diff
78 );
79 }
80 }
81 }};
82}
83
84pub fn is_balanced(entry: &JournalEntry) -> bool {
86 let total_debits: Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
87 let total_credits: Decimal = entry.lines.iter().map(|l| l.credit_amount).sum();
88 total_debits == total_credits
89}
90
91pub fn calculate_imbalance(entry: &JournalEntry) -> Decimal {
93 let total_debits: Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
94 let total_credits: Decimal = entry.lines.iter().map(|l| l.credit_amount).sum();
95 total_debits - total_credits
96}
97
98pub fn check_benford_distribution(amounts: &[Decimal]) -> (f64, bool) {
101 let expected = [
102 0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
103 ];
104 let mut counts = [0u64; 9];
105 let mut total = 0u64;
106
107 for amount in amounts.iter() {
108 if *amount > Decimal::ZERO {
109 let first_digit = amount
110 .to_string()
111 .chars()
112 .find(|c| c.is_ascii_digit() && *c != '0')
113 .map(|c| c.to_digit(10).unwrap() as usize);
114
115 if let Some(d) = first_digit {
116 if (1..=9).contains(&d) {
117 counts[d - 1] += 1;
118 total += 1;
119 }
120 }
121 }
122 }
123
124 if total == 0 {
125 return (0.0, true);
126 }
127
128 let mut chi_squared = 0.0;
130 for (count, exp) in counts.iter().zip(expected.iter()) {
131 let expected_count = exp * total as f64;
132 if expected_count > 0.0 {
133 let diff = *count as f64 - expected_count;
134 chi_squared += diff * diff / expected_count;
135 }
136 }
137
138 let passes = chi_squared < 20.090;
141
142 (chi_squared, passes)
143}
144
145pub fn check_accounting_equation(
147 total_assets: Decimal,
148 total_liabilities: Decimal,
149 total_equity: Decimal,
150) -> bool {
151 total_assets == total_liabilities + total_equity
152}
153
154pub fn check_trial_balance(debit_balances: &[Decimal], credit_balances: &[Decimal]) -> bool {
156 let total_debits: Decimal = debit_balances.iter().copied().sum();
157 let total_credits: Decimal = credit_balances.iter().copied().sum();
158 total_debits == total_credits
159}
160
161#[macro_export]
168macro_rules! assert_benford_passes {
169 ($amounts:expr, $threshold:expr) => {{
170 let (chi_squared, passes) = $crate::assertions::check_benford_distribution(&$amounts);
171 assert!(
172 passes || chi_squared < $threshold,
173 "Benford's Law test failed: chi-squared={:.4}, threshold={}",
174 chi_squared,
175 $threshold
176 );
177 }};
178 ($amounts:expr) => {{
179 let (chi_squared, passes) = $crate::assertions::check_benford_distribution(&$amounts);
180 assert!(
181 passes,
182 "Benford's Law test failed: chi-squared={:.4}, p < 0.01 threshold=20.090",
183 chi_squared
184 );
185 }};
186}
187
188#[derive(Debug, Clone)]
190pub struct BalanceSnapshot {
191 pub assets: Decimal,
193 pub liabilities: Decimal,
195 pub equity: Decimal,
197 pub period: String,
199}
200
201impl BalanceSnapshot {
202 pub fn new(assets: Decimal, liabilities: Decimal, equity: Decimal, period: &str) -> Self {
204 Self {
205 assets,
206 liabilities,
207 equity,
208 period: period.into(),
209 }
210 }
211
212 pub fn is_coherent(&self, tolerance: Decimal) -> bool {
214 let diff = self.assets - (self.liabilities + self.equity);
215 diff.abs() <= tolerance
216 }
217}
218
219#[macro_export]
222macro_rules! assert_balance_coherent {
223 ($snapshots:expr, $tolerance:expr) => {{
224 let tolerance =
225 rust_decimal::Decimal::try_from($tolerance).unwrap_or(rust_decimal::Decimal::ZERO);
226 for snapshot in $snapshots.iter() {
227 assert!(
228 snapshot.is_coherent(tolerance),
229 "Balance not coherent for period {}: assets={}, liabilities={}, equity={}, diff={}",
230 snapshot.period,
231 snapshot.assets,
232 snapshot.liabilities,
233 snapshot.equity,
234 snapshot.assets - (snapshot.liabilities + snapshot.equity)
235 );
236 }
237 }};
238}
239
240#[derive(Debug, Clone)]
242pub struct SubledgerReconciliation {
243 pub subledger: String,
245 pub subledger_total: Decimal,
247 pub gl_balance: Decimal,
249 pub period: String,
251}
252
253impl SubledgerReconciliation {
254 pub fn new(
256 subledger: &str,
257 subledger_total: Decimal,
258 gl_balance: Decimal,
259 period: &str,
260 ) -> Self {
261 Self {
262 subledger: subledger.into(),
263 subledger_total,
264 gl_balance,
265 period: period.into(),
266 }
267 }
268
269 pub fn is_reconciled(&self, tolerance: Decimal) -> bool {
271 let diff = (self.subledger_total - self.gl_balance).abs();
272 diff <= tolerance
273 }
274
275 pub fn difference(&self) -> Decimal {
277 self.subledger_total - self.gl_balance
278 }
279}
280
281#[macro_export]
283macro_rules! assert_subledger_reconciled {
284 ($reconciliations:expr, $tolerance:expr) => {{
285 let tolerance =
286 rust_decimal::Decimal::try_from($tolerance).unwrap_or(rust_decimal::Decimal::ZERO);
287 for recon in $reconciliations.iter() {
288 assert!(
289 recon.is_reconciled(tolerance),
290 "Subledger {} not reconciled for period {}: subledger={}, gl={}, diff={}",
291 recon.subledger,
292 recon.period,
293 recon.subledger_total,
294 recon.gl_balance,
295 recon.difference()
296 );
297 }
298 }};
299}
300
301#[derive(Debug, Clone)]
303pub struct DocumentChainResult {
304 pub chain_id: String,
306 pub is_complete: bool,
308 pub missing_steps: Vec<String>,
310 pub expected_steps: usize,
312 pub actual_steps: usize,
314}
315
316impl DocumentChainResult {
317 pub fn new(chain_id: &str, expected_steps: usize, actual_steps: usize) -> Self {
319 Self {
320 chain_id: chain_id.into(),
321 is_complete: actual_steps >= expected_steps,
322 missing_steps: Vec::new(),
323 expected_steps,
324 actual_steps,
325 }
326 }
327
328 pub fn complete(chain_id: &str, steps: usize) -> Self {
330 Self::new(chain_id, steps, steps)
331 }
332
333 pub fn incomplete(
335 chain_id: &str,
336 expected: usize,
337 actual: usize,
338 missing: Vec<String>,
339 ) -> Self {
340 Self {
341 chain_id: chain_id.into(),
342 is_complete: false,
343 missing_steps: missing,
344 expected_steps: expected,
345 actual_steps: actual,
346 }
347 }
348
349 pub fn completion_rate(&self) -> f64 {
351 if self.expected_steps == 0 {
352 1.0
353 } else {
354 self.actual_steps as f64 / self.expected_steps as f64
355 }
356 }
357}
358
359pub fn check_document_chain_completeness(chains: &[DocumentChainResult]) -> (f64, usize, usize) {
361 if chains.is_empty() {
362 return (1.0, 0, 0);
363 }
364
365 let complete_count = chains.iter().filter(|c| c.is_complete).count();
366 let total_count = chains.len();
367 let rate = complete_count as f64 / total_count as f64;
368
369 (rate, complete_count, total_count)
370}
371
372#[macro_export]
374macro_rules! assert_document_chain_complete {
375 ($chains:expr, $threshold:expr) => {{
376 let (rate, complete, total) =
377 $crate::assertions::check_document_chain_completeness(&$chains);
378 assert!(
379 rate >= $threshold,
380 "Document chain completeness {:.2}% below threshold {:.2}%: {}/{} complete",
381 rate * 100.0,
382 $threshold * 100.0,
383 complete,
384 total
385 );
386
387 for chain in $chains.iter().filter(|c| !c.is_complete) {
389 eprintln!(
390 "Incomplete chain {}: {}/{} steps, missing: {:?}",
391 chain.chain_id, chain.actual_steps, chain.expected_steps, chain.missing_steps
392 );
393 }
394 }};
395}
396
397#[derive(Debug, Clone)]
399pub struct FidelityResult {
400 pub overall_score: f64,
402 pub statistical_score: f64,
404 pub schema_score: f64,
406 pub correlation_score: f64,
408 pub passes: bool,
410 pub threshold: f64,
412}
413
414impl FidelityResult {
415 pub fn new(statistical: f64, schema: f64, correlation: f64, threshold: f64) -> Self {
417 let overall = statistical * 0.50 + schema * 0.25 + correlation * 0.25;
419
420 Self {
421 overall_score: overall,
422 statistical_score: statistical,
423 schema_score: schema,
424 correlation_score: correlation,
425 passes: overall >= threshold,
426 threshold,
427 }
428 }
429
430 pub fn perfect(threshold: f64) -> Self {
432 Self::new(1.0, 1.0, 1.0, threshold)
433 }
434}
435
436pub fn check_fidelity(
438 statistical_score: f64,
439 schema_score: f64,
440 correlation_score: f64,
441 threshold: f64,
442) -> FidelityResult {
443 FidelityResult::new(
444 statistical_score,
445 schema_score,
446 correlation_score,
447 threshold,
448 )
449}
450
451#[macro_export]
453macro_rules! assert_fidelity_passes {
454 ($result:expr) => {{
455 assert!(
456 $result.passes,
457 "Fidelity check failed: overall={:.4} < threshold={:.4}\n \
458 statistical={:.4}, schema={:.4}, correlation={:.4}",
459 $result.overall_score,
460 $result.threshold,
461 $result.statistical_score,
462 $result.schema_score,
463 $result.correlation_score
464 );
465 }};
466 ($statistical:expr, $schema:expr, $correlation:expr, $threshold:expr) => {{
467 let result =
468 $crate::assertions::check_fidelity($statistical, $schema, $correlation, $threshold);
469 assert!(
470 result.passes,
471 "Fidelity check failed: overall={:.4} < threshold={:.4}\n \
472 statistical={:.4}, schema={:.4}, correlation={:.4}",
473 result.overall_score,
474 result.threshold,
475 result.statistical_score,
476 result.schema_score,
477 result.correlation_score
478 );
479 }};
480}
481
482pub fn benford_mad(amounts: &[Decimal]) -> f64 {
484 let expected = [
485 0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
486 ];
487 let mut counts = [0u64; 9];
488 let mut total = 0u64;
489
490 for amount in amounts.iter() {
491 if *amount > Decimal::ZERO {
492 let first_digit = amount
493 .to_string()
494 .chars()
495 .find(|c| c.is_ascii_digit() && *c != '0')
496 .and_then(|c| c.to_digit(10))
497 .map(|d| d as usize);
498
499 if let Some(d) = first_digit {
500 if (1..=9).contains(&d) {
501 counts[d - 1] += 1;
502 total += 1;
503 }
504 }
505 }
506 }
507
508 if total == 0 {
509 return 0.0;
510 }
511
512 let mut mad = 0.0;
514 for (count, exp) in counts.iter().zip(expected.iter()) {
515 let observed = *count as f64 / total as f64;
516 mad += (observed - exp).abs();
517 }
518
519 mad / 9.0
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::fixtures::*;
526
527 #[test]
528 fn test_is_balanced() {
529 let entry = balanced_journal_entry(Decimal::new(10000, 2));
530 assert!(is_balanced(&entry));
531 }
532
533 #[test]
534 fn test_is_not_balanced() {
535 let entry = unbalanced_journal_entry();
536 assert!(!is_balanced(&entry));
537 }
538
539 #[test]
540 fn test_calculate_imbalance_balanced() {
541 let entry = balanced_journal_entry(Decimal::new(10000, 2));
542 assert_eq!(calculate_imbalance(&entry), Decimal::ZERO);
543 }
544
545 #[test]
546 fn test_calculate_imbalance_unbalanced() {
547 let entry = unbalanced_journal_entry();
548 let imbalance = calculate_imbalance(&entry);
549 assert_ne!(imbalance, Decimal::ZERO);
550 }
551
552 #[test]
553 fn test_check_accounting_equation() {
554 assert!(check_accounting_equation(
556 Decimal::new(1000, 0),
557 Decimal::new(600, 0),
558 Decimal::new(400, 0)
559 ));
560
561 assert!(!check_accounting_equation(
563 Decimal::new(1000, 0),
564 Decimal::new(600, 0),
565 Decimal::new(300, 0)
566 ));
567 }
568
569 #[test]
570 fn test_check_trial_balance() {
571 let debits = vec![Decimal::new(1000, 0), Decimal::new(500, 0)];
572 let credits = vec![Decimal::new(1500, 0)];
573 assert!(check_trial_balance(&debits, &credits));
574
575 let unbalanced_credits = vec![Decimal::new(1000, 0)];
576 assert!(!check_trial_balance(&debits, &unbalanced_credits));
577 }
578
579 #[test]
580 fn test_benford_distribution_perfect() {
581 let mut amounts = Vec::new();
583 let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46]; for (digit, count) in expected_counts.iter().enumerate() {
586 let base = Decimal::new((digit + 1) as i64, 0);
587 for _ in 0..*count {
588 amounts.push(base);
589 }
590 }
591
592 let (chi_squared, passes) = check_benford_distribution(&amounts);
593 assert!(passes, "Chi-squared: {}", chi_squared);
594 }
595
596 #[test]
597 fn test_assert_balanced_macro() {
598 let entry = balanced_journal_entry(Decimal::new(10000, 2));
599 assert_balanced!(entry); }
601
602 #[test]
603 fn test_assert_all_balanced_macro() {
604 let entries = [
605 balanced_journal_entry(Decimal::new(10000, 2)),
606 balanced_journal_entry(Decimal::new(20000, 2)),
607 balanced_journal_entry(Decimal::new(30000, 2)),
608 ];
609 assert_all_balanced!(entries); }
611
612 #[test]
617 fn test_balance_snapshot_coherent() {
618 let snapshot = BalanceSnapshot::new(
619 Decimal::new(1000, 0),
620 Decimal::new(600, 0),
621 Decimal::new(400, 0),
622 "2025-01",
623 );
624 assert!(snapshot.is_coherent(Decimal::ZERO));
625 }
626
627 #[test]
628 fn test_balance_snapshot_incoherent() {
629 let snapshot = BalanceSnapshot::new(
630 Decimal::new(1000, 0),
631 Decimal::new(600, 0),
632 Decimal::new(300, 0), "2025-01",
634 );
635 assert!(!snapshot.is_coherent(Decimal::ZERO));
636 }
637
638 #[test]
639 fn test_balance_snapshot_with_tolerance() {
640 let snapshot = BalanceSnapshot::new(
641 Decimal::new(1001, 0), Decimal::new(600, 0),
643 Decimal::new(400, 0),
644 "2025-01",
645 );
646 assert!(!snapshot.is_coherent(Decimal::ZERO));
647 assert!(snapshot.is_coherent(Decimal::new(1, 0)));
648 assert!(snapshot.is_coherent(Decimal::new(5, 0)));
649 }
650
651 #[test]
652 fn test_assert_balance_coherent_macro() {
653 let snapshots = [
654 BalanceSnapshot::new(
655 Decimal::new(1000, 0),
656 Decimal::new(600, 0),
657 Decimal::new(400, 0),
658 "2025-01",
659 ),
660 BalanceSnapshot::new(
661 Decimal::new(1200, 0),
662 Decimal::new(700, 0),
663 Decimal::new(500, 0),
664 "2025-02",
665 ),
666 ];
667 assert_balance_coherent!(snapshots, 0.0);
668 }
669
670 #[test]
671 fn test_subledger_reconciliation() {
672 let recon = SubledgerReconciliation::new(
673 "AR",
674 Decimal::new(50000, 0),
675 Decimal::new(50000, 0),
676 "2025-01",
677 );
678 assert!(recon.is_reconciled(Decimal::ZERO));
679 assert_eq!(recon.difference(), Decimal::ZERO);
680 }
681
682 #[test]
683 fn test_subledger_reconciliation_with_tolerance() {
684 let recon = SubledgerReconciliation::new(
685 "AP",
686 Decimal::new(50010, 0), Decimal::new(50000, 0),
688 "2025-01",
689 );
690 assert!(!recon.is_reconciled(Decimal::new(5, 0)));
691 assert!(recon.is_reconciled(Decimal::new(10, 0)));
692 assert!(recon.is_reconciled(Decimal::new(100, 0)));
693 }
694
695 #[test]
696 fn test_assert_subledger_reconciled_macro() {
697 let reconciliations = [
698 SubledgerReconciliation::new(
699 "AR",
700 Decimal::new(50000, 0),
701 Decimal::new(50000, 0),
702 "2025-01",
703 ),
704 SubledgerReconciliation::new(
705 "AP",
706 Decimal::new(30000, 0),
707 Decimal::new(30000, 0),
708 "2025-01",
709 ),
710 ];
711 assert_subledger_reconciled!(reconciliations, 0.0);
712 }
713
714 #[test]
715 fn test_document_chain_complete() {
716 let chain = DocumentChainResult::complete("PO-001", 5);
717 assert!(chain.is_complete);
718 assert_eq!(chain.completion_rate(), 1.0);
719 }
720
721 #[test]
722 fn test_document_chain_incomplete() {
723 let chain =
724 DocumentChainResult::incomplete("PO-002", 5, 3, vec!["Payment".into(), "Close".into()]);
725 assert!(!chain.is_complete);
726 assert_eq!(chain.completion_rate(), 0.6);
727 }
728
729 #[test]
730 fn test_check_document_chain_completeness() {
731 let chains = vec![
732 DocumentChainResult::complete("PO-001", 5),
733 DocumentChainResult::complete("PO-002", 5),
734 DocumentChainResult::incomplete("PO-003", 5, 3, vec!["Payment".into()]),
735 ];
736
737 let (rate, complete, total) = check_document_chain_completeness(&chains);
738 assert_eq!(complete, 2);
739 assert_eq!(total, 3);
740 assert!((rate - 0.6667).abs() < 0.01);
741 }
742
743 #[test]
744 fn test_assert_document_chain_complete_macro() {
745 let chains = vec![
746 DocumentChainResult::complete("PO-001", 5),
747 DocumentChainResult::complete("PO-002", 5),
748 DocumentChainResult::complete("PO-003", 5),
749 ];
750 assert_document_chain_complete!(chains, 0.9);
751 }
752
753 #[test]
754 fn test_fidelity_result() {
755 let result = FidelityResult::new(0.95, 1.0, 0.90, 0.80);
756
757 assert!((result.overall_score - 0.95).abs() < 0.001);
759 assert!(result.passes);
760 }
761
762 #[test]
763 fn test_fidelity_result_fails() {
764 let result = FidelityResult::new(0.50, 0.50, 0.50, 0.80);
765
766 assert!((result.overall_score - 0.50).abs() < 0.001);
768 assert!(!result.passes);
769 }
770
771 #[test]
772 fn test_fidelity_perfect() {
773 let result = FidelityResult::perfect(0.90);
774 assert_eq!(result.overall_score, 1.0);
775 assert!(result.passes);
776 }
777
778 #[test]
779 fn test_assert_fidelity_passes_macro() {
780 let result = FidelityResult::new(0.95, 1.0, 0.90, 0.80);
781 assert_fidelity_passes!(result);
782 }
783
784 #[test]
785 fn test_assert_fidelity_passes_inline() {
786 assert_fidelity_passes!(0.95, 1.0, 0.90, 0.80);
787 }
788
789 #[test]
790 fn test_benford_mad() {
791 let mut amounts = Vec::new();
793 let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46];
794
795 for (digit, count) in expected_counts.iter().enumerate() {
796 let base = Decimal::new((digit + 1) as i64, 0);
797 for _ in 0..*count {
798 amounts.push(base);
799 }
800 }
801
802 let mad = benford_mad(&amounts);
803 assert!(
804 mad < 0.01,
805 "Perfect Benford distribution should have very low MAD: {}",
806 mad
807 );
808 }
809
810 #[test]
811 fn test_benford_mad_uniform() {
812 let mut amounts = Vec::new();
814 for digit in 1..=9 {
815 for _ in 0..100 {
816 amounts.push(Decimal::new(digit, 0));
817 }
818 }
819
820 let mad = benford_mad(&amounts);
821 assert!(
822 mad > 0.02,
823 "Uniform distribution should have high MAD: {}",
824 mad
825 );
826 }
827}