Skip to main content

datasynth_graph/models/
nodes.rs

1//! Node models for graph representation.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Unique identifier for a node.
9pub type NodeId = u64;
10
11/// Type of node in the graph.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum NodeType {
14    /// GL Account node.
15    Account,
16    /// Journal Entry document node.
17    JournalEntry,
18    /// Vendor node.
19    Vendor,
20    /// Customer node.
21    Customer,
22    /// User/Employee node.
23    User,
24    /// Company/Legal Entity node.
25    Company,
26    /// Cost Center node.
27    CostCenter,
28    /// Profit Center node.
29    ProfitCenter,
30    /// Material node.
31    Material,
32    /// Fixed Asset node.
33    FixedAsset,
34    /// Custom node type.
35    Custom(String),
36}
37
38impl NodeType {
39    /// Returns the type name as a string.
40    pub fn as_str(&self) -> &str {
41        match self {
42            NodeType::Account => "Account",
43            NodeType::JournalEntry => "JournalEntry",
44            NodeType::Vendor => "Vendor",
45            NodeType::Customer => "Customer",
46            NodeType::User => "User",
47            NodeType::Company => "Company",
48            NodeType::CostCenter => "CostCenter",
49            NodeType::ProfitCenter => "ProfitCenter",
50            NodeType::Material => "Material",
51            NodeType::FixedAsset => "FixedAsset",
52            NodeType::Custom(s) => s,
53        }
54    }
55}
56
57/// A node in the graph.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct GraphNode {
60    /// Unique node ID.
61    pub id: NodeId,
62    /// Node type.
63    pub node_type: NodeType,
64    /// External ID (e.g., account code, vendor ID).
65    pub external_id: String,
66    /// Node label for display.
67    pub label: String,
68    /// Numeric features for ML.
69    pub features: Vec<f64>,
70    /// Categorical features (will be one-hot encoded).
71    pub categorical_features: HashMap<String, String>,
72    /// Node properties.
73    pub properties: HashMap<String, NodeProperty>,
74    /// Labels for supervised learning.
75    pub labels: Vec<String>,
76    /// Is this node an anomaly?
77    pub is_anomaly: bool,
78    /// Anomaly type if anomalous.
79    pub anomaly_type: Option<String>,
80}
81
82impl GraphNode {
83    /// Creates a new graph node.
84    pub fn new(id: NodeId, node_type: NodeType, external_id: String, label: String) -> Self {
85        Self {
86            id,
87            node_type,
88            external_id,
89            label,
90            features: Vec::new(),
91            categorical_features: HashMap::new(),
92            properties: HashMap::new(),
93            labels: Vec::new(),
94            is_anomaly: false,
95            anomaly_type: None,
96        }
97    }
98
99    /// Adds a numeric feature.
100    pub fn with_feature(mut self, value: f64) -> Self {
101        self.features.push(value);
102        self
103    }
104
105    /// Adds multiple numeric features.
106    pub fn with_features(mut self, values: Vec<f64>) -> Self {
107        self.features.extend(values);
108        self
109    }
110
111    /// Adds a categorical feature.
112    pub fn with_categorical(mut self, name: &str, value: &str) -> Self {
113        self.categorical_features
114            .insert(name.to_string(), value.to_string());
115        self
116    }
117
118    /// Adds a property.
119    pub fn with_property(mut self, name: &str, value: NodeProperty) -> Self {
120        self.properties.insert(name.to_string(), value);
121        self
122    }
123
124    /// Marks the node as anomalous.
125    pub fn as_anomaly(mut self, anomaly_type: &str) -> Self {
126        self.is_anomaly = true;
127        self.anomaly_type = Some(anomaly_type.to_string());
128        self
129    }
130
131    /// Adds a label.
132    pub fn with_label(mut self, label: &str) -> Self {
133        self.labels.push(label.to_string());
134        self
135    }
136
137    /// Returns the feature vector dimension.
138    pub fn feature_dim(&self) -> usize {
139        self.features.len()
140    }
141}
142
143/// Property value for a node.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub enum NodeProperty {
146    /// String value.
147    String(String),
148    /// Integer value.
149    Int(i64),
150    /// Float value.
151    Float(f64),
152    /// Decimal value.
153    Decimal(Decimal),
154    /// Boolean value.
155    Bool(bool),
156    /// Date value.
157    Date(NaiveDate),
158    /// List of strings.
159    StringList(Vec<String>),
160}
161
162impl NodeProperty {
163    /// Converts to string representation.
164    pub fn to_string_value(&self) -> String {
165        match self {
166            NodeProperty::String(s) => s.clone(),
167            NodeProperty::Int(i) => i.to_string(),
168            NodeProperty::Float(f) => f.to_string(),
169            NodeProperty::Decimal(d) => d.to_string(),
170            NodeProperty::Bool(b) => b.to_string(),
171            NodeProperty::Date(d) => d.to_string(),
172            NodeProperty::StringList(v) => v.join(","),
173        }
174    }
175
176    /// Converts to numeric value (for features).
177    pub fn to_numeric(&self) -> Option<f64> {
178        match self {
179            NodeProperty::Int(i) => Some(*i as f64),
180            NodeProperty::Float(f) => Some(*f),
181            NodeProperty::Decimal(d) => (*d).try_into().ok(),
182            NodeProperty::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
183            _ => None,
184        }
185    }
186}
187
188/// Account node with accounting-specific features.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct AccountNode {
191    /// Base node.
192    pub node: GraphNode,
193    /// Account code.
194    pub account_code: String,
195    /// Account name.
196    pub account_name: String,
197    /// Account type (Asset, Liability, etc.).
198    pub account_type: String,
199    /// Account category.
200    pub account_category: Option<String>,
201    /// Is balance sheet account.
202    pub is_balance_sheet: bool,
203    /// Normal balance (Debit/Credit).
204    pub normal_balance: String,
205    /// Company code.
206    pub company_code: String,
207    /// Country code (ISO 3166-1 alpha-2) of the owning company.
208    pub country: Option<String>,
209}
210
211impl AccountNode {
212    /// Creates a new account node.
213    pub fn new(
214        id: NodeId,
215        account_code: String,
216        account_name: String,
217        account_type: String,
218        company_code: String,
219    ) -> Self {
220        let node = GraphNode::new(
221            id,
222            NodeType::Account,
223            account_code.clone(),
224            format!("{} - {}", account_code, account_name),
225        );
226
227        Self {
228            node,
229            account_code,
230            account_name,
231            account_type,
232            account_category: None,
233            is_balance_sheet: false,
234            normal_balance: "Debit".to_string(),
235            company_code,
236            country: None,
237        }
238    }
239
240    /// Computes features for the account node.
241    pub fn compute_features(&mut self) {
242        // Account type encoding
243        let type_feature = match self.account_type.as_str() {
244            "Asset" => 0.0,
245            "Liability" => 1.0,
246            "Equity" => 2.0,
247            "Revenue" => 3.0,
248            "Expense" => 4.0,
249            _ => 5.0,
250        };
251        self.node.features.push(type_feature);
252
253        // Balance sheet indicator
254        self.node
255            .features
256            .push(if self.is_balance_sheet { 1.0 } else { 0.0 });
257
258        // Normal balance encoding
259        self.node.features.push(if self.normal_balance == "Debit" {
260            1.0
261        } else {
262            0.0
263        });
264
265        // Account code as normalized numeric feature [0, 1]
266        // Parse up to 4 leading digits and divide by 10000.
267        let code_prefix: String = self
268            .account_code
269            .chars()
270            .take(4)
271            .take_while(|c| c.is_ascii_digit())
272            .collect();
273        if let Ok(code_num) = code_prefix.parse::<f64>() {
274            self.node.features.push(code_num / 10000.0);
275        } else {
276            self.node.features.push(0.0);
277        }
278
279        // Add categorical features
280        self.node
281            .categorical_features
282            .insert("account_type".to_string(), self.account_type.clone());
283        self.node
284            .categorical_features
285            .insert("company_code".to_string(), self.company_code.clone());
286        if let Some(ref country) = self.country {
287            self.node
288                .categorical_features
289                .insert("country".to_string(), country.clone());
290        }
291    }
292}
293
294/// User node for approval networks.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct UserNode {
297    /// Base node.
298    pub node: GraphNode,
299    /// User ID.
300    pub user_id: String,
301    /// User name.
302    pub user_name: String,
303    /// Department.
304    pub department: Option<String>,
305    /// Role.
306    pub role: Option<String>,
307    /// Manager ID.
308    pub manager_id: Option<String>,
309    /// Approval limit.
310    pub approval_limit: Option<Decimal>,
311    /// Is active.
312    pub is_active: bool,
313}
314
315impl UserNode {
316    /// Creates a new user node.
317    pub fn new(id: NodeId, user_id: String, user_name: String) -> Self {
318        let node = GraphNode::new(id, NodeType::User, user_id.clone(), user_name.clone());
319
320        Self {
321            node,
322            user_id,
323            user_name,
324            department: None,
325            role: None,
326            manager_id: None,
327            approval_limit: None,
328            is_active: true,
329        }
330    }
331
332    /// Computes features for the user node.
333    pub fn compute_features(&mut self) {
334        // Active status
335        self.node
336            .features
337            .push(if self.is_active { 1.0 } else { 0.0 });
338
339        // Approval limit (log-scaled)
340        if let Some(limit) = self.approval_limit {
341            let limit_f64: f64 = limit.try_into().unwrap_or(0.0);
342            self.node.features.push((limit_f64 + 1.0).ln());
343        } else {
344            self.node.features.push(0.0);
345        }
346
347        // Add categorical features
348        if let Some(ref dept) = self.department {
349            self.node
350                .categorical_features
351                .insert("department".to_string(), dept.clone());
352        }
353        if let Some(ref role) = self.role {
354            self.node
355                .categorical_features
356                .insert("role".to_string(), role.clone());
357        }
358    }
359}
360
361/// Company/Entity node for entity relationship graphs.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct CompanyNode {
364    /// Base node.
365    pub node: GraphNode,
366    /// Company code.
367    pub company_code: String,
368    /// Company name.
369    pub company_name: String,
370    /// Country.
371    pub country: String,
372    /// Currency.
373    pub currency: String,
374    /// Is parent company.
375    pub is_parent: bool,
376    /// Parent company code.
377    pub parent_code: Option<String>,
378    /// Ownership percentage (if subsidiary).
379    pub ownership_percent: Option<Decimal>,
380}
381
382impl CompanyNode {
383    /// Creates a new company node.
384    pub fn new(id: NodeId, company_code: String, company_name: String) -> Self {
385        let node = GraphNode::new(
386            id,
387            NodeType::Company,
388            company_code.clone(),
389            company_name.clone(),
390        );
391
392        Self {
393            node,
394            company_code,
395            company_name,
396            country: "US".to_string(),
397            currency: "USD".to_string(),
398            is_parent: false,
399            parent_code: None,
400            ownership_percent: None,
401        }
402    }
403
404    /// Computes features for the company node.
405    pub fn compute_features(&mut self) {
406        // Is parent
407        self.node
408            .features
409            .push(if self.is_parent { 1.0 } else { 0.0 });
410
411        // Ownership percentage
412        if let Some(pct) = self.ownership_percent {
413            let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
414            self.node.features.push(pct_f64 / 100.0);
415        } else {
416            self.node.features.push(1.0); // 100% for parent
417        }
418
419        // Add categorical features
420        self.node
421            .categorical_features
422            .insert("country".to_string(), self.country.clone());
423        self.node
424            .categorical_features
425            .insert("currency".to_string(), self.currency.clone());
426    }
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_graph_node_creation() {
436        let node = GraphNode::new(1, NodeType::Account, "1000".to_string(), "Cash".to_string())
437            .with_feature(100.0)
438            .with_categorical("type", "Asset");
439
440        assert_eq!(node.id, 1);
441        assert_eq!(node.features.len(), 1);
442        assert!(node.categorical_features.contains_key("type"));
443    }
444
445    #[test]
446    fn test_account_node() {
447        let mut account = AccountNode::new(
448            1,
449            "1000".to_string(),
450            "Cash".to_string(),
451            "Asset".to_string(),
452            "1000".to_string(),
453        );
454        account.is_balance_sheet = true;
455        account.compute_features();
456
457        assert!(!account.node.features.is_empty());
458    }
459}