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}