Skip to main content

datasynth_core/distributions/
temporal_context.rs

1//! Unified temporal-context bundle (v3.4.1+).
2//!
3//! Historically each generator built its own [`HolidayCalendar`] +
4//! [`BusinessDayCalculator`], typically only for a single year. That left
5//! multi-year pipelines silently skipping holiday suppression for years
6//! outside the first one, and required each generator to replicate the
7//! same construction boilerplate.
8//!
9//! [`TemporalContext`] centralises that construction: given a `Region` and
10//! an inclusive `(start_date, end_date)` span, it builds a single
11//! [`BusinessDayCalculator`] whose holiday calendar is the **union** of
12//! `HolidayCalendar::for_region(region, year)` for every year in the span.
13//! Generators then hold an `Arc<TemporalContext>` and call the convenience
14//! methods on it (`is_business_day`, `adjust_to_business_day`,
15//! `sample_business_day_in_range`) instead of tracking their own state.
16//!
17//! Construction is `O(years × holidays-per-year)` — negligible. Lookups
18//! route through the wrapped calculator, which is already tuned for the
19//! existing JE generator at ~200k entries/sec.
20
21use chrono::{Datelike, Duration, NaiveDate};
22use rand::{Rng, RngExt};
23use std::sync::Arc;
24
25use super::business_day::BusinessDayCalculator;
26use super::holidays::{HolidayCalendar, Region};
27
28/// Unified temporal-context bundle for business-day / holiday awareness.
29///
30/// `Arc<TemporalContext>` is the intended ownership model — construct once
31/// in the orchestrator, clone into each generator via
32/// `generator.set_temporal_context(Arc::clone(&ctx))`.
33#[derive(Debug, Clone)]
34pub struct TemporalContext {
35    region: Region,
36    start_date: NaiveDate,
37    end_date: NaiveDate,
38    calculator: BusinessDayCalculator,
39}
40
41impl TemporalContext {
42    /// Build a temporal context that covers the inclusive span
43    /// `[start_date, end_date]` in the given `region`.
44    ///
45    /// Holiday calendars are loaded per-year and merged into a single
46    /// calendar so multi-year pipelines work correctly.
47    pub fn new(region: Region, start_date: NaiveDate, end_date: NaiveDate) -> Self {
48        let start_year = start_date.year();
49        // The end-year may extend by up to one year because settlement rules
50        // (T+N, month-end-rollovers) can push posting dates past the raw
51        // `end_date`. Add a one-year buffer so the calculator covers those.
52        let end_year = end_date.year() + 1;
53
54        let mut merged = HolidayCalendar::new(region, start_year);
55        for year in start_year..=end_year {
56            let yearly = HolidayCalendar::for_region(region, year);
57            for holiday in yearly.holidays {
58                merged.add_holiday(holiday);
59            }
60        }
61
62        let calculator = BusinessDayCalculator::new(merged);
63
64        Self {
65            region,
66            start_date,
67            end_date,
68            calculator,
69        }
70    }
71
72    /// Convenience wrapper around [`TemporalContext::new`] that returns an
73    /// `Arc`.
74    pub fn shared(region: Region, start_date: NaiveDate, end_date: NaiveDate) -> Arc<Self> {
75        Arc::new(Self::new(region, start_date, end_date))
76    }
77
78    /// Region this context was built for.
79    pub fn region(&self) -> Region {
80        self.region
81    }
82
83    /// Inclusive start of the covered span.
84    pub fn start_date(&self) -> NaiveDate {
85        self.start_date
86    }
87
88    /// Inclusive end of the covered span.
89    pub fn end_date(&self) -> NaiveDate {
90        self.end_date
91    }
92
93    /// Access the wrapped [`BusinessDayCalculator`] directly. Useful when
94    /// callers need settlement-rule or half-day APIs the shortcuts below
95    /// don't expose.
96    pub fn calculator(&self) -> &BusinessDayCalculator {
97        &self.calculator
98    }
99
100    /// Is `date` a business day in this region?
101    pub fn is_business_day(&self, date: NaiveDate) -> bool {
102        self.calculator.is_business_day(date)
103    }
104
105    /// Snap `date` forward to the next business day (inclusive — if `date`
106    /// is already a business day, it's returned unchanged). Used by
107    /// document-flow generators to ensure posting dates never land on a
108    /// weekend or holiday.
109    pub fn adjust_to_business_day(&self, date: NaiveDate) -> NaiveDate {
110        self.calculator.next_business_day(date, true)
111    }
112
113    /// Snap `date` backward to the previous business day (inclusive).
114    pub fn adjust_to_previous_business_day(&self, date: NaiveDate) -> NaiveDate {
115        self.calculator.prev_business_day(date, true)
116    }
117
118    /// Sample a business day uniformly from `[start, end]` (inclusive).
119    ///
120    /// Implementation strategy: sample a raw offset from the RNG, then snap
121    /// forward to the next business day. This preserves existing RNG call
122    /// counts for unit tests that rely on a specific draw sequence — each
123    /// sample consumes exactly one `rng.random_range(...)` call, just like
124    /// the pre-v3.4.1 raw `rng.random_range(0..=days_range)` pattern.
125    ///
126    /// If snapping forward would exceed `end`, the result is clamped to the
127    /// nearest preceding business day (to guarantee the result stays in
128    /// range).
129    pub fn sample_business_day_in_range<R: Rng + ?Sized>(
130        &self,
131        rng: &mut R,
132        start: NaiveDate,
133        end: NaiveDate,
134    ) -> NaiveDate {
135        let span_days = (end - start).num_days().max(0) as u32;
136        let raw_offset = rng.random_range(0..=span_days) as i64;
137        let raw_date = start + Duration::days(raw_offset);
138        let snapped = self.adjust_to_business_day(raw_date);
139        if snapped > end {
140            self.adjust_to_previous_business_day(end)
141        } else {
142            snapped
143        }
144    }
145}
146
147/// Parse a region code (e.g. "US", "DE") into [`Region`]. Falls back to
148/// [`Region::US`] for unrecognised codes to match the legacy behaviour at
149/// `je_generator.rs::parse_region`.
150pub fn parse_region_code(code: &str) -> Region {
151    match code.to_uppercase().as_str() {
152        "US" => Region::US,
153        "DE" => Region::DE,
154        "GB" | "UK" => Region::GB,
155        "FR" => Region::FR,
156        "IT" => Region::IT,
157        "ES" => Region::ES,
158        "CA" => Region::CA,
159        "CN" => Region::CN,
160        "JP" => Region::JP,
161        "IN" => Region::IN,
162        "BR" => Region::BR,
163        "MX" => Region::MX,
164        "AU" => Region::AU,
165        "SG" => Region::SG,
166        "KR" => Region::KR,
167        _ => Region::US,
168    }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used)]
173mod tests {
174    use super::*;
175    use chrono::Weekday;
176    use rand::SeedableRng;
177    use rand_chacha::ChaCha8Rng;
178
179    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
180        NaiveDate::from_ymd_opt(y, m, day).unwrap()
181    }
182
183    #[test]
184    fn weekend_is_not_business_day() {
185        let ctx = TemporalContext::new(Region::US, d(2024, 1, 1), d(2024, 12, 31));
186        // 2024-01-06 was a Saturday
187        assert_eq!(d(2024, 1, 6).weekday(), Weekday::Sat);
188        assert!(!ctx.is_business_day(d(2024, 1, 6)));
189        // 2024-01-08 was a Monday
190        assert!(ctx.is_business_day(d(2024, 1, 8)));
191    }
192
193    #[test]
194    fn adjust_snaps_weekend_forward() {
195        let ctx = TemporalContext::new(Region::US, d(2024, 1, 1), d(2024, 12, 31));
196        // Saturday Jan 6 → should snap to Monday Jan 8
197        let adjusted = ctx.adjust_to_business_day(d(2024, 1, 6));
198        assert_eq!(adjusted, d(2024, 1, 8));
199    }
200
201    #[test]
202    fn sample_never_returns_weekend() {
203        let ctx = TemporalContext::new(Region::US, d(2024, 1, 1), d(2024, 12, 31));
204        let mut rng = ChaCha8Rng::seed_from_u64(42);
205        for _ in 0..1000 {
206            let date = ctx.sample_business_day_in_range(&mut rng, d(2024, 1, 1), d(2024, 12, 31));
207            assert!(
208                ctx.is_business_day(date),
209                "sampled non-business day: {date}"
210            );
211        }
212    }
213
214    #[test]
215    fn multi_year_span_includes_both_years() {
216        let ctx = TemporalContext::new(Region::US, d(2024, 1, 1), d(2025, 12, 31));
217        // US Independence Day exists in both 2024 and 2025
218        assert!(!ctx.is_business_day(d(2024, 7, 4)));
219        assert!(!ctx.is_business_day(d(2025, 7, 4)));
220    }
221
222    #[test]
223    fn parse_region_code_fallback() {
224        assert_eq!(parse_region_code("US"), Region::US);
225        assert_eq!(parse_region_code("de"), Region::DE);
226        assert_eq!(parse_region_code("UK"), Region::GB);
227        assert_eq!(parse_region_code("ZZ"), Region::US); // fallback
228    }
229}