Skip to main content

datasynth_graph/builders/
entity_graph.rs

1//! Entity relationship graph builder.
2//!
3//! Builds a graph where:
4//! - Nodes are legal entities (companies)
5//! - Edges are ownership/intercompany relationships
6
7use std::collections::HashMap;
8
9use rust_decimal::Decimal;
10
11use datasynth_core::models::intercompany::IntercompanyRelationship;
12use datasynth_core::models::Company;
13
14use crate::models::{CompanyNode, EdgeType, Graph, GraphEdge, GraphType, NodeId, OwnershipEdge};
15
16/// Configuration for entity graph building.
17#[derive(Debug, Clone)]
18pub struct EntityGraphConfig {
19    /// Whether to include intercompany transaction edges.
20    pub include_intercompany_edges: bool,
21    /// Whether to compute consolidation path weights.
22    pub compute_consolidation_weights: bool,
23    /// Minimum ownership percentage to include.
24    pub min_ownership_percent: Decimal,
25    /// Whether to include indirect ownership paths.
26    pub include_indirect_ownership: bool,
27}
28
29impl Default for EntityGraphConfig {
30    fn default() -> Self {
31        Self {
32            include_intercompany_edges: true,
33            compute_consolidation_weights: true,
34            min_ownership_percent: Decimal::ZERO,
35            include_indirect_ownership: true,
36        }
37    }
38}
39
40/// Builder for entity relationship graphs.
41pub struct EntityGraphBuilder {
42    config: EntityGraphConfig,
43    graph: Graph,
44    /// Map from company code to node ID.
45    company_nodes: HashMap<String, NodeId>,
46    /// Ownership relationships for indirect computation.
47    ownership_edges: Vec<(String, String, Decimal)>,
48}
49
50impl EntityGraphBuilder {
51    /// Creates a new entity graph builder.
52    pub fn new(config: EntityGraphConfig) -> Self {
53        Self {
54            config,
55            graph: Graph::new("entity_network", GraphType::EntityRelationship),
56            company_nodes: HashMap::new(),
57            ownership_edges: Vec::new(),
58        }
59    }
60
61    /// Adds companies to the graph.
62    pub fn add_companies(&mut self, companies: &[Company]) {
63        for company in companies {
64            self.get_or_create_company_node(company);
65        }
66    }
67
68    /// Adds ownership relationships to the graph.
69    pub fn add_ownership_relationships(&mut self, relationships: &[IntercompanyRelationship]) {
70        for rel in relationships {
71            if rel.ownership_percentage < self.config.min_ownership_percent {
72                continue;
73            }
74
75            let parent_id = self.ensure_company_node(&rel.parent_company, &rel.parent_company);
76            let subsidiary_id =
77                self.ensure_company_node(&rel.subsidiary_company, &rel.subsidiary_company);
78
79            // Store for indirect computation
80            self.ownership_edges.push((
81                rel.parent_company.clone(),
82                rel.subsidiary_company.clone(),
83                rel.ownership_percentage,
84            ));
85
86            // Create ownership edge
87            let mut edge = OwnershipEdge::new(
88                0,
89                parent_id,
90                subsidiary_id,
91                rel.ownership_percentage,
92                rel.effective_date,
93            );
94            edge.parent_code = rel.parent_company.clone();
95            edge.subsidiary_code = rel.subsidiary_company.clone();
96            edge.consolidation_method = rel.consolidation_method.as_str().to_string();
97            edge.compute_features();
98
99            self.graph.add_edge(edge.edge);
100        }
101    }
102
103    /// Adds an intercompany transaction edge.
104    pub fn add_intercompany_edge(
105        &mut self,
106        from_company: &str,
107        to_company: &str,
108        amount: Decimal,
109        transaction_type: &str,
110    ) {
111        if !self.config.include_intercompany_edges {
112            return;
113        }
114
115        let from_id = self.ensure_company_node(from_company, from_company);
116        let to_id = self.ensure_company_node(to_company, to_company);
117
118        let amount_f64: f64 = amount.try_into().unwrap_or(0.0);
119        let edge = GraphEdge::new(0, from_id, to_id, EdgeType::Intercompany)
120            .with_weight(amount_f64)
121            .with_feature((amount_f64.abs() + 1.0).ln())
122            .with_feature(Self::encode_transaction_type(transaction_type));
123
124        self.graph.add_edge(edge);
125    }
126
127    /// Gets or creates a company node from a Company struct.
128    fn get_or_create_company_node(&mut self, company: &Company) -> NodeId {
129        if let Some(&id) = self.company_nodes.get(&company.company_code) {
130            return id;
131        }
132
133        let mut company_node = CompanyNode::new(
134            0,
135            company.company_code.clone(),
136            company.company_name.clone(),
137        );
138        company_node.country = company.country.clone();
139        company_node.currency = company.local_currency.clone();
140        company_node.is_parent = company.is_parent;
141        company_node.parent_code = company.parent_company.clone();
142        company_node.ownership_percent = company.ownership_percentage;
143        company_node.compute_features();
144
145        let id = self.graph.add_node(company_node.node);
146        self.company_nodes.insert(company.company_code.clone(), id);
147        id
148    }
149
150    /// Ensures a company node exists.
151    fn ensure_company_node(&mut self, company_code: &str, company_name: &str) -> NodeId {
152        if let Some(&id) = self.company_nodes.get(company_code) {
153            return id;
154        }
155
156        let mut company_node =
157            CompanyNode::new(0, company_code.to_string(), company_name.to_string());
158        company_node.compute_features();
159
160        let id = self.graph.add_node(company_node.node);
161        self.company_nodes.insert(company_code.to_string(), id);
162        id
163    }
164
165    /// Encodes transaction type as numeric feature.
166    fn encode_transaction_type(transaction_type: &str) -> f64 {
167        match transaction_type {
168            "GoodsSale" => 1.0,
169            "ServiceProvided" => 2.0,
170            "Loan" => 3.0,
171            "Dividend" => 4.0,
172            "ManagementFee" => 5.0,
173            "Royalty" => 6.0,
174            "CostSharing" => 7.0,
175            _ => 0.0,
176        }
177    }
178
179    /// Computes indirect ownership percentages.
180    fn compute_indirect_ownership(&self) -> HashMap<(String, String), Decimal> {
181        let mut indirect: HashMap<(String, String), Decimal> = HashMap::new();
182
183        // Start with direct ownership
184        for (parent, subsidiary, pct) in &self.ownership_edges {
185            indirect.insert((parent.clone(), subsidiary.clone()), *pct);
186        }
187
188        // Iterate to find transitive ownership (simple BFS approach)
189        let mut changed = true;
190        let max_iterations = 10;
191        let mut iteration = 0;
192
193        while changed && iteration < max_iterations {
194            changed = false;
195            iteration += 1;
196
197            let current_indirect: Vec<_> = indirect.iter().map(|(k, v)| (k.clone(), *v)).collect();
198
199            for ((parent, subsidiary), pct) in &current_indirect {
200                // Find all entities owned by subsidiary
201                for (child_parent, child_sub, child_pct) in &self.ownership_edges {
202                    if child_parent == subsidiary {
203                        let key = (parent.clone(), child_sub.clone());
204                        let indirect_pct = *pct * *child_pct / Decimal::ONE_HUNDRED;
205
206                        if let Some(existing) = indirect.get(&key) {
207                            if indirect_pct > *existing {
208                                indirect.insert(key, indirect_pct);
209                                changed = true;
210                            }
211                        } else {
212                            indirect.insert(key, indirect_pct);
213                            changed = true;
214                        }
215                    }
216                }
217            }
218        }
219
220        indirect
221    }
222
223    /// Adds indirect ownership edges to the graph.
224    pub fn add_indirect_ownership_edges(&mut self) {
225        if !self.config.include_indirect_ownership {
226            return;
227        }
228
229        let indirect = self.compute_indirect_ownership();
230
231        // Direct ownership keys
232        let direct: std::collections::HashSet<_> = self
233            .ownership_edges
234            .iter()
235            .map(|(p, s, _)| (p.clone(), s.clone()))
236            .collect();
237
238        // Add edges for indirect-only relationships
239        for ((parent, subsidiary), pct) in indirect {
240            if direct.contains(&(parent.clone(), subsidiary.clone())) {
241                continue; // Skip direct ownership (already added)
242            }
243
244            if pct < self.config.min_ownership_percent {
245                continue;
246            }
247
248            if let (Some(&parent_id), Some(&sub_id)) = (
249                self.company_nodes.get(&parent),
250                self.company_nodes.get(&subsidiary),
251            ) {
252                let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
253                let edge = GraphEdge::new(0, parent_id, sub_id, EdgeType::Ownership)
254                    .with_weight(pct_f64)
255                    .with_feature(pct_f64 / 100.0)
256                    .with_feature(1.0); // Mark as indirect
257
258                self.graph.add_edge(edge);
259            }
260        }
261    }
262
263    /// Builds the final graph.
264    pub fn build(mut self) -> Graph {
265        // Add indirect ownership edges if configured
266        if self.config.include_indirect_ownership {
267            self.add_indirect_ownership_edges();
268        }
269
270        self.graph.compute_statistics();
271        self.graph
272    }
273
274    /// Returns the company code to node ID mapping.
275    pub fn company_node_map(&self) -> &HashMap<String, NodeId> {
276        &self.company_nodes
277    }
278}
279
280/// Builds a consolidated ownership hierarchy.
281#[derive(Debug, Clone)]
282pub struct OwnershipHierarchy {
283    /// Root company code.
284    pub root: String,
285    /// Children with ownership percentage.
286    pub children: Vec<OwnershipHierarchyNode>,
287}
288
289/// Node in ownership hierarchy.
290#[derive(Debug, Clone)]
291pub struct OwnershipHierarchyNode {
292    /// Company code.
293    pub company_code: String,
294    /// Direct ownership percentage from parent.
295    pub direct_ownership: Decimal,
296    /// Effective ownership from root.
297    pub effective_ownership: Decimal,
298    /// Children.
299    pub children: Vec<OwnershipHierarchyNode>,
300}
301
302impl OwnershipHierarchy {
303    /// Builds hierarchy from relationships.
304    pub fn from_relationships(root: &str, relationships: &[IntercompanyRelationship]) -> Self {
305        let children = Self::build_children(root, Decimal::ONE_HUNDRED, relationships);
306        Self {
307            root: root.to_string(),
308            children,
309        }
310    }
311
312    fn build_children(
313        parent: &str,
314        parent_effective: Decimal,
315        relationships: &[IntercompanyRelationship],
316    ) -> Vec<OwnershipHierarchyNode> {
317        let mut children = Vec::new();
318
319        for rel in relationships {
320            if rel.parent_company == parent {
321                let effective = parent_effective * rel.ownership_percentage / Decimal::ONE_HUNDRED;
322                let grandchildren =
323                    Self::build_children(&rel.subsidiary_company, effective, relationships);
324
325                children.push(OwnershipHierarchyNode {
326                    company_code: rel.subsidiary_company.clone(),
327                    direct_ownership: rel.ownership_percentage,
328                    effective_ownership: effective,
329                    children: grandchildren,
330                });
331            }
332        }
333
334        children
335    }
336
337    /// Returns all companies with effective ownership.
338    pub fn all_companies(&self) -> Vec<(String, Decimal)> {
339        let mut result = vec![(self.root.clone(), Decimal::ONE_HUNDRED)];
340        Self::collect_companies(&self.children, &mut result);
341        result
342    }
343
344    fn collect_companies(nodes: &[OwnershipHierarchyNode], result: &mut Vec<(String, Decimal)>) {
345        for node in nodes {
346            result.push((node.company_code.clone(), node.effective_ownership));
347            Self::collect_companies(&node.children, result);
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use chrono::NaiveDate;
356    use datasynth_core::models::intercompany::ConsolidationMethod;
357    use rust_decimal_macros::dec;
358
359    fn create_test_relationship(
360        parent: &str,
361        subsidiary: &str,
362        pct: Decimal,
363    ) -> IntercompanyRelationship {
364        IntercompanyRelationship {
365            relationship_id: format!("REL-{}-{}", parent, subsidiary),
366            parent_company: parent.to_string(),
367            subsidiary_company: subsidiary.to_string(),
368            ownership_percentage: pct,
369            consolidation_method: ConsolidationMethod::Full,
370            effective_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
371            end_date: None,
372            transfer_pricing_policy: None,
373            holding_type: datasynth_core::models::intercompany::HoldingType::Direct,
374            functional_currency: "USD".to_string(),
375            requires_elimination: true,
376            reporting_segment: None,
377        }
378    }
379
380    #[test]
381    fn test_entity_graph() {
382        let mut builder = EntityGraphBuilder::new(EntityGraphConfig::default());
383
384        let relationships = vec![
385            create_test_relationship("1000", "1100", dec!(100)),
386            create_test_relationship("1000", "1200", dec!(100)),
387            create_test_relationship("1100", "1110", dec!(80)),
388        ];
389
390        builder.add_ownership_relationships(&relationships);
391
392        let graph = builder.build();
393
394        assert_eq!(graph.node_count(), 4); // 1000, 1100, 1200, 1110
395        assert!(graph.edge_count() >= 3); // Direct + indirect edges
396    }
397
398    #[test]
399    fn test_ownership_hierarchy() {
400        let relationships = vec![
401            create_test_relationship("HQ", "US", dec!(100)),
402            create_test_relationship("HQ", "EU", dec!(100)),
403            create_test_relationship("US", "US-WEST", dec!(100)),
404            create_test_relationship("EU", "DE", dec!(80)),
405        ];
406
407        let hierarchy = OwnershipHierarchy::from_relationships("HQ", &relationships);
408
409        assert_eq!(hierarchy.root, "HQ");
410        assert_eq!(hierarchy.children.len(), 2);
411
412        let all = hierarchy.all_companies();
413        assert_eq!(all.len(), 5);
414
415        // Check effective ownership of DE (100% * 80% = 80%)
416        let de = all.iter().find(|(c, _)| c == "DE").unwrap();
417        assert_eq!(de.1, dec!(80));
418    }
419
420    #[test]
421    fn test_indirect_ownership() {
422        let config = EntityGraphConfig {
423            include_indirect_ownership: true,
424            ..Default::default()
425        };
426        let mut builder = EntityGraphBuilder::new(config);
427
428        let relationships = vec![
429            create_test_relationship("A", "B", dec!(100)),
430            create_test_relationship("B", "C", dec!(50)),
431        ];
432
433        builder.add_ownership_relationships(&relationships);
434        let graph = builder.build();
435
436        // Should have A->B (direct), B->C (direct), and A->C (indirect)
437        assert_eq!(graph.node_count(), 3);
438    }
439}