Skip to main content

datasynth_core/distributions/
temporal.rs

1//! Temporal distribution samplers for realistic posting patterns.
2//!
3//! Implements seasonality, working hour patterns, and period-end spikes
4//! commonly observed in enterprise accounting systems.
5
6use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use serde::{Deserialize, Serialize};
10
11use super::holidays::HolidayCalendar;
12use super::period_end::PeriodEndDynamics;
13use super::seasonality::IndustrySeasonality;
14
15/// Configuration for seasonality patterns.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SeasonalityConfig {
18    /// Enable month-end volume spikes
19    pub month_end_spike: bool,
20    /// Month-end spike multiplier (e.g., 2.5 = 2.5x normal volume)
21    pub month_end_multiplier: f64,
22    /// Days before month-end to start spike
23    pub month_end_lead_days: u32,
24
25    /// Enable quarter-end spikes
26    pub quarter_end_spike: bool,
27    /// Quarter-end spike multiplier
28    pub quarter_end_multiplier: f64,
29
30    /// Enable year-end spikes
31    pub year_end_spike: bool,
32    /// Year-end spike multiplier
33    pub year_end_multiplier: f64,
34
35    /// Activity level on weekends (0.0 = no activity, 1.0 = normal)
36    pub weekend_activity: f64,
37    /// Activity level on holidays
38    pub holiday_activity: f64,
39
40    /// Enable day-of-week patterns (Monday catch-up, Friday slowdown)
41    pub day_of_week_patterns: bool,
42    /// Monday activity multiplier (catch-up from weekend)
43    pub monday_multiplier: f64,
44    /// Tuesday activity multiplier
45    pub tuesday_multiplier: f64,
46    /// Wednesday activity multiplier
47    pub wednesday_multiplier: f64,
48    /// Thursday activity multiplier
49    pub thursday_multiplier: f64,
50    /// Friday activity multiplier (early departures)
51    pub friday_multiplier: f64,
52}
53
54impl Default for SeasonalityConfig {
55    fn default() -> Self {
56        Self {
57            month_end_spike: true,
58            month_end_multiplier: 2.5,
59            month_end_lead_days: 5,
60            quarter_end_spike: true,
61            quarter_end_multiplier: 4.0,
62            year_end_spike: true,
63            year_end_multiplier: 6.0,
64            weekend_activity: 0.1,
65            holiday_activity: 0.05,
66            // Day-of-week patterns: humans work differently across the week
67            day_of_week_patterns: true,
68            monday_multiplier: 1.3,    // Catch-up from weekend backlog
69            tuesday_multiplier: 1.1,   // Still catching up
70            wednesday_multiplier: 1.0, // Midweek normal
71            thursday_multiplier: 1.0,  // Midweek normal
72            friday_multiplier: 0.85,   // Early departures, winding down
73        }
74    }
75}
76
77/// Configuration for working hours pattern.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct WorkingHoursConfig {
80    /// Start of working day (hour, 0-23)
81    pub day_start: u8,
82    /// End of working day (hour, 0-23)
83    pub day_end: u8,
84    /// Peak hours during the day
85    pub peak_hours: Vec<u8>,
86    /// Weight for peak hours (multiplier)
87    pub peak_weight: f64,
88    /// Probability of after-hours posting
89    pub after_hours_probability: f64,
90}
91
92impl Default for WorkingHoursConfig {
93    fn default() -> Self {
94        Self {
95            day_start: 8,
96            day_end: 18,
97            peak_hours: vec![9, 10, 11, 14, 15, 16],
98            peak_weight: 1.5,
99            after_hours_probability: 0.05,
100        }
101    }
102}
103
104/// Configuration for intra-day posting patterns.
105///
106/// Defines segments of the business day with different activity multipliers,
107/// allowing for realistic modeling of morning spikes, lunch dips, and end-of-day rushes.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct IntraDayPatterns {
110    /// Whether intra-day patterns are enabled.
111    pub enabled: bool,
112    /// Time segments with activity multipliers.
113    pub segments: Vec<IntraDaySegment>,
114}
115
116impl Default for IntraDayPatterns {
117    fn default() -> Self {
118        Self {
119            enabled: true,
120            segments: vec![
121                IntraDaySegment {
122                    name: "morning_spike".to_string(),
123                    start: NaiveTime::from_hms_opt(8, 30, 0).unwrap(),
124                    end: NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
125                    multiplier: 1.8,
126                    posting_type: PostingType::Both,
127                },
128                IntraDaySegment {
129                    name: "mid_morning".to_string(),
130                    start: NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
131                    end: NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
132                    multiplier: 1.2,
133                    posting_type: PostingType::Both,
134                },
135                IntraDaySegment {
136                    name: "lunch_dip".to_string(),
137                    start: NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
138                    end: NaiveTime::from_hms_opt(13, 30, 0).unwrap(),
139                    multiplier: 0.4,
140                    posting_type: PostingType::Human,
141                },
142                IntraDaySegment {
143                    name: "afternoon".to_string(),
144                    start: NaiveTime::from_hms_opt(13, 30, 0).unwrap(),
145                    end: NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
146                    multiplier: 1.1,
147                    posting_type: PostingType::Both,
148                },
149                IntraDaySegment {
150                    name: "eod_rush".to_string(),
151                    start: NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
152                    end: NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
153                    multiplier: 1.5,
154                    posting_type: PostingType::Both,
155                },
156            ],
157        }
158    }
159}
160
161impl IntraDayPatterns {
162    /// Creates intra-day patterns with no segments (disabled).
163    pub fn disabled() -> Self {
164        Self {
165            enabled: false,
166            segments: Vec::new(),
167        }
168    }
169
170    /// Creates patterns with custom segments.
171    pub fn with_segments(segments: Vec<IntraDaySegment>) -> Self {
172        Self {
173            enabled: true,
174            segments,
175        }
176    }
177
178    /// Gets the multiplier for a given time based on posting type.
179    pub fn get_multiplier(&self, time: NaiveTime, is_human: bool) -> f64 {
180        if !self.enabled {
181            return 1.0;
182        }
183
184        for segment in &self.segments {
185            if time >= segment.start && time < segment.end {
186                // Check if this segment applies to the posting type
187                let applies = match segment.posting_type {
188                    PostingType::Human => is_human,
189                    PostingType::System => !is_human,
190                    PostingType::Both => true,
191                };
192                if applies {
193                    return segment.multiplier;
194                }
195            }
196        }
197
198        1.0 // Default multiplier if no segment matches
199    }
200}
201
202/// A segment of the business day with specific activity patterns.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct IntraDaySegment {
205    /// Name of the segment (e.g., "morning_spike", "lunch_dip").
206    pub name: String,
207    /// Start time of the segment.
208    pub start: NaiveTime,
209    /// End time of the segment.
210    pub end: NaiveTime,
211    /// Activity multiplier for this segment (1.0 = normal).
212    pub multiplier: f64,
213    /// Type of postings this segment applies to.
214    pub posting_type: PostingType,
215}
216
217/// Type of posting for intra-day pattern matching.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum PostingType {
221    /// Human/manual postings only.
222    Human,
223    /// System/automated postings only.
224    System,
225    /// Both human and system postings.
226    Both,
227}
228
229/// Sampler for temporal patterns in transaction generation.
230pub struct TemporalSampler {
231    rng: ChaCha8Rng,
232    seasonality_config: SeasonalityConfig,
233    working_hours_config: WorkingHoursConfig,
234    /// List of holiday dates (legacy)
235    holidays: Vec<NaiveDate>,
236    /// Industry-specific seasonality patterns (optional).
237    industry_seasonality: Option<IndustrySeasonality>,
238    /// Regional holiday calendar (optional).
239    holiday_calendar: Option<HolidayCalendar>,
240    /// Period-end dynamics for decay curves (optional).
241    period_end_dynamics: Option<PeriodEndDynamics>,
242    /// Whether to use period-end dynamics instead of legacy flat multipliers.
243    use_period_end_dynamics: bool,
244    /// Intra-day patterns for time-of-day activity variation.
245    intra_day_patterns: Option<IntraDayPatterns>,
246}
247
248impl TemporalSampler {
249    /// Create a new temporal sampler.
250    pub fn new(seed: u64) -> Self {
251        Self::with_config(
252            seed,
253            SeasonalityConfig::default(),
254            WorkingHoursConfig::default(),
255            Vec::new(),
256        )
257    }
258
259    /// Create a temporal sampler with custom configuration.
260    pub fn with_config(
261        seed: u64,
262        seasonality_config: SeasonalityConfig,
263        working_hours_config: WorkingHoursConfig,
264        holidays: Vec<NaiveDate>,
265    ) -> Self {
266        Self {
267            rng: ChaCha8Rng::seed_from_u64(seed),
268            seasonality_config,
269            working_hours_config,
270            holidays,
271            industry_seasonality: None,
272            holiday_calendar: None,
273            period_end_dynamics: None,
274            use_period_end_dynamics: false,
275            intra_day_patterns: None,
276        }
277    }
278
279    /// Create a temporal sampler with full enhanced configuration.
280    #[allow(clippy::too_many_arguments)]
281    pub fn with_full_config(
282        seed: u64,
283        seasonality_config: SeasonalityConfig,
284        working_hours_config: WorkingHoursConfig,
285        holidays: Vec<NaiveDate>,
286        industry_seasonality: Option<IndustrySeasonality>,
287        holiday_calendar: Option<HolidayCalendar>,
288    ) -> Self {
289        Self {
290            rng: ChaCha8Rng::seed_from_u64(seed),
291            seasonality_config,
292            working_hours_config,
293            holidays,
294            industry_seasonality,
295            holiday_calendar,
296            period_end_dynamics: None,
297            use_period_end_dynamics: false,
298            intra_day_patterns: None,
299        }
300    }
301
302    /// Create a temporal sampler with period-end dynamics.
303    #[allow(clippy::too_many_arguments)]
304    pub fn with_period_end_dynamics(
305        seed: u64,
306        seasonality_config: SeasonalityConfig,
307        working_hours_config: WorkingHoursConfig,
308        holidays: Vec<NaiveDate>,
309        industry_seasonality: Option<IndustrySeasonality>,
310        holiday_calendar: Option<HolidayCalendar>,
311        period_end_dynamics: PeriodEndDynamics,
312    ) -> Self {
313        Self {
314            rng: ChaCha8Rng::seed_from_u64(seed),
315            seasonality_config,
316            working_hours_config,
317            holidays,
318            industry_seasonality,
319            holiday_calendar,
320            period_end_dynamics: Some(period_end_dynamics),
321            use_period_end_dynamics: true,
322            intra_day_patterns: None,
323        }
324    }
325
326    /// Sets the intra-day patterns for time-of-day activity variation.
327    pub fn set_intra_day_patterns(&mut self, patterns: IntraDayPatterns) {
328        self.intra_day_patterns = Some(patterns);
329    }
330
331    /// Gets the intra-day multiplier for a given time.
332    pub fn get_intra_day_multiplier(&self, time: NaiveTime, is_human: bool) -> f64 {
333        self.intra_day_patterns
334            .as_ref()
335            .map(|p| p.get_multiplier(time, is_human))
336            .unwrap_or(1.0)
337    }
338
339    /// Set industry-specific seasonality.
340    pub fn with_industry_seasonality(mut self, seasonality: IndustrySeasonality) -> Self {
341        self.industry_seasonality = Some(seasonality);
342        self
343    }
344
345    /// Set regional holiday calendar.
346    pub fn with_holiday_calendar(mut self, calendar: HolidayCalendar) -> Self {
347        self.holiday_calendar = Some(calendar);
348        self
349    }
350
351    /// Set industry seasonality (mutable reference version).
352    pub fn set_industry_seasonality(&mut self, seasonality: IndustrySeasonality) {
353        self.industry_seasonality = Some(seasonality);
354    }
355
356    /// Set holiday calendar (mutable reference version).
357    pub fn set_holiday_calendar(&mut self, calendar: HolidayCalendar) {
358        self.holiday_calendar = Some(calendar);
359    }
360
361    /// Set period-end dynamics.
362    pub fn with_period_end(mut self, dynamics: PeriodEndDynamics) -> Self {
363        self.period_end_dynamics = Some(dynamics);
364        self.use_period_end_dynamics = true;
365        self
366    }
367
368    /// Set period-end dynamics (mutable reference version).
369    pub fn set_period_end_dynamics(&mut self, dynamics: PeriodEndDynamics) {
370        self.period_end_dynamics = Some(dynamics);
371        self.use_period_end_dynamics = true;
372    }
373
374    /// Get the period-end dynamics if set.
375    pub fn period_end_dynamics(&self) -> Option<&PeriodEndDynamics> {
376        self.period_end_dynamics.as_ref()
377    }
378
379    /// Enable or disable period-end dynamics usage.
380    pub fn set_use_period_end_dynamics(&mut self, enabled: bool) {
381        self.use_period_end_dynamics = enabled;
382    }
383
384    /// Get the industry seasonality if set.
385    pub fn industry_seasonality(&self) -> Option<&IndustrySeasonality> {
386        self.industry_seasonality.as_ref()
387    }
388
389    /// Get the holiday calendar if set.
390    pub fn holiday_calendar(&self) -> Option<&HolidayCalendar> {
391        self.holiday_calendar.as_ref()
392    }
393
394    /// Generate US federal holidays for a given year.
395    pub fn generate_us_holidays(year: i32) -> Vec<NaiveDate> {
396        let mut holidays = Vec::new();
397
398        // New Year's Day
399        holidays.push(NaiveDate::from_ymd_opt(year, 1, 1).unwrap());
400        // Independence Day
401        holidays.push(NaiveDate::from_ymd_opt(year, 7, 4).unwrap());
402        // Christmas
403        holidays.push(NaiveDate::from_ymd_opt(year, 12, 25).unwrap());
404        // Thanksgiving (4th Thursday of November)
405        let first_thursday = (1..=7)
406            .map(|d| NaiveDate::from_ymd_opt(year, 11, d).unwrap())
407            .find(|d| d.weekday() == Weekday::Thu)
408            .unwrap();
409        let thanksgiving = first_thursday + Duration::weeks(3);
410        holidays.push(thanksgiving);
411
412        holidays
413    }
414
415    /// Check if a date is a weekend.
416    pub fn is_weekend(&self, date: NaiveDate) -> bool {
417        matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
418    }
419
420    /// Get the day-of-week activity multiplier.
421    ///
422    /// Returns a multiplier based on the day of the week:
423    /// - Monday: Higher activity (catch-up from weekend)
424    /// - Tuesday: Slightly elevated
425    /// - Wednesday/Thursday: Normal
426    /// - Friday: Reduced (early departures, winding down)
427    /// - Saturday/Sunday: Uses weekend_activity setting
428    pub fn get_day_of_week_multiplier(&self, date: NaiveDate) -> f64 {
429        if !self.seasonality_config.day_of_week_patterns {
430            return 1.0;
431        }
432
433        match date.weekday() {
434            Weekday::Mon => self.seasonality_config.monday_multiplier,
435            Weekday::Tue => self.seasonality_config.tuesday_multiplier,
436            Weekday::Wed => self.seasonality_config.wednesday_multiplier,
437            Weekday::Thu => self.seasonality_config.thursday_multiplier,
438            Weekday::Fri => self.seasonality_config.friday_multiplier,
439            Weekday::Sat | Weekday::Sun => 1.0, // Weekend activity handled separately
440        }
441    }
442
443    /// Check if a date is a holiday.
444    pub fn is_holiday(&self, date: NaiveDate) -> bool {
445        // Check legacy holidays list
446        if self.holidays.contains(&date) {
447            return true;
448        }
449
450        // Check holiday calendar if available
451        if let Some(ref calendar) = self.holiday_calendar {
452            if calendar.is_holiday(date) {
453                return true;
454            }
455        }
456
457        false
458    }
459
460    /// Get the holiday activity multiplier for a date.
461    fn get_holiday_multiplier(&self, date: NaiveDate) -> f64 {
462        // Check holiday calendar first (more accurate)
463        if let Some(ref calendar) = self.holiday_calendar {
464            let mult = calendar.get_multiplier(date);
465            if mult < 1.0 {
466                return mult;
467            }
468        }
469
470        // Fall back to legacy holidays with default multiplier
471        if self.holidays.contains(&date) {
472            return self.seasonality_config.holiday_activity;
473        }
474
475        1.0
476    }
477
478    /// Check if a date is month-end (last N days of month).
479    pub fn is_month_end(&self, date: NaiveDate) -> bool {
480        let last_day = Self::last_day_of_month(date);
481        let days_until_end = (last_day - date).num_days();
482        days_until_end >= 0 && days_until_end < self.seasonality_config.month_end_lead_days as i64
483    }
484
485    /// Check if a date is quarter-end.
486    pub fn is_quarter_end(&self, date: NaiveDate) -> bool {
487        let month = date.month();
488        let is_quarter_end_month = matches!(month, 3 | 6 | 9 | 12);
489        is_quarter_end_month && self.is_month_end(date)
490    }
491
492    /// Check if a date is year-end.
493    pub fn is_year_end(&self, date: NaiveDate) -> bool {
494        date.month() == 12 && self.is_month_end(date)
495    }
496
497    /// Get the last day of the month for a given date.
498    pub fn last_day_of_month(date: NaiveDate) -> NaiveDate {
499        let year = date.year();
500        let month = date.month();
501
502        if month == 12 {
503            NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() - Duration::days(1)
504        } else {
505            NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() - Duration::days(1)
506        }
507    }
508
509    /// Get the activity multiplier for a specific date.
510    ///
511    /// Combines:
512    /// - Base seasonality (month-end, quarter-end, year-end spikes)
513    /// - Day-of-week patterns (Monday catch-up, Friday slowdown)
514    /// - Weekend activity reduction
515    /// - Holiday activity reduction (from calendar or legacy list)
516    /// - Industry-specific seasonality (if configured)
517    /// - Period-end dynamics (if configured, replaces legacy flat multipliers)
518    pub fn get_date_multiplier(&self, date: NaiveDate) -> f64 {
519        let mut multiplier = 1.0;
520
521        // Weekend reduction
522        if self.is_weekend(date) {
523            multiplier *= self.seasonality_config.weekend_activity;
524        } else {
525            // Day-of-week patterns (only for weekdays)
526            multiplier *= self.get_day_of_week_multiplier(date);
527        }
528
529        // Holiday reduction (using enhanced calendar if available)
530        let holiday_mult = self.get_holiday_multiplier(date);
531        if holiday_mult < 1.0 {
532            multiplier *= holiday_mult;
533        }
534
535        // Period-end spikes - use dynamics if available, otherwise legacy flat multipliers
536        if self.use_period_end_dynamics {
537            if let Some(ref dynamics) = self.period_end_dynamics {
538                let period_mult = dynamics.get_multiplier_for_date(date);
539                multiplier *= period_mult;
540            }
541        } else {
542            // Legacy flat multipliers (take the highest applicable)
543            if self.seasonality_config.year_end_spike && self.is_year_end(date) {
544                multiplier *= self.seasonality_config.year_end_multiplier;
545            } else if self.seasonality_config.quarter_end_spike && self.is_quarter_end(date) {
546                multiplier *= self.seasonality_config.quarter_end_multiplier;
547            } else if self.seasonality_config.month_end_spike && self.is_month_end(date) {
548                multiplier *= self.seasonality_config.month_end_multiplier;
549            }
550        }
551
552        // Industry-specific seasonality
553        if let Some(ref industry) = self.industry_seasonality {
554            let industry_mult = industry.get_multiplier(date);
555            // Industry multipliers are additive to base (they represent deviations from normal)
556            // A multiplier > 1.0 increases activity, < 1.0 decreases it
557            multiplier *= industry_mult;
558        }
559
560        multiplier
561    }
562
563    /// Get the period-end multiplier for a date.
564    ///
565    /// Returns the period-end component of the date multiplier,
566    /// using dynamics if available, otherwise legacy flat multipliers.
567    pub fn get_period_end_multiplier(&self, date: NaiveDate) -> f64 {
568        if self.use_period_end_dynamics {
569            if let Some(ref dynamics) = self.period_end_dynamics {
570                return dynamics.get_multiplier_for_date(date);
571            }
572        }
573
574        // Legacy flat multipliers
575        if self.seasonality_config.year_end_spike && self.is_year_end(date) {
576            self.seasonality_config.year_end_multiplier
577        } else if self.seasonality_config.quarter_end_spike && self.is_quarter_end(date) {
578            self.seasonality_config.quarter_end_multiplier
579        } else if self.seasonality_config.month_end_spike && self.is_month_end(date) {
580            self.seasonality_config.month_end_multiplier
581        } else {
582            1.0
583        }
584    }
585
586    /// Get the base multiplier without industry seasonality.
587    pub fn get_base_date_multiplier(&self, date: NaiveDate) -> f64 {
588        let mut multiplier = 1.0;
589
590        if self.is_weekend(date) {
591            multiplier *= self.seasonality_config.weekend_activity;
592        } else {
593            // Day-of-week patterns (only for weekdays)
594            multiplier *= self.get_day_of_week_multiplier(date);
595        }
596
597        let holiday_mult = self.get_holiday_multiplier(date);
598        if holiday_mult < 1.0 {
599            multiplier *= holiday_mult;
600        }
601
602        // Period-end spikes - use dynamics if available
603        if self.use_period_end_dynamics {
604            if let Some(ref dynamics) = self.period_end_dynamics {
605                let period_mult = dynamics.get_multiplier_for_date(date);
606                multiplier *= period_mult;
607            }
608        } else {
609            // Legacy flat multipliers
610            if self.seasonality_config.year_end_spike && self.is_year_end(date) {
611                multiplier *= self.seasonality_config.year_end_multiplier;
612            } else if self.seasonality_config.quarter_end_spike && self.is_quarter_end(date) {
613                multiplier *= self.seasonality_config.quarter_end_multiplier;
614            } else if self.seasonality_config.month_end_spike && self.is_month_end(date) {
615                multiplier *= self.seasonality_config.month_end_multiplier;
616            }
617        }
618
619        multiplier
620    }
621
622    /// Get only the industry seasonality multiplier for a date.
623    pub fn get_industry_multiplier(&self, date: NaiveDate) -> f64 {
624        self.industry_seasonality
625            .as_ref()
626            .map(|s| s.get_multiplier(date))
627            .unwrap_or(1.0)
628    }
629
630    /// Sample a posting date within a range based on seasonality.
631    pub fn sample_date(&mut self, start: NaiveDate, end: NaiveDate) -> NaiveDate {
632        let days = (end - start).num_days() as usize;
633        if days == 0 {
634            return start;
635        }
636
637        // Build weighted distribution based on activity levels
638        let mut weights: Vec<f64> = (0..=days)
639            .map(|d| {
640                let date = start + Duration::days(d as i64);
641                self.get_date_multiplier(date)
642            })
643            .collect();
644
645        // Normalize weights
646        let total: f64 = weights.iter().sum();
647        weights.iter_mut().for_each(|w| *w /= total);
648
649        // Sample using weights
650        let p: f64 = self.rng.gen();
651        let mut cumulative = 0.0;
652        for (i, weight) in weights.iter().enumerate() {
653            cumulative += weight;
654            if p < cumulative {
655                return start + Duration::days(i as i64);
656            }
657        }
658
659        end
660    }
661
662    /// Sample a posting time based on working hours.
663    pub fn sample_time(&mut self, is_human: bool) -> NaiveTime {
664        if !is_human {
665            // Automated systems can post any time, but prefer off-hours
666            let hour = if self.rng.gen::<f64>() < 0.7 {
667                // 70% off-peak hours (night batch processing)
668                self.rng.gen_range(22..=23).clamp(0, 23)
669                    + if self.rng.gen_bool(0.5) {
670                        0
671                    } else {
672                        self.rng.gen_range(0..=5)
673                    }
674            } else {
675                self.rng.gen_range(0..24)
676            };
677            let minute = self.rng.gen_range(0..60);
678            let second = self.rng.gen_range(0..60);
679            return NaiveTime::from_hms_opt(hour.clamp(0, 23) as u32, minute, second).unwrap();
680        }
681
682        // Human users follow working hours
683        let hour = if self.rng.gen::<f64>() < self.working_hours_config.after_hours_probability {
684            // After hours
685            if self.rng.gen_bool(0.5) {
686                self.rng.gen_range(6..self.working_hours_config.day_start)
687            } else {
688                self.rng.gen_range(self.working_hours_config.day_end..22)
689            }
690        } else {
691            // Normal working hours with peak weighting
692            let is_peak = self.rng.gen::<f64>() < 0.6; // 60% during peak
693            if is_peak && !self.working_hours_config.peak_hours.is_empty() {
694                *self
695                    .working_hours_config
696                    .peak_hours
697                    .choose(&mut self.rng)
698                    .unwrap()
699            } else {
700                self.rng.gen_range(
701                    self.working_hours_config.day_start..self.working_hours_config.day_end,
702                )
703            }
704        };
705
706        let minute = self.rng.gen_range(0..60);
707        let second = self.rng.gen_range(0..60);
708
709        NaiveTime::from_hms_opt(hour as u32, minute, second).unwrap()
710    }
711
712    /// Calculate expected transaction count for a date given daily average.
713    pub fn expected_count_for_date(&self, date: NaiveDate, daily_average: f64) -> u64 {
714        let multiplier = self.get_date_multiplier(date);
715        (daily_average * multiplier).round() as u64
716    }
717
718    /// Reset the sampler with a new seed.
719    pub fn reset(&mut self, seed: u64) {
720        self.rng = ChaCha8Rng::seed_from_u64(seed);
721    }
722}
723
724/// Time period specification for generation.
725#[derive(Debug, Clone)]
726pub struct TimePeriod {
727    /// Start date (inclusive)
728    pub start_date: NaiveDate,
729    /// End date (inclusive)
730    pub end_date: NaiveDate,
731    /// Fiscal year
732    pub fiscal_year: u16,
733    /// Fiscal periods covered
734    pub fiscal_periods: Vec<u8>,
735}
736
737impl TimePeriod {
738    /// Create a time period for a full fiscal year.
739    pub fn fiscal_year(year: u16) -> Self {
740        Self {
741            start_date: NaiveDate::from_ymd_opt(year as i32, 1, 1).unwrap(),
742            end_date: NaiveDate::from_ymd_opt(year as i32, 12, 31).unwrap(),
743            fiscal_year: year,
744            fiscal_periods: (1..=12).collect(),
745        }
746    }
747
748    /// Create a time period for specific months.
749    pub fn months(year: u16, start_month: u8, num_months: u8) -> Self {
750        let start_date = NaiveDate::from_ymd_opt(year as i32, start_month as u32, 1).unwrap();
751        let end_month = ((start_month - 1 + num_months - 1) % 12) + 1;
752        let end_year = year + (start_month as u16 - 1 + num_months as u16 - 1) / 12;
753        let end_date = TemporalSampler::last_day_of_month(
754            NaiveDate::from_ymd_opt(end_year as i32, end_month as u32, 1).unwrap(),
755        );
756
757        Self {
758            start_date,
759            end_date,
760            fiscal_year: year,
761            fiscal_periods: (start_month..start_month + num_months).collect(),
762        }
763    }
764
765    /// Get total days in the period.
766    pub fn total_days(&self) -> i64 {
767        (self.end_date - self.start_date).num_days() + 1
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use chrono::Timelike;
775
776    #[test]
777    fn test_is_weekend() {
778        let sampler = TemporalSampler::new(42);
779        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
780        let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
781        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
782
783        assert!(sampler.is_weekend(saturday));
784        assert!(sampler.is_weekend(sunday));
785        assert!(!sampler.is_weekend(monday));
786    }
787
788    #[test]
789    fn test_is_month_end() {
790        let sampler = TemporalSampler::new(42);
791        let month_end = NaiveDate::from_ymd_opt(2024, 6, 28).unwrap();
792        let month_start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
793
794        assert!(sampler.is_month_end(month_end));
795        assert!(!sampler.is_month_end(month_start));
796    }
797
798    #[test]
799    fn test_date_multiplier() {
800        let sampler = TemporalSampler::new(42);
801
802        // Regular weekday (Wednesday = 1.0)
803        let regular_day = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap(); // Wednesday
804        assert!((sampler.get_date_multiplier(regular_day) - 1.0).abs() < 0.01);
805
806        // Weekend
807        let weekend = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); // Saturday
808        assert!(sampler.get_date_multiplier(weekend) < 0.2);
809
810        // Month end
811        let month_end = NaiveDate::from_ymd_opt(2024, 6, 28).unwrap();
812        assert!(sampler.get_date_multiplier(month_end) > 2.0);
813    }
814
815    #[test]
816    fn test_day_of_week_patterns() {
817        let sampler = TemporalSampler::new(42);
818
819        // June 2024: 10=Mon, 11=Tue, 12=Wed, 13=Thu, 14=Fri
820        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
821        let tuesday = NaiveDate::from_ymd_opt(2024, 6, 11).unwrap();
822        let wednesday = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
823        let thursday = NaiveDate::from_ymd_opt(2024, 6, 13).unwrap();
824        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
825
826        // Monday should have highest weekday multiplier (catch-up)
827        let mon_mult = sampler.get_day_of_week_multiplier(monday);
828        assert!((mon_mult - 1.3).abs() < 0.01);
829
830        // Tuesday slightly elevated
831        let tue_mult = sampler.get_day_of_week_multiplier(tuesday);
832        assert!((tue_mult - 1.1).abs() < 0.01);
833
834        // Wednesday/Thursday normal
835        let wed_mult = sampler.get_day_of_week_multiplier(wednesday);
836        let thu_mult = sampler.get_day_of_week_multiplier(thursday);
837        assert!((wed_mult - 1.0).abs() < 0.01);
838        assert!((thu_mult - 1.0).abs() < 0.01);
839
840        // Friday reduced (winding down)
841        let fri_mult = sampler.get_day_of_week_multiplier(friday);
842        assert!((fri_mult - 0.85).abs() < 0.01);
843
844        // Verify the pattern is applied in get_date_multiplier
845        // (excluding period-end effects)
846        assert!(sampler.get_date_multiplier(monday) > sampler.get_date_multiplier(friday));
847    }
848
849    #[test]
850    fn test_sample_time_human() {
851        let mut sampler = TemporalSampler::new(42);
852
853        for _ in 0..100 {
854            let time = sampler.sample_time(true);
855            // Most times should be during working hours
856            let hour = time.hour();
857            // Just verify it's a valid time
858            assert!(hour < 24);
859        }
860    }
861
862    #[test]
863    fn test_time_period() {
864        let period = TimePeriod::fiscal_year(2024);
865        assert_eq!(period.total_days(), 366); // 2024 is leap year
866
867        let partial = TimePeriod::months(2024, 1, 6);
868        assert!(partial.total_days() > 180);
869        assert!(partial.total_days() < 185);
870    }
871}