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}