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}
208
209impl AccountNode {
210    /// Creates a new account node.
211    pub fn new(
212        id: NodeId,
213        account_code: String,
214        account_name: String,
215        account_type: String,
216        company_code: String,
217    ) -> Self {
218        let node = GraphNode::new(
219            id,
220            NodeType::Account,
221            account_code.clone(),
222            format!("{} - {}", account_code, account_name),
223        );
224
225        Self {
226            node,
227            account_code,
228            account_name,
229            account_type,
230            account_category: None,
231            is_balance_sheet: false,
232            normal_balance: "Debit".to_string(),
233            company_code,
234        }
235    }
236
237    /// Computes features for the account node.
238    pub fn compute_features(&mut self) {
239        // Account type encoding
240        let type_feature = match self.account_type.as_str() {
241            "Asset" => 0.0,
242            "Liability" => 1.0,
243            "Equity" => 2.0,
244            "Revenue" => 3.0,
245            "Expense" => 4.0,
246            _ => 5.0,
247        };
248        self.node.features.push(type_feature);
249
250        // Balance sheet indicator
251        self.node
252            .features
253            .push(if self.is_balance_sheet { 1.0 } else { 0.0 });
254
255        // Normal balance encoding
256        self.node.features.push(if self.normal_balance == "Debit" {
257            1.0
258        } else {
259            0.0
260        });
261
262        // Account code as numeric (first digits)
263        if let Ok(code_num) = self.account_code[..1.min(self.account_code.len())].parse::<f64>() {
264            self.node.features.push(code_num);
265        }
266
267        // Add categorical features
268        self.node
269            .categorical_features
270            .insert("account_type".to_string(), self.account_type.clone());
271        self.node
272            .categorical_features
273            .insert("company_code".to_string(), self.company_code.clone());
274    }
275}
276
277/// User node for approval networks.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct UserNode {
280    /// Base node.
281    pub node: GraphNode,
282    /// User ID.
283    pub user_id: String,
284    /// User name.
285    pub user_name: String,
286    /// Department.
287    pub department: Option<String>,
288    /// Role.
289    pub role: Option<String>,
290    /// Manager ID.
291    pub manager_id: Option<String>,
292    /// Approval limit.
293    pub approval_limit: Option<Decimal>,
294    /// Is active.
295    pub is_active: bool,
296}
297
298impl UserNode {
299    /// Creates a new user node.
300    pub fn new(id: NodeId, user_id: String, user_name: String) -> Self {
301        let node = GraphNode::new(id, NodeType::User, user_id.clone(), user_name.clone());
302
303        Self {
304            node,
305            user_id,
306            user_name,
307            department: None,
308            role: None,
309            manager_id: None,
310            approval_limit: None,
311            is_active: true,
312        }
313    }
314
315    /// Computes features for the user node.
316    pub fn compute_features(&mut self) {
317        // Active status
318        self.node
319            .features
320            .push(if self.is_active { 1.0 } else { 0.0 });
321
322        // Approval limit (log-scaled)
323        if let Some(limit) = self.approval_limit {
324            let limit_f64: f64 = limit.try_into().unwrap_or(0.0);
325            self.node.features.push((limit_f64 + 1.0).ln());
326        } else {
327            self.node.features.push(0.0);
328        }
329
330        // Add categorical features
331        if let Some(ref dept) = self.department {
332            self.node
333                .categorical_features
334                .insert("department".to_string(), dept.clone());
335        }
336        if let Some(ref role) = self.role {
337            self.node
338                .categorical_features
339                .insert("role".to_string(), role.clone());
340        }
341    }
342}
343
344/// Company/Entity node for entity relationship graphs.
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct CompanyNode {
347    /// Base node.
348    pub node: GraphNode,
349    /// Company code.
350    pub company_code: String,
351    /// Company name.
352    pub company_name: String,
353    /// Country.
354    pub country: String,
355    /// Currency.
356    pub currency: String,
357    /// Is parent company.
358    pub is_parent: bool,
359    /// Parent company code.
360    pub parent_code: Option<String>,
361    /// Ownership percentage (if subsidiary).
362    pub ownership_percent: Option<Decimal>,
363}
364
365impl CompanyNode {
366    /// Creates a new company node.
367    pub fn new(id: NodeId, company_code: String, company_name: String) -> Self {
368        let node = GraphNode::new(
369            id,
370            NodeType::Company,
371            company_code.clone(),
372            company_name.clone(),
373        );
374
375        Self {
376            node,
377            company_code,
378            company_name,
379            country: "US".to_string(),
380            currency: "USD".to_string(),
381            is_parent: false,
382            parent_code: None,
383            ownership_percent: None,
384        }
385    }
386
387    /// Computes features for the company node.
388    pub fn compute_features(&mut self) {
389        // Is parent
390        self.node
391            .features
392            .push(if self.is_parent { 1.0 } else { 0.0 });
393
394        // Ownership percentage
395        if let Some(pct) = self.ownership_percent {
396            let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
397            self.node.features.push(pct_f64 / 100.0);
398        } else {
399            self.node.features.push(1.0); // 100% for parent
400        }
401
402        // Add categorical features
403        self.node
404            .categorical_features
405            .insert("country".to_string(), self.country.clone());
406        self.node
407            .categorical_features
408            .insert("currency".to_string(), self.currency.clone());
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_graph_node_creation() {
418        let node = GraphNode::new(1, NodeType::Account, "1000".to_string(), "Cash".to_string())
419            .with_feature(100.0)
420            .with_categorical("type", "Asset");
421
422        assert_eq!(node.id, 1);
423        assert_eq!(node.features.len(), 1);
424        assert!(node.categorical_features.contains_key("type"));
425    }
426
427    #[test]
428    fn test_account_node() {
429        let mut account = AccountNode::new(
430            1,
431            "1000".to_string(),
432            "Cash".to_string(),
433            "Asset".to_string(),
434            "1000".to_string(),
435        );
436        account.is_balance_sheet = true;
437        account.compute_features();
438
439        assert!(!account.node.features.is_empty());
440    }
441}