Skip to main content

datasynth_core/models/
generation_session.rs

1//! Models for the unified generation pipeline's session state.
2//!
3//! These types track the state of a multi-period generation run,
4//! including fiscal period decomposition, balance carry-forward,
5//! document ID sequencing, and deterministic seed advancement.
6
7use chrono::{Datelike, NaiveDate};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::hash::{Hash, Hasher};
11
12// ---------------------------------------------------------------------------
13// GenerationPeriod — one slice of the total generation time span
14// ---------------------------------------------------------------------------
15
16/// A single period within a generation run.
17///
18/// The unified pipeline decomposes the total requested time span into
19/// fiscal-year-aligned periods. Each period is generated independently
20/// with its own RNG seed derived from the master seed.
21///
22/// Named `GenerationPeriod` to avoid collision with the accounting-level
23/// [`FiscalPeriod`](super::FiscalPeriod) in `period_close`.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct GenerationPeriod {
26    /// Zero-based index of this period in the run.
27    pub index: usize,
28    /// Human-readable label, e.g. "FY2024" or "FY2024-H1".
29    pub label: String,
30    /// First calendar date of the period (inclusive).
31    pub start_date: NaiveDate,
32    /// Last calendar date of the period (inclusive).
33    pub end_date: NaiveDate,
34    /// Number of months covered by this period.
35    pub months: u32,
36}
37
38impl GenerationPeriod {
39    /// Decompose a total generation span into fiscal-year-aligned periods.
40    ///
41    /// # Arguments
42    /// * `start_date`  — first day of the generation window
43    /// * `total_months` — total months requested (e.g. 36 for 3 years)
44    /// * `fiscal_year_months` — months per fiscal year (typically 12)
45    ///
46    /// # Returns
47    /// A `Vec<GenerationPeriod>` covering the entire span. The last period
48    /// may be shorter than `fiscal_year_months` if `total_months` is not
49    /// evenly divisible.
50    pub fn compute_periods(
51        start_date: NaiveDate,
52        total_months: u32,
53        fiscal_year_months: u32,
54    ) -> Vec<GenerationPeriod> {
55        assert!(fiscal_year_months > 0, "fiscal_year_months must be > 0");
56        assert!(total_months > 0, "total_months must be > 0");
57
58        let mut periods = Vec::new();
59        let mut remaining = total_months;
60        let mut cursor = start_date;
61        let mut index: usize = 0;
62
63        while remaining > 0 {
64            let months = remaining.min(fiscal_year_months);
65            let end = add_months(cursor, months)
66                .pred_opt()
67                .expect("valid predecessor date");
68            let label = format!("FY{}", cursor.year());
69
70            periods.push(GenerationPeriod {
71                index,
72                label,
73                start_date: cursor,
74                end_date: end,
75                months,
76            });
77
78            cursor = add_months(cursor, months);
79            remaining -= months;
80            index += 1;
81        }
82
83        periods
84    }
85}
86
87// ---------------------------------------------------------------------------
88// SessionState — mutable state carried across periods
89// ---------------------------------------------------------------------------
90
91/// Accumulated state for a multi-period generation session.
92///
93/// This struct is serializable so it can be checkpointed to disk and
94/// resumed later (e.g. after a crash or for incremental generation).
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct SessionState {
97    /// Master RNG seed for the entire run.
98    pub rng_seed: u64,
99    /// Index of the *next* period to generate (0 = fresh run).
100    pub period_cursor: usize,
101    /// GL and sub-ledger balances carried forward.
102    pub balance_state: BalanceState,
103    /// Next sequential document IDs.
104    pub document_id_state: DocumentIdState,
105    /// Counts of master data entities generated so far.
106    pub entity_counts: EntityCounts,
107    /// Per-period generation log (one entry per completed period).
108    pub generation_log: Vec<PeriodLog>,
109    /// SHA-256 hash of the config that created this session, used to
110    /// detect config drift on resume.
111    pub config_hash: String,
112}
113
114impl SessionState {
115    /// Create a fresh session state for a new generation run.
116    pub fn new(rng_seed: u64, config_hash: String) -> Self {
117        Self {
118            rng_seed,
119            period_cursor: 0,
120            balance_state: BalanceState::default(),
121            document_id_state: DocumentIdState::default(),
122            entity_counts: EntityCounts::default(),
123            generation_log: Vec::new(),
124            config_hash,
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// BalanceState
131// ---------------------------------------------------------------------------
132
133/// GL and sub-ledger balances carried forward between periods.
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct BalanceState {
136    /// Per-GL-account running balance (account_id -> balance).
137    pub gl_balances: HashMap<String, f64>,
138    /// Total accounts-receivable balance.
139    pub ar_total: f64,
140    /// Total accounts-payable balance.
141    pub ap_total: f64,
142    /// Net book value of all fixed assets.
143    pub fa_net_book_value: f64,
144    /// Retained earnings balance.
145    pub retained_earnings: f64,
146}
147
148// ---------------------------------------------------------------------------
149// DocumentIdState
150// ---------------------------------------------------------------------------
151
152/// Sequential document-ID counters so IDs never collide across periods.
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154pub struct DocumentIdState {
155    /// Next purchase-order number.
156    pub next_po_number: u64,
157    /// Next sales-order number.
158    pub next_so_number: u64,
159    /// Next journal-entry number.
160    pub next_je_number: u64,
161    /// Next invoice number.
162    pub next_invoice_number: u64,
163    /// Next payment number.
164    pub next_payment_number: u64,
165    /// Next goods-receipt number.
166    pub next_gr_number: u64,
167}
168
169// ---------------------------------------------------------------------------
170// EntityCounts
171// ---------------------------------------------------------------------------
172
173/// Counts of master-data entities generated so far.
174///
175/// Used to avoid regenerating master data in subsequent periods and to
176/// allocate additional entities if growth is configured.
177#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178pub struct EntityCounts {
179    /// Number of vendor master records.
180    pub vendors: usize,
181    /// Number of customer master records.
182    pub customers: usize,
183    /// Number of employee master records.
184    pub employees: usize,
185    /// Number of material master records.
186    pub materials: usize,
187    /// Number of fixed-asset master records.
188    pub fixed_assets: usize,
189}
190
191// ---------------------------------------------------------------------------
192// PeriodLog
193// ---------------------------------------------------------------------------
194
195/// Summary of what was generated in a single period.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct PeriodLog {
198    /// Label of the period (e.g. "FY2024").
199    pub period_label: String,
200    /// Number of journal entries generated.
201    pub journal_entries: usize,
202    /// Number of documents generated (PO, SO, GR, invoices, etc.).
203    pub documents: usize,
204    /// Number of anomalies injected.
205    pub anomalies: usize,
206    /// Wall-clock duration of the period generation in seconds.
207    pub duration_secs: f64,
208}
209
210// ---------------------------------------------------------------------------
211// Free functions
212// ---------------------------------------------------------------------------
213
214/// Derive a deterministic per-period RNG seed from the master seed.
215///
216/// Uses `DefaultHasher` (SipHash) to mix the seed with the period index,
217/// producing a well-distributed child seed.
218pub fn advance_seed(seed: u64, period_index: usize) -> u64 {
219    let mut hasher = std::collections::hash_map::DefaultHasher::new();
220    seed.hash(&mut hasher);
221    period_index.hash(&mut hasher);
222    hasher.finish()
223}
224
225/// Add `months` calendar months to a `NaiveDate`, clamping the day to the
226/// last valid day of the target month.
227///
228/// # Examples
229/// ```
230/// use chrono::NaiveDate;
231/// use datasynth_core::models::generation_session::add_months;
232///
233/// let d = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
234/// // Jan 31 + 1 month → Feb 29 (2024 is a leap year)
235/// assert_eq!(add_months(d, 1), NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
236/// ```
237pub fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
238    let total_months = date.year() as i64 * 12 + (date.month() as i64 - 1) + months as i64;
239    let target_year = (total_months / 12) as i32;
240    let target_month = (total_months % 12) as u32 + 1;
241
242    // Clamp day to last valid day of target month
243    let max_day = last_day_of_month(target_year, target_month);
244    let day = date.day().min(max_day);
245
246    NaiveDate::from_ymd_opt(target_year, target_month, day).expect("valid date after add_months")
247}
248
249/// Return the last day of the given year/month.
250fn last_day_of_month(year: i32, month: u32) -> u32 {
251    if month == 12 {
252        31
253    } else {
254        NaiveDate::from_ymd_opt(year, month + 1, 1)
255            .expect("valid next-month date")
256            .pred_opt()
257            .expect("valid predecessor")
258            .day()
259    }
260}
261
262// ===========================================================================
263// Tests
264// ===========================================================================
265
266#[cfg(test)]
267#[allow(clippy::unwrap_used, clippy::approx_constant)]
268mod tests {
269    use super::*;
270    use chrono::NaiveDate;
271
272    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
273        NaiveDate::from_ymd_opt(y, m, d).unwrap()
274    }
275
276    #[test]
277    fn test_compute_periods_single_year() {
278        let periods = GenerationPeriod::compute_periods(date(2024, 1, 1), 12, 12);
279        assert_eq!(periods.len(), 1);
280        assert_eq!(periods[0].index, 0);
281        assert_eq!(periods[0].label, "FY2024");
282        assert_eq!(periods[0].start_date, date(2024, 1, 1));
283        assert_eq!(periods[0].end_date, date(2024, 12, 31));
284        assert_eq!(periods[0].months, 12);
285    }
286
287    #[test]
288    fn test_compute_periods_three_years() {
289        let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 36, 12);
290        assert_eq!(periods.len(), 3);
291
292        assert_eq!(periods[0].label, "FY2022");
293        assert_eq!(periods[0].start_date, date(2022, 1, 1));
294        assert_eq!(periods[0].end_date, date(2022, 12, 31));
295        assert_eq!(periods[0].months, 12);
296
297        assert_eq!(periods[1].label, "FY2023");
298        assert_eq!(periods[1].start_date, date(2023, 1, 1));
299        assert_eq!(periods[1].end_date, date(2023, 12, 31));
300        assert_eq!(periods[1].months, 12);
301
302        assert_eq!(periods[2].label, "FY2024");
303        assert_eq!(periods[2].start_date, date(2024, 1, 1));
304        assert_eq!(periods[2].end_date, date(2024, 12, 31));
305        assert_eq!(periods[2].months, 12);
306    }
307
308    #[test]
309    fn test_compute_periods_partial() {
310        let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 18, 12);
311        assert_eq!(periods.len(), 2);
312
313        assert_eq!(periods[0].label, "FY2022");
314        assert_eq!(periods[0].months, 12);
315        assert_eq!(periods[0].end_date, date(2022, 12, 31));
316
317        assert_eq!(periods[1].label, "FY2023");
318        assert_eq!(periods[1].months, 6);
319        assert_eq!(periods[1].start_date, date(2023, 1, 1));
320        assert_eq!(periods[1].end_date, date(2023, 6, 30));
321    }
322
323    #[test]
324    fn test_advance_seed_deterministic() {
325        let a = advance_seed(42, 0);
326        let b = advance_seed(42, 0);
327        assert_eq!(a, b, "same inputs must produce same seed");
328    }
329
330    #[test]
331    fn test_advance_seed_differs_by_index() {
332        let a = advance_seed(42, 0);
333        let b = advance_seed(42, 1);
334        assert_ne!(a, b, "different indices must produce different seeds");
335    }
336
337    #[test]
338    fn test_session_state_serde_roundtrip() {
339        let mut state = SessionState::new(12345, "abc123hash".to_string());
340        state.period_cursor = 2;
341        state.balance_state.ar_total = 50_000.0;
342        state.balance_state.retained_earnings = 100_000.0;
343        state
344            .balance_state
345            .gl_balances
346            .insert("1100".to_string(), 50_000.0);
347        state.document_id_state.next_je_number = 500;
348        state.entity_counts.vendors = 42;
349        state.generation_log.push(PeriodLog {
350            period_label: "FY2024".to_string(),
351            journal_entries: 1000,
352            documents: 2500,
353            anomalies: 25,
354            duration_secs: 3.14,
355        });
356
357        let json = serde_json::to_string(&state).expect("serialize");
358        let restored: SessionState = serde_json::from_str(&json).expect("deserialize");
359
360        assert_eq!(restored.rng_seed, 12345);
361        assert_eq!(restored.period_cursor, 2);
362        assert_eq!(restored.balance_state.ar_total, 50_000.0);
363        assert_eq!(restored.balance_state.retained_earnings, 100_000.0);
364        assert_eq!(
365            restored.balance_state.gl_balances.get("1100"),
366            Some(&50_000.0)
367        );
368        assert_eq!(restored.document_id_state.next_je_number, 500);
369        assert_eq!(restored.entity_counts.vendors, 42);
370        assert_eq!(restored.generation_log.len(), 1);
371        assert_eq!(restored.generation_log[0].journal_entries, 1000);
372        assert_eq!(restored.config_hash, "abc123hash");
373    }
374
375    #[test]
376    fn test_balance_state_default() {
377        let bs = BalanceState::default();
378        assert!(bs.gl_balances.is_empty());
379        assert_eq!(bs.ar_total, 0.0);
380        assert_eq!(bs.ap_total, 0.0);
381        assert_eq!(bs.fa_net_book_value, 0.0);
382        assert_eq!(bs.retained_earnings, 0.0);
383    }
384
385    #[test]
386    fn test_add_months_basic() {
387        assert_eq!(add_months(date(2024, 1, 1), 1), date(2024, 2, 1));
388        assert_eq!(add_months(date(2024, 1, 1), 12), date(2025, 1, 1));
389        assert_eq!(add_months(date(2024, 11, 1), 2), date(2025, 1, 1));
390    }
391
392    #[test]
393    fn test_add_months_day_clamping() {
394        // Jan 31 + 1 month → Feb 29 (leap year 2024)
395        assert_eq!(add_months(date(2024, 1, 31), 1), date(2024, 2, 29));
396        // Jan 31 + 1 month → Feb 28 (non-leap year 2023)
397        assert_eq!(add_months(date(2023, 1, 31), 1), date(2023, 2, 28));
398    }
399
400    #[test]
401    fn test_document_id_state_default() {
402        let d = DocumentIdState::default();
403        assert_eq!(d.next_po_number, 0);
404        assert_eq!(d.next_so_number, 0);
405        assert_eq!(d.next_je_number, 0);
406        assert_eq!(d.next_invoice_number, 0);
407        assert_eq!(d.next_payment_number, 0);
408        assert_eq!(d.next_gr_number, 0);
409    }
410
411    #[test]
412    fn test_entity_counts_default() {
413        let e = EntityCounts::default();
414        assert_eq!(e.vendors, 0);
415        assert_eq!(e.customers, 0);
416        assert_eq!(e.employees, 0);
417        assert_eq!(e.materials, 0);
418        assert_eq!(e.fixed_assets, 0);
419    }
420}