1use chrono::{Datelike, NaiveDate};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use super::nodes::NodeId;
9
10pub type EdgeId = u64;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum EdgeType {
16 Transaction,
18 Approval,
20 ReportsTo,
22 Ownership,
24 Intercompany,
26 DocumentReference,
28 CostAllocation,
30 Custom(String),
32}
33
34impl EdgeType {
35 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52pub enum EdgeDirection {
53 Directed,
55 Undirected,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct GraphEdge {
62 pub id: EdgeId,
64 pub source: NodeId,
66 pub target: NodeId,
68 pub edge_type: EdgeType,
70 pub direction: EdgeDirection,
72 pub weight: f64,
74 pub features: Vec<f64>,
76 pub properties: HashMap<String, EdgeProperty>,
78 pub labels: Vec<String>,
80 pub is_anomaly: bool,
82 pub anomaly_type: Option<String>,
84 pub timestamp: Option<NaiveDate>,
86}
87
88impl GraphEdge {
89 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 pub fn with_weight(mut self, weight: f64) -> Self {
109 self.weight = weight;
110 self
111 }
112
113 pub fn with_feature(mut self, value: f64) -> Self {
115 self.features.push(value);
116 self
117 }
118
119 pub fn with_features(mut self, values: Vec<f64>) -> Self {
121 self.features.extend(values);
122 self
123 }
124
125 pub fn with_property(mut self, name: &str, value: EdgeProperty) -> Self {
127 self.properties.insert(name.to_string(), value);
128 self
129 }
130
131 pub fn with_timestamp(mut self, timestamp: NaiveDate) -> Self {
133 self.timestamp = Some(timestamp);
134 self
135 }
136
137 pub fn undirected(mut self) -> Self {
139 self.direction = EdgeDirection::Undirected;
140 self
141 }
142
143 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 pub fn with_label(mut self, label: &str) -> Self {
152 self.labels.push(label.to_string());
153 self
154 }
155
156 pub fn feature_dim(&self) -> usize {
158 self.features.len()
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub enum EdgeProperty {
165 String(String),
167 Int(i64),
169 Float(f64),
171 Decimal(Decimal),
173 Bool(bool),
175 Date(NaiveDate),
177}
178
179impl EdgeProperty {
180 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TransactionEdge {
207 pub edge: GraphEdge,
209 pub document_number: String,
211 pub company_code: String,
213 pub posting_date: NaiveDate,
215 pub debit_amount: Decimal,
217 pub credit_amount: Decimal,
219 pub is_debit: bool,
221 pub cost_center: Option<String>,
223 pub business_process: Option<String>,
225}
226
227impl TransactionEdge {
228 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 pub fn compute_features(&mut self) {
267 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 self.edge.features.push((amount.abs() + 1.0).ln());
276
277 self.edge
279 .features
280 .push(if self.is_debit { 1.0 } else { 0.0 });
281
282 let weekday = self.posting_date.weekday().num_days_from_monday() as f64;
284 self.edge.features.push(weekday / 6.0); let day = self.posting_date.day() as f64;
287 self.edge.features.push(day / 31.0); let month = self.posting_date.month() as f64;
290 self.edge.features.push(month / 12.0); 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 let is_year_end = month == 12.0;
300 self.edge.features.push(if is_year_end { 1.0 } else { 0.0 });
301
302 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct ApprovalEdge {
331 pub edge: GraphEdge,
333 pub document_number: String,
335 pub approval_date: NaiveDate,
337 pub amount: Decimal,
339 pub action: String,
341 pub within_limit: bool,
343}
344
345impl ApprovalEdge {
346 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 pub fn compute_features(&mut self) {
374 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 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 self.edge
389 .features
390 .push(if self.within_limit { 1.0 } else { 0.0 });
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct OwnershipEdge {
397 pub edge: GraphEdge,
399 pub parent_code: String,
401 pub subsidiary_code: String,
403 pub ownership_percent: Decimal,
405 pub consolidation_method: String,
407 pub effective_date: NaiveDate,
409}
410
411impl OwnershipEdge {
412 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 pub fn compute_features(&mut self) {
437 let pct: f64 = self.ownership_percent.try_into().unwrap_or(0.0);
439 self.edge.features.push(pct / 100.0);
440
441 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 let prob1 = TransactionEdge::benford_probability(1);
488 assert!((prob1 - 0.301).abs() < 0.001);
489
490 let prob9 = TransactionEdge::benford_probability(9);
492 assert!((prob9 - 0.046).abs() < 0.001);
493 }
494}