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)]
267mod tests {
268    use super::*;
269    use chrono::NaiveDate;
270
271    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
272        NaiveDate::from_ymd_opt(y, m, d).unwrap()
273    }
274
275    #[test]
276    fn test_compute_periods_single_year() {
277        let periods = GenerationPeriod::compute_periods(date(2024, 1, 1), 12, 12);
278        assert_eq!(periods.len(), 1);
279        assert_eq!(periods[0].index, 0);
280        assert_eq!(periods[0].label, "FY2024");
281        assert_eq!(periods[0].start_date, date(2024, 1, 1));
282        assert_eq!(periods[0].end_date, date(2024, 12, 31));
283        assert_eq!(periods[0].months, 12);
284    }
285
286    #[test]
287    fn test_compute_periods_three_years() {
288        let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 36, 12);
289        assert_eq!(periods.len(), 3);
290
291        assert_eq!(periods[0].label, "FY2022");
292        assert_eq!(periods[0].start_date, date(2022, 1, 1));
293        assert_eq!(periods[0].end_date, date(2022, 12, 31));
294        assert_eq!(periods[0].months, 12);
295
296        assert_eq!(periods[1].label, "FY2023");
297        assert_eq!(periods[1].start_date, date(2023, 1, 1));
298        assert_eq!(periods[1].end_date, date(2023, 12, 31));
299        assert_eq!(periods[1].months, 12);
300
301        assert_eq!(periods[2].label, "FY2024");
302        assert_eq!(periods[2].start_date, date(2024, 1, 1));
303        assert_eq!(periods[2].end_date, date(2024, 12, 31));
304        assert_eq!(periods[2].months, 12);
305    }
306
307    #[test]
308    fn test_compute_periods_partial() {
309        let periods = GenerationPeriod::compute_periods(date(2022, 1, 1), 18, 12);
310        assert_eq!(periods.len(), 2);
311
312        assert_eq!(periods[0].label, "FY2022");
313        assert_eq!(periods[0].months, 12);
314        assert_eq!(periods[0].end_date, date(2022, 12, 31));
315
316        assert_eq!(periods[1].label, "FY2023");
317        assert_eq!(periods[1].months, 6);
318        assert_eq!(periods[1].start_date, date(2023, 1, 1));
319        assert_eq!(periods[1].end_date, date(2023, 6, 30));
320    }
321
322    #[test]
323    fn test_advance_seed_deterministic() {
324        let a = advance_seed(42, 0);
325        let b = advance_seed(42, 0);
326        assert_eq!(a, b, "same inputs must produce same seed");
327    }
328
329    #[test]
330    fn test_advance_seed_differs_by_index() {
331        let a = advance_seed(42, 0);
332        let b = advance_seed(42, 1);
333        assert_ne!(a, b, "different indices must produce different seeds");
334    }
335
336    #[test]
337    fn test_session_state_serde_roundtrip() {
338        let mut state = SessionState::new(12345, "abc123hash".to_string());
339        state.period_cursor = 2;
340        state.balance_state.ar_total = 50_000.0;
341        state.balance_state.retained_earnings = 100_000.0;
342        state
343            .balance_state
344            .gl_balances
345            .insert("1100".to_string(), 50_000.0);
346        state.document_id_state.next_je_number = 500;
347        state.entity_counts.vendors = 42;
348        state.generation_log.push(PeriodLog {
349            period_label: "FY2024".to_string(),
350            journal_entries: 1000,
351            documents: 2500,
352            anomalies: 25,
353            duration_secs: 3.14,
354        });
355
356        let json = serde_json::to_string(&state).expect("serialize");
357        let restored: SessionState = serde_json::from_str(&json).expect("deserialize");
358
359        assert_eq!(restored.rng_seed, 12345);
360        assert_eq!(restored.period_cursor, 2);
361        assert_eq!(restored.balance_state.ar_total, 50_000.0);
362        assert_eq!(restored.balance_state.retained_earnings, 100_000.0);
363        assert_eq!(
364            restored.balance_state.gl_balances.get("1100"),
365            Some(&50_000.0)
366        );
367        assert_eq!(restored.document_id_state.next_je_number, 500);
368        assert_eq!(restored.entity_counts.vendors, 42);
369        assert_eq!(restored.generation_log.len(), 1);
370        assert_eq!(restored.generation_log[0].journal_entries, 1000);
371        assert_eq!(restored.config_hash, "abc123hash");
372    }
373
374    #[test]
375    fn test_balance_state_default() {
376        let bs = BalanceState::default();
377        assert!(bs.gl_balances.is_empty());
378        assert_eq!(bs.ar_total, 0.0);
379        assert_eq!(bs.ap_total, 0.0);
380        assert_eq!(bs.fa_net_book_value, 0.0);
381        assert_eq!(bs.retained_earnings, 0.0);
382    }
383
384    #[test]
385    fn test_add_months_basic() {
386        assert_eq!(add_months(date(2024, 1, 1), 1), date(2024, 2, 1));
387        assert_eq!(add_months(date(2024, 1, 1), 12), date(2025, 1, 1));
388        assert_eq!(add_months(date(2024, 11, 1), 2), date(2025, 1, 1));
389    }
390
391    #[test]
392    fn test_add_months_day_clamping() {
393        // Jan 31 + 1 month → Feb 29 (leap year 2024)
394        assert_eq!(add_months(date(2024, 1, 31), 1), date(2024, 2, 29));
395        // Jan 31 + 1 month → Feb 28 (non-leap year 2023)
396        assert_eq!(add_months(date(2023, 1, 31), 1), date(2023, 2, 28));
397    }
398
399    #[test]
400    fn test_document_id_state_default() {
401        let d = DocumentIdState::default();
402        assert_eq!(d.next_po_number, 0);
403        assert_eq!(d.next_so_number, 0);
404        assert_eq!(d.next_je_number, 0);
405        assert_eq!(d.next_invoice_number, 0);
406        assert_eq!(d.next_payment_number, 0);
407        assert_eq!(d.next_gr_number, 0);
408    }
409
410    #[test]
411    fn test_entity_counts_default() {
412        let e = EntityCounts::default();
413        assert_eq!(e.vendors, 0);
414        assert_eq!(e.customers, 0);
415        assert_eq!(e.employees, 0);
416        assert_eq!(e.materials, 0);
417        assert_eq!(e.fixed_assets, 0);
418    }
419}