Skip to main content

datasynth_graph/models/
edges.rs

1//! Edge models for graph representation.
2
3use chrono::{Datelike, NaiveDate};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use super::nodes::NodeId;
9
10/// Unique identifier for an edge.
11pub type EdgeId = u64;
12
13/// Type of edge in the graph.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum EdgeType {
16    /// Transaction flow (debit/credit).
17    Transaction,
18    /// Approval relationship.
19    Approval,
20    /// Reports-to relationship.
21    ReportsTo,
22    /// Ownership relationship.
23    Ownership,
24    /// Intercompany relationship.
25    Intercompany,
26    /// Document reference.
27    DocumentReference,
28    /// Cost allocation.
29    CostAllocation,
30    /// Custom edge type.
31    Custom(String),
32}
33
34impl EdgeType {
35    /// Returns the type name as a string.
36    pub fn as_str(&self) -> &str {
37        match self {
38            EdgeType::Transaction => "Transaction",
39            EdgeType::Approval => "Approval",
40            EdgeType::ReportsTo => "ReportsTo",
41            EdgeType::Ownership => "Ownership",
42            EdgeType::Intercompany => "Intercompany",
43            EdgeType::DocumentReference => "DocumentReference",
44            EdgeType::CostAllocation => "CostAllocation",
45            EdgeType::Custom(s) => s,
46        }
47    }
48}
49
50/// Direction of an edge.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52pub enum EdgeDirection {
53    /// Directed edge (source -> target).
54    Directed,
55    /// Undirected edge (bidirectional).
56    Undirected,
57}
58
59/// An edge in the graph.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct GraphEdge {
62    /// Unique edge ID.
63    pub id: EdgeId,
64    /// Source node ID.
65    pub source: NodeId,
66    /// Target node ID.
67    pub target: NodeId,
68    /// Edge type.
69    pub edge_type: EdgeType,
70    /// Edge direction.
71    pub direction: EdgeDirection,
72    /// Edge weight (e.g., transaction amount).
73    pub weight: f64,
74    /// Edge features for ML.
75    pub features: Vec<f64>,
76    /// Edge properties.
77    pub properties: HashMap<String, EdgeProperty>,
78    /// Labels for supervised learning.
79    pub labels: Vec<String>,
80    /// Is this edge anomalous?
81    pub is_anomaly: bool,
82    /// Anomaly type if anomalous.
83    pub anomaly_type: Option<String>,
84    /// Timestamp (for temporal graphs).
85    pub timestamp: Option<NaiveDate>,
86}
87
88impl GraphEdge {
89    /// Creates a new graph edge.
90    pub fn new(id: EdgeId, source: NodeId, target: NodeId, edge_type: EdgeType) -> Self {
91        Self {
92            id,
93            source,
94            target,
95            edge_type,
96            direction: EdgeDirection::Directed,
97            weight: 1.0,
98            features: Vec::new(),
99            properties: HashMap::new(),
100            labels: Vec::new(),
101            is_anomaly: false,
102            anomaly_type: None,
103            timestamp: None,
104        }
105    }
106
107    /// Sets the edge weight.
108    pub fn with_weight(mut self, weight: f64) -> Self {
109        self.weight = weight;
110        self
111    }
112
113    /// Adds a numeric feature.
114    pub fn with_feature(mut self, value: f64) -> Self {
115        self.features.push(value);
116        self
117    }
118
119    /// Adds multiple numeric features.
120    pub fn with_features(mut self, values: Vec<f64>) -> Self {
121        self.features.extend(values);
122        self
123    }
124
125    /// Adds a property.
126    pub fn with_property(mut self, name: &str, value: EdgeProperty) -> Self {
127        self.properties.insert(name.to_string(), value);
128        self
129    }
130
131    /// Sets the timestamp.
132    pub fn with_timestamp(mut self, timestamp: NaiveDate) -> Self {
133        self.timestamp = Some(timestamp);
134        self
135    }
136
137    /// Makes the edge undirected.
138    pub fn undirected(mut self) -> Self {
139        self.direction = EdgeDirection::Undirected;
140        self
141    }
142
143    /// Marks the edge as anomalous.
144    pub fn as_anomaly(mut self, anomaly_type: &str) -> Self {
145        self.is_anomaly = true;
146        self.anomaly_type = Some(anomaly_type.to_string());
147        self
148    }
149
150    /// Adds a label.
151    pub fn with_label(mut self, label: &str) -> Self {
152        self.labels.push(label.to_string());
153        self
154    }
155
156    /// Returns the feature vector dimension.
157    pub fn feature_dim(&self) -> usize {
158        self.features.len()
159    }
160}
161
162/// Property value for an edge.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub enum EdgeProperty {
165    /// String value.
166    String(String),
167    /// Integer value.
168    Int(i64),
169    /// Float value.
170    Float(f64),
171    /// Decimal value.
172    Decimal(Decimal),
173    /// Boolean value.
174    Bool(bool),
175    /// Date value.
176    Date(NaiveDate),
177}
178
179impl EdgeProperty {
180    /// Converts to string representation.
181    pub fn to_string_value(&self) -> String {
182        match self {
183            EdgeProperty::String(s) => s.clone(),
184            EdgeProperty::Int(i) => i.to_string(),
185            EdgeProperty::Float(f) => f.to_string(),
186            EdgeProperty::Decimal(d) => d.to_string(),
187            EdgeProperty::Bool(b) => b.to_string(),
188            EdgeProperty::Date(d) => d.to_string(),
189        }
190    }
191
192    /// Converts to numeric value.
193    pub fn to_numeric(&self) -> Option<f64> {
194        match self {
195            EdgeProperty::Int(i) => Some(*i as f64),
196            EdgeProperty::Float(f) => Some(*f),
197            EdgeProperty::Decimal(d) => (*d).try_into().ok(),
198            EdgeProperty::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
199            _ => None,
200        }
201    }
202}
203
204/// Transaction edge with accounting-specific features.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TransactionEdge {
207    /// Base edge.
208    pub edge: GraphEdge,
209    /// Document number.
210    pub document_number: String,
211    /// Company code.
212    pub company_code: String,
213    /// Posting date.
214    pub posting_date: NaiveDate,
215    /// Debit amount.
216    pub debit_amount: Decimal,
217    /// Credit amount.
218    pub credit_amount: Decimal,
219    /// Is this a debit (true) or credit (false)?
220    pub is_debit: bool,
221    /// Cost center.
222    pub cost_center: Option<String>,
223    /// Business process.
224    pub business_process: Option<String>,
225}
226
227impl TransactionEdge {
228    /// Creates a new transaction edge.
229    pub fn new(
230        id: EdgeId,
231        source: NodeId,
232        target: NodeId,
233        document_number: String,
234        posting_date: NaiveDate,
235        amount: Decimal,
236        is_debit: bool,
237    ) -> Self {
238        let amount_f64: f64 = amount.try_into().unwrap_or(0.0);
239        let mut edge = GraphEdge::new(id, source, target, EdgeType::Transaction)
240            .with_weight(amount_f64.abs())
241            .with_timestamp(posting_date);
242
243        edge.properties.insert(
244            "document_number".to_string(),
245            EdgeProperty::String(document_number.clone()),
246        );
247        edge.properties
248            .insert("posting_date".to_string(), EdgeProperty::Date(posting_date));
249        edge.properties
250            .insert("is_debit".to_string(), EdgeProperty::Bool(is_debit));
251
252        Self {
253            edge,
254            document_number,
255            company_code: String::new(),
256            posting_date,
257            debit_amount: if is_debit { amount } else { Decimal::ZERO },
258            credit_amount: if !is_debit { amount } else { Decimal::ZERO },
259            is_debit,
260            cost_center: None,
261            business_process: None,
262        }
263    }
264
265    /// Computes features for the transaction edge.
266    pub fn compute_features(&mut self) {
267        // Amount features
268        let amount: f64 = if self.is_debit {
269            self.debit_amount.try_into().unwrap_or(0.0)
270        } else {
271            self.credit_amount.try_into().unwrap_or(0.0)
272        };
273
274        // Log amount
275        self.edge.features.push((amount.abs() + 1.0).ln());
276
277        // Debit/Credit indicator
278        self.edge
279            .features
280            .push(if self.is_debit { 1.0 } else { 0.0 });
281
282        // Temporal features
283        let weekday = self.posting_date.weekday().num_days_from_monday() as f64;
284        self.edge.features.push(weekday / 6.0); // Normalized to [0, 1]
285
286        let day = self.posting_date.day() as f64;
287        self.edge.features.push(day / 31.0); // Normalized
288
289        let month = self.posting_date.month() as f64;
290        self.edge.features.push(month / 12.0); // Normalized
291
292        // Is month end (last 3 days)
293        let is_month_end = day >= 28.0;
294        self.edge
295            .features
296            .push(if is_month_end { 1.0 } else { 0.0 });
297
298        // Is year end (December)
299        let is_year_end = month == 12.0;
300        self.edge.features.push(if is_year_end { 1.0 } else { 0.0 });
301
302        // Benford's law probability (first digit)
303        let first_digit = Self::extract_first_digit(amount);
304        let benford_prob = Self::benford_probability(first_digit);
305        self.edge.features.push(benford_prob);
306    }
307
308    /// Extracts the first significant digit of a number.
309    fn extract_first_digit(value: f64) -> u32 {
310        if value == 0.0 {
311            return 0;
312        }
313        let abs_val = value.abs();
314        let log10 = abs_val.log10().floor();
315        let normalized = abs_val / 10_f64.powf(log10);
316        normalized.floor() as u32
317    }
318
319    /// Returns the expected Benford's law probability for a digit.
320    fn benford_probability(digit: u32) -> f64 {
321        if digit == 0 || digit > 9 {
322            return 0.0;
323        }
324        (1.0 + 1.0 / digit as f64).log10()
325    }
326}
327
328/// Approval edge for approval networks.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct ApprovalEdge {
331    /// Base edge.
332    pub edge: GraphEdge,
333    /// Document that was approved.
334    pub document_number: String,
335    /// Approval date.
336    pub approval_date: NaiveDate,
337    /// Approval amount.
338    pub amount: Decimal,
339    /// Approval action (Approve, Reject, Forward).
340    pub action: String,
341    /// Is this within the user's approval limit?
342    pub within_limit: bool,
343}
344
345impl ApprovalEdge {
346    /// Creates a new approval edge.
347    pub fn new(
348        id: EdgeId,
349        approver_node: NodeId,
350        requester_node: NodeId,
351        document_number: String,
352        approval_date: NaiveDate,
353        amount: Decimal,
354        action: &str,
355    ) -> Self {
356        let amount_f64: f64 = amount.try_into().unwrap_or(0.0);
357        let edge = GraphEdge::new(id, approver_node, requester_node, EdgeType::Approval)
358            .with_weight(amount_f64)
359            .with_timestamp(approval_date)
360            .with_property("action", EdgeProperty::String(action.to_string()));
361
362        Self {
363            edge,
364            document_number,
365            approval_date,
366            amount,
367            action: action.to_string(),
368            within_limit: true,
369        }
370    }
371
372    /// Computes features for the approval edge.
373    pub fn compute_features(&mut self) {
374        // Amount (log-scaled)
375        let amount_f64: f64 = self.amount.try_into().unwrap_or(0.0);
376        self.edge.features.push((amount_f64.abs() + 1.0).ln());
377
378        // Action encoding
379        let action_code = match self.action.as_str() {
380            "Approve" => 1.0,
381            "Reject" => 0.0,
382            "Forward" => 0.5,
383            _ => 0.5,
384        };
385        self.edge.features.push(action_code);
386
387        // Within limit
388        self.edge
389            .features
390            .push(if self.within_limit { 1.0 } else { 0.0 });
391    }
392}
393
394/// Ownership edge for entity relationship graphs.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct OwnershipEdge {
397    /// Base edge.
398    pub edge: GraphEdge,
399    /// Parent company code.
400    pub parent_code: String,
401    /// Subsidiary company code.
402    pub subsidiary_code: String,
403    /// Ownership percentage.
404    pub ownership_percent: Decimal,
405    /// Consolidation method.
406    pub consolidation_method: String,
407    /// Effective date.
408    pub effective_date: NaiveDate,
409}
410
411impl OwnershipEdge {
412    /// Creates a new ownership edge.
413    pub fn new(
414        id: EdgeId,
415        parent_node: NodeId,
416        subsidiary_node: NodeId,
417        ownership_percent: Decimal,
418        effective_date: NaiveDate,
419    ) -> Self {
420        let pct_f64: f64 = ownership_percent.try_into().unwrap_or(0.0);
421        let edge = GraphEdge::new(id, parent_node, subsidiary_node, EdgeType::Ownership)
422            .with_weight(pct_f64)
423            .with_timestamp(effective_date);
424
425        Self {
426            edge,
427            parent_code: String::new(),
428            subsidiary_code: String::new(),
429            ownership_percent,
430            consolidation_method: "Full".to_string(),
431            effective_date,
432        }
433    }
434
435    /// Computes features for the ownership edge.
436    pub fn compute_features(&mut self) {
437        // Ownership percentage (normalized)
438        let pct: f64 = self.ownership_percent.try_into().unwrap_or(0.0);
439        self.edge.features.push(pct / 100.0);
440
441        // Consolidation method encoding
442        let method_code = match self.consolidation_method.as_str() {
443            "Full" => 1.0,
444            "Proportional" => 0.5,
445            "Equity" => 0.25,
446            _ => 0.0,
447        };
448        self.edge.features.push(method_code);
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn test_graph_edge_creation() {
458        let edge = GraphEdge::new(1, 10, 20, EdgeType::Transaction)
459            .with_weight(1000.0)
460            .with_feature(0.5);
461
462        assert_eq!(edge.id, 1);
463        assert_eq!(edge.source, 10);
464        assert_eq!(edge.target, 20);
465        assert_eq!(edge.weight, 1000.0);
466    }
467
468    #[test]
469    fn test_transaction_edge() {
470        let mut tx = TransactionEdge::new(
471            1,
472            10,
473            20,
474            "DOC001".to_string(),
475            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
476            Decimal::new(10000, 2),
477            true,
478        );
479        tx.compute_features();
480
481        assert!(!tx.edge.features.is_empty());
482    }
483
484    #[test]
485    fn test_benford_probability() {
486        // Digit 1 should have ~30.1% probability
487        let prob1 = TransactionEdge::benford_probability(1);
488        assert!((prob1 - 0.301).abs() < 0.001);
489
490        // Digit 9 should have ~4.6% probability
491        let prob9 = TransactionEdge::benford_probability(9);
492        assert!((prob9 - 0.046).abs() < 0.001);
493    }
494}