Skip to main content

datasynth_generators/
organizational_event_generator.rs

1//! Generator for organizational events (acquisitions, divestitures, etc.).
2//!
3//! Produces realistic organizational events across a date range for a set of
4//! companies. Event types are drawn from a weighted distribution and each
5//! event is populated with sensible random configuration values while
6//! remaining fully deterministic given the same seed.
7
8use chrono::NaiveDate;
9use datasynth_core::utils::seeded_rng;
10use rand::Rng;
11use rand_chacha::ChaCha8Rng;
12use std::collections::HashMap;
13
14use datasynth_core::models::organizational_event::{
15    AcquisitionConfig, DateRange, DivestitureConfig, IntegrationPhaseConfig,
16    LeadershipChangeConfig, MergerConfig, OrganizationalEvent, OrganizationalEventType, PolicyArea,
17    PolicyChangeDetail, ReorganizationConfig, ReportingChange, WorkforceReductionConfig,
18};
19
20/// Probability distribution across event types.
21///
22/// The six weights correspond to:
23/// `[acquisition, divestiture, reorganization, leadership_change, workforce_reduction, merger]`
24#[derive(Debug, Clone)]
25pub struct OrgEventGeneratorConfig {
26    /// Probability distribution across event types.
27    pub type_weights: [f64; 6],
28    /// Average number of events per year.
29    pub events_per_year: f64,
30}
31
32impl Default for OrgEventGeneratorConfig {
33    fn default() -> Self {
34        Self {
35            type_weights: [0.15, 0.10, 0.25, 0.20, 0.15, 0.15],
36            events_per_year: 3.0,
37        }
38    }
39}
40
41/// Generates [`OrganizationalEvent`] instances for a given date range.
42///
43/// Each generated event includes a fully-populated type-specific configuration
44/// (acquisition, divestiture, reorganization, leadership change, workforce
45/// reduction, or merger).
46pub struct OrganizationalEventGenerator {
47    rng: ChaCha8Rng,
48    config: OrgEventGeneratorConfig,
49    event_counter: usize,
50}
51
52/// Discriminator added to the seed so this generator's RNG stream does not
53/// overlap with other generators that may share the same base seed.
54const SEED_DISCRIMINATOR: u64 = 0xAE_0B;
55
56impl OrganizationalEventGenerator {
57    /// Create a new generator with the given seed and default config.
58    pub fn new(seed: u64) -> Self {
59        Self {
60            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
61            config: OrgEventGeneratorConfig::default(),
62            event_counter: 0,
63        }
64    }
65
66    /// Create a new generator with the given seed and custom config.
67    pub fn with_config(seed: u64, config: OrgEventGeneratorConfig) -> Self {
68        Self {
69            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
70            config,
71            event_counter: 0,
72        }
73    }
74
75    /// Generate organizational events within the given date range for the
76    /// given company codes.
77    ///
78    /// Events are returned sorted by effective date.
79    pub fn generate_events(
80        &mut self,
81        start_date: NaiveDate,
82        end_date: NaiveDate,
83        company_codes: &[String],
84    ) -> Vec<OrganizationalEvent> {
85        let total_days = (end_date - start_date).num_days().max(1) as f64;
86        let total_years = total_days / 365.25;
87        let expected_count = (self.config.events_per_year * total_years).round() as usize;
88        let count = expected_count.max(1);
89
90        let mut events = Vec::with_capacity(count);
91
92        for _ in 0..count {
93            self.event_counter += 1;
94            let days_offset = self.rng.random_range(0..total_days as i64);
95            let effective_date = start_date + chrono::Duration::days(days_offset);
96
97            let company_code = if company_codes.is_empty() {
98                "C001".to_string()
99            } else {
100                let idx = self.rng.random_range(0..company_codes.len());
101                company_codes[idx].clone()
102            };
103
104            let event = self.build_event(effective_date, &company_code);
105            events.push(event);
106        }
107
108        events.sort_by_key(|e| e.effective_date);
109        events
110    }
111
112    /// Pick an event type variant index from the configured weights.
113    fn pick_event_type_index(&mut self) -> usize {
114        let weights = &self.config.type_weights;
115        let total: f64 = weights.iter().sum();
116        let mut r: f64 = self.rng.random_range(0.0..total);
117
118        for (i, &w) in weights.iter().enumerate() {
119            r -= w;
120            if r <= 0.0 {
121                return i;
122            }
123        }
124        0
125    }
126
127    /// Build a complete [`OrganizationalEvent`] with a randomly chosen type
128    /// and populated configuration.
129    fn build_event(
130        &mut self,
131        effective_date: NaiveDate,
132        company_code: &str,
133    ) -> OrganizationalEvent {
134        let event_id = format!("ORG-EVT-{:06}", self.event_counter);
135        let type_idx = self.pick_event_type_index();
136
137        let event_type = match type_idx {
138            0 => self.build_acquisition(effective_date, company_code),
139            1 => self.build_divestiture(effective_date, company_code),
140            2 => self.build_reorganization(effective_date),
141            3 => self.build_leadership_change(effective_date),
142            4 => self.build_workforce_reduction(effective_date),
143            _ => self.build_merger(effective_date, company_code),
144        };
145
146        let description = match &event_type {
147            OrganizationalEventType::Acquisition(c) => Some(format!(
148                "Acquisition of {} by {}",
149                c.acquired_entity_code, company_code
150            )),
151            OrganizationalEventType::Divestiture(c) => Some(format!(
152                "Divestiture of {} from {}",
153                c.divested_entity_code, company_code
154            )),
155            OrganizationalEventType::Reorganization(_) => {
156                Some(format!("Organizational restructuring at {}", company_code))
157            }
158            OrganizationalEventType::LeadershipChange(c) => {
159                Some(format!("{} transition at {}", c.role, company_code))
160            }
161            OrganizationalEventType::WorkforceReduction(c) => Some(format!(
162                "Workforce reduction ({:.0}%) at {}",
163                c.reduction_percent * 100.0,
164                company_code
165            )),
166            OrganizationalEventType::Merger(c) => Some(format!(
167                "Merger with {} for {}",
168                c.merged_entity_code, company_code
169            )),
170        };
171
172        let tags = vec![
173            format!("company:{}", company_code),
174            format!("type:{}", event_type.type_name()),
175        ];
176
177        OrganizationalEvent {
178            event_id,
179            event_type,
180            effective_date,
181            description,
182            tags,
183        }
184    }
185
186    // ------------------------------------------------------------------
187    // Type-specific builders
188    // ------------------------------------------------------------------
189
190    fn build_acquisition(
191        &mut self,
192        effective_date: NaiveDate,
193        company_code: &str,
194    ) -> OrganizationalEventType {
195        let seq = self.event_counter;
196        let entity_code = format!("ACQ-{}-{:04}", company_code, seq);
197        let volume_mult = self.rng.random_range(1.10..1.60);
198        let parallel_days = self.rng.random_range(15..60_u32);
199
200        let cutover = effective_date + chrono::Duration::days(parallel_days as i64);
201        let stabilization_end = cutover + chrono::Duration::days(self.rng.random_range(60..120));
202
203        OrganizationalEventType::Acquisition(AcquisitionConfig {
204            acquired_entity_code: entity_code.clone(),
205            acquired_entity_name: Some(format!("Acquired Entity {}", seq)),
206            acquisition_date: effective_date,
207            volume_multiplier: volume_mult,
208            integration_error_rate: self.rng.random_range(0.02..0.08),
209            parallel_posting_days: parallel_days,
210            coding_error_rate: self.rng.random_range(0.01..0.05),
211            integration_phases: IntegrationPhaseConfig {
212                parallel_run: Some(DateRange {
213                    start: effective_date,
214                    end: cutover - chrono::Duration::days(1),
215                }),
216                cutover_date: cutover,
217                stabilization_end,
218                parallel_run_error_rate: self.rng.random_range(0.05..0.12),
219                stabilization_error_rate: self.rng.random_range(0.01..0.05),
220            },
221            purchase_price_allocation: None,
222        })
223    }
224
225    fn build_divestiture(
226        &mut self,
227        effective_date: NaiveDate,
228        company_code: &str,
229    ) -> OrganizationalEventType {
230        let seq = self.event_counter;
231        let entity_code = format!("DIV-{}-{:04}", company_code, seq);
232        let transition = self.rng.random_range(2..6_u32);
233
234        OrganizationalEventType::Divestiture(DivestitureConfig {
235            divested_entity_code: entity_code,
236            divested_entity_name: Some(format!("Divested Unit {}", seq)),
237            divestiture_date: effective_date,
238            volume_reduction: self.rng.random_range(0.50..0.85),
239            transition_months: transition,
240            remove_entity: true,
241            account_closures: Vec::new(),
242            disposal_gain_loss: None,
243        })
244    }
245
246    fn build_reorganization(&mut self, effective_date: NaiveDate) -> OrganizationalEventType {
247        let transition = self.rng.random_range(2..6_u32);
248        let remap_count = self.rng.random_range(1..4_usize);
249
250        let mut cost_center_remapping = HashMap::new();
251        for i in 0..remap_count {
252            cost_center_remapping.insert(
253                format!("CC-{:03}", 100 + i * 10),
254                format!("CC-{:03}", 500 + i * 10),
255            );
256        }
257
258        let reporting_changes = if self.rng.random_bool(0.5) {
259            vec![ReportingChange {
260                entity: "Engineering".to_string(),
261                from_reports_to: "VP Engineering".to_string(),
262                to_reports_to: "CTO".to_string(),
263            }]
264        } else {
265            Vec::new()
266        };
267
268        OrganizationalEventType::Reorganization(ReorganizationConfig {
269            description: Some("Organizational restructuring".to_string()),
270            effective_date,
271            cost_center_remapping,
272            department_remapping: HashMap::new(),
273            reporting_changes,
274            transition_months: transition,
275            transition_error_rate: self.rng.random_range(0.02..0.06),
276        })
277    }
278
279    fn build_leadership_change(&mut self, effective_date: NaiveDate) -> OrganizationalEventType {
280        let roles = ["CFO", "CEO", "Controller", "COO", "CTO", "VP Finance"];
281        let role_idx = self.rng.random_range(0..roles.len());
282        let role = roles[role_idx].to_string();
283
284        let policy_changes = if self.rng.random_bool(0.6) {
285            vec![PolicyChangeDetail {
286                policy_area: PolicyArea::ApprovalThreshold,
287                description: "Updated approval thresholds".to_string(),
288                old_value: None,
289                new_value: None,
290            }]
291        } else {
292            Vec::new()
293        };
294
295        OrganizationalEventType::LeadershipChange(LeadershipChangeConfig {
296            role,
297            change_date: effective_date,
298            policy_changes,
299            vendor_review_triggered: self.rng.random_bool(0.3),
300            policy_transition_months: self.rng.random_range(3..9_u32),
301            policy_change_error_rate: self.rng.random_range(0.01..0.04),
302        })
303    }
304
305    fn build_workforce_reduction(&mut self, effective_date: NaiveDate) -> OrganizationalEventType {
306        let departments = ["Finance", "Operations", "Sales", "Engineering", "HR"];
307        let affected_count = self.rng.random_range(1..=3_usize);
308        let mut affected = Vec::with_capacity(affected_count);
309        for i in 0..affected_count {
310            let idx = (self.rng.random_range(0..departments.len()) + i) % departments.len();
311            let dept = departments[idx].to_string();
312            if !affected.contains(&dept) {
313                affected.push(dept);
314            }
315        }
316
317        OrganizationalEventType::WorkforceReduction(WorkforceReductionConfig {
318            reduction_date: effective_date,
319            reduction_percent: self.rng.random_range(0.05..0.20),
320            affected_departments: affected,
321            error_rate_increase: self.rng.random_range(0.02..0.08),
322            processing_time_increase: self.rng.random_range(1.1..1.5),
323            transition_months: self.rng.random_range(3..9_u32),
324            severance_costs: None,
325        })
326    }
327
328    fn build_merger(
329        &mut self,
330        effective_date: NaiveDate,
331        company_code: &str,
332    ) -> OrganizationalEventType {
333        let seq = self.event_counter;
334        let entity_code = format!("MRG-{}-{:04}", company_code, seq);
335        let volume_mult = self.rng.random_range(1.50..2.20);
336
337        let cutover = effective_date + chrono::Duration::days(self.rng.random_range(30..90));
338        let stabilization_end = cutover + chrono::Duration::days(self.rng.random_range(90..180));
339
340        OrganizationalEventType::Merger(MergerConfig {
341            merged_entity_code: entity_code,
342            merged_entity_name: Some(format!("Merged Entity {}", seq)),
343            merger_date: effective_date,
344            volume_multiplier: volume_mult,
345            integration_error_rate: self.rng.random_range(0.03..0.08),
346            integration_phases: IntegrationPhaseConfig {
347                parallel_run: Some(DateRange {
348                    start: effective_date,
349                    end: cutover - chrono::Duration::days(1),
350                }),
351                cutover_date: cutover,
352                stabilization_end,
353                parallel_run_error_rate: self.rng.random_range(0.05..0.12),
354                stabilization_error_rate: self.rng.random_range(0.02..0.05),
355            },
356            fair_value_adjustments: Vec::new(),
357            goodwill: None,
358        })
359    }
360}
361
362#[cfg(test)]
363#[allow(clippy::unwrap_used)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_deterministic_generation() {
369        let mut gen1 = OrganizationalEventGenerator::new(42);
370        let mut gen2 = OrganizationalEventGenerator::new(42);
371        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
372        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
373        let companies = vec!["C001".to_string(), "C002".to_string()];
374
375        let events1 = gen1.generate_events(start, end, &companies);
376        let events2 = gen2.generate_events(start, end, &companies);
377
378        assert_eq!(events1.len(), events2.len());
379        for (e1, e2) in events1.iter().zip(events2.iter()) {
380            assert_eq!(e1.event_id, e2.event_id);
381            assert_eq!(e1.effective_date, e2.effective_date);
382            assert_eq!(e1.event_type.type_name(), e2.event_type.type_name());
383        }
384    }
385
386    #[test]
387    fn test_events_sorted_by_date() {
388        let mut gen = OrganizationalEventGenerator::new(42);
389        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
390        let end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
391        let companies = vec!["C001".to_string()];
392
393        let events = gen.generate_events(start, end, &companies);
394        for w in events.windows(2) {
395            assert!(w[0].effective_date <= w[1].effective_date);
396        }
397    }
398
399    #[test]
400    fn test_all_event_types_generated() {
401        let config = OrgEventGeneratorConfig {
402            type_weights: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
403            events_per_year: 100.0,
404        };
405        let mut gen = OrganizationalEventGenerator::with_config(42, config);
406        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
407        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
408        let companies = vec!["C001".to_string()];
409
410        let events = gen.generate_events(start, end, &companies);
411
412        let has_acquisition = events
413            .iter()
414            .any(|e| matches!(e.event_type, OrganizationalEventType::Acquisition(_)));
415        let has_divestiture = events
416            .iter()
417            .any(|e| matches!(e.event_type, OrganizationalEventType::Divestiture(_)));
418        let has_reorg = events
419            .iter()
420            .any(|e| matches!(e.event_type, OrganizationalEventType::Reorganization(_)));
421        let has_leadership = events
422            .iter()
423            .any(|e| matches!(e.event_type, OrganizationalEventType::LeadershipChange(_)));
424        let has_workforce = events
425            .iter()
426            .any(|e| matches!(e.event_type, OrganizationalEventType::WorkforceReduction(_)));
427        let has_merger = events
428            .iter()
429            .any(|e| matches!(e.event_type, OrganizationalEventType::Merger(_)));
430
431        assert!(has_acquisition, "should generate acquisitions");
432        assert!(has_divestiture, "should generate divestitures");
433        assert!(has_reorg, "should generate reorganizations");
434        assert!(has_leadership, "should generate leadership changes");
435        assert!(has_workforce, "should generate workforce reductions");
436        assert!(has_merger, "should generate mergers");
437    }
438
439    #[test]
440    fn test_events_within_date_range() {
441        let mut gen = OrganizationalEventGenerator::new(42);
442        let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
443        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
444        let companies = vec!["C001".to_string()];
445
446        let events = gen.generate_events(start, end, &companies);
447        for e in &events {
448            assert!(e.effective_date >= start, "event date before start");
449            assert!(e.effective_date <= end, "event date after end");
450        }
451    }
452
453    #[test]
454    fn test_empty_company_codes() {
455        let mut gen = OrganizationalEventGenerator::new(42);
456        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
457        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
458
459        let events = gen.generate_events(start, end, &[]);
460        assert!(!events.is_empty(), "should still generate events");
461        // With empty company_codes, tags should use "C001" fallback
462        for e in &events {
463            assert!(
464                e.tags.iter().any(|t| t == "company:C001"),
465                "should use C001 fallback"
466            );
467        }
468    }
469
470    #[test]
471    fn test_event_has_tags_and_description() {
472        let mut gen = OrganizationalEventGenerator::new(99);
473        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
474        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
475        let companies = vec!["ACME".to_string()];
476
477        let events = gen.generate_events(start, end, &companies);
478        for e in &events {
479            assert!(e.description.is_some(), "event should have a description");
480            assert!(!e.tags.is_empty(), "event should have tags");
481            assert!(
482                e.tags.iter().any(|t| t.starts_with("company:")),
483                "should have company tag"
484            );
485            assert!(
486                e.tags.iter().any(|t| t.starts_with("type:")),
487                "should have type tag"
488            );
489        }
490    }
491
492    #[test]
493    fn test_acquisition_config_populated() {
494        let config = OrgEventGeneratorConfig {
495            type_weights: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
496            events_per_year: 5.0,
497        };
498        let mut gen = OrganizationalEventGenerator::with_config(42, config);
499        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
500        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
501        let companies = vec!["C001".to_string()];
502
503        let events = gen.generate_events(start, end, &companies);
504        for e in &events {
505            if let OrganizationalEventType::Acquisition(ref acq) = e.event_type {
506                assert!(!acq.acquired_entity_code.is_empty());
507                assert!(acq.volume_multiplier >= 1.10);
508                assert!(acq.integration_error_rate > 0.0);
509                assert!(acq.integration_phases.parallel_run.is_some());
510            } else {
511                panic!("expected only acquisitions");
512            }
513        }
514    }
515
516    #[test]
517    fn test_event_is_active_at_effective_date() {
518        let mut gen = OrganizationalEventGenerator::new(42);
519        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
520        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
521        let companies = vec!["C001".to_string()];
522
523        let events = gen.generate_events(start, end, &companies);
524        for e in &events {
525            assert!(
526                e.is_active_at(e.effective_date),
527                "event should be active at its effective date"
528            );
529        }
530    }
531}