Skip to main content

ringkernel_accnet/models/
flow.rs

1//! Transaction flow representation - edges in the accounting network graph.
2//!
3//! A flow represents monetary movement from one account to another,
4//! derived from journal entry transformation.
5
6use super::{Decimal128, HybridTimestamp, SolvingMethod};
7use rkyv::{Archive, Deserialize, Serialize};
8use uuid::Uuid;
9
10/// A directed edge in the accounting network representing monetary flow.
11/// GPU-aligned to 64 bytes.
12#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
13#[repr(C, align(64))]
14pub struct TransactionFlow {
15    // === Edge endpoints (4 bytes) ===
16    /// Source account index (debited account)
17    pub source_account_index: u16,
18    /// Target account index (credited account)
19    pub target_account_index: u16,
20
21    // === Amount (16 bytes) ===
22    /// Monetary amount transferred
23    pub amount: Decimal128,
24
25    // === Provenance (20 bytes) ===
26    /// Original journal entry ID
27    pub journal_entry_id: Uuid,
28    /// Line item index in the journal entry
29    pub debit_line_index: u16,
30    /// Corresponding credit line index
31    pub credit_line_index: u16,
32
33    // === Temporal (8 bytes) ===
34    /// When this flow was recorded
35    pub timestamp: HybridTimestamp,
36
37    // === Quality metrics (8 bytes) ===
38    /// Confidence score (0.0 - 1.0)
39    pub confidence: f32,
40    /// Transformation method used
41    pub method_used: SolvingMethod,
42    /// Flags
43    pub flags: FlowFlags,
44    /// Padding
45    pub _pad: [u8; 2],
46
47    // === Reserved (8 bytes) ===
48    /// Reserved for future use.
49    pub _reserved: [u8; 8],
50}
51
52/// Bit flags for flow properties.
53#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
54#[repr(transparent)]
55pub struct FlowFlags(pub u8);
56
57impl FlowFlags {
58    /// Flag: Flow derived from shadow bookings (Method E).
59    pub const HAS_SHADOW_BOOKINGS: u8 = 1 << 0;
60    /// Flag: Flow uses higher aggregate matching (Method D).
61    pub const USES_HIGHER_AGGREGATE: u8 = 1 << 1;
62    /// Flag: Flow flagged for audit review.
63    pub const FLAGGED_FOR_AUDIT: u8 = 1 << 2;
64    /// Flag: Flow is a reversal of another transaction.
65    pub const IS_REVERSAL: u8 = 1 << 3;
66    /// Flag: Flow is part of a circular pattern.
67    pub const IS_CIRCULAR: u8 = 1 << 4;
68    /// Flag: Flow detected as anomalous.
69    pub const IS_ANOMALOUS: u8 = 1 << 5;
70    /// Flag: Flow violates GAAP rules.
71    pub const IS_GAAP_VIOLATION: u8 = 1 << 6;
72    /// Flag: Flow is part of a fraud pattern.
73    pub const IS_FRAUD_PATTERN: u8 = 1 << 7;
74
75    /// Create new empty flags.
76    pub fn new() -> Self {
77        Self(0)
78    }
79
80    /// Check if a flag is set.
81    pub fn has(&self, flag: u8) -> bool {
82        self.0 & flag != 0
83    }
84
85    /// Set a flag.
86    pub fn set(&mut self, flag: u8) {
87        self.0 |= flag;
88    }
89
90    /// Clear a flag.
91    pub fn clear(&mut self, flag: u8) {
92        self.0 &= !flag;
93    }
94}
95
96impl TransactionFlow {
97    /// Create a new transaction flow.
98    pub fn new(
99        source: u16,
100        target: u16,
101        amount: Decimal128,
102        journal_entry_id: Uuid,
103        timestamp: HybridTimestamp,
104    ) -> Self {
105        Self {
106            source_account_index: source,
107            target_account_index: target,
108            amount,
109            journal_entry_id,
110            debit_line_index: 0,
111            credit_line_index: 0,
112            timestamp,
113            confidence: 1.0,
114            method_used: SolvingMethod::MethodA,
115            flags: FlowFlags::new(),
116            _pad: [0; 2],
117            _reserved: [0; 8],
118        }
119    }
120
121    /// Create a flow with full provenance.
122    #[allow(clippy::too_many_arguments)]
123    pub fn with_provenance(
124        source: u16,
125        target: u16,
126        amount: Decimal128,
127        journal_entry_id: Uuid,
128        debit_line_index: u16,
129        credit_line_index: u16,
130        timestamp: HybridTimestamp,
131        method: SolvingMethod,
132        confidence: f32,
133    ) -> Self {
134        Self {
135            source_account_index: source,
136            target_account_index: target,
137            amount,
138            journal_entry_id,
139            debit_line_index,
140            credit_line_index,
141            timestamp,
142            confidence,
143            method_used: method,
144            flags: FlowFlags::new(),
145            _pad: [0; 2],
146            _reserved: [0; 8],
147        }
148    }
149
150    /// Check if this flow is a self-loop (same source and target).
151    pub fn is_self_loop(&self) -> bool {
152        self.source_account_index == self.target_account_index
153    }
154
155    /// Check if this flow has high confidence.
156    pub fn is_high_confidence(&self) -> bool {
157        self.confidence >= 0.9
158    }
159
160    /// Check if this flow is flagged as anomalous.
161    pub fn is_anomalous(&self) -> bool {
162        self.flags.has(FlowFlags::IS_ANOMALOUS)
163            || self.flags.has(FlowFlags::IS_CIRCULAR)
164            || self.flags.has(FlowFlags::IS_FRAUD_PATTERN)
165            || self.flags.has(FlowFlags::IS_GAAP_VIOLATION)
166    }
167}
168
169/// Aggregated flow statistics between two accounts.
170/// Used for visualization edge weights.
171#[derive(Debug, Clone, Default)]
172pub struct AggregatedFlow {
173    /// Source account index
174    pub source: u16,
175    /// Target account index
176    pub target: u16,
177    /// Total amount transferred
178    pub total_amount: f64,
179    /// Number of individual transactions
180    pub transaction_count: u32,
181    /// Average confidence across transactions
182    pub avg_confidence: f32,
183    /// Earliest transaction
184    pub first_timestamp: HybridTimestamp,
185    /// Latest transaction
186    pub last_timestamp: HybridTimestamp,
187    /// Method distribution
188    pub method_counts: [u32; 5], // Methods A-E
189    /// Number of flagged transactions
190    pub flagged_count: u32,
191}
192
193impl AggregatedFlow {
194    /// Create a new aggregated flow between two accounts.
195    pub fn new(source: u16, target: u16) -> Self {
196        Self {
197            source,
198            target,
199            ..Default::default()
200        }
201    }
202
203    /// Add a flow to this aggregation.
204    pub fn add(&mut self, flow: &TransactionFlow) {
205        self.total_amount += flow.amount.to_f64();
206        self.transaction_count += 1;
207
208        // Update running average confidence
209        let n = self.transaction_count as f32;
210        self.avg_confidence = self.avg_confidence * (n - 1.0) / n + flow.confidence / n;
211
212        // Update timestamps
213        if self.transaction_count == 1 {
214            self.first_timestamp = flow.timestamp;
215            self.last_timestamp = flow.timestamp;
216        } else {
217            if flow.timestamp < self.first_timestamp {
218                self.first_timestamp = flow.timestamp;
219            }
220            if flow.timestamp > self.last_timestamp {
221                self.last_timestamp = flow.timestamp;
222            }
223        }
224
225        // Update method distribution
226        let method_idx = flow.method_used as usize;
227        if method_idx < 5 {
228            self.method_counts[method_idx] += 1;
229        }
230
231        // Count flagged
232        if flow.is_anomalous() {
233            self.flagged_count += 1;
234        }
235    }
236
237    /// Get the dominant solving method for this flow.
238    pub fn dominant_method(&self) -> SolvingMethod {
239        let max_idx = self
240            .method_counts
241            .iter()
242            .enumerate()
243            .max_by_key(|(_, &count)| count)
244            .map(|(idx, _)| idx)
245            .unwrap_or(0);
246
247        match max_idx {
248            0 => SolvingMethod::MethodA,
249            1 => SolvingMethod::MethodB,
250            2 => SolvingMethod::MethodC,
251            3 => SolvingMethod::MethodD,
252            4 => SolvingMethod::MethodE,
253            _ => SolvingMethod::MethodA,
254        }
255    }
256
257    /// Calculate risk score based on flags and confidence.
258    pub fn risk_score(&self) -> f32 {
259        let flag_ratio = self.flagged_count as f32 / self.transaction_count.max(1) as f32;
260        let confidence_factor = 1.0 - self.avg_confidence;
261        0.6 * flag_ratio + 0.4 * confidence_factor
262    }
263}
264
265/// Flow direction for analysis.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum FlowDirection {
268    /// Money flowing into an account
269    Inflow,
270    /// Money flowing out of an account
271    Outflow,
272    /// Both directions (for graph traversal)
273    Both,
274}
275
276/// Edge in the graph for traversal algorithms.
277#[derive(Debug, Clone, Copy)]
278pub struct GraphEdge {
279    /// Source node index.
280    pub from: u16,
281    /// Target node index.
282    pub to: u16,
283    /// Edge weight (amount, frequency, or custom metric).
284    pub weight: f64,
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_transaction_flow_size() {
293        let size = std::mem::size_of::<TransactionFlow>();
294        assert!(
295            size >= 64,
296            "TransactionFlow should be at least 64 bytes, got {}",
297            size
298        );
299        assert!(
300            size.is_multiple_of(64),
301            "TransactionFlow should be 64-byte aligned, got {}",
302            size
303        );
304    }
305
306    #[test]
307    fn test_aggregated_flow() {
308        let mut agg = AggregatedFlow::new(0, 1);
309
310        let flow1 = TransactionFlow::new(
311            0,
312            1,
313            Decimal128::from_f64(100.0),
314            Uuid::new_v4(),
315            HybridTimestamp::now(),
316        );
317
318        let mut flow2 = TransactionFlow::new(
319            0,
320            1,
321            Decimal128::from_f64(200.0),
322            Uuid::new_v4(),
323            HybridTimestamp::now(),
324        );
325        flow2.method_used = SolvingMethod::MethodB;
326
327        agg.add(&flow1);
328        agg.add(&flow2);
329
330        assert_eq!(agg.transaction_count, 2);
331        assert!((agg.total_amount - 300.0).abs() < 0.01);
332        // With equal counts (1 A, 1 B), max_by_key returns the last one with max count
333        let dominant = agg.dominant_method();
334        assert!(dominant == SolvingMethod::MethodA || dominant == SolvingMethod::MethodB);
335    }
336
337    #[test]
338    fn test_flow_flags() {
339        let mut flow = TransactionFlow::new(
340            0,
341            1,
342            Decimal128::from_f64(100.0),
343            Uuid::new_v4(),
344            HybridTimestamp::now(),
345        );
346
347        assert!(!flow.is_anomalous());
348
349        flow.flags.set(FlowFlags::IS_CIRCULAR);
350        assert!(flow.is_anomalous());
351    }
352}