1use crate::schema::GeneratorConfig;
4use datasynth_core::error::{SynthError, SynthResult};
5
6const MAX_PERIOD_MONTHS: u32 = 120;
8
9fn is_valid_time_format(s: &str) -> bool {
11 if s.len() != 5 {
12 return false;
13 }
14 let chars: Vec<char> = s.chars().collect();
15 if chars[2] != ':' {
16 return false;
17 }
18 let hours: Option<u8> = s[0..2].parse().ok();
20 let minutes: Option<u8> = s[3..5].parse().ok();
21 match (hours, minutes) {
22 (Some(h), Some(m)) => h <= 23 && m <= 59,
23 _ => false,
24 }
25}
26
27pub fn validate_config(config: &GeneratorConfig) -> SynthResult<()> {
29 validate_global_settings(config)?;
30 validate_companies(config)?;
31 validate_transactions(config)?;
32 validate_output(config)?;
33 validate_fraud(config)?;
34 validate_internal_controls(config)?;
35 validate_approval(config)?;
36 validate_master_data(config)?;
37 validate_document_flows(config)?;
38 validate_intercompany(config)?;
39 validate_balance(config)?;
40 validate_accounting_standards(config)?;
41 validate_audit_standards(config)?;
42 validate_distributions(config)?;
43 validate_temporal_patterns(config)?;
44 validate_vendor_network(config)?;
45 validate_customer_segmentation(config)?;
46 validate_relationship_strength(config)?;
47 validate_cross_process_links(config)?;
48 validate_anomaly_injection(config)?;
49 Ok(())
50}
51
52fn validate_global_settings(config: &GeneratorConfig) -> SynthResult<()> {
54 if config.global.period_months == 0 {
55 return Err(SynthError::validation(
56 "period_months must be greater than 0",
57 ));
58 }
59 if config.global.period_months > MAX_PERIOD_MONTHS {
60 return Err(SynthError::validation(format!(
61 "period_months must be at most {} (10 years), got {}",
62 MAX_PERIOD_MONTHS, config.global.period_months
63 )));
64 }
65 Ok(())
66}
67
68fn validate_companies(config: &GeneratorConfig) -> SynthResult<()> {
70 if config.companies.is_empty() {
71 return Err(SynthError::validation(
72 "At least one company must be configured",
73 ));
74 }
75
76 for company in &config.companies {
77 if company.code.is_empty() {
78 return Err(SynthError::validation("Company code cannot be empty"));
79 }
80 if company.currency.len() != 3 {
81 return Err(SynthError::validation(format!(
82 "Invalid currency code '{}' for company '{}'",
83 company.currency, company.code
84 )));
85 }
86 if company.volume_weight < 0.0 {
87 return Err(SynthError::validation(format!(
88 "volume_weight must be non-negative for company '{}'",
89 company.code
90 )));
91 }
92 }
93 Ok(())
94}
95
96fn validate_transactions(config: &GeneratorConfig) -> SynthResult<()> {
98 let line_dist = &config.transactions.line_item_distribution;
100 if let Err(e) = line_dist.validate() {
101 return Err(SynthError::validation(e));
102 }
103
104 let source_sum = config.transactions.source_distribution.manual
106 + config.transactions.source_distribution.automated
107 + config.transactions.source_distribution.recurring
108 + config.transactions.source_distribution.adjustment;
109 if (source_sum - 1.0).abs() > 0.01 {
110 return Err(SynthError::validation(format!(
111 "Source distribution must sum to 1.0, got {}",
112 source_sum
113 )));
114 }
115
116 let bp_sum = config.business_processes.o2c_weight
118 + config.business_processes.p2p_weight
119 + config.business_processes.r2r_weight
120 + config.business_processes.h2r_weight
121 + config.business_processes.a2r_weight;
122 if (bp_sum - 1.0).abs() > 0.01 {
123 return Err(SynthError::validation(format!(
124 "Business process weights must sum to 1.0, got {}",
125 bp_sum
126 )));
127 }
128
129 let tolerance = config.transactions.benford.tolerance;
131 if !(0.0..=1.0).contains(&tolerance) {
132 return Err(SynthError::validation(format!(
133 "benford.tolerance must be between 0.0 and 1.0, got {}",
134 tolerance
135 )));
136 }
137
138 Ok(())
139}
140
141fn validate_output(config: &GeneratorConfig) -> SynthResult<()> {
143 let level = config.output.compression.level;
144 if config.output.compression.enabled && !(1..=9).contains(&level) {
145 return Err(SynthError::validation(format!(
146 "compression.level must be between 1 and 9, got {}",
147 level
148 )));
149 }
150
151 if config.output.batch_size == 0 {
152 return Err(SynthError::validation("batch_size must be greater than 0"));
153 }
154
155 Ok(())
156}
157
158fn validate_fraud(config: &GeneratorConfig) -> SynthResult<()> {
160 if !config.fraud.enabled {
161 return Ok(());
162 }
163
164 if config.fraud.fraud_rate < 0.0 || config.fraud.fraud_rate > 1.0 {
165 return Err(SynthError::validation(
166 "fraud_rate must be between 0.0 and 1.0",
167 ));
168 }
169
170 if config.fraud.clustering_factor < 0.0 {
171 return Err(SynthError::validation(
172 "clustering_factor must be non-negative",
173 ));
174 }
175
176 let thresholds = &config.fraud.approval_thresholds;
178 for i in 1..thresholds.len() {
179 if thresholds[i] <= thresholds[i - 1] {
180 return Err(SynthError::validation(format!(
181 "fraud.approval_thresholds must be in strictly ascending order: {} is not greater than {}",
182 thresholds[i], thresholds[i - 1]
183 )));
184 }
185 }
186
187 let dist = &config.fraud.fraud_type_distribution;
189 let sum = dist.suspense_account_abuse
190 + dist.fictitious_transaction
191 + dist.revenue_manipulation
192 + dist.expense_capitalization
193 + dist.split_transaction
194 + dist.timing_anomaly
195 + dist.unauthorized_access
196 + dist.duplicate_payment;
197 if (sum - 1.0).abs() > 0.01 {
198 return Err(SynthError::validation(format!(
199 "fraud_type_distribution must sum to 1.0, got {}",
200 sum
201 )));
202 }
203
204 Ok(())
205}
206
207fn validate_internal_controls(config: &GeneratorConfig) -> SynthResult<()> {
209 if !config.internal_controls.enabled {
210 return Ok(());
211 }
212
213 let exception_rate = config.internal_controls.exception_rate;
214 if !(0.0..=1.0).contains(&exception_rate) {
215 return Err(SynthError::validation(format!(
216 "exception_rate must be between 0.0 and 1.0, got {}",
217 exception_rate
218 )));
219 }
220
221 let sod_rate = config.internal_controls.sod_violation_rate;
222 if !(0.0..=1.0).contains(&sod_rate) {
223 return Err(SynthError::validation(format!(
224 "sod_violation_rate must be between 0.0 and 1.0, got {}",
225 sod_rate
226 )));
227 }
228
229 if config.internal_controls.sox_materiality_threshold < 0.0 {
230 return Err(SynthError::validation(
231 "sox_materiality_threshold must be non-negative",
232 ));
233 }
234
235 Ok(())
236}
237
238fn validate_approval(config: &GeneratorConfig) -> SynthResult<()> {
240 if !config.approval.enabled {
241 return Ok(());
242 }
243
244 if config.approval.auto_approve_threshold < 0.0 {
245 return Err(SynthError::validation(
246 "auto_approve_threshold must be non-negative",
247 ));
248 }
249
250 let rejection_rate = config.approval.rejection_rate;
251 if !(0.0..=1.0).contains(&rejection_rate) {
252 return Err(SynthError::validation(format!(
253 "rejection_rate must be between 0.0 and 1.0, got {}",
254 rejection_rate
255 )));
256 }
257
258 let revision_rate = config.approval.revision_rate;
259 if !(0.0..=1.0).contains(&revision_rate) {
260 return Err(SynthError::validation(format!(
261 "revision_rate must be between 0.0 and 1.0, got {}",
262 revision_rate
263 )));
264 }
265
266 if rejection_rate + revision_rate > 1.0 {
268 return Err(SynthError::validation(format!(
269 "rejection_rate + revision_rate must not exceed 1.0, got {}",
270 rejection_rate + revision_rate
271 )));
272 }
273
274 let thresholds = &config.approval.thresholds;
276 for i in 1..thresholds.len() {
277 if thresholds[i].amount <= thresholds[i - 1].amount {
278 return Err(SynthError::validation(format!(
279 "approval.thresholds must have strictly ascending amounts: {} is not greater than {}",
280 thresholds[i].amount, thresholds[i - 1].amount
281 )));
282 }
283 }
284
285 Ok(())
286}
287
288fn validate_master_data(config: &GeneratorConfig) -> SynthResult<()> {
290 let vendor_ic = config.master_data.vendors.intercompany_percent;
292 if !(0.0..=1.0).contains(&vendor_ic) {
293 return Err(SynthError::validation(format!(
294 "vendors.intercompany_percent must be between 0.0 and 1.0, got {}",
295 vendor_ic
296 )));
297 }
298
299 let customer_ic = config.master_data.customers.intercompany_percent;
301 if !(0.0..=1.0).contains(&customer_ic) {
302 return Err(SynthError::validation(format!(
303 "customers.intercompany_percent must be between 0.0 and 1.0, got {}",
304 customer_ic
305 )));
306 }
307
308 let bom_percent = config.master_data.materials.bom_percent;
310 if !(0.0..=1.0).contains(&bom_percent) {
311 return Err(SynthError::validation(format!(
312 "materials.bom_percent must be between 0.0 and 1.0, got {}",
313 bom_percent
314 )));
315 }
316
317 let fully_dep = config.master_data.fixed_assets.fully_depreciated_percent;
319 if !(0.0..=1.0).contains(&fully_dep) {
320 return Err(SynthError::validation(format!(
321 "fixed_assets.fully_depreciated_percent must be between 0.0 and 1.0, got {}",
322 fully_dep
323 )));
324 }
325
326 Ok(())
327}
328
329fn validate_document_flows(config: &GeneratorConfig) -> SynthResult<()> {
331 let p2p = &config.document_flows.p2p;
333 if p2p.enabled {
334 validate_rate("p2p.three_way_match_rate", p2p.three_way_match_rate)?;
335 validate_rate("p2p.partial_delivery_rate", p2p.partial_delivery_rate)?;
336 validate_rate("p2p.price_variance_rate", p2p.price_variance_rate)?;
337 validate_rate("p2p.quantity_variance_rate", p2p.quantity_variance_rate)?;
338
339 if p2p.max_price_variance_percent < 0.0 {
340 return Err(SynthError::validation(
341 "p2p.max_price_variance_percent must be non-negative",
342 ));
343 }
344
345 validate_p2p_payment_behavior(&p2p.payment_behavior)?;
347 }
348
349 let o2c = &config.document_flows.o2c;
351 if o2c.enabled {
352 validate_rate(
353 "o2c.credit_check_failure_rate",
354 o2c.credit_check_failure_rate,
355 )?;
356 validate_rate("o2c.partial_shipment_rate", o2c.partial_shipment_rate)?;
357 validate_rate("o2c.return_rate", o2c.return_rate)?;
358 validate_rate("o2c.bad_debt_rate", o2c.bad_debt_rate)?;
359
360 validate_rate(
362 "o2c.cash_discount.eligible_rate",
363 o2c.cash_discount.eligible_rate,
364 )?;
365 validate_rate("o2c.cash_discount.taken_rate", o2c.cash_discount.taken_rate)?;
366 validate_rate(
367 "o2c.cash_discount.discount_percent",
368 o2c.cash_discount.discount_percent,
369 )?;
370
371 validate_o2c_payment_behavior(&o2c.payment_behavior)?;
373 }
374
375 Ok(())
376}
377
378fn validate_p2p_payment_behavior(
380 config: &crate::schema::P2PPaymentBehaviorConfig,
381) -> SynthResult<()> {
382 validate_rate(
383 "p2p.payment_behavior.late_payment_rate",
384 config.late_payment_rate,
385 )?;
386 validate_rate(
387 "p2p.payment_behavior.partial_payment_rate",
388 config.partial_payment_rate,
389 )?;
390 validate_rate(
391 "p2p.payment_behavior.payment_correction_rate",
392 config.payment_correction_rate,
393 )?;
394
395 let late_dist = &config.late_payment_days_distribution;
397 let late_sum = late_dist.slightly_late_1_to_7
398 + late_dist.late_8_to_14
399 + late_dist.very_late_15_to_30
400 + late_dist.severely_late_31_to_60
401 + late_dist.extremely_late_over_60;
402 if (late_sum - 1.0).abs() > 0.01 {
403 return Err(SynthError::validation(format!(
404 "p2p.payment_behavior.late_payment_days_distribution must sum to 1.0, got {}",
405 late_sum
406 )));
407 }
408
409 Ok(())
410}
411
412fn validate_o2c_payment_behavior(
414 config: &crate::schema::O2CPaymentBehaviorConfig,
415) -> SynthResult<()> {
416 let dunning = &config.dunning;
418 if dunning.enabled {
419 validate_rate(
420 "o2c.payment_behavior.dunning.dunning_block_rate",
421 dunning.dunning_block_rate,
422 )?;
423
424 if dunning.level_2_days_overdue <= dunning.level_1_days_overdue {
426 return Err(SynthError::validation(
427 "dunning.level_2_days_overdue must be greater than level_1_days_overdue",
428 ));
429 }
430 if dunning.level_3_days_overdue <= dunning.level_2_days_overdue {
431 return Err(SynthError::validation(
432 "dunning.level_3_days_overdue must be greater than level_2_days_overdue",
433 ));
434 }
435 if dunning.collection_days_overdue <= dunning.level_3_days_overdue {
436 return Err(SynthError::validation(
437 "dunning.collection_days_overdue must be greater than level_3_days_overdue",
438 ));
439 }
440
441 let rates = &dunning.payment_after_dunning_rates;
443 let rates_sum = rates.after_level_1
444 + rates.after_level_2
445 + rates.after_level_3
446 + rates.during_collection
447 + rates.never_pay;
448 if (rates_sum - 1.0).abs() > 0.01 {
449 return Err(SynthError::validation(format!(
450 "dunning.payment_after_dunning_rates must sum to 1.0, got {}",
451 rates_sum
452 )));
453 }
454 }
455
456 let partial = &config.partial_payments;
458 validate_rate("o2c.payment_behavior.partial_payments.rate", partial.rate)?;
459 let partial_dist = &partial.percentage_distribution;
460 let partial_sum = partial_dist.pay_25_percent
461 + partial_dist.pay_50_percent
462 + partial_dist.pay_75_percent
463 + partial_dist.pay_random_percent;
464 if (partial_sum - 1.0).abs() > 0.01 {
465 return Err(SynthError::validation(format!(
466 "partial_payments.percentage_distribution must sum to 1.0, got {}",
467 partial_sum
468 )));
469 }
470
471 let short = &config.short_payments;
473 validate_rate("o2c.payment_behavior.short_payments.rate", short.rate)?;
474 validate_rate(
475 "o2c.payment_behavior.short_payments.max_short_percent",
476 short.max_short_percent,
477 )?;
478 let short_dist = &short.reason_distribution;
479 let short_sum = short_dist.pricing_dispute
480 + short_dist.quality_issue
481 + short_dist.quantity_discrepancy
482 + short_dist.unauthorized_deduction
483 + short_dist.incorrect_discount;
484 if (short_sum - 1.0).abs() > 0.01 {
485 return Err(SynthError::validation(format!(
486 "short_payments.reason_distribution must sum to 1.0, got {}",
487 short_sum
488 )));
489 }
490
491 validate_rate(
493 "o2c.payment_behavior.on_account_payments.rate",
494 config.on_account_payments.rate,
495 )?;
496
497 let corrections = &config.payment_corrections;
499 validate_rate(
500 "o2c.payment_behavior.payment_corrections.rate",
501 corrections.rate,
502 )?;
503 let corr_dist = &corrections.type_distribution;
504 let corr_sum = corr_dist.nsf
505 + corr_dist.chargeback
506 + corr_dist.wrong_amount
507 + corr_dist.wrong_customer
508 + corr_dist.duplicate_payment;
509 if (corr_sum - 1.0).abs() > 0.01 {
510 return Err(SynthError::validation(format!(
511 "payment_corrections.type_distribution must sum to 1.0, got {}",
512 corr_sum
513 )));
514 }
515
516 Ok(())
517}
518
519fn validate_intercompany(config: &GeneratorConfig) -> SynthResult<()> {
521 if !config.intercompany.enabled {
522 return Ok(());
523 }
524
525 validate_rate(
526 "intercompany.ic_transaction_rate",
527 config.intercompany.ic_transaction_rate,
528 )?;
529
530 if config.intercompany.markup_percent < 0.0 {
531 return Err(SynthError::validation(
532 "intercompany.markup_percent must be non-negative",
533 ));
534 }
535
536 let dist = &config.intercompany.transaction_type_distribution;
538 let sum = dist.goods_sale
539 + dist.service_provided
540 + dist.loan
541 + dist.dividend
542 + dist.management_fee
543 + dist.royalty
544 + dist.cost_sharing;
545 if (sum - 1.0).abs() > 0.01 {
546 return Err(SynthError::validation(format!(
547 "intercompany.transaction_type_distribution must sum to 1.0, got {}",
548 sum
549 )));
550 }
551
552 Ok(())
553}
554
555fn validate_balance(config: &GeneratorConfig) -> SynthResult<()> {
557 let balance = &config.balance;
558
559 if balance.target_gross_margin < 0.0 || balance.target_gross_margin > 1.0 {
560 return Err(SynthError::validation(format!(
561 "target_gross_margin must be between 0.0 and 1.0, got {}",
562 balance.target_gross_margin
563 )));
564 }
565
566 if balance.target_current_ratio < 0.0 {
567 return Err(SynthError::validation(
568 "target_current_ratio must be non-negative",
569 ));
570 }
571
572 if balance.target_debt_to_equity < 0.0 {
573 return Err(SynthError::validation(
574 "target_debt_to_equity must be non-negative",
575 ));
576 }
577
578 Ok(())
579}
580
581fn validate_rate(field_name: &str, value: f64) -> SynthResult<()> {
583 if !(0.0..=1.0).contains(&value) {
584 return Err(SynthError::validation(format!(
585 "{} must be between 0.0 and 1.0, got {}",
586 field_name, value
587 )));
588 }
589 Ok(())
590}
591
592fn validate_accounting_standards(config: &GeneratorConfig) -> SynthResult<()> {
594 let standards = &config.accounting_standards;
595
596 if !standards.enabled {
597 return Ok(());
598 }
599
600 if standards.revenue_recognition.enabled {
602 let rev = &standards.revenue_recognition;
603
604 if rev.avg_obligations_per_contract < 1.0 {
605 return Err(SynthError::validation(
606 "avg_obligations_per_contract must be >= 1.0",
607 ));
608 }
609
610 validate_rate(
611 "revenue_recognition.variable_consideration_rate",
612 rev.variable_consideration_rate,
613 )?;
614
615 validate_rate(
616 "revenue_recognition.over_time_recognition_rate",
617 rev.over_time_recognition_rate,
618 )?;
619 }
620
621 if standards.leases.enabled {
623 let lease = &standards.leases;
624
625 if lease.avg_lease_term_months == 0 {
626 return Err(SynthError::validation(
627 "lease.avg_lease_term_months must be > 0",
628 ));
629 }
630
631 validate_rate("lease.finance_lease_percent", lease.finance_lease_percent)?;
632 validate_rate("lease.real_estate_percent", lease.real_estate_percent)?;
633 }
634
635 if standards.fair_value.enabled {
637 let fv = &standards.fair_value;
638
639 let level_sum = fv.level1_percent + fv.level2_percent + fv.level3_percent;
641 if (level_sum - 1.0).abs() > 0.01 {
642 return Err(SynthError::validation(format!(
643 "fair_value level percentages must sum to 1.0, got {}",
644 level_sum
645 )));
646 }
647
648 validate_rate("fair_value.level1_percent", fv.level1_percent)?;
649 validate_rate("fair_value.level2_percent", fv.level2_percent)?;
650 validate_rate("fair_value.level3_percent", fv.level3_percent)?;
651 }
652
653 if standards.impairment.enabled {
655 let imp = &standards.impairment;
656
657 validate_rate("impairment.impairment_rate", imp.impairment_rate)?;
658 }
659
660 Ok(())
661}
662
663fn validate_audit_standards(config: &GeneratorConfig) -> SynthResult<()> {
665 let standards = &config.audit_standards;
666
667 if !standards.enabled {
668 return Ok(());
669 }
670
671 if standards.isa_compliance.enabled {
673 let valid_levels = ["basic", "standard", "comprehensive"];
674 if !valid_levels.contains(&standards.isa_compliance.compliance_level.as_str()) {
675 return Err(SynthError::validation(format!(
676 "isa_compliance.compliance_level must be one of {:?}, got '{}'",
677 valid_levels, standards.isa_compliance.compliance_level
678 )));
679 }
680
681 let valid_frameworks = ["isa", "pcaob", "dual"];
682 if !valid_frameworks.contains(&standards.isa_compliance.framework.as_str()) {
683 return Err(SynthError::validation(format!(
684 "isa_compliance.framework must be one of {:?}, got '{}'",
685 valid_frameworks, standards.isa_compliance.framework
686 )));
687 }
688 }
689
690 if standards.analytical_procedures.enabled {
692 let ap = &standards.analytical_procedures;
693
694 if ap.procedures_per_account == 0 {
695 return Err(SynthError::validation(
696 "analytical_procedures.procedures_per_account must be > 0",
697 ));
698 }
699
700 validate_rate(
701 "analytical_procedures.variance_probability",
702 ap.variance_probability,
703 )?;
704 }
705
706 if standards.confirmations.enabled {
708 let conf = &standards.confirmations;
709
710 validate_rate(
711 "confirmations.positive_response_rate",
712 conf.positive_response_rate,
713 )?;
714
715 validate_rate("confirmations.exception_rate", conf.exception_rate)?;
716
717 let total_rate = conf.positive_response_rate + conf.exception_rate;
719 if total_rate > 1.0 {
720 return Err(SynthError::validation(
721 "confirmations: positive_response_rate + exception_rate cannot exceed 1.0",
722 ));
723 }
724 }
725
726 if standards.opinion.enabled {
728 let op = &standards.opinion;
729
730 if op.generate_kam && op.average_kam_count == 0 {
731 return Err(SynthError::validation(
732 "opinion.average_kam_count must be > 0 when generate_kam is true",
733 ));
734 }
735 }
736
737 if standards.sox.enabled {
739 let sox = &standards.sox;
740
741 if sox.materiality_threshold < 0.0 {
742 return Err(SynthError::validation(
743 "sox.materiality_threshold must be >= 0",
744 ));
745 }
746 }
747
748 if standards.pcaob.enabled {
750 if standards.isa_compliance.enabled
752 && standards.isa_compliance.framework != "pcaob"
753 && standards.isa_compliance.framework != "dual"
754 {
755 return Err(SynthError::validation(
756 "When PCAOB is enabled, ISA framework must be 'pcaob' or 'dual'",
757 ));
758 }
759 }
760
761 Ok(())
762}
763
764fn validate_distributions(config: &GeneratorConfig) -> SynthResult<()> {
766 let dist = &config.distributions;
767
768 if !dist.enabled {
769 return Ok(());
770 }
771
772 validate_mixture_config(&dist.amounts)?;
774
775 validate_correlation_config(&dist.correlations)?;
777
778 for (i, cond) in dist.conditional.iter().enumerate() {
780 validate_conditional_config(cond, i)?;
781 }
782
783 validate_regime_changes(&dist.regime_changes)?;
785
786 validate_statistical_validation(&dist.validation)?;
788
789 Ok(())
790}
791
792fn validate_mixture_config(
794 config: &crate::schema::MixtureDistributionSchemaConfig,
795) -> SynthResult<()> {
796 if !config.enabled {
797 return Ok(());
798 }
799
800 if config.components.is_empty() {
801 return Err(SynthError::validation(
802 "distributions.amounts.components cannot be empty when enabled",
803 ));
804 }
805
806 let weight_sum: f64 = config.components.iter().map(|c| c.weight).sum();
808 if (weight_sum - 1.0).abs() > 0.01 {
809 return Err(SynthError::validation(format!(
810 "distributions.amounts.components weights must sum to 1.0, got {}",
811 weight_sum
812 )));
813 }
814
815 for (i, comp) in config.components.iter().enumerate() {
817 if comp.weight < 0.0 || comp.weight > 1.0 {
818 return Err(SynthError::validation(format!(
819 "distributions.amounts.components[{}].weight must be between 0.0 and 1.0, got {}",
820 i, comp.weight
821 )));
822 }
823
824 if comp.sigma <= 0.0 {
825 return Err(SynthError::validation(format!(
826 "distributions.amounts.components[{}].sigma must be positive, got {}",
827 i, comp.sigma
828 )));
829 }
830 }
831
832 if config.min_value < 0.0 {
834 return Err(SynthError::validation(
835 "distributions.amounts.min_value must be non-negative",
836 ));
837 }
838
839 if let Some(max) = config.max_value {
840 if max <= config.min_value {
841 return Err(SynthError::validation(format!(
842 "distributions.amounts.max_value ({}) must be greater than min_value ({})",
843 max, config.min_value
844 )));
845 }
846 }
847
848 Ok(())
849}
850
851fn validate_correlation_config(config: &crate::schema::CorrelationSchemaConfig) -> SynthResult<()> {
853 if !config.enabled {
854 return Ok(());
855 }
856
857 let n = config.fields.len();
858 if n < 2 {
859 return Err(SynthError::validation(
860 "distributions.correlations.fields must have at least 2 fields",
861 ));
862 }
863
864 let expected_matrix_size = n * (n - 1) / 2;
866 if config.matrix.len() != expected_matrix_size {
867 return Err(SynthError::validation(format!(
868 "distributions.correlations.matrix must have {} elements for {} fields, got {}",
869 expected_matrix_size,
870 n,
871 config.matrix.len()
872 )));
873 }
874
875 for (i, &r) in config.matrix.iter().enumerate() {
877 if !(-1.0..=1.0).contains(&r) {
878 return Err(SynthError::validation(format!(
879 "distributions.correlations.matrix[{}] must be in [-1, 1], got {}",
880 i, r
881 )));
882 }
883 }
884
885 for expected in &config.expected_correlations {
887 if !(-1.0..=1.0).contains(&expected.expected_r) {
888 return Err(SynthError::validation(format!(
889 "expected_correlation for ({}, {}): expected_r must be in [-1, 1], got {}",
890 expected.field1, expected.field2, expected.expected_r
891 )));
892 }
893 if expected.tolerance <= 0.0 || expected.tolerance > 1.0 {
894 return Err(SynthError::validation(format!(
895 "expected_correlation for ({}, {}): tolerance must be in (0, 1], got {}",
896 expected.field1, expected.field2, expected.tolerance
897 )));
898 }
899 }
900
901 Ok(())
902}
903
904fn validate_conditional_config(
906 config: &crate::schema::ConditionalDistributionSchemaConfig,
907 index: usize,
908) -> SynthResult<()> {
909 if config.output_field.is_empty() {
910 return Err(SynthError::validation(format!(
911 "distributions.conditional[{}].output_field cannot be empty",
912 index
913 )));
914 }
915
916 if config.input_field.is_empty() {
917 return Err(SynthError::validation(format!(
918 "distributions.conditional[{}].input_field cannot be empty",
919 index
920 )));
921 }
922
923 for i in 1..config.breakpoints.len() {
925 if config.breakpoints[i].threshold <= config.breakpoints[i - 1].threshold {
926 return Err(SynthError::validation(format!(
927 "distributions.conditional[{}].breakpoints must be in ascending order: {} is not greater than {}",
928 index, config.breakpoints[i].threshold, config.breakpoints[i - 1].threshold
929 )));
930 }
931 }
932
933 if let (Some(min), Some(max)) = (config.min_value, config.max_value) {
935 if max <= min {
936 return Err(SynthError::validation(format!(
937 "distributions.conditional[{}].max_value ({}) must be greater than min_value ({})",
938 index, max, min
939 )));
940 }
941 }
942
943 Ok(())
944}
945
946fn validate_regime_changes(config: &crate::schema::RegimeChangeSchemaConfig) -> SynthResult<()> {
948 if !config.enabled {
949 return Ok(());
950 }
951
952 for (i, change) in config.changes.iter().enumerate() {
954 if change.date.is_empty() {
956 return Err(SynthError::validation(format!(
957 "distributions.regime_changes.changes[{}].date cannot be empty",
958 i
959 )));
960 }
961
962 for (j, effect) in change.effects.iter().enumerate() {
964 if effect.multiplier < 0.0 {
965 return Err(SynthError::validation(format!(
966 "distributions.regime_changes.changes[{}].effects[{}].multiplier must be non-negative, got {}",
967 i, j, effect.multiplier
968 )));
969 }
970 }
971 }
972
973 if let Some(ref cycle) = config.economic_cycle {
975 if cycle.enabled {
976 if cycle.period_months == 0 {
977 return Err(SynthError::validation(
978 "distributions.regime_changes.economic_cycle.period_months must be > 0",
979 ));
980 }
981
982 if cycle.amplitude < 0.0 || cycle.amplitude > 1.0 {
983 return Err(SynthError::validation(format!(
984 "distributions.regime_changes.economic_cycle.amplitude must be in [0, 1], got {}",
985 cycle.amplitude
986 )));
987 }
988
989 for (i, recession) in cycle.recessions.iter().enumerate() {
991 if recession.duration_months == 0 {
992 return Err(SynthError::validation(format!(
993 "distributions.regime_changes.economic_cycle.recessions[{}].duration_months must be > 0",
994 i
995 )));
996 }
997
998 if recession.severity < 0.0 || recession.severity > 1.0 {
999 return Err(SynthError::validation(format!(
1000 "distributions.regime_changes.economic_cycle.recessions[{}].severity must be in [0, 1], got {}",
1001 i, recession.severity
1002 )));
1003 }
1004 }
1005 }
1006 }
1007
1008 for (i, drift) in config.parameter_drifts.iter().enumerate() {
1010 if drift.parameter.is_empty() {
1011 return Err(SynthError::validation(format!(
1012 "distributions.regime_changes.parameter_drifts[{}].parameter cannot be empty",
1013 i
1014 )));
1015 }
1016
1017 if let Some(end) = drift.end_period {
1018 if end <= drift.start_period {
1019 return Err(SynthError::validation(format!(
1020 "distributions.regime_changes.parameter_drifts[{}].end_period ({}) must be > start_period ({})",
1021 i, end, drift.start_period
1022 )));
1023 }
1024 }
1025 }
1026
1027 Ok(())
1028}
1029
1030fn validate_statistical_validation(
1032 config: &crate::schema::StatisticalValidationSchemaConfig,
1033) -> SynthResult<()> {
1034 if !config.enabled {
1035 return Ok(());
1036 }
1037
1038 for (i, test) in config.tests.iter().enumerate() {
1040 match test {
1041 crate::schema::StatisticalTestConfig::BenfordFirstDigit {
1042 threshold_mad,
1043 warning_mad,
1044 } => {
1045 if *threshold_mad <= 0.0 {
1046 return Err(SynthError::validation(format!(
1047 "distributions.validation.tests[{}].threshold_mad must be positive",
1048 i
1049 )));
1050 }
1051 if *warning_mad <= 0.0 {
1052 return Err(SynthError::validation(format!(
1053 "distributions.validation.tests[{}].warning_mad must be positive",
1054 i
1055 )));
1056 }
1057 }
1058 crate::schema::StatisticalTestConfig::DistributionFit {
1059 ks_significance, ..
1060 } => {
1061 if *ks_significance <= 0.0 || *ks_significance >= 1.0 {
1062 return Err(SynthError::validation(format!(
1063 "distributions.validation.tests[{}].ks_significance must be in (0, 1), got {}",
1064 i, ks_significance
1065 )));
1066 }
1067 }
1068 crate::schema::StatisticalTestConfig::ChiSquared { bins, significance } => {
1069 if *bins < 2 {
1070 return Err(SynthError::validation(format!(
1071 "distributions.validation.tests[{}].bins must be >= 2, got {}",
1072 i, bins
1073 )));
1074 }
1075 if *significance <= 0.0 || *significance >= 1.0 {
1076 return Err(SynthError::validation(format!(
1077 "distributions.validation.tests[{}].significance must be in (0, 1), got {}",
1078 i, significance
1079 )));
1080 }
1081 }
1082 crate::schema::StatisticalTestConfig::AndersonDarling { significance, .. } => {
1083 if *significance <= 0.0 || *significance >= 1.0 {
1084 return Err(SynthError::validation(format!(
1085 "distributions.validation.tests[{}].significance must be in (0, 1), got {}",
1086 i, significance
1087 )));
1088 }
1089 }
1090 crate::schema::StatisticalTestConfig::CorrelationCheck {
1091 expected_correlations,
1092 } => {
1093 for expected in expected_correlations {
1094 if !(-1.0..=1.0).contains(&expected.expected_r) {
1095 return Err(SynthError::validation(format!(
1096 "distributions.validation.tests[{}]: expected_r must be in [-1, 1]",
1097 i
1098 )));
1099 }
1100 }
1101 }
1102 }
1103 }
1104
1105 Ok(())
1106}
1107
1108fn validate_temporal_patterns(config: &GeneratorConfig) -> SynthResult<()> {
1110 let temporal = &config.temporal_patterns;
1111
1112 if !temporal.enabled {
1113 return Ok(());
1114 }
1115
1116 validate_business_day_config(&temporal.business_days)?;
1118
1119 validate_period_end_config(&temporal.period_end)?;
1121
1122 validate_processing_lag_config(&temporal.processing_lags)?;
1124
1125 validate_calendar_config(&temporal.calendars)?;
1127
1128 validate_fiscal_calendar_config(&temporal.fiscal_calendar)?;
1130
1131 validate_intraday_config(&temporal.intraday)?;
1133
1134 validate_timezone_config(&temporal.timezones)?;
1136
1137 Ok(())
1138}
1139
1140fn validate_business_day_config(
1142 config: &crate::schema::BusinessDaySchemaConfig,
1143) -> SynthResult<()> {
1144 if !config.enabled {
1145 return Ok(());
1146 }
1147
1148 let valid_policies = ["full_day", "half_day", "non_business_day"];
1150 if !valid_policies.contains(&config.half_day_policy.as_str()) {
1151 return Err(SynthError::validation(format!(
1152 "temporal_patterns.business_days.half_day_policy must be one of {:?}, got '{}'",
1153 valid_policies, config.half_day_policy
1154 )));
1155 }
1156
1157 let valid_conventions = [
1159 "modified_following",
1160 "preceding",
1161 "following",
1162 "end_of_month",
1163 ];
1164 if !valid_conventions.contains(&config.month_end_convention.as_str()) {
1165 return Err(SynthError::validation(format!(
1166 "temporal_patterns.business_days.month_end_convention must be one of {:?}, got '{}'",
1167 valid_conventions, config.month_end_convention
1168 )));
1169 }
1170
1171 let rules = &config.settlement_rules;
1173 if rules.equity_days < 0 {
1174 return Err(SynthError::validation(
1175 "temporal_patterns.business_days.settlement_rules.equity_days must be non-negative",
1176 ));
1177 }
1178 if rules.government_bonds_days < 0 {
1179 return Err(SynthError::validation(
1180 "temporal_patterns.business_days.settlement_rules.government_bonds_days must be non-negative",
1181 ));
1182 }
1183 if rules.fx_spot_days < 0 {
1184 return Err(SynthError::validation(
1185 "temporal_patterns.business_days.settlement_rules.fx_spot_days must be non-negative",
1186 ));
1187 }
1188
1189 if !rules.wire_cutoff_time.contains(':') {
1191 return Err(SynthError::validation(format!(
1192 "temporal_patterns.business_days.settlement_rules.wire_cutoff_time must be in HH:MM format, got '{}'",
1193 rules.wire_cutoff_time
1194 )));
1195 }
1196
1197 if let Some(ref weekend_days) = config.weekend_days {
1199 let valid_days = [
1200 "monday",
1201 "tuesday",
1202 "wednesday",
1203 "thursday",
1204 "friday",
1205 "saturday",
1206 "sunday",
1207 ];
1208 for day in weekend_days {
1209 if !valid_days.contains(&day.to_lowercase().as_str()) {
1210 return Err(SynthError::validation(format!(
1211 "temporal_patterns.business_days.weekend_days contains invalid day '{}', must be one of {:?}",
1212 day, valid_days
1213 )));
1214 }
1215 }
1216 }
1217
1218 Ok(())
1219}
1220
1221fn validate_period_end_config(config: &crate::schema::PeriodEndSchemaConfig) -> SynthResult<()> {
1223 if let Some(ref model) = config.model {
1225 let valid_models = ["flat", "exponential", "extended_crunch", "daily_profile"];
1226 if !valid_models.contains(&model.as_str()) {
1227 return Err(SynthError::validation(format!(
1228 "temporal_patterns.period_end.model must be one of {:?}, got '{}'",
1229 valid_models, model
1230 )));
1231 }
1232 }
1233
1234 if let Some(ref month_end) = config.month_end {
1236 validate_period_end_model_config(month_end, "month_end")?;
1237 }
1238
1239 if let Some(ref quarter_end) = config.quarter_end {
1241 validate_period_end_model_config(quarter_end, "quarter_end")?;
1242 }
1243
1244 if let Some(ref year_end) = config.year_end {
1246 validate_period_end_model_config(year_end, "year_end")?;
1247 }
1248
1249 Ok(())
1250}
1251
1252fn validate_period_end_model_config(
1254 config: &crate::schema::PeriodEndModelSchemaConfig,
1255 name: &str,
1256) -> SynthResult<()> {
1257 if let Some(mult) = config.additional_multiplier {
1259 if mult <= 0.0 {
1260 return Err(SynthError::validation(format!(
1261 "temporal_patterns.period_end.{}.additional_multiplier must be positive, got {}",
1262 name, mult
1263 )));
1264 }
1265 }
1266
1267 if let Some(mult) = config.base_multiplier {
1268 if mult <= 0.0 {
1269 return Err(SynthError::validation(format!(
1270 "temporal_patterns.period_end.{}.base_multiplier must be positive, got {}",
1271 name, mult
1272 )));
1273 }
1274 }
1275
1276 if let Some(mult) = config.peak_multiplier {
1277 if mult <= 0.0 {
1278 return Err(SynthError::validation(format!(
1279 "temporal_patterns.period_end.{}.peak_multiplier must be positive, got {}",
1280 name, mult
1281 )));
1282 }
1283 }
1284
1285 if let Some(rate) = config.decay_rate {
1287 if rate <= 0.0 || rate > 1.0 {
1288 return Err(SynthError::validation(format!(
1289 "temporal_patterns.period_end.{}.decay_rate must be in (0, 1], got {}",
1290 name, rate
1291 )));
1292 }
1293 }
1294
1295 if let Some(day) = config.start_day {
1297 if day > 0 {
1298 return Err(SynthError::validation(format!(
1299 "temporal_patterns.period_end.{}.start_day must be <= 0 (days before period end), got {}",
1300 name, day
1301 )));
1302 }
1303 }
1304
1305 if let Some(days) = config.sustained_high_days {
1307 if days <= 0 {
1308 return Err(SynthError::validation(format!(
1309 "temporal_patterns.period_end.{}.sustained_high_days must be positive, got {}",
1310 name, days
1311 )));
1312 }
1313 }
1314
1315 Ok(())
1316}
1317
1318fn validate_processing_lag_config(
1320 config: &crate::schema::ProcessingLagSchemaConfig,
1321) -> SynthResult<()> {
1322 if !config.enabled {
1323 return Ok(());
1324 }
1325
1326 let lag_configs = [
1328 (&config.sales_order_lag, "sales_order_lag"),
1329 (&config.purchase_order_lag, "purchase_order_lag"),
1330 (&config.goods_receipt_lag, "goods_receipt_lag"),
1331 (&config.invoice_receipt_lag, "invoice_receipt_lag"),
1332 (&config.invoice_issue_lag, "invoice_issue_lag"),
1333 (&config.payment_lag, "payment_lag"),
1334 (&config.journal_entry_lag, "journal_entry_lag"),
1335 ];
1336
1337 for (lag_opt, name) in lag_configs {
1338 if let Some(lag) = lag_opt {
1339 validate_lag_distribution(lag, name)?;
1340 }
1341 }
1342
1343 if let Some(ref cross_day) = config.cross_day_posting {
1345 for (hour, prob) in &cross_day.probability_by_hour {
1346 if *hour > 23 {
1347 return Err(SynthError::validation(format!(
1348 "temporal_patterns.processing_lags.cross_day_posting.probability_by_hour contains invalid hour {}, must be 0-23",
1349 hour
1350 )));
1351 }
1352 if !(*prob >= 0.0 && *prob <= 1.0) {
1353 return Err(SynthError::validation(format!(
1354 "temporal_patterns.processing_lags.cross_day_posting.probability_by_hour[{}] must be in [0, 1], got {}",
1355 hour, prob
1356 )));
1357 }
1358 }
1359 }
1360
1361 Ok(())
1362}
1363
1364fn validate_lag_distribution(
1366 config: &crate::schema::LagDistributionSchemaConfig,
1367 name: &str,
1368) -> SynthResult<()> {
1369 if config.sigma <= 0.0 {
1371 return Err(SynthError::validation(format!(
1372 "temporal_patterns.processing_lags.{}.sigma must be positive, got {}",
1373 name, config.sigma
1374 )));
1375 }
1376
1377 if let Some(min) = config.min_hours {
1379 if min < 0.0 {
1380 return Err(SynthError::validation(format!(
1381 "temporal_patterns.processing_lags.{}.min_hours must be non-negative, got {}",
1382 name, min
1383 )));
1384 }
1385 }
1386
1387 if let Some(max) = config.max_hours {
1388 if max < 0.0 {
1389 return Err(SynthError::validation(format!(
1390 "temporal_patterns.processing_lags.{}.max_hours must be non-negative, got {}",
1391 name, max
1392 )));
1393 }
1394
1395 if let Some(min) = config.min_hours {
1396 if max < min {
1397 return Err(SynthError::validation(format!(
1398 "temporal_patterns.processing_lags.{}.max_hours ({}) must be >= min_hours ({})",
1399 name, max, min
1400 )));
1401 }
1402 }
1403 }
1404
1405 Ok(())
1406}
1407
1408fn validate_calendar_config(config: &crate::schema::CalendarSchemaConfig) -> SynthResult<()> {
1410 let valid_regions = [
1412 "US", "DE", "GB", "CN", "JP", "IN", "BR", "MX", "AU", "SG", "KR",
1413 ];
1414 for region in &config.regions {
1415 let region_upper = region.to_uppercase();
1416 if !valid_regions.contains(®ion_upper.as_str()) {
1417 return Err(SynthError::validation(format!(
1418 "temporal_patterns.calendars.regions contains invalid region '{}', must be one of {:?}",
1419 region, valid_regions
1420 )));
1421 }
1422 }
1423
1424 for (i, holiday) in config.custom_holidays.iter().enumerate() {
1426 if holiday.name.is_empty() {
1427 return Err(SynthError::validation(format!(
1428 "temporal_patterns.calendars.custom_holidays[{}].name cannot be empty",
1429 i
1430 )));
1431 }
1432
1433 if holiday.month < 1 || holiday.month > 12 {
1434 return Err(SynthError::validation(format!(
1435 "temporal_patterns.calendars.custom_holidays[{}].month must be 1-12, got {}",
1436 i, holiday.month
1437 )));
1438 }
1439
1440 if holiday.day < 1 || holiday.day > 31 {
1441 return Err(SynthError::validation(format!(
1442 "temporal_patterns.calendars.custom_holidays[{}].day must be 1-31, got {}",
1443 i, holiday.day
1444 )));
1445 }
1446
1447 if holiday.activity_multiplier < 0.0 || holiday.activity_multiplier > 1.0 {
1448 return Err(SynthError::validation(format!(
1449 "temporal_patterns.calendars.custom_holidays[{}].activity_multiplier must be in [0, 1], got {}",
1450 i, holiday.activity_multiplier
1451 )));
1452 }
1453 }
1454
1455 Ok(())
1456}
1457
1458fn validate_fiscal_calendar_config(
1460 config: &crate::schema::FiscalCalendarSchemaConfig,
1461) -> SynthResult<()> {
1462 if !config.enabled {
1463 return Ok(());
1464 }
1465
1466 let valid_types = [
1468 "calendar_year",
1469 "custom",
1470 "four_four_five",
1471 "thirteen_period",
1472 ];
1473 if !valid_types.contains(&config.calendar_type.as_str()) {
1474 return Err(SynthError::validation(format!(
1475 "temporal_patterns.fiscal_calendar.calendar_type must be one of {:?}, got '{}'",
1476 valid_types, config.calendar_type
1477 )));
1478 }
1479
1480 if config.calendar_type == "custom" {
1482 if let Some(month) = config.year_start_month {
1483 if !(1..=12).contains(&month) {
1484 return Err(SynthError::validation(format!(
1485 "temporal_patterns.fiscal_calendar.year_start_month must be 1-12, got {}",
1486 month
1487 )));
1488 }
1489 } else {
1490 return Err(SynthError::validation(
1491 "temporal_patterns.fiscal_calendar.year_start_month is required for custom calendar type",
1492 ));
1493 }
1494
1495 if let Some(day) = config.year_start_day {
1496 if !(1..=31).contains(&day) {
1497 return Err(SynthError::validation(format!(
1498 "temporal_patterns.fiscal_calendar.year_start_day must be 1-31, got {}",
1499 day
1500 )));
1501 }
1502 }
1503 }
1504
1505 if config.calendar_type == "four_four_five" {
1507 if let Some(ref cfg) = config.four_four_five {
1508 let valid_patterns = ["four_four_five", "four_five_four", "five_four_four"];
1509 if !valid_patterns.contains(&cfg.pattern.as_str()) {
1510 return Err(SynthError::validation(format!(
1511 "temporal_patterns.fiscal_calendar.four_four_five.pattern must be one of {:?}, got '{}'",
1512 valid_patterns, cfg.pattern
1513 )));
1514 }
1515
1516 let valid_anchors = ["first_sunday", "last_saturday", "nearest_saturday"];
1517 if !valid_anchors.contains(&cfg.anchor_type.as_str()) {
1518 return Err(SynthError::validation(format!(
1519 "temporal_patterns.fiscal_calendar.four_four_five.anchor_type must be one of {:?}, got '{}'",
1520 valid_anchors, cfg.anchor_type
1521 )));
1522 }
1523
1524 if !(1..=12).contains(&cfg.anchor_month) {
1525 return Err(SynthError::validation(format!(
1526 "temporal_patterns.fiscal_calendar.four_four_five.anchor_month must be 1-12, got {}",
1527 cfg.anchor_month
1528 )));
1529 }
1530
1531 let valid_placements = ["q4_period3", "q1_period1"];
1532 if !valid_placements.contains(&cfg.leap_week_placement.as_str()) {
1533 return Err(SynthError::validation(format!(
1534 "temporal_patterns.fiscal_calendar.four_four_five.leap_week_placement must be one of {:?}, got '{}'",
1535 valid_placements, cfg.leap_week_placement
1536 )));
1537 }
1538 }
1539 }
1540
1541 Ok(())
1542}
1543
1544fn validate_intraday_config(config: &crate::schema::IntraDaySchemaConfig) -> SynthResult<()> {
1546 if !config.enabled {
1547 return Ok(());
1548 }
1549
1550 for (i, segment) in config.segments.iter().enumerate() {
1552 if segment.name.is_empty() {
1554 return Err(SynthError::validation(format!(
1555 "temporal_patterns.intraday.segments[{}].name cannot be empty",
1556 i
1557 )));
1558 }
1559
1560 if !is_valid_time_format(&segment.start) {
1562 return Err(SynthError::validation(format!(
1563 "temporal_patterns.intraday.segments[{}].start must be in HH:MM format, got '{}'",
1564 i, segment.start
1565 )));
1566 }
1567 if !is_valid_time_format(&segment.end) {
1568 return Err(SynthError::validation(format!(
1569 "temporal_patterns.intraday.segments[{}].end must be in HH:MM format, got '{}'",
1570 i, segment.end
1571 )));
1572 }
1573
1574 if segment.multiplier < 0.0 {
1576 return Err(SynthError::validation(format!(
1577 "temporal_patterns.intraday.segments[{}].multiplier must be non-negative, got {}",
1578 i, segment.multiplier
1579 )));
1580 }
1581
1582 let valid_posting_types = ["human", "system", "both"];
1584 if !valid_posting_types.contains(&segment.posting_type.as_str()) {
1585 return Err(SynthError::validation(format!(
1586 "temporal_patterns.intraday.segments[{}].posting_type must be one of {:?}, got '{}'",
1587 i, valid_posting_types, segment.posting_type
1588 )));
1589 }
1590 }
1591
1592 Ok(())
1593}
1594
1595fn validate_timezone_config(config: &crate::schema::TimezoneSchemaConfig) -> SynthResult<()> {
1597 if !config.enabled {
1598 return Ok(());
1599 }
1600
1601 if !is_valid_iana_timezone(&config.default_timezone) {
1603 return Err(SynthError::validation(format!(
1604 "temporal_patterns.timezones.default_timezone '{}' is not a valid IANA timezone name",
1605 config.default_timezone
1606 )));
1607 }
1608
1609 if !is_valid_iana_timezone(&config.consolidation_timezone) {
1611 return Err(SynthError::validation(format!(
1612 "temporal_patterns.timezones.consolidation_timezone '{}' is not a valid IANA timezone name",
1613 config.consolidation_timezone
1614 )));
1615 }
1616
1617 for (i, mapping) in config.entity_mappings.iter().enumerate() {
1619 if mapping.pattern.is_empty() {
1620 return Err(SynthError::validation(format!(
1621 "temporal_patterns.timezones.entity_mappings[{}].pattern cannot be empty",
1622 i
1623 )));
1624 }
1625
1626 if !is_valid_iana_timezone(&mapping.timezone) {
1627 return Err(SynthError::validation(format!(
1628 "temporal_patterns.timezones.entity_mappings[{}].timezone '{}' is not a valid IANA timezone name",
1629 i, mapping.timezone
1630 )));
1631 }
1632 }
1633
1634 Ok(())
1635}
1636
1637fn is_valid_iana_timezone(tz: &str) -> bool {
1640 if tz == "UTC" || tz == "GMT" {
1643 return true;
1644 }
1645
1646 let parts: Vec<&str> = tz.split('/').collect();
1648 if parts.len() >= 2 {
1649 let valid_continents = [
1650 "Africa",
1651 "America",
1652 "Antarctica",
1653 "Arctic",
1654 "Asia",
1655 "Atlantic",
1656 "Australia",
1657 "Europe",
1658 "Indian",
1659 "Pacific",
1660 "Etc",
1661 ];
1662 if valid_continents.contains(&parts[0]) {
1663 return true;
1664 }
1665 }
1666
1667 false
1668}
1669
1670fn validate_vendor_network(config: &GeneratorConfig) -> SynthResult<()> {
1672 let vn = &config.vendor_network;
1673
1674 if !vn.enabled {
1675 return Ok(());
1676 }
1677
1678 if vn.depth == 0 || vn.depth > 3 {
1680 return Err(SynthError::validation(format!(
1681 "vendor_network.depth must be between 1 and 3, got {}",
1682 vn.depth
1683 )));
1684 }
1685
1686 if vn.tier1.min > vn.tier1.max {
1688 return Err(SynthError::validation(format!(
1689 "vendor_network.tier1.min ({}) must be <= max ({})",
1690 vn.tier1.min, vn.tier1.max
1691 )));
1692 }
1693
1694 if vn.tier2_per_parent.min > vn.tier2_per_parent.max {
1696 return Err(SynthError::validation(format!(
1697 "vendor_network.tier2_per_parent.min ({}) must be <= max ({})",
1698 vn.tier2_per_parent.min, vn.tier2_per_parent.max
1699 )));
1700 }
1701
1702 if vn.tier3_per_parent.min > vn.tier3_per_parent.max {
1704 return Err(SynthError::validation(format!(
1705 "vendor_network.tier3_per_parent.min ({}) must be <= max ({})",
1706 vn.tier3_per_parent.min, vn.tier3_per_parent.max
1707 )));
1708 }
1709
1710 let clusters = &vn.clusters;
1712 let cluster_sum = clusters.reliable_strategic
1713 + clusters.standard_operational
1714 + clusters.transactional
1715 + clusters.problematic;
1716 if (cluster_sum - 1.0).abs() > 0.01 {
1717 return Err(SynthError::validation(format!(
1718 "vendor_network.clusters distribution must sum to 1.0, got {}",
1719 cluster_sum
1720 )));
1721 }
1722
1723 let deps = &vn.dependencies;
1725 validate_rate(
1726 "vendor_network.dependencies.max_single_vendor_concentration",
1727 deps.max_single_vendor_concentration,
1728 )?;
1729 validate_rate(
1730 "vendor_network.dependencies.top_5_concentration",
1731 deps.top_5_concentration,
1732 )?;
1733 validate_rate(
1734 "vendor_network.dependencies.single_source_percent",
1735 deps.single_source_percent,
1736 )?;
1737
1738 if deps.max_single_vendor_concentration > deps.top_5_concentration {
1740 return Err(SynthError::validation(format!(
1741 "vendor_network.dependencies.max_single_vendor_concentration ({}) should be <= top_5_concentration ({})",
1742 deps.max_single_vendor_concentration, deps.top_5_concentration
1743 )));
1744 }
1745
1746 Ok(())
1747}
1748
1749fn validate_customer_segmentation(config: &GeneratorConfig) -> SynthResult<()> {
1751 let cs = &config.customer_segmentation;
1752
1753 if !cs.enabled {
1754 return Ok(());
1755 }
1756
1757 let segments = &cs.value_segments;
1759
1760 let revenue_sum = segments.enterprise.revenue_share
1762 + segments.mid_market.revenue_share
1763 + segments.smb.revenue_share
1764 + segments.consumer.revenue_share;
1765 if (revenue_sum - 1.0).abs() > 0.01 {
1766 return Err(SynthError::validation(format!(
1767 "customer_segmentation.value_segments revenue_share must sum to 1.0, got {}",
1768 revenue_sum
1769 )));
1770 }
1771
1772 let customer_sum = segments.enterprise.customer_share
1774 + segments.mid_market.customer_share
1775 + segments.smb.customer_share
1776 + segments.consumer.customer_share;
1777 if (customer_sum - 1.0).abs() > 0.01 {
1778 return Err(SynthError::validation(format!(
1779 "customer_segmentation.value_segments customer_share must sum to 1.0, got {}",
1780 customer_sum
1781 )));
1782 }
1783
1784 for (name, seg) in [
1786 ("enterprise", &segments.enterprise),
1787 ("mid_market", &segments.mid_market),
1788 ("smb", &segments.smb),
1789 ("consumer", &segments.consumer),
1790 ] {
1791 validate_rate(
1792 &format!(
1793 "customer_segmentation.value_segments.{}.revenue_share",
1794 name
1795 ),
1796 seg.revenue_share,
1797 )?;
1798 validate_rate(
1799 &format!(
1800 "customer_segmentation.value_segments.{}.customer_share",
1801 name
1802 ),
1803 seg.customer_share,
1804 )?;
1805 }
1806
1807 let lifecycle = &cs.lifecycle;
1809 let lifecycle_sum = lifecycle.prospect_rate
1810 + lifecycle.new_rate
1811 + lifecycle.growth_rate
1812 + lifecycle.mature_rate
1813 + lifecycle.at_risk_rate
1814 + lifecycle.churned_rate;
1815 if (lifecycle_sum - 1.0).abs() > 0.01 {
1816 return Err(SynthError::validation(format!(
1817 "customer_segmentation.lifecycle distribution must sum to 1.0, got {}",
1818 lifecycle_sum
1819 )));
1820 }
1821
1822 let networks = &cs.networks;
1824 validate_rate(
1825 "customer_segmentation.networks.referrals.referral_rate",
1826 networks.referrals.referral_rate,
1827 )?;
1828 validate_rate(
1829 "customer_segmentation.networks.corporate_hierarchies.probability",
1830 networks.corporate_hierarchies.probability,
1831 )?;
1832
1833 Ok(())
1834}
1835
1836fn validate_relationship_strength(config: &GeneratorConfig) -> SynthResult<()> {
1838 let rs = &config.relationship_strength;
1839
1840 if !rs.enabled {
1841 return Ok(());
1842 }
1843
1844 let calc = &rs.calculation;
1846 let weight_sum = calc.transaction_volume_weight
1847 + calc.transaction_count_weight
1848 + calc.relationship_duration_weight
1849 + calc.recency_weight
1850 + calc.mutual_connections_weight;
1851 if (weight_sum - 1.0).abs() > 0.01 {
1852 return Err(SynthError::validation(format!(
1853 "relationship_strength.calculation weights must sum to 1.0, got {}",
1854 weight_sum
1855 )));
1856 }
1857
1858 validate_rate(
1860 "relationship_strength.calculation.transaction_volume_weight",
1861 calc.transaction_volume_weight,
1862 )?;
1863 validate_rate(
1864 "relationship_strength.calculation.transaction_count_weight",
1865 calc.transaction_count_weight,
1866 )?;
1867 validate_rate(
1868 "relationship_strength.calculation.relationship_duration_weight",
1869 calc.relationship_duration_weight,
1870 )?;
1871 validate_rate(
1872 "relationship_strength.calculation.recency_weight",
1873 calc.recency_weight,
1874 )?;
1875 validate_rate(
1876 "relationship_strength.calculation.mutual_connections_weight",
1877 calc.mutual_connections_weight,
1878 )?;
1879
1880 if calc.recency_half_life_days == 0 {
1882 return Err(SynthError::validation(
1883 "relationship_strength.calculation.recency_half_life_days must be positive",
1884 ));
1885 }
1886
1887 let thresh = &rs.thresholds;
1889 validate_rate("relationship_strength.thresholds.strong", thresh.strong)?;
1890 validate_rate("relationship_strength.thresholds.moderate", thresh.moderate)?;
1891 validate_rate("relationship_strength.thresholds.weak", thresh.weak)?;
1892
1893 if thresh.strong <= thresh.moderate {
1895 return Err(SynthError::validation(format!(
1896 "relationship_strength.thresholds.strong ({}) must be > moderate ({})",
1897 thresh.strong, thresh.moderate
1898 )));
1899 }
1900 if thresh.moderate <= thresh.weak {
1901 return Err(SynthError::validation(format!(
1902 "relationship_strength.thresholds.moderate ({}) must be > weak ({})",
1903 thresh.moderate, thresh.weak
1904 )));
1905 }
1906
1907 Ok(())
1908}
1909
1910fn validate_cross_process_links(config: &GeneratorConfig) -> SynthResult<()> {
1912 let cpl = &config.cross_process_links;
1913
1914 if !cpl.enabled {
1915 return Ok(());
1916 }
1917
1918 if cpl.inventory_p2p_o2c {
1923 if !config.document_flows.p2p.enabled {
1924 return Err(SynthError::validation(
1925 "cross_process_links.inventory_p2p_o2c requires document_flows.p2p to be enabled",
1926 ));
1927 }
1928 if !config.document_flows.o2c.enabled {
1929 return Err(SynthError::validation(
1930 "cross_process_links.inventory_p2p_o2c requires document_flows.o2c to be enabled",
1931 ));
1932 }
1933 }
1934
1935 if cpl.intercompany_bilateral && !config.intercompany.enabled {
1937 return Err(SynthError::validation(
1938 "cross_process_links.intercompany_bilateral requires intercompany to be enabled",
1939 ));
1940 }
1941
1942 Ok(())
1943}
1944
1945fn validate_anomaly_injection(config: &GeneratorConfig) -> SynthResult<()> {
1947 let ai = &config.anomaly_injection;
1948
1949 if !ai.enabled {
1950 return Ok(());
1951 }
1952
1953 if ai.rates.total_rate < 0.0 || ai.rates.total_rate > 1.0 {
1955 return Err(SynthError::validation(
1956 "anomaly_injection.rates.total_rate must be between 0.0 and 1.0",
1957 ));
1958 }
1959 if ai.rates.fraud_rate < 0.0 || ai.rates.fraud_rate > 1.0 {
1960 return Err(SynthError::validation(
1961 "anomaly_injection.rates.fraud_rate must be between 0.0 and 1.0",
1962 ));
1963 }
1964 if ai.rates.error_rate < 0.0 || ai.rates.error_rate > 1.0 {
1965 return Err(SynthError::validation(
1966 "anomaly_injection.rates.error_rate must be between 0.0 and 1.0",
1967 ));
1968 }
1969 if ai.rates.process_rate < 0.0 || ai.rates.process_rate > 1.0 {
1970 return Err(SynthError::validation(
1971 "anomaly_injection.rates.process_rate must be between 0.0 and 1.0",
1972 ));
1973 }
1974
1975 let sub_rate_sum = ai.rates.fraud_rate + ai.rates.error_rate + ai.rates.process_rate;
1977 if sub_rate_sum > ai.rates.total_rate + 0.001 {
1978 return Err(SynthError::validation(format!(
1979 "anomaly_injection sub-rates sum ({}) exceeds total_rate ({})",
1980 sub_rate_sum, ai.rates.total_rate
1981 )));
1982 }
1983
1984 if ai.multi_stage_schemes.enabled {
1986 let emb = &ai.multi_stage_schemes.embezzlement;
1987 if emb.probability < 0.0 || emb.probability > 1.0 {
1988 return Err(SynthError::validation(
1989 "embezzlement.probability must be between 0.0 and 1.0",
1990 ));
1991 }
1992
1993 let rev = &ai.multi_stage_schemes.revenue_manipulation;
1994 if rev.probability < 0.0 || rev.probability > 1.0 {
1995 return Err(SynthError::validation(
1996 "revenue_manipulation.probability must be between 0.0 and 1.0",
1997 ));
1998 }
1999
2000 let kick = &ai.multi_stage_schemes.kickback;
2001 if kick.probability < 0.0 || kick.probability > 1.0 {
2002 return Err(SynthError::validation(
2003 "kickback.probability must be between 0.0 and 1.0",
2004 ));
2005 }
2006 if kick.inflation_min > kick.inflation_max {
2007 return Err(SynthError::validation(
2008 "kickback.inflation_min must be less than or equal to inflation_max",
2009 ));
2010 }
2011 }
2012
2013 if ai.near_miss.enabled {
2015 if ai.near_miss.proportion < 0.0 || ai.near_miss.proportion > 1.0 {
2016 return Err(SynthError::validation(
2017 "near_miss.proportion must be between 0.0 and 1.0",
2018 ));
2019 }
2020 if ai.near_miss.near_duplicate_days.min > ai.near_miss.near_duplicate_days.max {
2021 return Err(SynthError::validation(
2022 "near_miss.near_duplicate_days.min must be less than or equal to max",
2023 ));
2024 }
2025 if ai.near_miss.threshold_proximity_range.min > ai.near_miss.threshold_proximity_range.max {
2026 return Err(SynthError::validation(
2027 "near_miss.threshold_proximity_range.min must be less than or equal to max",
2028 ));
2029 }
2030 if ai.near_miss.corrected_error_lag.min > ai.near_miss.corrected_error_lag.max {
2031 return Err(SynthError::validation(
2032 "near_miss.corrected_error_lag.min must be less than or equal to max",
2033 ));
2034 }
2035 }
2036
2037 if ai.difficulty_classification.enabled {
2039 let dist = &ai.difficulty_classification.target_distribution;
2040 let dist_sum = dist.trivial + dist.easy + dist.moderate + dist.hard + dist.expert;
2041 if (dist_sum - 1.0).abs() > 0.01 {
2042 return Err(SynthError::validation(format!(
2043 "difficulty_classification.target_distribution must sum to 1.0, got {}",
2044 dist_sum
2045 )));
2046 }
2047 }
2048
2049 if ai.context_aware.enabled {
2051 let vendor = &ai.context_aware.vendor_rules;
2052 if vendor.new_vendor_error_multiplier < 1.0 {
2053 return Err(SynthError::validation(
2054 "vendor_rules.new_vendor_error_multiplier must be >= 1.0",
2055 ));
2056 }
2057
2058 let emp = &ai.context_aware.employee_rules;
2059 if emp.new_employee_error_rate < 0.0 || emp.new_employee_error_rate > 1.0 {
2060 return Err(SynthError::validation(
2061 "employee_rules.new_employee_error_rate must be between 0.0 and 1.0",
2062 ));
2063 }
2064
2065 let baseline = &ai.context_aware.behavioral_baseline;
2066 if baseline.enabled && baseline.deviation_threshold_std <= 0.0 {
2067 return Err(SynthError::validation(
2068 "behavioral_baseline.deviation_threshold_std must be positive",
2069 ));
2070 }
2071 }
2072
2073 let mat = &ai.labeling.materiality_thresholds;
2075 if mat.trivial >= mat.immaterial {
2076 return Err(SynthError::validation(
2077 "materiality_thresholds.trivial must be less than immaterial",
2078 ));
2079 }
2080 if mat.immaterial >= mat.material {
2081 return Err(SynthError::validation(
2082 "materiality_thresholds.immaterial must be less than material",
2083 ));
2084 }
2085 if mat.material >= mat.highly_material {
2086 return Err(SynthError::validation(
2087 "materiality_thresholds.material must be less than highly_material",
2088 ));
2089 }
2090
2091 Ok(())
2092}
2093
2094#[cfg(test)]
2095mod tests {
2096 use super::*;
2097 use crate::presets::{create_preset, demo_preset, stress_test_preset};
2098 use crate::schema::*;
2099 use datasynth_core::models::{CoAComplexity, IndustrySector};
2100
2101 fn minimal_valid_config() -> GeneratorConfig {
2103 GeneratorConfig {
2104 global: GlobalConfig {
2105 seed: Some(42),
2106 industry: IndustrySector::Manufacturing,
2107 start_date: "2024-01-01".to_string(),
2108 period_months: 3,
2109 group_currency: "USD".to_string(),
2110 parallel: true,
2111 worker_threads: 0,
2112 memory_limit_mb: 0,
2113 },
2114 companies: vec![CompanyConfig {
2115 code: "TEST".to_string(),
2116 name: "Test Company".to_string(),
2117 currency: "USD".to_string(),
2118 country: "US".to_string(),
2119 fiscal_year_variant: "K4".to_string(),
2120 annual_transaction_volume: TransactionVolume::TenK,
2121 volume_weight: 1.0,
2122 }],
2123 chart_of_accounts: ChartOfAccountsConfig {
2124 complexity: CoAComplexity::Small,
2125 industry_specific: true,
2126 custom_accounts: None,
2127 min_hierarchy_depth: 2,
2128 max_hierarchy_depth: 5,
2129 },
2130 transactions: TransactionConfig::default(),
2131 output: OutputConfig::default(),
2132 fraud: FraudConfig::default(),
2133 internal_controls: InternalControlsConfig::default(),
2134 business_processes: BusinessProcessConfig::default(),
2135 user_personas: UserPersonaConfig::default(),
2136 templates: TemplateConfig::default(),
2137 approval: ApprovalConfig::default(),
2138 departments: DepartmentConfig::default(),
2139 master_data: MasterDataConfig::default(),
2140 document_flows: DocumentFlowConfig::default(),
2141 intercompany: IntercompanyConfig::default(),
2142 balance: BalanceConfig::default(),
2143 ocpm: OcpmConfig::default(),
2144 audit: AuditGenerationConfig::default(),
2145 banking: datasynth_banking::BankingConfig::default(),
2146 data_quality: DataQualitySchemaConfig::default(),
2147 scenario: ScenarioConfig::default(),
2148 temporal: TemporalDriftConfig::default(),
2149 graph_export: GraphExportConfig::default(),
2150 streaming: StreamingSchemaConfig::default(),
2151 rate_limit: RateLimitSchemaConfig::default(),
2152 temporal_attributes: TemporalAttributeSchemaConfig::default(),
2153 relationships: RelationshipSchemaConfig::default(),
2154 accounting_standards: AccountingStandardsConfig::default(),
2155 audit_standards: AuditStandardsConfig::default(),
2156 distributions: AdvancedDistributionConfig::default(),
2157 temporal_patterns: TemporalPatternsConfig::default(),
2158 vendor_network: VendorNetworkSchemaConfig::default(),
2159 customer_segmentation: CustomerSegmentationSchemaConfig::default(),
2160 relationship_strength: RelationshipStrengthSchemaConfig::default(),
2161 cross_process_links: CrossProcessLinksSchemaConfig::default(),
2162 organizational_events: OrganizationalEventsSchemaConfig::default(),
2163 behavioral_drift: BehavioralDriftSchemaConfig::default(),
2164 market_drift: MarketDriftSchemaConfig::default(),
2165 drift_labeling: DriftLabelingSchemaConfig::default(),
2166 anomaly_injection: EnhancedAnomalyConfig::default(),
2167 industry_specific: IndustrySpecificConfig::default(),
2168 }
2169 }
2170
2171 #[test]
2176 fn test_valid_period_months() {
2177 let config = minimal_valid_config();
2178 assert!(validate_config(&config).is_ok());
2179 }
2180
2181 #[test]
2182 fn test_zero_period_months_rejected() {
2183 let mut config = minimal_valid_config();
2184 config.global.period_months = 0;
2185 let result = validate_config(&config);
2186 assert!(result.is_err());
2187 assert!(result.unwrap_err().to_string().contains("period_months"));
2188 }
2189
2190 #[test]
2191 fn test_large_period_months_accepted() {
2192 let mut config = minimal_valid_config();
2193 config.global.period_months = 120; assert!(validate_config(&config).is_ok());
2195 }
2196
2197 #[test]
2198 fn test_period_months_exceeds_max_rejected() {
2199 let mut config = minimal_valid_config();
2200 config.global.period_months = 121; let result = validate_config(&config);
2202 assert!(result.is_err());
2203 assert!(result.unwrap_err().to_string().contains("period_months"));
2204 }
2205
2206 #[test]
2211 fn test_empty_companies_rejected() {
2212 let mut config = minimal_valid_config();
2213 config.companies.clear();
2214 let result = validate_config(&config);
2215 assert!(result.is_err());
2216 assert!(result.unwrap_err().to_string().contains("company"));
2217 }
2218
2219 #[test]
2220 fn test_empty_company_code_rejected() {
2221 let mut config = minimal_valid_config();
2222 config.companies[0].code = "".to_string();
2223 let result = validate_config(&config);
2224 assert!(result.is_err());
2225 assert!(result.unwrap_err().to_string().contains("Company code"));
2226 }
2227
2228 #[test]
2229 fn test_invalid_currency_code_rejected() {
2230 let mut config = minimal_valid_config();
2231 config.companies[0].currency = "US".to_string(); let result = validate_config(&config);
2233 assert!(result.is_err());
2234 assert!(result.unwrap_err().to_string().contains("currency"));
2235 }
2236
2237 #[test]
2238 fn test_long_currency_code_rejected() {
2239 let mut config = minimal_valid_config();
2240 config.companies[0].currency = "USDD".to_string(); let result = validate_config(&config);
2242 assert!(result.is_err());
2243 assert!(result.unwrap_err().to_string().contains("currency"));
2244 }
2245
2246 #[test]
2247 fn test_multiple_companies_validated() {
2248 let mut config = minimal_valid_config();
2249 config.companies.push(CompanyConfig {
2250 code: "SUB1".to_string(),
2251 name: "Subsidiary 1".to_string(),
2252 currency: "EUR".to_string(),
2253 country: "DE".to_string(),
2254 fiscal_year_variant: "K4".to_string(),
2255 annual_transaction_volume: TransactionVolume::TenK,
2256 volume_weight: 0.5,
2257 });
2258 assert!(validate_config(&config).is_ok());
2259 }
2260
2261 #[test]
2262 fn test_second_company_invalid_currency_rejected() {
2263 let mut config = minimal_valid_config();
2264 config.companies.push(CompanyConfig {
2265 code: "SUB1".to_string(),
2266 name: "Subsidiary 1".to_string(),
2267 currency: "EU".to_string(), country: "DE".to_string(),
2269 fiscal_year_variant: "K4".to_string(),
2270 annual_transaction_volume: TransactionVolume::TenK,
2271 volume_weight: 0.5,
2272 });
2273 let result = validate_config(&config);
2274 assert!(result.is_err());
2275 }
2276
2277 #[test]
2282 fn test_valid_source_distribution() {
2283 let config = minimal_valid_config();
2284 assert!(validate_config(&config).is_ok());
2286 }
2287
2288 #[test]
2289 fn test_source_distribution_not_summing_to_one_rejected() {
2290 let mut config = minimal_valid_config();
2291 config.transactions.source_distribution = SourceDistribution {
2292 manual: 0.5,
2293 automated: 0.5,
2294 recurring: 0.5, adjustment: 0.1,
2296 };
2297 let result = validate_config(&config);
2298 assert!(result.is_err());
2299 assert!(result
2300 .unwrap_err()
2301 .to_string()
2302 .contains("Source distribution"));
2303 }
2304
2305 #[test]
2306 fn test_source_distribution_slightly_off_accepted() {
2307 let mut config = minimal_valid_config();
2308 config.transactions.source_distribution = SourceDistribution {
2310 manual: 0.20,
2311 automated: 0.70,
2312 recurring: 0.07,
2313 adjustment: 0.025, };
2315 assert!(validate_config(&config).is_ok());
2316 }
2317
2318 #[test]
2323 fn test_valid_business_process_weights() {
2324 let config = minimal_valid_config();
2325 assert!(validate_config(&config).is_ok());
2327 }
2328
2329 #[test]
2330 fn test_business_process_weights_not_summing_to_one_rejected() {
2331 let mut config = minimal_valid_config();
2332 config.business_processes = BusinessProcessConfig {
2333 o2c_weight: 0.5,
2334 p2p_weight: 0.5,
2335 r2r_weight: 0.5, h2r_weight: 0.1,
2337 a2r_weight: 0.1,
2338 };
2339 let result = validate_config(&config);
2340 assert!(result.is_err());
2341 assert!(result
2342 .unwrap_err()
2343 .to_string()
2344 .contains("Business process weights"));
2345 }
2346
2347 #[test]
2352 fn test_fraud_disabled_invalid_rate_accepted() {
2353 let mut config = minimal_valid_config();
2354 config.fraud.enabled = false;
2355 config.fraud.fraud_rate = 5.0; assert!(validate_config(&config).is_ok());
2357 }
2358
2359 #[test]
2360 fn test_fraud_enabled_valid_rate_accepted() {
2361 let mut config = minimal_valid_config();
2362 config.fraud.enabled = true;
2363 config.fraud.fraud_rate = 0.05;
2364 assert!(validate_config(&config).is_ok());
2365 }
2366
2367 #[test]
2368 fn test_fraud_enabled_negative_rate_rejected() {
2369 let mut config = minimal_valid_config();
2370 config.fraud.enabled = true;
2371 config.fraud.fraud_rate = -0.1;
2372 let result = validate_config(&config);
2373 assert!(result.is_err());
2374 assert!(result.unwrap_err().to_string().contains("fraud_rate"));
2375 }
2376
2377 #[test]
2378 fn test_fraud_enabled_rate_above_one_rejected() {
2379 let mut config = minimal_valid_config();
2380 config.fraud.enabled = true;
2381 config.fraud.fraud_rate = 1.5;
2382 let result = validate_config(&config);
2383 assert!(result.is_err());
2384 assert!(result.unwrap_err().to_string().contains("fraud_rate"));
2385 }
2386
2387 #[test]
2388 fn test_fraud_rate_zero_accepted() {
2389 let mut config = minimal_valid_config();
2390 config.fraud.enabled = true;
2391 config.fraud.fraud_rate = 0.0;
2392 assert!(validate_config(&config).is_ok());
2393 }
2394
2395 #[test]
2396 fn test_fraud_rate_one_accepted() {
2397 let mut config = minimal_valid_config();
2398 config.fraud.enabled = true;
2399 config.fraud.fraud_rate = 1.0;
2400 assert!(validate_config(&config).is_ok());
2401 }
2402
2403 #[test]
2408 fn test_demo_preset_valid() {
2409 let config = demo_preset();
2410 assert!(validate_config(&config).is_ok());
2411 }
2412
2413 #[test]
2414 fn test_stress_test_preset_valid() {
2415 let config = stress_test_preset();
2416 assert!(validate_config(&config).is_ok());
2417 }
2418
2419 #[test]
2420 fn test_manufacturing_preset_valid() {
2421 let config = create_preset(
2422 IndustrySector::Manufacturing,
2423 2,
2424 12,
2425 CoAComplexity::Medium,
2426 TransactionVolume::HundredK,
2427 );
2428 assert!(validate_config(&config).is_ok());
2429 }
2430
2431 #[test]
2432 fn test_retail_preset_valid() {
2433 let config = create_preset(
2434 IndustrySector::Retail,
2435 3,
2436 6,
2437 CoAComplexity::Large,
2438 TransactionVolume::OneM,
2439 );
2440 assert!(validate_config(&config).is_ok());
2441 }
2442
2443 #[test]
2444 fn test_financial_services_preset_valid() {
2445 let config = create_preset(
2446 IndustrySector::FinancialServices,
2447 2,
2448 12,
2449 CoAComplexity::Large,
2450 TransactionVolume::TenM,
2451 );
2452 assert!(validate_config(&config).is_ok());
2453 }
2454
2455 #[test]
2456 fn test_healthcare_preset_valid() {
2457 let config = create_preset(
2458 IndustrySector::Healthcare,
2459 2,
2460 6,
2461 CoAComplexity::Medium,
2462 TransactionVolume::HundredK,
2463 );
2464 assert!(validate_config(&config).is_ok());
2465 }
2466
2467 #[test]
2468 fn test_technology_preset_valid() {
2469 let config = create_preset(
2470 IndustrySector::Technology,
2471 3,
2472 12,
2473 CoAComplexity::Medium,
2474 TransactionVolume::HundredK,
2475 );
2476 assert!(validate_config(&config).is_ok());
2477 }
2478
2479 #[test]
2484 fn test_transaction_volume_counts() {
2485 assert_eq!(TransactionVolume::TenK.count(), 10_000);
2486 assert_eq!(TransactionVolume::HundredK.count(), 100_000);
2487 assert_eq!(TransactionVolume::OneM.count(), 1_000_000);
2488 assert_eq!(TransactionVolume::TenM.count(), 10_000_000);
2489 assert_eq!(TransactionVolume::HundredM.count(), 100_000_000);
2490 assert_eq!(TransactionVolume::Custom(50_000).count(), 50_000);
2491 }
2492
2493 #[test]
2498 fn test_source_distribution_default_sums_to_one() {
2499 let dist = SourceDistribution::default();
2500 let sum = dist.manual + dist.automated + dist.recurring + dist.adjustment;
2501 assert!((sum - 1.0).abs() < 0.001);
2502 }
2503
2504 #[test]
2505 fn test_business_process_default_sums_to_one() {
2506 let bp = BusinessProcessConfig::default();
2507 let sum = bp.o2c_weight + bp.p2p_weight + bp.r2r_weight + bp.h2r_weight + bp.a2r_weight;
2508 assert!((sum - 1.0).abs() < 0.001);
2509 }
2510
2511 #[test]
2512 fn test_fraud_type_distribution_default_sums_to_one() {
2513 let dist = FraudTypeDistribution::default();
2514 let sum = dist.suspense_account_abuse
2515 + dist.fictitious_transaction
2516 + dist.revenue_manipulation
2517 + dist.expense_capitalization
2518 + dist.split_transaction
2519 + dist.timing_anomaly
2520 + dist.unauthorized_access
2521 + dist.duplicate_payment;
2522 assert!((sum - 1.0).abs() < 0.001);
2523 }
2524
2525 #[test]
2526 fn test_persona_distribution_default_sums_to_one() {
2527 let dist = PersonaDistribution::default();
2528 let sum = dist.junior_accountant
2529 + dist.senior_accountant
2530 + dist.controller
2531 + dist.manager
2532 + dist.automated_system;
2533 assert!((sum - 1.0).abs() < 0.001);
2534 }
2535
2536 #[test]
2537 fn test_payment_terms_distribution_default_sums_to_one() {
2538 let dist = PaymentTermsDistribution::default();
2539 let sum = dist.net_30
2540 + dist.net_60
2541 + dist.net_90
2542 + dist.two_ten_net_30
2543 + dist.due_on_receipt
2544 + dist.end_of_month;
2545 assert!((sum - 1.0).abs() < 0.001);
2546 }
2547
2548 #[test]
2549 fn test_vendor_behavior_distribution_default_sums_to_one() {
2550 let dist = VendorBehaviorDistribution::default();
2551 let sum = dist.reliable
2552 + dist.sometimes_late
2553 + dist.inconsistent_quality
2554 + dist.premium
2555 + dist.budget;
2556 assert!((sum - 1.0).abs() < 0.001);
2557 }
2558
2559 #[test]
2560 fn test_credit_rating_distribution_default_sums_to_one() {
2561 let dist = CreditRatingDistribution::default();
2562 let sum = dist.aaa + dist.aa + dist.a + dist.bbb + dist.bb + dist.b + dist.below_b;
2563 assert!((sum - 1.0).abs() < 0.001);
2564 }
2565
2566 #[test]
2567 fn test_payment_behavior_distribution_default_sums_to_one() {
2568 let dist = PaymentBehaviorDistribution::default();
2569 let sum = dist.early_payer
2570 + dist.on_time
2571 + dist.occasional_late
2572 + dist.frequent_late
2573 + dist.discount_taker;
2574 assert!((sum - 1.0).abs() < 0.001);
2575 }
2576
2577 #[test]
2578 fn test_material_type_distribution_default_sums_to_one() {
2579 let dist = MaterialTypeDistribution::default();
2580 let sum = dist.raw_material
2581 + dist.semi_finished
2582 + dist.finished_good
2583 + dist.trading_good
2584 + dist.operating_supply
2585 + dist.service;
2586 assert!((sum - 1.0).abs() < 0.001);
2587 }
2588
2589 #[test]
2590 fn test_valuation_method_distribution_default_sums_to_one() {
2591 let dist = ValuationMethodDistribution::default();
2592 let sum = dist.standard_cost + dist.moving_average + dist.fifo + dist.lifo;
2593 assert!((sum - 1.0).abs() < 0.001);
2594 }
2595
2596 #[test]
2597 fn test_asset_class_distribution_default_sums_to_one() {
2598 let dist = AssetClassDistribution::default();
2599 let sum = dist.buildings
2600 + dist.machinery
2601 + dist.vehicles
2602 + dist.it_equipment
2603 + dist.furniture
2604 + dist.land
2605 + dist.leasehold;
2606 assert!((sum - 1.0).abs() < 0.001);
2607 }
2608
2609 #[test]
2610 fn test_depreciation_method_distribution_default_sums_to_one() {
2611 let dist = DepreciationMethodDistribution::default();
2612 let sum = dist.straight_line
2613 + dist.declining_balance
2614 + dist.double_declining
2615 + dist.sum_of_years
2616 + dist.units_of_production;
2617 assert!((sum - 1.0).abs() < 0.001);
2618 }
2619
2620 #[test]
2621 fn test_employee_department_distribution_default_sums_to_one() {
2622 let dist = EmployeeDepartmentDistribution::default();
2623 let sum = dist.finance
2624 + dist.procurement
2625 + dist.sales
2626 + dist.warehouse
2627 + dist.it
2628 + dist.hr
2629 + dist.operations
2630 + dist.executive;
2631 assert!((sum - 1.0).abs() < 0.001);
2632 }
2633
2634 #[test]
2635 fn test_ic_transaction_type_distribution_default_sums_to_one() {
2636 let dist = ICTransactionTypeDistribution::default();
2637 let sum = dist.goods_sale
2638 + dist.service_provided
2639 + dist.loan
2640 + dist.dividend
2641 + dist.management_fee
2642 + dist.royalty
2643 + dist.cost_sharing;
2644 assert!((sum - 1.0).abs() < 0.001);
2645 }
2646
2647 #[test]
2652 fn test_compression_level_valid() {
2653 let mut config = minimal_valid_config();
2654 config.output.compression.enabled = true;
2655 config.output.compression.level = 5;
2656 assert!(validate_config(&config).is_ok());
2657 }
2658
2659 #[test]
2660 fn test_compression_level_zero_rejected() {
2661 let mut config = minimal_valid_config();
2662 config.output.compression.enabled = true;
2663 config.output.compression.level = 0;
2664 let result = validate_config(&config);
2665 assert!(result.is_err());
2666 assert!(result
2667 .unwrap_err()
2668 .to_string()
2669 .contains("compression.level"));
2670 }
2671
2672 #[test]
2673 fn test_compression_level_ten_rejected() {
2674 let mut config = minimal_valid_config();
2675 config.output.compression.enabled = true;
2676 config.output.compression.level = 10;
2677 let result = validate_config(&config);
2678 assert!(result.is_err());
2679 assert!(result
2680 .unwrap_err()
2681 .to_string()
2682 .contains("compression.level"));
2683 }
2684
2685 #[test]
2686 fn test_compression_disabled_ignores_level() {
2687 let mut config = minimal_valid_config();
2688 config.output.compression.enabled = false;
2689 config.output.compression.level = 0; assert!(validate_config(&config).is_ok());
2691 }
2692
2693 #[test]
2698 fn test_approval_thresholds_ascending_accepted() {
2699 let mut config = minimal_valid_config();
2700 config.approval.enabled = true;
2701 config.approval.thresholds = vec![
2702 ApprovalThresholdConfig {
2703 amount: 1000.0,
2704 level: 1,
2705 roles: vec!["accountant".to_string()],
2706 },
2707 ApprovalThresholdConfig {
2708 amount: 5000.0,
2709 level: 2,
2710 roles: vec!["manager".to_string()],
2711 },
2712 ApprovalThresholdConfig {
2713 amount: 10000.0,
2714 level: 3,
2715 roles: vec!["director".to_string()],
2716 },
2717 ];
2718 assert!(validate_config(&config).is_ok());
2719 }
2720
2721 #[test]
2722 fn test_approval_thresholds_not_ascending_rejected() {
2723 let mut config = minimal_valid_config();
2724 config.approval.enabled = true;
2725 config.approval.thresholds = vec![
2726 ApprovalThresholdConfig {
2727 amount: 5000.0,
2728 level: 1,
2729 roles: vec!["accountant".to_string()],
2730 },
2731 ApprovalThresholdConfig {
2732 amount: 1000.0, level: 2,
2734 roles: vec!["manager".to_string()],
2735 },
2736 ];
2737 let result = validate_config(&config);
2738 assert!(result.is_err());
2739 assert!(result.unwrap_err().to_string().contains("ascending"));
2740 }
2741
2742 #[test]
2743 fn test_fraud_approval_thresholds_ascending_accepted() {
2744 let mut config = minimal_valid_config();
2745 config.fraud.enabled = true;
2746 config.fraud.fraud_rate = 0.05;
2747 config.fraud.approval_thresholds = vec![1000.0, 5000.0, 10000.0];
2748 assert!(validate_config(&config).is_ok());
2749 }
2750
2751 #[test]
2752 fn test_fraud_approval_thresholds_not_ascending_rejected() {
2753 let mut config = minimal_valid_config();
2754 config.fraud.enabled = true;
2755 config.fraud.fraud_rate = 0.05;
2756 config.fraud.approval_thresholds = vec![5000.0, 1000.0, 10000.0]; let result = validate_config(&config);
2758 assert!(result.is_err());
2759 assert!(result.unwrap_err().to_string().contains("ascending"));
2760 }
2761
2762 #[test]
2767 fn test_internal_controls_rates_valid() {
2768 let mut config = minimal_valid_config();
2769 config.internal_controls.enabled = true;
2770 config.internal_controls.exception_rate = 0.05;
2771 config.internal_controls.sod_violation_rate = 0.02;
2772 assert!(validate_config(&config).is_ok());
2773 }
2774
2775 #[test]
2776 fn test_internal_controls_exception_rate_invalid() {
2777 let mut config = minimal_valid_config();
2778 config.internal_controls.enabled = true;
2779 config.internal_controls.exception_rate = 1.5; let result = validate_config(&config);
2781 assert!(result.is_err());
2782 assert!(result.unwrap_err().to_string().contains("exception_rate"));
2783 }
2784
2785 #[test]
2786 fn test_approval_rejection_plus_revision_exceeds_one_rejected() {
2787 let mut config = minimal_valid_config();
2788 config.approval.enabled = true;
2789 config.approval.rejection_rate = 0.6;
2790 config.approval.revision_rate = 0.6; let result = validate_config(&config);
2792 assert!(result.is_err());
2793 assert!(result
2794 .unwrap_err()
2795 .to_string()
2796 .contains("must not exceed 1.0"));
2797 }
2798
2799 #[test]
2800 fn test_master_data_intercompany_percent_invalid() {
2801 let mut config = minimal_valid_config();
2802 config.master_data.vendors.intercompany_percent = 1.5; let result = validate_config(&config);
2804 assert!(result.is_err());
2805 assert!(result
2806 .unwrap_err()
2807 .to_string()
2808 .contains("intercompany_percent"));
2809 }
2810
2811 #[test]
2812 fn test_balance_gross_margin_invalid() {
2813 let mut config = minimal_valid_config();
2814 config.balance.target_gross_margin = 1.5; let result = validate_config(&config);
2816 assert!(result.is_err());
2817 assert!(result
2818 .unwrap_err()
2819 .to_string()
2820 .contains("target_gross_margin"));
2821 }
2822
2823 #[test]
2824 fn test_negative_volume_weight_rejected() {
2825 let mut config = minimal_valid_config();
2826 config.companies[0].volume_weight = -0.5;
2827 let result = validate_config(&config);
2828 assert!(result.is_err());
2829 assert!(result.unwrap_err().to_string().contains("volume_weight"));
2830 }
2831
2832 #[test]
2833 fn test_benford_tolerance_invalid() {
2834 let mut config = minimal_valid_config();
2835 config.transactions.benford.tolerance = 1.5; let result = validate_config(&config);
2837 assert!(result.is_err());
2838 assert!(result
2839 .unwrap_err()
2840 .to_string()
2841 .contains("benford.tolerance"));
2842 }
2843
2844 #[test]
2845 fn test_batch_size_zero_rejected() {
2846 let mut config = minimal_valid_config();
2847 config.output.batch_size = 0;
2848 let result = validate_config(&config);
2849 assert!(result.is_err());
2850 assert!(result.unwrap_err().to_string().contains("batch_size"));
2851 }
2852
2853 #[test]
2858 fn test_temporal_patterns_disabled_passes() {
2859 let mut config = minimal_valid_config();
2860 config.temporal_patterns.enabled = false;
2861 assert!(validate_config(&config).is_ok());
2862 }
2863
2864 #[test]
2865 fn test_temporal_patterns_enabled_with_defaults_passes() {
2866 let mut config = minimal_valid_config();
2867 config.temporal_patterns.enabled = true;
2868 assert!(validate_config(&config).is_ok());
2869 }
2870
2871 #[test]
2872 fn test_business_day_invalid_half_day_policy() {
2873 let mut config = minimal_valid_config();
2874 config.temporal_patterns.enabled = true;
2875 config.temporal_patterns.business_days.enabled = true;
2876 config.temporal_patterns.business_days.half_day_policy = "invalid_policy".to_string();
2877 let result = validate_config(&config);
2878 assert!(result.is_err());
2879 assert!(result.unwrap_err().to_string().contains("half_day_policy"));
2880 }
2881
2882 #[test]
2883 fn test_business_day_valid_half_day_policies() {
2884 let mut config = minimal_valid_config();
2885 config.temporal_patterns.enabled = true;
2886 config.temporal_patterns.business_days.enabled = true;
2887
2888 for policy in ["full_day", "half_day", "non_business_day"] {
2889 config.temporal_patterns.business_days.half_day_policy = policy.to_string();
2890 assert!(
2891 validate_config(&config).is_ok(),
2892 "Expected '{}' to be valid",
2893 policy
2894 );
2895 }
2896 }
2897
2898 #[test]
2899 fn test_business_day_invalid_month_end_convention() {
2900 let mut config = minimal_valid_config();
2901 config.temporal_patterns.enabled = true;
2902 config.temporal_patterns.business_days.enabled = true;
2903 config.temporal_patterns.business_days.month_end_convention = "invalid".to_string();
2904 let result = validate_config(&config);
2905 assert!(result.is_err());
2906 assert!(result
2907 .unwrap_err()
2908 .to_string()
2909 .contains("month_end_convention"));
2910 }
2911
2912 #[test]
2913 fn test_period_end_invalid_model() {
2914 let mut config = minimal_valid_config();
2915 config.temporal_patterns.enabled = true;
2916 config.temporal_patterns.period_end.model = Some("invalid_model".to_string());
2917 let result = validate_config(&config);
2918 assert!(result.is_err());
2919 assert!(result.unwrap_err().to_string().contains("model"));
2920 }
2921
2922 #[test]
2923 fn test_period_end_valid_models() {
2924 let mut config = minimal_valid_config();
2925 config.temporal_patterns.enabled = true;
2926
2927 for model in ["flat", "exponential", "daily_profile", "extended_crunch"] {
2928 config.temporal_patterns.period_end.model = Some(model.to_string());
2929 assert!(
2930 validate_config(&config).is_ok(),
2931 "Expected model '{}' to be valid",
2932 model
2933 );
2934 }
2935 }
2936
2937 #[test]
2938 fn test_period_end_invalid_decay_rate() {
2939 let mut config = minimal_valid_config();
2940 config.temporal_patterns.enabled = true;
2941 config.temporal_patterns.period_end.model = Some("exponential".to_string());
2942 config.temporal_patterns.period_end.month_end = Some(PeriodEndModelSchemaConfig {
2943 inherit_from: None,
2944 additional_multiplier: None,
2945 start_day: Some(-10),
2946 base_multiplier: Some(1.0),
2947 peak_multiplier: Some(3.5),
2948 decay_rate: Some(1.5), sustained_high_days: None,
2950 });
2951 let result = validate_config(&config);
2952 assert!(result.is_err());
2953 assert!(result.unwrap_err().to_string().contains("decay_rate"));
2954 }
2955
2956 #[test]
2957 fn test_period_end_negative_multiplier() {
2958 let mut config = minimal_valid_config();
2959 config.temporal_patterns.enabled = true;
2960 config.temporal_patterns.period_end.month_end = Some(PeriodEndModelSchemaConfig {
2961 inherit_from: None,
2962 additional_multiplier: None,
2963 start_day: Some(-10),
2964 base_multiplier: Some(1.0),
2965 peak_multiplier: Some(-1.0), decay_rate: Some(0.3),
2967 sustained_high_days: None,
2968 });
2969 let result = validate_config(&config);
2970 assert!(result.is_err());
2971 assert!(result.unwrap_err().to_string().contains("multiplier"));
2972 }
2973
2974 #[test]
2975 fn test_processing_lag_negative_mu_allowed() {
2976 let mut config = minimal_valid_config();
2979 config.temporal_patterns.enabled = true;
2980 config.temporal_patterns.processing_lags.enabled = true;
2981 config.temporal_patterns.processing_lags.sales_order_lag =
2982 Some(LagDistributionSchemaConfig {
2983 mu: -1.0, sigma: 0.8,
2985 min_hours: None,
2986 max_hours: None,
2987 });
2988 assert!(validate_config(&config).is_ok());
2989 }
2990
2991 #[test]
2992 fn test_processing_lag_negative_sigma() {
2993 let mut config = minimal_valid_config();
2994 config.temporal_patterns.enabled = true;
2995 config.temporal_patterns.processing_lags.enabled = true;
2996 config.temporal_patterns.processing_lags.goods_receipt_lag =
2997 Some(LagDistributionSchemaConfig {
2998 mu: 1.5,
2999 sigma: -0.5, min_hours: None,
3001 max_hours: None,
3002 });
3003 let result = validate_config(&config);
3004 assert!(result.is_err());
3005 assert!(result.unwrap_err().to_string().contains("sigma"));
3006 }
3007
3008 #[test]
3009 fn test_fiscal_calendar_invalid_year_start_month() {
3010 let mut config = minimal_valid_config();
3011 config.temporal_patterns.enabled = true;
3012 config.temporal_patterns.fiscal_calendar.enabled = true;
3013 config.temporal_patterns.fiscal_calendar.calendar_type = "custom".to_string();
3014 config.temporal_patterns.fiscal_calendar.year_start_month = Some(13); let result = validate_config(&config);
3016 assert!(result.is_err());
3017 assert!(result.unwrap_err().to_string().contains("year_start_month"));
3018 }
3019
3020 #[test]
3021 fn test_fiscal_calendar_invalid_year_start_day() {
3022 let mut config = minimal_valid_config();
3023 config.temporal_patterns.enabled = true;
3024 config.temporal_patterns.fiscal_calendar.enabled = true;
3025 config.temporal_patterns.fiscal_calendar.calendar_type = "custom".to_string();
3026 config.temporal_patterns.fiscal_calendar.year_start_month = Some(2);
3027 config.temporal_patterns.fiscal_calendar.year_start_day = Some(32); let result = validate_config(&config);
3029 assert!(result.is_err());
3030 assert!(result.unwrap_err().to_string().contains("year_start_day"));
3031 }
3032
3033 #[test]
3034 fn test_intraday_invalid_time_format() {
3035 let mut config = minimal_valid_config();
3036 config.temporal_patterns.enabled = true;
3037 config.temporal_patterns.intraday.enabled = true;
3038 config.temporal_patterns.intraday.segments = vec![IntraDaySegmentSchemaConfig {
3039 name: "test".to_string(),
3040 start: "25:00".to_string(), end: "10:00".to_string(),
3042 multiplier: 1.5,
3043 posting_type: "both".to_string(),
3044 }];
3045 let result = validate_config(&config);
3046 assert!(result.is_err());
3047 assert!(result.unwrap_err().to_string().contains("HH:MM format"));
3048 }
3049
3050 #[test]
3051 fn test_intraday_invalid_posting_type() {
3052 let mut config = minimal_valid_config();
3053 config.temporal_patterns.enabled = true;
3054 config.temporal_patterns.intraday.enabled = true;
3055 config.temporal_patterns.intraday.segments = vec![IntraDaySegmentSchemaConfig {
3056 name: "test".to_string(),
3057 start: "08:00".to_string(),
3058 end: "10:00".to_string(),
3059 multiplier: 1.5,
3060 posting_type: "invalid".to_string(),
3061 }];
3062 let result = validate_config(&config);
3063 assert!(result.is_err());
3064 assert!(result.unwrap_err().to_string().contains("posting_type"));
3065 }
3066
3067 #[test]
3068 fn test_intraday_negative_multiplier() {
3069 let mut config = minimal_valid_config();
3070 config.temporal_patterns.enabled = true;
3071 config.temporal_patterns.intraday.enabled = true;
3072 config.temporal_patterns.intraday.segments = vec![IntraDaySegmentSchemaConfig {
3073 name: "test".to_string(),
3074 start: "08:00".to_string(),
3075 end: "10:00".to_string(),
3076 multiplier: -1.0, posting_type: "both".to_string(),
3078 }];
3079 let result = validate_config(&config);
3080 assert!(result.is_err());
3081 assert!(result.unwrap_err().to_string().contains("multiplier"));
3082 }
3083
3084 #[test]
3085 fn test_timezone_invalid_default() {
3086 let mut config = minimal_valid_config();
3087 config.temporal_patterns.enabled = true;
3088 config.temporal_patterns.timezones.enabled = true;
3089 config.temporal_patterns.timezones.default_timezone = "Invalid/Timezone".to_string();
3090 let result = validate_config(&config);
3091 assert!(result.is_err());
3092 assert!(result.unwrap_err().to_string().contains("timezone"));
3093 }
3094
3095 #[test]
3096 fn test_timezone_valid_iana_names() {
3097 let mut config = minimal_valid_config();
3098 config.temporal_patterns.enabled = true;
3099 config.temporal_patterns.timezones.enabled = true;
3100 config.temporal_patterns.timezones.consolidation_timezone = "UTC".to_string();
3101
3102 for tz in [
3103 "America/New_York",
3104 "Europe/London",
3105 "Asia/Tokyo",
3106 "UTC",
3107 "Pacific/Auckland",
3108 ] {
3109 config.temporal_patterns.timezones.default_timezone = tz.to_string();
3110 let result = validate_config(&config);
3111 assert!(
3112 result.is_ok(),
3113 "Expected timezone '{}' to be valid, got error: {:?}",
3114 tz,
3115 result.err()
3116 );
3117 }
3118 }
3119
3120 #[test]
3121 fn test_timezone_invalid_entity_mapping() {
3122 let mut config = minimal_valid_config();
3123 config.temporal_patterns.enabled = true;
3124 config.temporal_patterns.timezones.enabled = true;
3125 config.temporal_patterns.timezones.entity_mappings = vec![EntityTimezoneMapping {
3126 pattern: "EU_*".to_string(),
3127 timezone: "Invalid/TZ".to_string(),
3128 }];
3129 let result = validate_config(&config);
3130 assert!(result.is_err());
3131 assert!(result.unwrap_err().to_string().contains("timezone"));
3132 }
3133}