1use chrono::{Datelike, Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
9use rand_chacha::ChaCha8Rng;
10use uuid::Uuid;
11
12use datasynth_core::models::audit::{
13 Assertion, AuditEngagement, AuditEvidence, EvidenceSource, EvidenceType, ReliabilityAssessment,
14 ReliabilityLevel, Workpaper,
15};
16
17#[derive(Debug, Clone, Default)]
23pub struct EvidenceContext {
24 pub risk_level: Option<String>, pub account_balance: Option<f64>,
28 pub assertion: Option<String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct EvidenceGeneratorConfig {
35 pub evidence_per_workpaper: (u32, u32),
37 pub external_third_party_probability: f64,
39 pub high_reliability_probability: f64,
41 pub ai_extraction_probability: f64,
43 pub file_size_range: (u64, u64),
45 pub period_end_date: Option<NaiveDate>,
48}
49
50impl Default for EvidenceGeneratorConfig {
51 fn default() -> Self {
52 Self {
53 evidence_per_workpaper: (1, 5),
54 external_third_party_probability: 0.20,
55 high_reliability_probability: 0.40,
56 ai_extraction_probability: 0.15,
57 file_size_range: (10_000, 5_000_000),
58 period_end_date: None,
59 }
60 }
61}
62
63pub struct EvidenceGenerator {
65 rng: ChaCha8Rng,
66 config: EvidenceGeneratorConfig,
67 evidence_counter: u32,
68}
69
70impl EvidenceGenerator {
71 pub fn new(seed: u64) -> Self {
73 Self {
74 rng: seeded_rng(seed, 0),
75 config: EvidenceGeneratorConfig::default(),
76 evidence_counter: 0,
77 }
78 }
79
80 pub fn with_config(seed: u64, config: EvidenceGeneratorConfig) -> Self {
82 Self {
83 rng: seeded_rng(seed, 0),
84 config,
85 evidence_counter: 0,
86 }
87 }
88
89 pub fn generate_evidence_for_workpaper(
91 &mut self,
92 workpaper: &Workpaper,
93 team_members: &[String],
94 base_date: NaiveDate,
95 ) -> Vec<AuditEvidence> {
96 let count = self.rng.random_range(
97 self.config.evidence_per_workpaper.0..=self.config.evidence_per_workpaper.1,
98 );
99
100 (0..count)
101 .map(|i| {
102 self.generate_evidence(
103 workpaper.engagement_id,
104 Some(workpaper.workpaper_id),
105 &workpaper.assertions_tested,
106 team_members,
107 base_date + Duration::days(i as i64),
108 )
109 })
110 .collect()
111 }
112
113 pub fn generate_evidence_for_workpaper_with_context(
122 &mut self,
123 workpaper: &Workpaper,
124 team_members: &[String],
125 base_date: NaiveDate,
126 context: &EvidenceContext,
127 ) -> Vec<AuditEvidence> {
128 let count = self.rng.random_range(
129 self.config.evidence_per_workpaper.0..=self.config.evidence_per_workpaper.1,
130 );
131
132 (0..count)
133 .map(|i| {
134 self.generate_evidence_with_context(
135 workpaper.engagement_id,
136 Some(workpaper.workpaper_id),
137 &workpaper.assertions_tested,
138 team_members,
139 base_date + Duration::days(i as i64),
140 context,
141 )
142 })
143 .collect()
144 }
145
146 fn generate_evidence_with_context(
148 &mut self,
149 engagement_id: Uuid,
150 workpaper_id: Option<Uuid>,
151 assertions: &[Assertion],
152 team_members: &[String],
153 obtained_date: NaiveDate,
154 context: &EvidenceContext,
155 ) -> AuditEvidence {
156 self.evidence_counter += 1;
157
158 let (evidence_type, source_type) =
160 if context.assertion.is_some() || context.risk_level.is_some() {
161 self.select_evidence_type_and_source_with_context(context)
162 } else {
163 self.select_evidence_type_and_source()
164 };
165
166 let title = self.generate_evidence_title(evidence_type);
167 let mut evidence = AuditEvidence::new(engagement_id, evidence_type, source_type, &title);
168 evidence.evidence_ref = format!("EV-{:06}", self.evidence_counter);
169
170 let description = self.generate_evidence_description(evidence_type, source_type);
171 evidence = evidence.with_description(&description);
172
173 let obtainer = self.select_team_member(team_members);
174 evidence = evidence.with_obtained_by(&obtainer, obtained_date);
175
176 let file_size = self
177 .rng
178 .random_range(self.config.file_size_range.0..=self.config.file_size_range.1);
179 let file_path = self.generate_file_path(evidence_type, self.evidence_counter);
180 let file_hash = format!("sha256:{:064x}", self.rng.random::<u128>());
181 evidence = evidence.with_file_info(&file_path, &file_hash, file_size);
182
183 let reliability = self.generate_reliability_assessment(source_type);
184 evidence = evidence.with_reliability(reliability);
185
186 if assertions.is_empty() {
187 evidence = evidence.with_assertions(vec![self.random_assertion()]);
188 } else {
189 evidence = evidence.with_assertions(assertions.to_vec());
190 }
191
192 if let Some(wp_id) = workpaper_id {
193 evidence.link_workpaper(wp_id);
194 }
195
196 if self.rng.random::<f64>() < self.config.ai_extraction_probability {
198 let terms = if let Some(balance) = context.account_balance {
199 self.generate_ai_terms_anchored(evidence_type, balance)
200 } else {
201 self.generate_ai_terms(evidence_type)
202 };
203 let confidence = self.rng.random_range(0.75..0.98);
204 let summary = self.generate_ai_summary(evidence_type);
205 evidence = evidence.with_ai_extraction(terms, confidence, &summary);
206 }
207
208 evidence
209 }
210
211 pub fn generate_evidence(
213 &mut self,
214 engagement_id: Uuid,
215 workpaper_id: Option<Uuid>,
216 assertions: &[Assertion],
217 team_members: &[String],
218 obtained_date: NaiveDate,
219 ) -> AuditEvidence {
220 self.evidence_counter += 1;
221
222 let (evidence_type, source_type) = self.select_evidence_type_and_source();
224 let title = self.generate_evidence_title(evidence_type);
225
226 let mut evidence = AuditEvidence::new(engagement_id, evidence_type, source_type, &title);
227
228 evidence.evidence_ref = format!("EV-{:06}", self.evidence_counter);
229
230 let description = self.generate_evidence_description(evidence_type, source_type);
232 evidence = evidence.with_description(&description);
233
234 let obtainer = self.select_team_member(team_members);
236 evidence = evidence.with_obtained_by(&obtainer, obtained_date);
237
238 let file_size = self
240 .rng
241 .random_range(self.config.file_size_range.0..=self.config.file_size_range.1);
242 let file_path = self.generate_file_path(evidence_type, self.evidence_counter);
243 let file_hash = format!("sha256:{:064x}", self.rng.random::<u128>());
244 evidence = evidence.with_file_info(&file_path, &file_hash, file_size);
245
246 let reliability = self.generate_reliability_assessment(source_type);
248 evidence = evidence.with_reliability(reliability);
249
250 if assertions.is_empty() {
252 evidence = evidence.with_assertions(vec![self.random_assertion()]);
253 } else {
254 evidence = evidence.with_assertions(assertions.to_vec());
255 }
256
257 if let Some(wp_id) = workpaper_id {
259 evidence.link_workpaper(wp_id);
260 }
261
262 if self.rng.random::<f64>() < self.config.ai_extraction_probability {
264 let terms = self.generate_ai_terms(evidence_type);
265 let confidence = self.rng.random_range(0.75..0.98);
266 let summary = self.generate_ai_summary(evidence_type);
267 evidence = evidence.with_ai_extraction(terms, confidence, &summary);
268 }
269
270 evidence
271 }
272
273 pub fn generate_evidence_for_engagement(
275 &mut self,
276 engagement: &AuditEngagement,
277 workpapers: &[Workpaper],
278 team_members: &[String],
279 ) -> Vec<AuditEvidence> {
280 let mut all_evidence = Vec::new();
281
282 for workpaper in workpapers {
283 let evidence = self.generate_evidence_for_workpaper(
284 workpaper,
285 team_members,
286 workpaper.preparer_date,
287 );
288 all_evidence.extend(evidence);
289 }
290
291 let standalone_count = self.rng.random_range(5..15);
293 for i in 0..standalone_count {
294 let date = engagement.fieldwork_start + Duration::days(i as i64 * 3);
295 let evidence =
296 self.generate_evidence(engagement.engagement_id, None, &[], team_members, date);
297 all_evidence.push(evidence);
298 }
299
300 all_evidence
301 }
302
303 fn select_evidence_type_and_source(&mut self) -> (EvidenceType, EvidenceSource) {
305 let is_external = self.rng.random::<f64>() < self.config.external_third_party_probability;
306
307 if is_external {
308 let external_types = [
309 (
310 EvidenceType::Confirmation,
311 EvidenceSource::ExternalThirdParty,
312 ),
313 (
314 EvidenceType::BankStatement,
315 EvidenceSource::ExternalThirdParty,
316 ),
317 (
318 EvidenceType::LegalLetter,
319 EvidenceSource::ExternalThirdParty,
320 ),
321 (
322 EvidenceType::Contract,
323 EvidenceSource::ExternalClientProvided,
324 ),
325 ];
326 let idx = self.rng.random_range(0..external_types.len());
327 external_types[idx]
328 } else {
329 let internal_types = [
330 (
331 EvidenceType::Document,
332 EvidenceSource::InternalClientPrepared,
333 ),
334 (
335 EvidenceType::Invoice,
336 EvidenceSource::InternalClientPrepared,
337 ),
338 (
339 EvidenceType::SystemExtract,
340 EvidenceSource::InternalClientPrepared,
341 ),
342 (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
343 (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
344 (
345 EvidenceType::MeetingMinutes,
346 EvidenceSource::InternalClientPrepared,
347 ),
348 (EvidenceType::Email, EvidenceSource::InternalClientPrepared),
349 ];
350 let idx = self.rng.random_range(0..internal_types.len());
351 internal_types[idx]
352 }
353 }
354
355 fn select_evidence_type_and_source_with_context(
364 &mut self,
365 context: &EvidenceContext,
366 ) -> (EvidenceType, EvidenceSource) {
367 let external_prob = match context.risk_level.as_deref() {
369 Some("High") => 0.40,
370 Some("Moderate") => 0.25,
371 _ => self.config.external_third_party_probability,
372 };
373
374 let assertion_str = context.assertion.as_deref().unwrap_or("");
376
377 let preferred_external: Vec<(EvidenceType, EvidenceSource)> = match assertion_str {
378 s if s.contains("Existence") || s.contains("Occurrence") => vec![
379 (
380 EvidenceType::Confirmation,
381 EvidenceSource::ExternalThirdParty,
382 ),
383 (
384 EvidenceType::BankStatement,
385 EvidenceSource::ExternalThirdParty,
386 ),
387 (
388 EvidenceType::PhysicalObservation,
389 EvidenceSource::AuditorPrepared,
390 ),
391 ],
392 s if s.contains("Valuation") => vec![
393 (
394 EvidenceType::SpecialistReport,
395 EvidenceSource::ExternalThirdParty,
396 ),
397 (
398 EvidenceType::Confirmation,
399 EvidenceSource::ExternalThirdParty,
400 ),
401 ],
402 _ => vec![
403 (
404 EvidenceType::Confirmation,
405 EvidenceSource::ExternalThirdParty,
406 ),
407 (
408 EvidenceType::BankStatement,
409 EvidenceSource::ExternalThirdParty,
410 ),
411 (
412 EvidenceType::LegalLetter,
413 EvidenceSource::ExternalThirdParty,
414 ),
415 (
416 EvidenceType::Contract,
417 EvidenceSource::ExternalClientProvided,
418 ),
419 ],
420 };
421
422 let preferred_internal: Vec<(EvidenceType, EvidenceSource)> = match assertion_str {
423 s if s.contains("Completeness") => vec![
424 (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
425 (
426 EvidenceType::SystemExtract,
427 EvidenceSource::InternalClientPrepared,
428 ),
429 (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
430 ],
431 s if s.contains("Valuation") => vec![
432 (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
433 (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
434 (
435 EvidenceType::SystemExtract,
436 EvidenceSource::InternalClientPrepared,
437 ),
438 ],
439 s if s.contains("Existence") || s.contains("Occurrence") => vec![
440 (
441 EvidenceType::Document,
442 EvidenceSource::InternalClientPrepared,
443 ),
444 (
445 EvidenceType::Invoice,
446 EvidenceSource::InternalClientPrepared,
447 ),
448 (
449 EvidenceType::SystemExtract,
450 EvidenceSource::InternalClientPrepared,
451 ),
452 ],
453 _ => vec![
454 (
455 EvidenceType::Document,
456 EvidenceSource::InternalClientPrepared,
457 ),
458 (
459 EvidenceType::Invoice,
460 EvidenceSource::InternalClientPrepared,
461 ),
462 (
463 EvidenceType::SystemExtract,
464 EvidenceSource::InternalClientPrepared,
465 ),
466 (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
467 (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
468 (
469 EvidenceType::MeetingMinutes,
470 EvidenceSource::InternalClientPrepared,
471 ),
472 (EvidenceType::Email, EvidenceSource::InternalClientPrepared),
473 ],
474 };
475
476 let is_external = self.rng.random::<f64>() < external_prob;
477
478 if is_external && !preferred_external.is_empty() {
479 let idx = self.rng.random_range(0..preferred_external.len());
480 preferred_external[idx]
481 } else if !preferred_internal.is_empty() {
482 let idx = self.rng.random_range(0..preferred_internal.len());
483 preferred_internal[idx]
484 } else {
485 self.select_evidence_type_and_source()
486 }
487 }
488
489 fn generate_ai_terms_anchored(
491 &mut self,
492 evidence_type: EvidenceType,
493 account_balance: f64,
494 ) -> std::collections::HashMap<String, String> {
495 let mut terms = std::collections::HashMap::new();
496
497 let default_end = NaiveDate::from_ymd_opt(2025, 12, 31).expect("valid date");
498 let period_end = self.config.period_end_date.unwrap_or(default_end);
499 let period_end_str = period_end.format("%Y-%m-%d").to_string();
500 let period_start_str = NaiveDate::from_ymd_opt(period_end.year(), 1, 1)
501 .expect("valid date")
502 .format("%Y-%m-%d")
503 .to_string();
504
505 let variance_pct = self.rng.random_range(-0.02..0.02);
507 let anchored_amount = account_balance * (1.0 + variance_pct);
508
509 match evidence_type {
510 EvidenceType::Invoice => {
511 terms.insert(
512 "invoice_number".into(),
513 format!("INV-{:06}", self.rng.random_range(100000..999999)),
514 );
515 let fraction = self.rng.random_range(0.005..0.05);
517 terms.insert(
518 "amount".into(),
519 format!("{:.2}", account_balance * fraction),
520 );
521 terms.insert("vendor".into(), "Extracted Vendor Name".into());
522 }
523 EvidenceType::Contract => {
524 terms.insert("effective_date".into(), period_start_str);
525 terms.insert(
526 "term_years".into(),
527 format!("{}", self.rng.random_range(1..5)),
528 );
529 terms.insert("total_value".into(), format!("{:.2}", anchored_amount));
530 }
531 EvidenceType::BankStatement => {
532 terms.insert("ending_balance".into(), format!("{:.2}", anchored_amount));
533 terms.insert("statement_date".into(), period_end_str);
534 }
535 EvidenceType::Confirmation => {
536 terms.insert(
537 "confirmed_balance".into(),
538 format!("{:.2}", anchored_amount),
539 );
540 terms.insert("confirmation_date".into(), period_end_str);
541 }
542 _ => {
543 terms.insert("document_date".into(), period_end_str);
544 terms.insert(
545 "reference".into(),
546 format!("REF-{:06}", self.rng.random_range(100000..999999)),
547 );
548 terms.insert("reported_amount".into(), format!("{:.2}", anchored_amount));
549 }
550 }
551
552 terms
553 }
554
555 fn generate_evidence_title(&mut self, evidence_type: EvidenceType) -> String {
557 let titles = match evidence_type {
558 EvidenceType::Confirmation => vec![
559 "Bank Confirmation - Primary Account",
560 "AR Confirmation - Major Customer",
561 "AP Confirmation - Key Vendor",
562 "Legal Confirmation",
563 "Investment Confirmation",
564 ],
565 EvidenceType::BankStatement => vec![
566 "Bank Statement - Operating Account",
567 "Bank Statement - Payroll Account",
568 "Bank Statement - Investment Account",
569 "Bank Statement - Foreign Currency",
570 ],
571 EvidenceType::Invoice => vec![
572 "Vendor Invoice Sample",
573 "Customer Invoice Sample",
574 "Intercompany Invoice",
575 "Service Invoice",
576 ],
577 EvidenceType::Contract => vec![
578 "Customer Contract",
579 "Vendor Agreement",
580 "Lease Agreement",
581 "Employment Contract Sample",
582 "Loan Agreement",
583 ],
584 EvidenceType::Document => vec![
585 "Supporting Documentation",
586 "Source Document",
587 "Transaction Support",
588 "Authorization Document",
589 ],
590 EvidenceType::Analysis => vec![
591 "Analytical Review",
592 "Variance Analysis",
593 "Trend Analysis",
594 "Ratio Analysis",
595 "Account Reconciliation Review",
596 ],
597 EvidenceType::SystemExtract => vec![
598 "ERP System Extract",
599 "GL Detail Extract",
600 "Transaction Log Extract",
601 "User Access Report",
602 ],
603 EvidenceType::MeetingMinutes => vec![
604 "Board Meeting Minutes",
605 "Audit Committee Minutes",
606 "Management Meeting Notes",
607 ],
608 EvidenceType::Email => vec![
609 "Management Inquiry Response",
610 "Confirmation Follow-up",
611 "Exception Explanation",
612 ],
613 EvidenceType::Recalculation => vec![
614 "Depreciation Recalculation",
615 "Interest Recalculation",
616 "Tax Provision Recalculation",
617 "Allowance Recalculation",
618 ],
619 EvidenceType::LegalLetter => vec!["Attorney Response Letter", "Litigation Summary"],
620 EvidenceType::ManagementRepresentation => vec![
621 "Management Representation Letter",
622 "Specific Representation",
623 ],
624 EvidenceType::SpecialistReport => vec![
625 "Valuation Specialist Report",
626 "Actuary Report",
627 "IT Specialist Assessment",
628 ],
629 EvidenceType::PhysicalObservation => vec![
630 "Inventory Count Observation",
631 "Fixed Asset Inspection",
632 "Physical Verification",
633 ],
634 };
635
636 let idx = self.rng.random_range(0..titles.len());
637 titles[idx].to_string()
638 }
639
640 fn generate_evidence_description(
642 &mut self,
643 evidence_type: EvidenceType,
644 source: EvidenceSource,
645 ) -> String {
646 let source_desc = source.description();
647 match evidence_type {
648 EvidenceType::Confirmation => {
649 format!("External confirmation {source_desc}. Response received and agreed to client records.")
650 }
651 EvidenceType::BankStatement => {
652 format!("Bank statement {source_desc}. Statement obtained for period-end reconciliation.")
653 }
654 EvidenceType::Invoice => {
655 "Invoice selected as part of sample testing. Examined for appropriate approval, accuracy, and proper period recording.".into()
656 }
657 EvidenceType::Analysis => {
658 "Auditor-prepared analytical procedure. Expectations developed based on prior year, industry data, and management budgets.".into()
659 }
660 EvidenceType::SystemExtract => {
661 format!("System report {source_desc}. Extract validated for completeness and accuracy.")
662 }
663 _ => format!("Supporting documentation {source_desc}."),
664 }
665 }
666
667 fn generate_reliability_assessment(&mut self, source: EvidenceSource) -> ReliabilityAssessment {
669 let base_reliability = source.inherent_reliability();
670
671 let independence = base_reliability;
672 let controls = if self.rng.random::<f64>() < self.config.high_reliability_probability {
673 ReliabilityLevel::High
674 } else {
675 ReliabilityLevel::Medium
676 };
677 let qualifications = if self.rng.random::<f64>() < 0.7 {
678 ReliabilityLevel::High
679 } else {
680 ReliabilityLevel::Medium
681 };
682 let objectivity = match source {
683 EvidenceSource::ExternalThirdParty | EvidenceSource::AuditorPrepared => {
684 ReliabilityLevel::High
685 }
686 _ => {
687 if self.rng.random::<f64>() < 0.5 {
688 ReliabilityLevel::Medium
689 } else {
690 ReliabilityLevel::Low
691 }
692 }
693 };
694
695 let notes = match base_reliability {
696 ReliabilityLevel::High => {
697 "Evidence obtained from independent source with high reliability"
698 }
699 ReliabilityLevel::Medium => "Evidence obtained from client with adequate controls",
700 ReliabilityLevel::Low => "Internal evidence requires corroboration",
701 };
702
703 ReliabilityAssessment::new(independence, controls, qualifications, objectivity, notes)
704 }
705
706 fn generate_file_path(&mut self, evidence_type: EvidenceType, counter: u32) -> String {
708 let extension = match evidence_type {
709 EvidenceType::SystemExtract => "xlsx",
710 EvidenceType::Analysis | EvidenceType::Recalculation => "xlsx",
711 EvidenceType::MeetingMinutes | EvidenceType::ManagementRepresentation => "pdf",
712 EvidenceType::Email => "msg",
713 _ => {
714 if self.rng.random::<f64>() < 0.6 {
715 "pdf"
716 } else {
717 "xlsx"
718 }
719 }
720 };
721
722 format!("/evidence/EV-{counter:06}.{extension}")
723 }
724
725 fn select_team_member(&mut self, team_members: &[String]) -> String {
727 if team_members.is_empty() {
728 format!("STAFF{:03}", self.rng.random_range(1..100))
729 } else {
730 let idx = self.rng.random_range(0..team_members.len());
731 team_members[idx].clone()
732 }
733 }
734
735 fn random_assertion(&mut self) -> Assertion {
737 let assertions = [
738 Assertion::Occurrence,
739 Assertion::Completeness,
740 Assertion::Accuracy,
741 Assertion::Cutoff,
742 Assertion::Classification,
743 Assertion::Existence,
744 Assertion::RightsAndObligations,
745 Assertion::ValuationAndAllocation,
746 Assertion::PresentationAndDisclosure,
747 ];
748 let idx = self.rng.random_range(0..assertions.len());
749 assertions[idx]
750 }
751
752 fn generate_ai_terms(
754 &mut self,
755 evidence_type: EvidenceType,
756 ) -> std::collections::HashMap<String, String> {
757 let mut terms = std::collections::HashMap::new();
758
759 let default_end = NaiveDate::from_ymd_opt(2025, 12, 31).expect("valid date");
760 let period_end = self.config.period_end_date.unwrap_or(default_end);
761 let period_end_str = period_end.format("%Y-%m-%d").to_string();
762 let period_start_str = NaiveDate::from_ymd_opt(period_end.year(), 1, 1)
764 .expect("valid date")
765 .format("%Y-%m-%d")
766 .to_string();
767
768 match evidence_type {
769 EvidenceType::Invoice => {
770 terms.insert(
771 "invoice_number".into(),
772 format!("INV-{:06}", self.rng.random_range(100000..999999)),
773 );
774 terms.insert(
775 "amount".into(),
776 format!("{:.2}", self.rng.random_range(1000.0..100000.0)),
777 );
778 terms.insert("vendor".into(), "Extracted Vendor Name".into());
779 }
780 EvidenceType::Contract => {
781 terms.insert("effective_date".into(), period_start_str);
782 terms.insert(
783 "term_years".into(),
784 format!("{}", self.rng.random_range(1..5)),
785 );
786 terms.insert(
787 "total_value".into(),
788 format!("{:.2}", self.rng.random_range(50000.0..500000.0)),
789 );
790 }
791 EvidenceType::BankStatement => {
792 terms.insert(
793 "ending_balance".into(),
794 format!("{:.2}", self.rng.random_range(100000.0..10000000.0)),
795 );
796 terms.insert("statement_date".into(), period_end_str);
797 }
798 _ => {
799 terms.insert("document_date".into(), period_end_str);
800 terms.insert(
801 "reference".into(),
802 format!("REF-{:06}", self.rng.random_range(100000..999999)),
803 );
804 }
805 }
806
807 terms
808 }
809
810 fn generate_ai_summary(&mut self, evidence_type: EvidenceType) -> String {
812 match evidence_type {
813 EvidenceType::Invoice => {
814 "Invoice for goods/services with standard payment terms. Amount within expected range.".into()
815 }
816 EvidenceType::Contract => {
817 "Multi-year agreement with standard commercial terms. Key provisions identified.".into()
818 }
819 EvidenceType::BankStatement => {
820 "Month-end bank statement showing reconciled balance. No unusual items noted.".into()
821 }
822 _ => "Document reviewed and key data points extracted.".into(),
823 }
824 }
825}
826
827#[cfg(test)]
828#[allow(clippy::unwrap_used)]
829mod tests {
830 use super::*;
831
832 #[test]
833 fn test_evidence_generation() {
834 let mut generator = EvidenceGenerator::new(42);
835 let evidence = generator.generate_evidence(
836 Uuid::new_v4(),
837 None,
838 &[Assertion::Occurrence],
839 &["STAFF001".into()],
840 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
841 );
842
843 assert!(!evidence.evidence_ref.is_empty());
844 assert!(!evidence.title.is_empty());
845 assert!(evidence.file_size.is_some());
846 }
847
848 #[test]
849 fn test_evidence_reliability() {
850 let mut generator = EvidenceGenerator::new(42);
851
852 for _ in 0..10 {
854 let evidence = generator.generate_evidence(
855 Uuid::new_v4(),
856 None,
857 &[],
858 &["STAFF001".into()],
859 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
860 );
861
862 assert!(!evidence.reliability_assessment.notes.is_empty());
864 }
865 }
866
867 #[test]
868 fn test_evidence_with_ai_extraction() {
869 let config = EvidenceGeneratorConfig {
870 ai_extraction_probability: 1.0, ..Default::default()
872 };
873 let mut generator = EvidenceGenerator::with_config(42, config);
874
875 let evidence = generator.generate_evidence(
876 Uuid::new_v4(),
877 None,
878 &[],
879 &["STAFF001".into()],
880 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
881 );
882
883 assert!(evidence.ai_extracted_terms.is_some());
884 assert!(evidence.ai_confidence.is_some());
885 assert!(evidence.ai_summary.is_some());
886 }
887
888 #[test]
889 fn test_evidence_with_context_existence_favors_confirmation() {
890 let mut generator = EvidenceGenerator::new(42);
893 let context = EvidenceContext {
894 risk_level: Some("High".into()),
895 account_balance: Some(1_250_000.0),
896 assertion: Some("Existence".into()),
897 };
898
899 let mut external_count = 0;
900 let total = 50;
901 for _ in 0..total {
902 let evidence = generator.generate_evidence_with_context(
903 Uuid::new_v4(),
904 None,
905 &[Assertion::Existence],
906 &["STAFF001".into()],
907 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
908 &context,
909 );
910 if matches!(
911 evidence.source_type,
912 EvidenceSource::ExternalThirdParty | EvidenceSource::AuditorPrepared
913 ) && matches!(
914 evidence.evidence_type,
915 EvidenceType::Confirmation
916 | EvidenceType::BankStatement
917 | EvidenceType::PhysicalObservation
918 ) {
919 external_count += 1;
920 }
921 }
922 assert!(
925 external_count > 5,
926 "Expected >5 confirmation/observation evidence, got {external_count}/{total}"
927 );
928 }
929
930 #[test]
931 fn test_evidence_with_context_completeness_favors_analysis() {
932 let mut generator = EvidenceGenerator::new(42);
933 let context = EvidenceContext {
934 risk_level: Some("Moderate".into()),
935 account_balance: None,
936 assertion: Some("Completeness".into()),
937 };
938
939 let mut analytical_count = 0;
940 let total = 50;
941 for _ in 0..total {
942 let evidence = generator.generate_evidence_with_context(
943 Uuid::new_v4(),
944 None,
945 &[Assertion::Completeness],
946 &["STAFF001".into()],
947 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
948 &context,
949 );
950 if matches!(
951 evidence.evidence_type,
952 EvidenceType::Analysis | EvidenceType::SystemExtract | EvidenceType::Recalculation
953 ) {
954 analytical_count += 1;
955 }
956 }
957 assert!(
959 analytical_count > 20,
960 "Expected >20 analytical evidence, got {analytical_count}/{total}"
961 );
962 }
963
964 #[test]
965 fn test_evidence_anchored_amounts() {
966 let config = EvidenceGeneratorConfig {
967 ai_extraction_probability: 1.0,
968 ..Default::default()
969 };
970 let mut generator = EvidenceGenerator::with_config(42, config);
971 let balance = 1_000_000.0;
972 let context = EvidenceContext {
973 risk_level: None,
974 account_balance: Some(balance),
975 assertion: None,
976 };
977
978 let evidence = generator.generate_evidence_with_context(
979 Uuid::new_v4(),
980 None,
981 &[],
982 &["STAFF001".into()],
983 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
984 &context,
985 );
986
987 assert!(evidence.ai_extracted_terms.is_some());
989 }
990
991 #[test]
992 fn test_evidence_workpaper_link() {
993 let mut generator = EvidenceGenerator::new(42);
994 let workpaper_id = Uuid::new_v4();
995
996 let evidence = generator.generate_evidence(
997 Uuid::new_v4(),
998 Some(workpaper_id),
999 &[Assertion::Completeness],
1000 &["STAFF001".into()],
1001 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1002 );
1003
1004 assert!(evidence.linked_workpapers.contains(&workpaper_id));
1005 }
1006}