Skip to main content

datasynth_runtime/
shard_context.rs

1//! Optional shard-mode context attached to
2//! [`crate::enhanced_orchestrator::EnhancedOrchestrator`].
3//!
4//! When a group engine (crate `datasynth-group`) drives per-entity
5//! generation, it pre-builds any IC journal entries via
6//! `datasynth_group::shard::ic_je_injector::inject_ic_journal_entries`
7//! and installs them here. The orchestrator appends them to its JE
8//! accumulator at the end of the journal-entry phase — no knowledge of IC
9//! pair plans or group-crate types leaks into this crate.
10//!
11//! # Architecture note — deviation from the v5.0 plan
12//!
13//! The plan originally placed the IC injector in `datasynth-generators`.
14//! We host it in `datasynth-group` instead: `IcPairPlan` lives there, and
15//! `datasynth-generators` deliberately does not depend on
16//! `datasynth-group`.  Keeping the generator-facing surface here opaque
17//! (a pre-built `Vec<JournalEntry>`) preserves layer direction:
18//! `datasynth-group` depends on `datasynth-runtime`, not the other way
19//! around.
20
21use datasynth_core::models::balance::EntityOpeningBalance;
22use datasynth_core::models::journal_entry::JournalEntry;
23
24/// Context for shard-mode generation.
25///
26/// When `EnhancedOrchestrator.shard_context == None` (the default), the
27/// orchestrator behaves byte-for-byte like the pre-v5.0 single-entity
28/// flow.  When `Some(ShardContext { extra_journal_entries, .. })`, those
29/// JEs are appended to Phase 4's accumulator just before the
30/// post-journal-entries resource check.
31#[derive(Debug, Clone, Default)]
32pub struct ShardContext {
33    /// This shard's entity code. Informational in v5.0 (the orchestrator
34    /// already carries the entity via `GeneratorConfig`); future phases
35    /// may use it for routing or logging.
36    pub entity_code: String,
37    /// 32-byte per-entity seed from
38    /// `manifest.ownership_graph.entities[i].entity_seed`.  Informational
39    /// in v5.0.
40    pub entity_seed: [u8; 32],
41    /// IC journal entries pre-built by the shard runner before calling
42    /// `EnhancedOrchestrator.generate()`.  Appended to the JE accumulator
43    /// at the end of phase 4.
44    pub extra_journal_entries: Vec<JournalEntry>,
45    /// **v5.3** — Opening-balance carryover from a prior period.  When
46    /// non-empty, the orchestrator's Phase 3b (opening balances)
47    /// **replaces** its generated openings with these carryover values
48    /// for this entity, seeding period-N+1 from period-N's closing TB
49    /// instead of generating a fresh opening from the industry-mix
50    /// generator.
51    ///
52    /// Empty by default — engagements that don't supply a prior period
53    /// (the v5.0–v5.2 default) see no behaviour change: the orchestrator
54    /// generates openings via `OpeningBalanceGenerator` as before.
55    ///
56    /// Callers prepare these by reading the prior period's per-entity
57    /// `period_close/trial_balances.json` and projecting onto BS-only
58    /// positions via `datasynth_group::aggregate::extract_opening_balances`.
59    pub opening_balances: Vec<EntityOpeningBalance>,
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use chrono::NaiveDate;
66    use datasynth_core::models::journal_entry::{
67        JournalEntry, JournalEntryHeader, JournalEntryLine,
68    };
69    use rust_decimal::Decimal;
70
71    #[test]
72    fn test_default_is_empty() {
73        let ctx = ShardContext::default();
74        assert!(ctx.entity_code.is_empty());
75        assert_eq!(ctx.entity_seed, [0u8; 32]);
76        assert!(ctx.extra_journal_entries.is_empty());
77        assert!(ctx.opening_balances.is_empty());
78    }
79
80    #[test]
81    fn test_can_hold_opening_balances_for_v53_carryover() {
82        use datasynth_core::models::balance::AccountType;
83        let mut ctx = ShardContext::default();
84        ctx.opening_balances.push(EntityOpeningBalance {
85            account_code: "1000".to_string(),
86            account_type: AccountType::Asset,
87            debit: Decimal::from(50_000),
88            credit: Decimal::ZERO,
89        });
90        ctx.opening_balances.push(EntityOpeningBalance {
91            account_code: "2000".to_string(),
92            account_type: AccountType::Liability,
93            debit: Decimal::ZERO,
94            credit: Decimal::from(30_000),
95        });
96        assert_eq!(ctx.opening_balances.len(), 2);
97        assert_eq!(ctx.opening_balances[0].account_code, "1000");
98        assert_eq!(ctx.opening_balances[1].net_balance(), Decimal::from(30_000));
99    }
100
101    #[test]
102    fn test_can_hold_journal_entries() {
103        let header = JournalEntryHeader::new(
104            "E_TEST".to_string(),
105            NaiveDate::from_ymd_opt(2024, 6, 15).expect("valid date"),
106        );
107        let mut je = JournalEntry::new(header);
108        let doc_id = je.header.document_id;
109        je.add_line(JournalEntryLine::debit(
110            doc_id,
111            1,
112            "1150".to_string(),
113            Decimal::from(100),
114        ));
115        je.add_line(JournalEntryLine::credit(
116            doc_id,
117            2,
118            "4500".to_string(),
119            Decimal::from(100),
120        ));
121        assert!(je.is_balanced());
122
123        let mut ctx = ShardContext {
124            entity_code: "E_TEST".to_string(),
125            entity_seed: [42u8; 32],
126            extra_journal_entries: Vec::new(),
127            opening_balances: Vec::new(),
128        };
129        ctx.extra_journal_entries.push(je);
130        assert_eq!(ctx.extra_journal_entries.len(), 1);
131        assert_eq!(ctx.extra_journal_entries[0].header.company_code, "E_TEST");
132        assert!(ctx.extra_journal_entries[0].is_balanced());
133    }
134}