Skip to main content

datasynth_core/distributions/
drift.rs

1//! Temporal drift simulation for realistic data distribution evolution.
2//!
3//! Implements gradual, sudden, and seasonal drift patterns commonly observed
4//! in real-world enterprise data, useful for training drift detection models.
5//!
6//! Enhanced with:
7//! - Regime changes (structural breaks like acquisitions, policy changes)
8//! - Economic cycles (sinusoidal patterns with recessions)
9//! - Parameter drifts (gradual changes in distribution parameters)
10
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use serde::{Deserialize, Serialize};
14
15/// Types of temporal drift patterns.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum DriftType {
19    /// Gradual, continuous drift over time (like inflation).
20    #[default]
21    Gradual,
22    /// Sudden, point-in-time shifts (like policy changes).
23    Sudden,
24    /// Recurring patterns that cycle (like seasonal variations).
25    Recurring,
26    /// Combination of gradual background drift with occasional sudden shifts.
27    Mixed,
28    /// Regime-based changes with structural breaks.
29    Regime,
30    /// Economic cycle patterns.
31    EconomicCycle,
32}
33
34/// Type of regime change event.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37#[derive(Default)]
38pub enum RegimeChangeType {
39    /// Acquisition - sudden volume and amount increase
40    Acquisition,
41    /// Divestiture - sudden volume and amount decrease
42    Divestiture,
43    /// Price increase - amounts increase, volume may decrease
44    PriceIncrease,
45    /// Price decrease - amounts decrease, volume may increase
46    PriceDecrease,
47    /// New product launch - volume ramp-up
48    ProductLaunch,
49    /// Product discontinuation - volume ramp-down
50    ProductDiscontinuation,
51    /// Policy change - affects patterns without changing volumes
52    #[default]
53    PolicyChange,
54    /// Competitor entry - market disruption
55    CompetitorEntry,
56    /// Custom effect with specified multipliers
57    Custom,
58}
59
60/// Effect of a regime change on specific fields.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RegimeEffect {
63    /// Field being affected (e.g., "transaction_volume", "amount_mean")
64    pub field: String,
65    /// Multiplier to apply (1.0 = no change, 1.5 = 50% increase)
66    pub multiplier: f64,
67}
68
69/// A single regime change event.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct RegimeChange {
72    /// Period when the change occurs (0-indexed)
73    pub period: u32,
74    /// Type of regime change
75    pub change_type: RegimeChangeType,
76    /// Description of the change
77    #[serde(default)]
78    pub description: Option<String>,
79    /// Custom effects (only used if change_type is Custom)
80    #[serde(default)]
81    pub effects: Vec<RegimeEffect>,
82    /// Transition duration in periods (0 = immediate, >0 = gradual)
83    #[serde(default)]
84    pub transition_periods: u32,
85}
86
87impl RegimeChange {
88    /// Create a new regime change.
89    pub fn new(period: u32, change_type: RegimeChangeType) -> Self {
90        Self {
91            period,
92            change_type,
93            description: None,
94            effects: Vec::new(),
95            transition_periods: 0,
96        }
97    }
98
99    /// Get the volume multiplier for this regime change.
100    pub fn volume_multiplier(&self) -> f64 {
101        match self.change_type {
102            RegimeChangeType::Acquisition => 1.35,
103            RegimeChangeType::Divestiture => 0.70,
104            RegimeChangeType::PriceIncrease => 0.95,
105            RegimeChangeType::PriceDecrease => 1.10,
106            RegimeChangeType::ProductLaunch => 1.20,
107            RegimeChangeType::ProductDiscontinuation => 0.85,
108            RegimeChangeType::PolicyChange => 1.0,
109            RegimeChangeType::CompetitorEntry => 0.90,
110            RegimeChangeType::Custom => self
111                .effects
112                .iter()
113                .find(|e| e.field == "transaction_volume")
114                .map(|e| e.multiplier)
115                .unwrap_or(1.0),
116        }
117    }
118
119    /// Get the amount mean multiplier for this regime change.
120    pub fn amount_mean_multiplier(&self) -> f64 {
121        match self.change_type {
122            RegimeChangeType::Acquisition => 1.15,
123            RegimeChangeType::Divestiture => 0.90,
124            RegimeChangeType::PriceIncrease => 1.25,
125            RegimeChangeType::PriceDecrease => 0.80,
126            RegimeChangeType::ProductLaunch => 0.90, // New products often cheaper
127            RegimeChangeType::ProductDiscontinuation => 1.10, // Remaining products higher-value
128            RegimeChangeType::PolicyChange => 1.0,
129            RegimeChangeType::CompetitorEntry => 0.95,
130            RegimeChangeType::Custom => self
131                .effects
132                .iter()
133                .find(|e| e.field == "amount_mean")
134                .map(|e| e.multiplier)
135                .unwrap_or(1.0),
136        }
137    }
138}
139
140/// Configuration for economic cycle patterns.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct EconomicCycleConfig {
143    /// Enable economic cycle patterns
144    pub enabled: bool,
145    /// Cycle length in periods (e.g., 48 months for a 4-year cycle)
146    #[serde(default = "default_cycle_length")]
147    pub cycle_length: u32,
148    /// Amplitude of the cycle (0.0-1.0, affects the peak-to-trough ratio)
149    #[serde(default = "default_amplitude")]
150    pub amplitude: f64,
151    /// Phase offset in periods (shifts the cycle start)
152    #[serde(default)]
153    pub phase_offset: u32,
154    /// Recession periods (list of periods that are in recession)
155    #[serde(default)]
156    pub recession_periods: Vec<u32>,
157    /// Recession severity multiplier (0.0-1.0, lower = more severe)
158    #[serde(default = "default_recession_severity")]
159    pub recession_severity: f64,
160}
161
162fn default_cycle_length() -> u32 {
163    48 // 4-year business cycle
164}
165
166fn default_amplitude() -> f64 {
167    0.15 // 15% peak-to-trough variation
168}
169
170fn default_recession_severity() -> f64 {
171    0.75 // 25% reduction during recession
172}
173
174impl Default for EconomicCycleConfig {
175    fn default() -> Self {
176        Self {
177            enabled: false,
178            cycle_length: 48,
179            amplitude: 0.15,
180            phase_offset: 0,
181            recession_periods: Vec::new(),
182            recession_severity: 0.75,
183        }
184    }
185}
186
187/// Types of parameter drift patterns.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
189#[serde(rename_all = "snake_case")]
190pub enum ParameterDriftType {
191    /// Linear drift: parameter = initial + rate * period
192    #[default]
193    Linear,
194    /// Exponential drift: parameter = initial * (1 + rate)^period
195    Exponential,
196    /// Logistic drift: S-curve transition between start and end values
197    Logistic,
198    /// Step drift: sudden change at specified periods
199    Step,
200}
201
202/// Configuration for a parameter drift.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct ParameterDrift {
205    /// Name of the parameter being drifted
206    pub parameter: String,
207    /// Type of drift pattern
208    pub drift_type: ParameterDriftType,
209    /// Initial value
210    pub initial_value: f64,
211    /// Final value (for logistic) or rate (for linear/exponential)
212    pub target_or_rate: f64,
213    /// Start period for the drift
214    #[serde(default)]
215    pub start_period: u32,
216    /// End period for the drift (for logistic), or ignored for linear/exponential
217    #[serde(default)]
218    pub end_period: Option<u32>,
219    /// Steepness for logistic curve (higher = sharper transition)
220    #[serde(default = "default_steepness")]
221    pub steepness: f64,
222}
223
224fn default_steepness() -> f64 {
225    0.1
226}
227
228impl Default for ParameterDrift {
229    fn default() -> Self {
230        Self {
231            parameter: String::new(),
232            drift_type: ParameterDriftType::Linear,
233            initial_value: 1.0,
234            target_or_rate: 0.01,
235            start_period: 0,
236            end_period: None,
237            steepness: 0.1,
238        }
239    }
240}
241
242impl ParameterDrift {
243    /// Calculate the parameter value at a given period.
244    pub fn value_at(&self, period: u32) -> f64 {
245        if period < self.start_period {
246            return self.initial_value;
247        }
248
249        let effective_period = period - self.start_period;
250
251        match self.drift_type {
252            ParameterDriftType::Linear => {
253                self.initial_value + self.target_or_rate * (effective_period as f64)
254            }
255            ParameterDriftType::Exponential => {
256                self.initial_value * (1.0 + self.target_or_rate).powi(effective_period as i32)
257            }
258            ParameterDriftType::Logistic => {
259                let end_period = self.end_period.unwrap_or(self.start_period + 24);
260                let midpoint = (self.start_period + end_period) as f64 / 2.0;
261                let t = period as f64;
262                let range = self.target_or_rate - self.initial_value;
263                self.initial_value + range / (1.0 + (-self.steepness * (t - midpoint)).exp())
264            }
265            ParameterDriftType::Step => {
266                if let Some(end) = self.end_period {
267                    if period >= end {
268                        return self.target_or_rate;
269                    }
270                }
271                self.initial_value
272            }
273        }
274    }
275}
276
277/// Configuration for temporal drift simulation.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct DriftConfig {
280    /// Enable temporal drift simulation.
281    pub enabled: bool,
282    /// Amount mean drift per period (e.g., 0.02 = 2% shift per month).
283    pub amount_mean_drift: f64,
284    /// Amount variance drift per period.
285    pub amount_variance_drift: f64,
286    /// Anomaly rate drift per period.
287    pub anomaly_rate_drift: f64,
288    /// Concept drift rate (0.0-1.0).
289    pub concept_drift_rate: f64,
290    /// Probability of sudden drift in any period.
291    pub sudden_drift_probability: f64,
292    /// Magnitude of sudden drift events.
293    pub sudden_drift_magnitude: f64,
294    /// Enable seasonal drift patterns.
295    pub seasonal_drift: bool,
296    /// Period to start drift (0 = from beginning).
297    pub drift_start_period: u32,
298    /// Type of drift pattern.
299    pub drift_type: DriftType,
300    /// Regime changes (structural breaks)
301    #[serde(default)]
302    pub regime_changes: Vec<RegimeChange>,
303    /// Economic cycle configuration
304    #[serde(default)]
305    pub economic_cycle: EconomicCycleConfig,
306    /// Parameter drifts
307    #[serde(default)]
308    pub parameter_drifts: Vec<ParameterDrift>,
309}
310
311impl Default for DriftConfig {
312    fn default() -> Self {
313        Self {
314            enabled: false,
315            amount_mean_drift: 0.02,
316            amount_variance_drift: 0.0,
317            anomaly_rate_drift: 0.0,
318            concept_drift_rate: 0.01,
319            sudden_drift_probability: 0.0,
320            sudden_drift_magnitude: 2.0,
321            seasonal_drift: false,
322            drift_start_period: 0,
323            drift_type: DriftType::Gradual,
324            regime_changes: Vec::new(),
325            economic_cycle: EconomicCycleConfig::default(),
326            parameter_drifts: Vec::new(),
327        }
328    }
329}
330
331impl DriftConfig {
332    /// Create a configuration with regime changes.
333    pub fn with_regime_changes(regime_changes: Vec<RegimeChange>) -> Self {
334        Self {
335            enabled: true,
336            drift_type: DriftType::Regime,
337            regime_changes,
338            ..Default::default()
339        }
340    }
341
342    /// Create a configuration with economic cycle.
343    pub fn with_economic_cycle(cycle_config: EconomicCycleConfig) -> Self {
344        Self {
345            enabled: true,
346            drift_type: DriftType::EconomicCycle,
347            economic_cycle: cycle_config,
348            ..Default::default()
349        }
350    }
351}
352
353/// Drift adjustments computed for a specific period.
354#[derive(Debug, Clone, Default)]
355pub struct DriftAdjustments {
356    /// Multiplier for amount mean (1.0 = no change).
357    pub amount_mean_multiplier: f64,
358    /// Multiplier for amount variance (1.0 = no change).
359    pub amount_variance_multiplier: f64,
360    /// Additive adjustment to anomaly rate.
361    pub anomaly_rate_adjustment: f64,
362    /// Overall concept drift factor (0.0-1.0).
363    pub concept_drift_factor: f64,
364    /// Whether a sudden drift event occurred.
365    pub sudden_drift_occurred: bool,
366    /// Seasonal factor (1.0 = baseline, varies by month).
367    pub seasonal_factor: f64,
368    /// Volume multiplier from regime changes (1.0 = no change).
369    pub volume_multiplier: f64,
370    /// Economic cycle factor (1.0 = neutral, varies with cycle).
371    pub economic_cycle_factor: f64,
372    /// Whether currently in a recession period.
373    pub in_recession: bool,
374    /// Active regime changes in this period.
375    pub active_regime_changes: Vec<RegimeChangeType>,
376    /// Parameter drift values for this period.
377    pub parameter_values: std::collections::HashMap<String, f64>,
378}
379
380impl DriftAdjustments {
381    /// No drift (identity adjustments).
382    pub fn none() -> Self {
383        Self {
384            amount_mean_multiplier: 1.0,
385            amount_variance_multiplier: 1.0,
386            anomaly_rate_adjustment: 0.0,
387            concept_drift_factor: 0.0,
388            sudden_drift_occurred: false,
389            seasonal_factor: 1.0,
390            volume_multiplier: 1.0,
391            economic_cycle_factor: 1.0,
392            in_recession: false,
393            active_regime_changes: Vec::new(),
394            parameter_values: std::collections::HashMap::new(),
395        }
396    }
397
398    /// Get the combined multiplier for transaction amounts.
399    pub fn combined_amount_multiplier(&self) -> f64 {
400        self.amount_mean_multiplier * self.seasonal_factor * self.economic_cycle_factor
401    }
402
403    /// Get the combined multiplier for transaction volume.
404    pub fn combined_volume_multiplier(&self) -> f64 {
405        self.volume_multiplier * self.seasonal_factor * self.economic_cycle_factor
406    }
407}
408
409/// Controller for computing and applying temporal drift.
410#[derive(Clone)]
411pub struct DriftController {
412    config: DriftConfig,
413    rng: ChaCha8Rng,
414    /// Track which periods had sudden drift events for reproducibility.
415    sudden_drift_periods: Vec<u32>,
416    /// Total periods in the simulation.
417    total_periods: u32,
418}
419
420impl DriftController {
421    /// Create a new drift controller with the given configuration.
422    pub fn new(config: DriftConfig, seed: u64, total_periods: u32) -> Self {
423        let mut controller = Self {
424            config,
425            rng: ChaCha8Rng::seed_from_u64(seed),
426            sudden_drift_periods: Vec::new(),
427            total_periods,
428        };
429
430        // Pre-compute sudden drift events for reproducibility
431        if controller.config.enabled
432            && (controller.config.drift_type == DriftType::Sudden
433                || controller.config.drift_type == DriftType::Mixed)
434        {
435            controller.precompute_sudden_drifts();
436        }
437
438        controller
439    }
440
441    /// Pre-compute which periods will have sudden drift events.
442    fn precompute_sudden_drifts(&mut self) {
443        for period in 0..self.total_periods {
444            if period >= self.config.drift_start_period
445                && self.rng.random::<f64>() < self.config.sudden_drift_probability
446            {
447                self.sudden_drift_periods.push(period);
448            }
449        }
450    }
451
452    /// Check if drift is enabled.
453    pub fn is_enabled(&self) -> bool {
454        self.config.enabled
455    }
456
457    /// Compute drift adjustments for a specific period (0-indexed).
458    pub fn compute_adjustments(&self, period: u32) -> DriftAdjustments {
459        if !self.config.enabled {
460            return DriftAdjustments::none();
461        }
462
463        // No drift before start period
464        if period < self.config.drift_start_period {
465            return DriftAdjustments::none();
466        }
467
468        let effective_period = period - self.config.drift_start_period;
469        let mut adjustments = DriftAdjustments::none();
470
471        match self.config.drift_type {
472            DriftType::Gradual => {
473                self.apply_gradual_drift(&mut adjustments, effective_period);
474            }
475            DriftType::Sudden => {
476                self.apply_sudden_drift(&mut adjustments, period);
477            }
478            DriftType::Recurring => {
479                self.apply_recurring_drift(&mut adjustments, effective_period);
480            }
481            DriftType::Mixed => {
482                // Combine gradual background drift with sudden events
483                self.apply_gradual_drift(&mut adjustments, effective_period);
484                self.apply_sudden_drift(&mut adjustments, period);
485            }
486            DriftType::Regime => {
487                self.apply_regime_drift(&mut adjustments, period);
488            }
489            DriftType::EconomicCycle => {
490                self.apply_economic_cycle(&mut adjustments, period);
491            }
492        }
493
494        // Apply seasonal drift if enabled (additive to other drift)
495        if self.config.seasonal_drift {
496            adjustments.seasonal_factor = self.compute_seasonal_factor(period);
497        }
498
499        // Apply parameter drifts
500        self.apply_parameter_drifts(&mut adjustments, period);
501
502        adjustments
503    }
504
505    /// Apply gradual drift (compound growth model).
506    fn apply_gradual_drift(&self, adjustments: &mut DriftAdjustments, effective_period: u32) {
507        let p = effective_period as f64;
508
509        // Compound growth: (1 + rate)^period
510        adjustments.amount_mean_multiplier = (1.0 + self.config.amount_mean_drift).powf(p);
511
512        adjustments.amount_variance_multiplier = (1.0 + self.config.amount_variance_drift).powf(p);
513
514        // Linear accumulation for anomaly rate
515        adjustments.anomaly_rate_adjustment = self.config.anomaly_rate_drift * p;
516
517        // Concept drift accumulates but is bounded 0-1
518        adjustments.concept_drift_factor = (self.config.concept_drift_rate * p).min(1.0);
519    }
520
521    /// Apply sudden drift based on pre-computed events.
522    fn apply_sudden_drift(&self, adjustments: &mut DriftAdjustments, period: u32) {
523        // Count how many sudden events have occurred up to this period
524        let events_occurred: usize = self
525            .sudden_drift_periods
526            .iter()
527            .filter(|&&p| p <= period)
528            .count();
529
530        if events_occurred > 0 {
531            adjustments.sudden_drift_occurred = self.sudden_drift_periods.contains(&period);
532
533            // Each sudden event multiplies by the magnitude
534            let cumulative_magnitude = self
535                .config
536                .sudden_drift_magnitude
537                .powi(events_occurred as i32);
538
539            adjustments.amount_mean_multiplier *= cumulative_magnitude;
540            adjustments.amount_variance_multiplier *= cumulative_magnitude.sqrt();
541            // Variance grows slower
542        }
543    }
544
545    /// Apply recurring (seasonal) drift patterns.
546    fn apply_recurring_drift(&self, adjustments: &mut DriftAdjustments, effective_period: u32) {
547        // 12-month cycle for seasonality
548        let cycle_position = (effective_period % 12) as f64;
549        let cycle_radians = (cycle_position / 12.0) * 2.0 * std::f64::consts::PI;
550
551        // Sinusoidal pattern with configurable amplitude
552        let seasonal_amplitude = self.config.concept_drift_rate;
553        adjustments.amount_mean_multiplier = 1.0 + seasonal_amplitude * cycle_radians.sin();
554
555        // Phase-shifted variance pattern
556        adjustments.amount_variance_multiplier =
557            1.0 + (seasonal_amplitude * 0.5) * (cycle_radians + std::f64::consts::FRAC_PI_2).sin();
558    }
559
560    /// Compute seasonal factor based on period (month).
561    fn compute_seasonal_factor(&self, period: u32) -> f64 {
562        // Map period to month (0-11)
563        let month = period % 12;
564
565        // Q4 spike (Oct-Dec), Q1 dip (Jan-Feb)
566        match month {
567            0 | 1 => 0.85, // Jan-Feb: post-holiday slowdown
568            2 => 0.90,     // Mar: recovering
569            3 | 4 => 0.95, // Apr-May: Q2 start
570            5 => 1.0,      // Jun: mid-year
571            6 | 7 => 0.95, // Jul-Aug: summer slowdown
572            8 => 1.0,      // Sep: back to business
573            9 => 1.10,     // Oct: Q4 ramp-up
574            10 => 1.20,    // Nov: pre-holiday surge
575            11 => 1.30,    // Dec: year-end close
576            _ => 1.0,
577        }
578    }
579
580    /// Get the list of periods with sudden drift events.
581    pub fn sudden_drift_periods(&self) -> &[u32] {
582        &self.sudden_drift_periods
583    }
584
585    /// Get the configuration.
586    pub fn config(&self) -> &DriftConfig {
587        &self.config
588    }
589
590    /// Apply regime change drift.
591    fn apply_regime_drift(&self, adjustments: &mut DriftAdjustments, period: u32) {
592        let mut volume_mult = 1.0;
593        let mut amount_mult = 1.0;
594
595        for regime_change in &self.config.regime_changes {
596            if period >= regime_change.period {
597                // Calculate transition factor
598                let periods_since = period - regime_change.period;
599                let transition_factor = if regime_change.transition_periods == 0 {
600                    1.0
601                } else {
602                    (periods_since as f64 / regime_change.transition_periods as f64).min(1.0)
603                };
604
605                // Apply multipliers with transition
606                let vol_change = regime_change.volume_multiplier() - 1.0;
607                let amt_change = regime_change.amount_mean_multiplier() - 1.0;
608
609                volume_mult *= 1.0 + vol_change * transition_factor;
610                amount_mult *= 1.0 + amt_change * transition_factor;
611
612                adjustments
613                    .active_regime_changes
614                    .push(regime_change.change_type);
615            }
616        }
617
618        adjustments.volume_multiplier = volume_mult;
619        adjustments.amount_mean_multiplier *= amount_mult;
620    }
621
622    /// Apply economic cycle pattern.
623    fn apply_economic_cycle(&self, adjustments: &mut DriftAdjustments, period: u32) {
624        let cycle = &self.config.economic_cycle;
625        if !cycle.enabled {
626            return;
627        }
628
629        // Calculate position in cycle (0.0 to 1.0)
630        let adjusted_period = period + cycle.phase_offset;
631        let cycle_position =
632            (adjusted_period % cycle.cycle_length) as f64 / cycle.cycle_length as f64;
633
634        // Sinusoidal cycle: 1.0 + amplitude * sin(2*pi*position)
635        let cycle_radians = cycle_position * 2.0 * std::f64::consts::PI;
636        let cycle_factor = 1.0 + cycle.amplitude * cycle_radians.sin();
637
638        // Check for recession
639        let in_recession = cycle.recession_periods.contains(&period);
640        adjustments.in_recession = in_recession;
641
642        // Apply recession severity if in recession
643        let final_factor = if in_recession {
644            cycle_factor * cycle.recession_severity
645        } else {
646            cycle_factor
647        };
648
649        adjustments.economic_cycle_factor = final_factor;
650        adjustments.amount_mean_multiplier *= final_factor;
651        adjustments.volume_multiplier = final_factor;
652    }
653
654    /// Apply parameter drifts.
655    fn apply_parameter_drifts(&self, adjustments: &mut DriftAdjustments, period: u32) {
656        for param_drift in &self.config.parameter_drifts {
657            let value = param_drift.value_at(period);
658            adjustments
659                .parameter_values
660                .insert(param_drift.parameter.clone(), value);
661        }
662    }
663
664    /// Get regime changes that occurred up to a given period.
665    pub fn regime_changes_until(&self, period: u32) -> Vec<&RegimeChange> {
666        self.config
667            .regime_changes
668            .iter()
669            .filter(|rc| rc.period <= period)
670            .collect()
671    }
672}
673
674#[cfg(test)]
675#[allow(clippy::unwrap_used)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn test_no_drift_when_disabled() {
681        let config = DriftConfig::default();
682        let controller = DriftController::new(config, 42, 12);
683
684        let adjustments = controller.compute_adjustments(6);
685        assert!(!controller.is_enabled());
686        assert!((adjustments.amount_mean_multiplier - 1.0).abs() < 0.001);
687        assert!((adjustments.anomaly_rate_adjustment).abs() < 0.001);
688    }
689
690    #[test]
691    fn test_gradual_drift() {
692        let config = DriftConfig {
693            enabled: true,
694            amount_mean_drift: 0.02,
695            anomaly_rate_drift: 0.001,
696            drift_type: DriftType::Gradual,
697            ..Default::default()
698        };
699        let controller = DriftController::new(config, 42, 12);
700
701        // Period 0: no drift yet
702        let adj0 = controller.compute_adjustments(0);
703        assert!((adj0.amount_mean_multiplier - 1.0).abs() < 0.001);
704
705        // Period 6: ~12.6% drift (1.02^6 ≈ 1.126)
706        let adj6 = controller.compute_adjustments(6);
707        assert!(adj6.amount_mean_multiplier > 1.10);
708        assert!(adj6.amount_mean_multiplier < 1.15);
709
710        // Period 12: ~26.8% drift (1.02^12 ≈ 1.268)
711        let adj12 = controller.compute_adjustments(12);
712        assert!(adj12.amount_mean_multiplier > 1.20);
713        assert!(adj12.amount_mean_multiplier < 1.30);
714    }
715
716    #[test]
717    fn test_drift_start_period() {
718        let config = DriftConfig {
719            enabled: true,
720            amount_mean_drift: 0.02,
721            drift_start_period: 3,
722            drift_type: DriftType::Gradual,
723            ..Default::default()
724        };
725        let controller = DriftController::new(config, 42, 12);
726
727        // Before drift start: no drift
728        let adj2 = controller.compute_adjustments(2);
729        assert!((adj2.amount_mean_multiplier - 1.0).abs() < 0.001);
730
731        // At drift start: no drift yet (effective_period = 0)
732        let adj3 = controller.compute_adjustments(3);
733        assert!((adj3.amount_mean_multiplier - 1.0).abs() < 0.001);
734
735        // After drift start: drift begins
736        let adj6 = controller.compute_adjustments(6);
737        assert!(adj6.amount_mean_multiplier > 1.0);
738    }
739
740    #[test]
741    fn test_seasonal_factor() {
742        let config = DriftConfig {
743            enabled: true,
744            seasonal_drift: true,
745            drift_type: DriftType::Gradual,
746            ..Default::default()
747        };
748        let controller = DriftController::new(config, 42, 12);
749
750        // December (month 11) should have highest seasonal factor
751        let adj_dec = controller.compute_adjustments(11);
752        assert!(adj_dec.seasonal_factor > 1.2);
753
754        // January (month 0) should have lower seasonal factor
755        let adj_jan = controller.compute_adjustments(0);
756        assert!(adj_jan.seasonal_factor < 0.9);
757    }
758
759    #[test]
760    fn test_sudden_drift_reproducibility() {
761        let config = DriftConfig {
762            enabled: true,
763            sudden_drift_probability: 0.5,
764            sudden_drift_magnitude: 1.5,
765            drift_type: DriftType::Sudden,
766            ..Default::default()
767        };
768
769        // Same seed should produce same sudden drift periods
770        let controller1 = DriftController::new(config.clone(), 42, 12);
771        let controller2 = DriftController::new(config, 42, 12);
772
773        assert_eq!(
774            controller1.sudden_drift_periods(),
775            controller2.sudden_drift_periods()
776        );
777    }
778
779    #[test]
780    fn test_regime_change() {
781        let config = DriftConfig {
782            enabled: true,
783            drift_type: DriftType::Regime,
784            regime_changes: vec![RegimeChange::new(6, RegimeChangeType::Acquisition)],
785            ..Default::default()
786        };
787        let controller = DriftController::new(config, 42, 12);
788
789        // Before regime change
790        let adj_before = controller.compute_adjustments(5);
791        assert!((adj_before.volume_multiplier - 1.0).abs() < 0.001);
792
793        // After regime change
794        let adj_after = controller.compute_adjustments(6);
795        assert!(adj_after.volume_multiplier > 1.3); // Acquisition increases volume
796        assert!(adj_after.amount_mean_multiplier > 1.1); // And amounts
797        assert!(adj_after
798            .active_regime_changes
799            .contains(&RegimeChangeType::Acquisition));
800    }
801
802    #[test]
803    fn test_regime_change_gradual_transition() {
804        let config = DriftConfig {
805            enabled: true,
806            drift_type: DriftType::Regime,
807            regime_changes: vec![RegimeChange {
808                period: 6,
809                change_type: RegimeChangeType::PriceIncrease,
810                description: None,
811                effects: vec![],
812                transition_periods: 4, // 4 period transition
813            }],
814            ..Default::default()
815        };
816        let controller = DriftController::new(config, 42, 12);
817
818        // At regime change start
819        let adj_start = controller.compute_adjustments(6);
820        // Midway through transition
821        let adj_mid = controller.compute_adjustments(8);
822        // After transition complete
823        let adj_end = controller.compute_adjustments(10);
824
825        // Should gradually increase
826        assert!(adj_start.amount_mean_multiplier < adj_mid.amount_mean_multiplier);
827        assert!(adj_mid.amount_mean_multiplier < adj_end.amount_mean_multiplier);
828    }
829
830    #[test]
831    fn test_economic_cycle() {
832        let config = DriftConfig {
833            enabled: true,
834            drift_type: DriftType::EconomicCycle,
835            economic_cycle: EconomicCycleConfig {
836                enabled: true,
837                cycle_length: 12, // 1-year cycle for testing
838                amplitude: 0.20,
839                phase_offset: 0,
840                recession_periods: vec![],
841                recession_severity: 0.75,
842            },
843            ..Default::default()
844        };
845        let controller = DriftController::new(config, 42, 24);
846
847        // At cycle start (period 0)
848        let adj_0 = controller.compute_adjustments(0);
849        // At cycle peak (period 3, which is 25% through cycle = 90 degrees)
850        let adj_3 = controller.compute_adjustments(3);
851        // At cycle trough (period 9, which is 75% through cycle = 270 degrees)
852        let adj_9 = controller.compute_adjustments(9);
853
854        // Peak should be higher than start
855        assert!(adj_3.economic_cycle_factor > adj_0.economic_cycle_factor);
856        // Trough should be lower than start
857        assert!(adj_9.economic_cycle_factor < adj_0.economic_cycle_factor);
858    }
859
860    #[test]
861    fn test_economic_cycle_recession() {
862        let config = DriftConfig {
863            enabled: true,
864            drift_type: DriftType::EconomicCycle,
865            economic_cycle: EconomicCycleConfig {
866                enabled: true,
867                cycle_length: 12,
868                amplitude: 0.10,
869                phase_offset: 0,
870                recession_periods: vec![6, 7, 8],
871                recession_severity: 0.70,
872            },
873            ..Default::default()
874        };
875        let controller = DriftController::new(config, 42, 12);
876
877        // Not in recession
878        let adj_5 = controller.compute_adjustments(5);
879        assert!(!adj_5.in_recession);
880
881        // In recession
882        let adj_7 = controller.compute_adjustments(7);
883        assert!(adj_7.in_recession);
884        assert!(adj_7.economic_cycle_factor < adj_5.economic_cycle_factor);
885    }
886
887    #[test]
888    fn test_parameter_drift_linear() {
889        let config = DriftConfig {
890            enabled: true,
891            drift_type: DriftType::Gradual,
892            parameter_drifts: vec![ParameterDrift {
893                parameter: "discount_rate".to_string(),
894                drift_type: ParameterDriftType::Linear,
895                initial_value: 0.02,
896                target_or_rate: 0.001, // Increases by 0.1% per period
897                start_period: 0,
898                end_period: None,
899                steepness: 0.1,
900            }],
901            ..Default::default()
902        };
903        let controller = DriftController::new(config, 42, 12);
904
905        let adj_0 = controller.compute_adjustments(0);
906        let adj_6 = controller.compute_adjustments(6);
907
908        let rate_0 = adj_0.parameter_values.get("discount_rate").unwrap();
909        let rate_6 = adj_6.parameter_values.get("discount_rate").unwrap();
910
911        // Should increase linearly
912        assert!((rate_0 - 0.02).abs() < 0.0001);
913        assert!((rate_6 - 0.026).abs() < 0.0001);
914    }
915
916    #[test]
917    fn test_parameter_drift_logistic() {
918        let config = DriftConfig {
919            enabled: true,
920            drift_type: DriftType::Gradual,
921            parameter_drifts: vec![ParameterDrift {
922                parameter: "market_share".to_string(),
923                drift_type: ParameterDriftType::Logistic,
924                initial_value: 0.10,  // 10% starting market share
925                target_or_rate: 0.40, // 40% target market share
926                start_period: 0,
927                end_period: Some(24), // 24 period transition
928                steepness: 0.3,
929            }],
930            ..Default::default()
931        };
932        let controller = DriftController::new(config, 42, 36);
933
934        let adj_0 = controller.compute_adjustments(0);
935        let adj_12 = controller.compute_adjustments(12);
936        let adj_24 = controller.compute_adjustments(24);
937
938        let share_0 = *adj_0.parameter_values.get("market_share").unwrap();
939        let share_12 = *adj_12.parameter_values.get("market_share").unwrap();
940        let share_24 = *adj_24.parameter_values.get("market_share").unwrap();
941
942        // S-curve: starts slow, accelerates in middle, slows at end
943        assert!(share_0 < 0.15); // Near initial
944        assert!(share_12 > 0.20 && share_12 < 0.30); // Around midpoint
945        assert!(share_24 > 0.35); // Near target
946    }
947
948    #[test]
949    fn test_combined_drift_adjustments() {
950        let adj = DriftAdjustments {
951            amount_mean_multiplier: 1.2,
952            seasonal_factor: 1.1,
953            economic_cycle_factor: 0.9,
954            volume_multiplier: 1.3,
955            ..DriftAdjustments::none()
956        };
957
958        // Combined amount = 1.2 * 1.1 * 0.9 = 1.188
959        assert!((adj.combined_amount_multiplier() - 1.188).abs() < 0.001);
960
961        // Combined volume = 1.3 * 1.1 * 0.9 = 1.287
962        assert!((adj.combined_volume_multiplier() - 1.287).abs() < 0.001);
963    }
964
965    #[test]
966    fn test_regime_change_volume_multipliers() {
967        assert!(RegimeChange::new(0, RegimeChangeType::Acquisition).volume_multiplier() > 1.0);
968        assert!(RegimeChange::new(0, RegimeChangeType::Divestiture).volume_multiplier() < 1.0);
969        assert!(
970            (RegimeChange::new(0, RegimeChangeType::PolicyChange).volume_multiplier() - 1.0).abs()
971                < 0.001
972        );
973    }
974}