Skip to main content

ringkernel_accnet/models/
patterns.rs

1//! Pattern definitions for fraud detection and GAAP compliance.
2//!
3//! These structures define the rules and detected patterns used by
4//! the analysis kernels.
5
6use super::{AccountType, Decimal128, HybridTimestamp};
7use rkyv::{Archive, Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Types of fraud patterns that can be detected.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
12#[archive(compare(PartialEq))]
13#[repr(u8)]
14pub enum FraudPatternType {
15    /// Money flowing in a circle: A → B → C → A
16    CircularFlow = 0,
17
18    /// Self-loop: A → B and B → A within short timeframe
19    SelfLoop = 1,
20
21    /// First digit distribution violates Benford's Law
22    BenfordViolation = 2,
23
24    /// Transactions clustered just below approval thresholds
25    ThresholdClustering = 3,
26
27    /// Entries posted outside business hours
28    AfterHoursEntry = 4,
29
30    /// Rapid multi-hop money movement (potential kiting)
31    HighVelocity = 5,
32
33    /// Implausible account pairings (e.g., Payroll → Fixed Assets)
34    UnusualPairing = 6,
35
36    /// Sudden activity on long-dormant accounts
37    DormantActivation = 7,
38
39    /// Round amounts (possible fabrication)
40    RoundAmounts = 8,
41
42    /// Duplicate transactions (possible double-payment)
43    DuplicateTransaction = 9,
44
45    /// Split transactions to avoid detection
46    StructuredTransactions = 10,
47
48    /// Unusual reversal patterns
49    ReversalAnomaly = 11,
50}
51
52impl FraudPatternType {
53    /// Risk weight for this pattern type (0.0 - 1.0).
54    pub fn risk_weight(&self) -> f32 {
55        match self {
56            FraudPatternType::CircularFlow => 0.95,
57            FraudPatternType::HighVelocity => 0.90,
58            FraudPatternType::ThresholdClustering => 0.85,
59            FraudPatternType::StructuredTransactions => 0.85,
60            FraudPatternType::DormantActivation => 0.80,
61            FraudPatternType::UnusualPairing => 0.75,
62            FraudPatternType::BenfordViolation => 0.70,
63            FraudPatternType::AfterHoursEntry => 0.60,
64            FraudPatternType::RoundAmounts => 0.50,
65            FraudPatternType::SelfLoop => 0.65,
66            FraudPatternType::DuplicateTransaction => 0.55,
67            FraudPatternType::ReversalAnomaly => 0.60,
68        }
69    }
70
71    /// Human-readable description.
72    pub fn description(&self) -> &'static str {
73        match self {
74            FraudPatternType::CircularFlow => "Circular money flow detected (A→B→C→A)",
75            FraudPatternType::SelfLoop => "Bidirectional flow between accounts",
76            FraudPatternType::BenfordViolation => "Amount distribution violates Benford's Law",
77            FraudPatternType::ThresholdClustering => "Amounts clustered below approval threshold",
78            FraudPatternType::AfterHoursEntry => "Entry posted outside business hours",
79            FraudPatternType::HighVelocity => "Rapid multi-hop money movement",
80            FraudPatternType::UnusualPairing => "Implausible account combination",
81            FraudPatternType::DormantActivation => "Dormant account suddenly activated",
82            FraudPatternType::RoundAmounts => "Suspicious round-number amounts",
83            FraudPatternType::DuplicateTransaction => "Potential duplicate transaction",
84            FraudPatternType::StructuredTransactions => "Structured to avoid detection",
85            FraudPatternType::ReversalAnomaly => "Unusual reversal pattern",
86        }
87    }
88
89    /// Icon for UI.
90    pub fn icon(&self) -> &'static str {
91        match self {
92            FraudPatternType::CircularFlow => "🔄",
93            FraudPatternType::SelfLoop => "↔️",
94            FraudPatternType::BenfordViolation => "📊",
95            FraudPatternType::ThresholdClustering => "📍",
96            FraudPatternType::AfterHoursEntry => "🌙",
97            FraudPatternType::HighVelocity => "⚡",
98            FraudPatternType::UnusualPairing => "❓",
99            FraudPatternType::DormantActivation => "💤",
100            FraudPatternType::RoundAmounts => "🔢",
101            FraudPatternType::DuplicateTransaction => "📋",
102            FraudPatternType::StructuredTransactions => "✂️",
103            FraudPatternType::ReversalAnomaly => "↩️",
104        }
105    }
106}
107
108/// A detected fraud pattern instance.
109#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
110#[repr(C)]
111pub struct FraudPattern {
112    /// Pattern identifier
113    pub id: Uuid,
114    /// Type of pattern
115    pub pattern_type: FraudPatternType,
116    /// Risk score (0.0 - 1.0)
117    pub risk_score: f32,
118    /// Total amount involved
119    pub amount: Decimal128,
120    /// Number of accounts involved
121    pub account_count: u16,
122    /// Number of transactions in the pattern
123    pub transaction_count: u16,
124    /// Time span of the pattern (days)
125    pub timeframe_days: u16,
126    /// Padding
127    pub _pad: u16,
128    /// First timestamp
129    pub first_seen: HybridTimestamp,
130    /// Last timestamp
131    pub last_seen: HybridTimestamp,
132    /// Involved account indices (up to 8)
133    pub involved_accounts: [u16; 8],
134}
135
136impl FraudPattern {
137    /// Create a new fraud pattern instance.
138    pub fn new(pattern_type: FraudPatternType) -> Self {
139        Self {
140            id: Uuid::new_v4(),
141            pattern_type,
142            risk_score: pattern_type.risk_weight(),
143            amount: Decimal128::ZERO,
144            account_count: 0,
145            transaction_count: 0,
146            timeframe_days: 0,
147            _pad: 0,
148            first_seen: HybridTimestamp::zero(),
149            last_seen: HybridTimestamp::zero(),
150            involved_accounts: [u16::MAX; 8],
151        }
152    }
153
154    /// Add an account to the pattern.
155    pub fn add_account(&mut self, account_index: u16) {
156        for i in 0..8 {
157            if self.involved_accounts[i] == u16::MAX {
158                self.involved_accounts[i] = account_index;
159                self.account_count += 1;
160                break;
161            }
162        }
163    }
164
165    /// Get involved accounts as a vector.
166    pub fn get_involved_accounts(&self) -> Vec<u16> {
167        self.involved_accounts
168            .iter()
169            .filter(|&&idx| idx != u16::MAX)
170            .copied()
171            .collect()
172    }
173}
174
175/// GAAP violation severity levels.
176#[derive(
177    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Archive, Serialize, Deserialize,
178)]
179#[archive(compare(PartialEq))]
180#[repr(u8)]
181pub enum ViolationSeverity {
182    /// Minor: Unusual but not necessarily wrong
183    Low = 0,
184    /// Moderate: Needs review
185    Medium = 1,
186    /// Significant: Likely error or policy violation
187    High = 2,
188    /// Critical: Definite violation requiring immediate action
189    Critical = 3,
190}
191
192impl ViolationSeverity {
193    /// Get color for this severity level (RGB).
194    pub fn color(&self) -> [u8; 3] {
195        match self {
196            ViolationSeverity::Low => [255, 235, 59],     // Yellow
197            ViolationSeverity::Medium => [255, 152, 0],   // Orange
198            ViolationSeverity::High => [244, 67, 54],     // Red
199            ViolationSeverity::Critical => [183, 28, 28], // Dark red
200        }
201    }
202}
203
204/// Types of GAAP violations.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
206#[archive(compare(PartialEq))]
207#[repr(u8)]
208pub enum GaapViolationType {
209    /// Revenue → Cash (should use Receivable)
210    RevenueToCashDirect = 0,
211
212    /// Revenue → Expense (impossible under accounting equation)
213    RevenueToExpense = 1,
214
215    /// Cash → Revenue (backward flow)
216    CashToRevenue = 2,
217
218    /// Expense → Asset (capitalization bypass)
219    ExpenseToAsset = 3,
220
221    /// Liability → Revenue (debt forgiveness misclassification)
222    LiabilityToRevenue = 4,
223
224    /// COGS without Inventory movement
225    CogsWithoutInventory = 5,
226
227    /// Direct increase to Accumulated Depreciation
228    AccumDepreciationIncrease = 6,
229
230    /// Direct modification of Retained Earnings (except closing)
231    RetainedEarningsModification = 7,
232
233    /// Intercompany imbalance
234    IntercompanyImbalance = 8,
235
236    /// Unbalanced entry
237    UnbalancedEntry = 9,
238}
239
240impl GaapViolationType {
241    /// Default severity for this violation type.
242    pub fn default_severity(&self) -> ViolationSeverity {
243        match self {
244            GaapViolationType::RevenueToExpense => ViolationSeverity::Critical,
245            GaapViolationType::UnbalancedEntry => ViolationSeverity::Critical,
246            GaapViolationType::RetainedEarningsModification => ViolationSeverity::High,
247            GaapViolationType::AccumDepreciationIncrease => ViolationSeverity::High,
248            GaapViolationType::RevenueToCashDirect => ViolationSeverity::Medium,
249            GaapViolationType::CashToRevenue => ViolationSeverity::Medium,
250            GaapViolationType::LiabilityToRevenue => ViolationSeverity::High,
251            GaapViolationType::ExpenseToAsset => ViolationSeverity::Medium,
252            GaapViolationType::CogsWithoutInventory => ViolationSeverity::Medium,
253            GaapViolationType::IntercompanyImbalance => ViolationSeverity::Low,
254        }
255    }
256
257    /// Description for UI.
258    pub fn description(&self) -> &'static str {
259        match self {
260            GaapViolationType::RevenueToCashDirect => "Revenue directly to Cash (bypass A/R)",
261            GaapViolationType::RevenueToExpense => {
262                "Revenue to Expense (accounting equation violation)"
263            }
264            GaapViolationType::CashToRevenue => "Cash to Revenue (backward flow)",
265            GaapViolationType::ExpenseToAsset => "Expense to Asset (improper capitalization)",
266            GaapViolationType::LiabilityToRevenue => "Liability to Revenue (misclassification)",
267            GaapViolationType::CogsWithoutInventory => "COGS without Inventory movement",
268            GaapViolationType::AccumDepreciationIncrease => "Direct Accum. Depreciation increase",
269            GaapViolationType::RetainedEarningsModification => {
270                "Direct Retained Earnings modification"
271            }
272            GaapViolationType::IntercompanyImbalance => "Intercompany accounts don't balance",
273            GaapViolationType::UnbalancedEntry => "Debits ≠ Credits",
274        }
275    }
276
277    /// Check if a flow between two account types constitutes this violation.
278    pub fn matches(&self, source_type: AccountType, target_type: AccountType) -> bool {
279        match self {
280            GaapViolationType::RevenueToCashDirect => {
281                source_type == AccountType::Revenue && target_type == AccountType::Asset
282            }
283            GaapViolationType::RevenueToExpense => {
284                source_type == AccountType::Revenue && target_type == AccountType::Expense
285            }
286            GaapViolationType::CashToRevenue => {
287                source_type == AccountType::Asset && target_type == AccountType::Revenue
288            }
289            GaapViolationType::ExpenseToAsset => {
290                source_type == AccountType::Expense && target_type == AccountType::Asset
291            }
292            GaapViolationType::LiabilityToRevenue => {
293                source_type == AccountType::Liability && target_type == AccountType::Revenue
294            }
295            _ => false, // Other violations need more context
296        }
297    }
298}
299
300/// A GAAP violation rule for detection.
301#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
302#[repr(C)]
303pub struct GaapViolationRule {
304    /// Rule identifier
305    pub rule_id: u32,
306    /// Violation type
307    pub violation_type: GaapViolationType,
308    /// Source account type (if applicable)
309    pub source_type: Option<AccountType>,
310    /// Target account type (if applicable)
311    pub target_type: Option<AccountType>,
312    /// Severity level
313    pub severity: ViolationSeverity,
314    /// Minimum amount to trigger (0 = any)
315    pub min_amount: f64,
316    /// Rule name hash (for display lookup)
317    pub rule_name_hash: u64,
318}
319
320/// A detected GAAP violation instance.
321#[derive(Debug, Clone)]
322pub struct GaapViolation {
323    /// Unique identifier
324    pub id: Uuid,
325    /// Type of violation
326    pub violation_type: GaapViolationType,
327    /// Severity
328    pub severity: ViolationSeverity,
329    /// Source account index
330    pub source_account: u16,
331    /// Target account index
332    pub target_account: u16,
333    /// Amount involved
334    pub amount: Decimal128,
335    /// Journal entry that caused the violation
336    pub journal_entry_id: Uuid,
337    /// When detected
338    pub detected_at: HybridTimestamp,
339    /// Description
340    pub description: String,
341}
342
343impl GaapViolation {
344    /// Create a new GAAP violation instance.
345    pub fn new(
346        violation_type: GaapViolationType,
347        source: u16,
348        target: u16,
349        amount: Decimal128,
350        journal_entry_id: Uuid,
351    ) -> Self {
352        Self {
353            id: Uuid::new_v4(),
354            violation_type,
355            severity: violation_type.default_severity(),
356            source_account: source,
357            target_account: target,
358            amount,
359            journal_entry_id,
360            detected_at: HybridTimestamp::now(),
361            description: violation_type.description().to_string(),
362        }
363    }
364}
365
366/// Benford's Law expected first-digit distribution.
367pub const BENFORD_EXPECTED: [f64; 9] = [
368    0.301, // 1
369    0.176, // 2
370    0.125, // 3
371    0.097, // 4
372    0.079, // 5
373    0.067, // 6
374    0.058, // 7
375    0.051, // 8
376    0.046, // 9
377];
378
379/// Calculate chi-squared statistic for Benford's Law compliance.
380pub fn benford_chi_squared(observed_counts: &[u32; 9], total: u32) -> f64 {
381    if total == 0 {
382        return 0.0;
383    }
384
385    let mut chi_sq = 0.0;
386    for i in 0..9 {
387        let expected = BENFORD_EXPECTED[i] * total as f64;
388        let observed = observed_counts[i] as f64;
389        if expected > 0.0 {
390            chi_sq += (observed - expected).powi(2) / expected;
391        }
392    }
393    chi_sq
394}
395
396/// Critical value for chi-squared with 8 degrees of freedom (p=0.05).
397pub const BENFORD_CHI_SQ_CRITICAL: f64 = 15.507;
398
399/// Check if a chi-squared value indicates Benford violation.
400pub fn is_benford_violation(chi_squared: f64) -> bool {
401    chi_squared > BENFORD_CHI_SQ_CRITICAL
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_benford_perfect_distribution() {
410        // Perfectly matching Benford's Law
411        let observed = [301, 176, 125, 97, 79, 67, 58, 51, 46];
412        let chi_sq = benford_chi_squared(&observed, 1000);
413        assert!(chi_sq < BENFORD_CHI_SQ_CRITICAL);
414    }
415
416    #[test]
417    fn test_benford_uniform_violation() {
418        // Uniform distribution (clearly not Benford)
419        let observed = [111, 111, 111, 111, 111, 111, 111, 111, 112];
420        let chi_sq = benford_chi_squared(&observed, 1000);
421        assert!(is_benford_violation(chi_sq));
422    }
423
424    #[test]
425    fn test_gaap_violation_matching() {
426        assert!(
427            GaapViolationType::RevenueToExpense.matches(AccountType::Revenue, AccountType::Expense)
428        );
429        assert!(
430            !GaapViolationType::RevenueToExpense.matches(AccountType::Asset, AccountType::Expense)
431        );
432    }
433
434    #[test]
435    fn test_fraud_pattern_accounts() {
436        let mut pattern = FraudPattern::new(FraudPatternType::CircularFlow);
437        pattern.add_account(0);
438        pattern.add_account(1);
439        pattern.add_account(2);
440
441        let accounts = pattern.get_involved_accounts();
442        assert_eq!(accounts, vec![0, 1, 2]);
443    }
444}