Skip to main content

datasynth_generators/
process_evolution_generator.rs

1//! Generator for process evolution events (automation, workflow changes, etc.).
2
3use chrono::NaiveDate;
4use datasynth_core::utils::seeded_rng;
5use rand::Rng;
6use rand_chacha::ChaCha8Rng;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9
10use datasynth_core::models::process_evolution::{
11    ApprovalWorkflowChangeConfig, ControlEnhancementConfig, PolicyCategory, PolicyChangeConfig,
12    ProcessAutomationConfig, ProcessEvolutionEvent, ProcessEvolutionType, RolloutCurve,
13    ThresholdChange, WorkflowType,
14};
15
16/// Configuration for the process evolution generator.
17///
18/// Controls the mix of event types and the average frequency.
19#[derive(Debug, Clone)]
20pub struct ProcEvoGeneratorConfig {
21    /// Weights: [workflow_change, automation, policy_change, control_enhancement]
22    pub type_weights: [f64; 4],
23    /// Average events per year.
24    pub events_per_year: f64,
25}
26
27impl Default for ProcEvoGeneratorConfig {
28    fn default() -> Self {
29        Self {
30            type_weights: [0.25, 0.30, 0.25, 0.20],
31            events_per_year: 4.0,
32        }
33    }
34}
35
36/// Generates [`ProcessEvolutionEvent`] instances for a given date range.
37///
38/// Each generated event includes a fully-populated type-specific configuration
39/// (approval workflow change, process automation, policy change, or control
40/// enhancement).
41pub struct ProcessEvolutionGenerator {
42    rng: ChaCha8Rng,
43    config: ProcEvoGeneratorConfig,
44    event_counter: usize,
45}
46
47/// Discriminator added to the seed so this generator's RNG stream does not
48/// overlap with other generators that may share the same base seed.
49const SEED_DISCRIMINATOR: u64 = 0xAE_0C;
50
51impl ProcessEvolutionGenerator {
52    /// Create a new generator with the given seed and default config.
53    pub fn new(seed: u64) -> Self {
54        Self {
55            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
56            config: ProcEvoGeneratorConfig::default(),
57            event_counter: 0,
58        }
59    }
60
61    /// Create a new generator with the given seed and custom config.
62    pub fn with_config(seed: u64, config: ProcEvoGeneratorConfig) -> Self {
63        Self {
64            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
65            config,
66            event_counter: 0,
67        }
68    }
69
70    /// Generate process evolution events within the given date range.
71    ///
72    /// Events are returned sorted by effective date.
73    pub fn generate_events(
74        &mut self,
75        start_date: NaiveDate,
76        end_date: NaiveDate,
77    ) -> Vec<ProcessEvolutionEvent> {
78        let total_days = (end_date - start_date).num_days().max(1) as f64;
79        let total_years = total_days / 365.25;
80        let expected_count = (self.config.events_per_year * total_years).round() as usize;
81        let count = expected_count.max(1);
82
83        let mut events = Vec::with_capacity(count);
84
85        for _ in 0..count {
86            self.event_counter += 1;
87            let days_offset = self.rng.random_range(0..total_days as i64);
88            let effective_date = start_date + chrono::Duration::days(days_offset);
89
90            let event = self.build_event(effective_date);
91            events.push(event);
92        }
93
94        events.sort_by_key(|e| e.effective_date);
95        events
96    }
97
98    /// Pick an event type variant index from the configured weights.
99    fn pick_event_type_index(&mut self) -> usize {
100        let weights = &self.config.type_weights;
101        let total: f64 = weights.iter().sum();
102        let mut r: f64 = self.rng.random_range(0.0..total);
103
104        for (i, &w) in weights.iter().enumerate() {
105            r -= w;
106            if r <= 0.0 {
107                return i;
108            }
109        }
110        0
111    }
112
113    /// Build a complete [`ProcessEvolutionEvent`] with a randomly chosen type
114    /// and populated configuration.
115    fn build_event(&mut self, effective_date: NaiveDate) -> ProcessEvolutionEvent {
116        let event_id = format!("PROC-EVT-{:06}", self.event_counter);
117        let type_idx = self.pick_event_type_index();
118
119        let event_type = match type_idx {
120            0 => self.build_workflow_change(),
121            1 => self.build_automation(),
122            2 => self.build_policy_change(),
123            _ => self.build_control_enhancement(),
124        };
125
126        let description = match &event_type {
127            ProcessEvolutionType::ApprovalWorkflowChange(c) => {
128                Some(format!("Workflow change from {:?} to {:?}", c.from, c.to))
129            }
130            ProcessEvolutionType::ProcessAutomation(c) => {
131                Some(format!("Automation of {} process", c.process_name))
132            }
133            ProcessEvolutionType::PolicyChange(c) => {
134                Some(format!("Policy change in {:?} category", c.category))
135            }
136            ProcessEvolutionType::ControlEnhancement(c) => {
137                Some(format!("Enhancement of control {}", c.control_id))
138            }
139        };
140
141        let tags = vec![format!("type:{}", event_type.type_name())];
142
143        ProcessEvolutionEvent {
144            event_id,
145            event_type,
146            effective_date,
147            description,
148            tags,
149        }
150    }
151
152    // ------------------------------------------------------------------
153    // Type-specific builders
154    // ------------------------------------------------------------------
155
156    fn build_workflow_change(&mut self) -> ProcessEvolutionType {
157        let all_types = [
158            WorkflowType::SingleApprover,
159            WorkflowType::DualApproval,
160            WorkflowType::MultiLevel,
161            WorkflowType::Automated,
162            WorkflowType::Matrix,
163            WorkflowType::Parallel,
164        ];
165
166        let from_idx = self.rng.random_range(0..all_types.len());
167        let mut to_idx = self.rng.random_range(0..all_types.len());
168        // Ensure from != to
169        while to_idx == from_idx {
170            to_idx = self.rng.random_range(0..all_types.len());
171        }
172
173        let from = all_types[from_idx];
174        let to = all_types[to_idx];
175        let time_delta = to.processing_time_multiplier() / from.processing_time_multiplier();
176        let error_rate_impact = self.rng.random_range(0.01..0.04);
177        let transition_months = self.rng.random_range(2..6_u32);
178
179        let threshold_changes = if self.rng.random_bool(0.4) {
180            let old_val = Decimal::from(self.rng.random_range(5000..10000_i64));
181            let new_val = Decimal::from(self.rng.random_range(10000..25000_i64));
182            vec![ThresholdChange {
183                category: "amount".to_string(),
184                old_threshold: old_val,
185                new_threshold: new_val,
186            }]
187        } else {
188            Vec::new()
189        };
190
191        ProcessEvolutionType::ApprovalWorkflowChange(ApprovalWorkflowChangeConfig {
192            from,
193            to,
194            time_delta,
195            error_rate_impact,
196            transition_months,
197            threshold_changes,
198        })
199    }
200
201    fn build_automation(&mut self) -> ProcessEvolutionType {
202        let process_names = [
203            "three_way_match",
204            "invoice_processing",
205            "expense_approval",
206            "bank_reconciliation",
207            "period_close_checklist",
208        ];
209        let idx = self.rng.random_range(0..process_names.len());
210        let process_name = process_names[idx].to_string();
211
212        let manual_rate_before = self.rng.random_range(0.60..0.90);
213        let manual_rate_after = self.rng.random_range(0.05..0.25);
214        let error_rate_before = self.rng.random_range(0.03..0.08);
215        let error_rate_after = self.rng.random_range(0.005..0.02);
216        let processing_time_reduction = self.rng.random_range(0.20..0.50);
217        let rollout_months = self.rng.random_range(3..12_u32);
218
219        let curves = [
220            RolloutCurve::Linear,
221            RolloutCurve::SCurve,
222            RolloutCurve::Exponential,
223            RolloutCurve::Step,
224        ];
225        let curve_idx = self.rng.random_range(0..curves.len());
226        let rollout_curve = curves[curve_idx];
227
228        ProcessEvolutionType::ProcessAutomation(ProcessAutomationConfig {
229            process_name,
230            manual_rate_before,
231            manual_rate_after,
232            error_rate_before,
233            error_rate_after,
234            processing_time_reduction,
235            rollout_months,
236            rollout_curve,
237            affected_transaction_types: Vec::new(),
238        })
239    }
240
241    fn build_policy_change(&mut self) -> ProcessEvolutionType {
242        let categories = [
243            PolicyCategory::ApprovalThreshold,
244            PolicyCategory::ExpensePolicy,
245            PolicyCategory::TravelPolicy,
246            PolicyCategory::ProcurementPolicy,
247            PolicyCategory::CreditPolicy,
248            PolicyCategory::InventoryPolicy,
249            PolicyCategory::DocumentationRequirement,
250            PolicyCategory::Other,
251        ];
252        let cat_idx = self.rng.random_range(0..categories.len());
253        let category = categories[cat_idx];
254
255        // Generate threshold values for threshold-type policies
256        let (old_value, new_value) = match category {
257            PolicyCategory::ApprovalThreshold
258            | PolicyCategory::ExpensePolicy
259            | PolicyCategory::CreditPolicy => {
260                let old = Decimal::from(self.rng.random_range(1000..10000_i64));
261                let new = Decimal::from(self.rng.random_range(5000..20000_i64));
262                (Some(old), Some(new))
263            }
264            PolicyCategory::InventoryPolicy | PolicyCategory::ProcurementPolicy => {
265                let old = Decimal::from(self.rng.random_range(100..500_i64));
266                let new = Decimal::from(self.rng.random_range(200..1000_i64));
267                (Some(old), Some(new))
268            }
269            _ => (None, None),
270        };
271
272        let transition_error_rate = self.rng.random_range(0.02..0.06);
273        let transition_months = self.rng.random_range(2..6_u32);
274
275        ProcessEvolutionType::PolicyChange(PolicyChangeConfig {
276            category,
277            description: Some(format!("Updated {} policy", category.code())),
278            old_value,
279            new_value,
280            transition_error_rate,
281            transition_months,
282            affected_controls: Vec::new(),
283        })
284    }
285
286    fn build_control_enhancement(&mut self) -> ProcessEvolutionType {
287        let control_id = format!("C-{:03}", self.event_counter);
288        let error_reduction = self.rng.random_range(0.01..0.05);
289        let processing_time_impact = self.rng.random_range(1.02..1.15);
290        let implementation_months = self.rng.random_range(1..4_u32);
291
292        let tolerance_change = if self.rng.random_bool(0.5) {
293            let old_tol = dec!(100) + Decimal::from(self.rng.random_range(0..400_i64));
294            let new_tol = dec!(50) + Decimal::from(self.rng.random_range(0..200_i64));
295            Some(datasynth_core::models::process_evolution::ToleranceChange {
296                old_tolerance: old_tol,
297                new_tolerance: new_tol,
298                tolerance_type: datasynth_core::models::process_evolution::ToleranceType::Absolute,
299            })
300        } else {
301            None
302        };
303
304        ProcessEvolutionType::ControlEnhancement(ControlEnhancementConfig {
305            control_id,
306            description: Some(format!(
307                "Enhanced control with {:.1}% error reduction",
308                error_reduction * 100.0
309            )),
310            tolerance_change,
311            error_reduction,
312            processing_time_impact,
313            implementation_months,
314            additional_evidence: Vec::new(),
315        })
316    }
317}
318
319#[cfg(test)]
320#[allow(clippy::unwrap_used)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_deterministic_generation() {
326        let mut gen1 = ProcessEvolutionGenerator::new(42);
327        let mut gen2 = ProcessEvolutionGenerator::new(42);
328        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
329        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
330
331        let events1 = gen1.generate_events(start, end);
332        let events2 = gen2.generate_events(start, end);
333
334        assert_eq!(events1.len(), events2.len());
335        for (e1, e2) in events1.iter().zip(events2.iter()) {
336            assert_eq!(e1.event_id, e2.event_id);
337            assert_eq!(e1.effective_date, e2.effective_date);
338            assert_eq!(e1.event_type.type_name(), e2.event_type.type_name());
339        }
340    }
341
342    #[test]
343    fn test_events_sorted_by_date() {
344        let mut gen = ProcessEvolutionGenerator::new(42);
345        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
346        let end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
347
348        let events = gen.generate_events(start, end);
349        for w in events.windows(2) {
350            assert!(w[0].effective_date <= w[1].effective_date);
351        }
352    }
353
354    #[test]
355    fn test_all_event_types_generated() {
356        let config = ProcEvoGeneratorConfig {
357            type_weights: [1.0, 1.0, 1.0, 1.0],
358            events_per_year: 100.0,
359        };
360        let mut gen = ProcessEvolutionGenerator::with_config(42, config);
361        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
362        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
363
364        let events = gen.generate_events(start, end);
365
366        let has_workflow = events.iter().any(|e| {
367            matches!(
368                e.event_type,
369                ProcessEvolutionType::ApprovalWorkflowChange(_)
370            )
371        });
372        let has_automation = events
373            .iter()
374            .any(|e| matches!(e.event_type, ProcessEvolutionType::ProcessAutomation(_)));
375        let has_policy = events
376            .iter()
377            .any(|e| matches!(e.event_type, ProcessEvolutionType::PolicyChange(_)));
378        let has_control = events
379            .iter()
380            .any(|e| matches!(e.event_type, ProcessEvolutionType::ControlEnhancement(_)));
381
382        assert!(has_workflow, "should generate workflow changes");
383        assert!(has_automation, "should generate automation events");
384        assert!(has_policy, "should generate policy changes");
385        assert!(has_control, "should generate control enhancements");
386    }
387
388    #[test]
389    fn test_events_within_date_range() {
390        let mut gen = ProcessEvolutionGenerator::new(42);
391        let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
392        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
393
394        let events = gen.generate_events(start, end);
395        for e in &events {
396            assert!(e.effective_date >= start, "event date before start");
397            assert!(e.effective_date <= end, "event date after end");
398        }
399    }
400
401    #[test]
402    fn test_workflow_change_valid_transitions() {
403        let config = ProcEvoGeneratorConfig {
404            type_weights: [1.0, 0.0, 0.0, 0.0],
405            events_per_year: 50.0,
406        };
407        let mut gen = ProcessEvolutionGenerator::with_config(42, config);
408        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
409        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
410
411        let events = gen.generate_events(start, end);
412        for e in &events {
413            if let ProcessEvolutionType::ApprovalWorkflowChange(ref c) = e.event_type {
414                assert_ne!(c.from, c.to, "from and to workflow types must differ");
415            } else {
416                panic!("expected only workflow change events");
417            }
418        }
419    }
420
421    #[test]
422    fn test_automation_config_populated() {
423        let config = ProcEvoGeneratorConfig {
424            type_weights: [0.0, 1.0, 0.0, 0.0],
425            events_per_year: 20.0,
426        };
427        let mut gen = ProcessEvolutionGenerator::with_config(42, config);
428        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
429        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
430
431        let events = gen.generate_events(start, end);
432        for e in &events {
433            if let ProcessEvolutionType::ProcessAutomation(ref c) = e.event_type {
434                assert!(
435                    !c.process_name.is_empty(),
436                    "process_name should not be empty"
437                );
438                assert!(
439                    c.manual_rate_before >= 0.60 && c.manual_rate_before <= 0.90,
440                    "manual_rate_before out of range: {}",
441                    c.manual_rate_before
442                );
443                assert!(
444                    c.manual_rate_after >= 0.05 && c.manual_rate_after <= 0.25,
445                    "manual_rate_after out of range: {}",
446                    c.manual_rate_after
447                );
448                assert!(
449                    c.manual_rate_before > c.manual_rate_after,
450                    "manual_rate_before should exceed manual_rate_after"
451                );
452            } else {
453                panic!("expected only automation events");
454            }
455        }
456    }
457
458    #[test]
459    fn test_s_curve_automation_progression() {
460        let config = ProcEvoGeneratorConfig {
461            type_weights: [0.0, 1.0, 0.0, 0.0],
462            events_per_year: 20.0,
463        };
464        let mut gen = ProcessEvolutionGenerator::with_config(42, config);
465        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
466        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
467
468        let events = gen.generate_events(start, end);
469
470        // Find an automation event with SCurve rollout
471        let s_curve_event = events.iter().find(|e| {
472            if let ProcessEvolutionType::ProcessAutomation(ref c) = e.event_type {
473                c.rollout_curve == RolloutCurve::SCurve
474            } else {
475                false
476            }
477        });
478
479        // With 20 events/year and only automation type, we should find at least one SCurve
480        // (SCurve is one of 4 curve types, so ~25% chance per event, 20 events => very likely)
481        if let Some(evt) = s_curve_event {
482            if let ProcessEvolutionType::ProcessAutomation(ref c) = evt.event_type {
483                let rate_0 = c.automation_rate_at_progress(0.0);
484                let rate_25 = c.automation_rate_at_progress(0.25);
485                let rate_50 = c.automation_rate_at_progress(0.5);
486                let rate_75 = c.automation_rate_at_progress(0.75);
487                let rate_100 = c.automation_rate_at_progress(1.0);
488
489                // S-curve: monotonically increasing
490                assert!(rate_0 <= rate_25, "rate should increase from 0% to 25%");
491                assert!(rate_25 <= rate_50, "rate should increase from 25% to 50%");
492                assert!(rate_50 <= rate_75, "rate should increase from 50% to 75%");
493                assert!(rate_75 <= rate_100, "rate should increase from 75% to 100%");
494
495                // S-curve should have faster growth in the middle than at the edges
496                let delta_first_quarter = rate_25 - rate_0;
497                let delta_second_quarter = rate_50 - rate_25;
498                let delta_last_quarter = rate_100 - rate_75;
499
500                assert!(
501                    delta_second_quarter > delta_first_quarter,
502                    "S-curve: middle growth ({}) should exceed early growth ({})",
503                    delta_second_quarter,
504                    delta_first_quarter
505                );
506                assert!(
507                    delta_second_quarter > delta_last_quarter,
508                    "S-curve: middle growth ({}) should exceed late growth ({})",
509                    delta_second_quarter,
510                    delta_last_quarter
511                );
512            }
513        } else {
514            // If no SCurve found (unlikely), test with a manual config
515            let manual_config = ProcessAutomationConfig {
516                manual_rate_before: 0.80,
517                manual_rate_after: 0.10,
518                rollout_curve: RolloutCurve::SCurve,
519                ..Default::default()
520            };
521            let rate_0 = manual_config.automation_rate_at_progress(0.0);
522            let rate_50 = manual_config.automation_rate_at_progress(0.5);
523            let rate_100 = manual_config.automation_rate_at_progress(1.0);
524            assert!(rate_0 < rate_50);
525            assert!(rate_50 < rate_100);
526        }
527    }
528}