rustkernel_graph/
cycles.rs

1//! Short cycle detection kernels.
2//!
3//! This module provides cycle participation analysis:
4//! - 2-cycle (reciprocal edge) detection
5//! - 3-cycle (triangle) detection - KEY AML INDICATOR
6//! - 4-cycle (square) detection - CRITICAL AML INDICATOR
7
8use crate::types::CsrGraph;
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashSet;
11
12// ============================================================================
13// Cycle Participation Result
14// ============================================================================
15
16/// Cycle participation result for a node.
17#[derive(Debug, Clone)]
18pub struct CycleParticipationResult {
19    /// Node index.
20    pub node_index: usize,
21    /// Number of 2-cycles (reciprocal edges) this node participates in.
22    pub cycle_count_2hop: u32,
23    /// Number of 3-cycles (triangles) this node participates in - KEY AML INDICATOR.
24    pub cycle_count_3hop: u32,
25    /// Number of 4-cycles (squares) this node participates in - CRITICAL AML INDICATOR.
26    pub cycle_count_4hop: u32,
27    /// Total weight (sum of edge weights) in all cycles.
28    pub total_cycle_weight: f64,
29    /// Cycle ratio: fraction of edges that participate in cycles `[0,1]`.
30    pub cycle_ratio: f64,
31    /// Risk level based on cycle participation.
32    pub risk_level: CycleRiskLevel,
33}
34
35/// Risk level based on cycle participation.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CycleRiskLevel {
38    /// Baseline - zero or minimal 2-cycles.
39    Baseline,
40    /// Low risk - 1-2 triangles (could be legitimate).
41    Low,
42    /// Medium risk - 3+ triangles (investigate for layering).
43    Medium,
44    /// High risk - ANY 4-cycles (structured laundering pattern).
45    High,
46    /// Critical risk - Multiple 4-cycles (professional laundering ring).
47    Critical,
48}
49
50// ============================================================================
51// Short Cycle Participation Kernel
52// ============================================================================
53
54/// Short cycle participation kernel.
55///
56/// Detects participation in 2-4 hop cycles. Critical for AML:
57/// - Triangles (3-cycles) indicate layering patterns
58/// - Squares (4-cycles) indicate organized money laundering
59#[derive(Debug, Clone)]
60pub struct ShortCycleParticipation {
61    metadata: KernelMetadata,
62}
63
64impl Default for ShortCycleParticipation {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl ShortCycleParticipation {
71    /// Create a new short cycle participation kernel.
72    #[must_use]
73    pub fn new() -> Self {
74        Self {
75            metadata: KernelMetadata::batch("graph/cycle-participation", Domain::GraphAnalytics)
76                .with_description("Short cycle (2-4 hop) participation detection")
77                .with_throughput(25_000)
78                .with_latency_us(200.0),
79        }
80    }
81
82    /// Detect 2-cycles (reciprocal edges) for all nodes.
83    pub fn detect_2_cycles(graph: &CsrGraph) -> Vec<u32> {
84        let n = graph.num_nodes;
85        let mut cycle_counts = vec![0u32; n];
86
87        // For each edge, check if reciprocal exists
88        for u in 0..n {
89            let neighbors_u: HashSet<u64> = graph.neighbors(u as u64).iter().copied().collect();
90
91            for &v in graph.neighbors(u as u64) {
92                // Check if edge v -> u exists (reciprocal)
93                if graph.neighbors(v).contains(&(u as u64)) {
94                    cycle_counts[u] += 1;
95                }
96            }
97        }
98
99        // Each reciprocal edge is counted once per endpoint
100        // (already normalized since we check u->v and v->u separately)
101
102        cycle_counts
103    }
104
105    /// Detect 3-cycles (triangles) for all nodes.
106    ///
107    /// This is the KEY AML INDICATOR for layering detection.
108    pub fn detect_3_cycles(graph: &CsrGraph) -> Vec<u32> {
109        let n = graph.num_nodes;
110        let mut cycle_counts = vec![0u32; n];
111
112        for u in 0..n {
113            let neighbors_u: HashSet<u64> = graph.neighbors(u as u64).iter().copied().collect();
114
115            for &v in graph.neighbors(u as u64) {
116                if v as usize <= u {
117                    continue; // Avoid counting twice
118                }
119
120                // Find common neighbors (triangles)
121                for &w in graph.neighbors(v) {
122                    if w as usize <= v as usize {
123                        continue;
124                    }
125
126                    if neighbors_u.contains(&w) {
127                        // Found triangle u-v-w
128                        cycle_counts[u] += 1;
129                        cycle_counts[v as usize] += 1;
130                        cycle_counts[w as usize] += 1;
131                    }
132                }
133            }
134        }
135
136        cycle_counts
137    }
138
139    /// Detect 4-cycles (squares) for all nodes.
140    ///
141    /// This is the CRITICAL AML INDICATOR for organized laundering.
142    pub fn detect_4_cycles(graph: &CsrGraph) -> Vec<u32> {
143        let n = graph.num_nodes;
144        let mut cycle_counts = vec![0u32; n];
145
146        // For efficiency, limit to smaller graphs or use sampling
147        if n > 1000 {
148            // Use sampling for large graphs
149            return Self::detect_4_cycles_sampled(graph, 0.1);
150        }
151
152        for u in 0..n {
153            let neighbors_u: HashSet<u64> = graph.neighbors(u as u64).iter().copied().collect();
154
155            // Enumerate 2-paths starting from u: u -> v -> w
156            for &v in graph.neighbors(u as u64) {
157                for &w in graph.neighbors(v) {
158                    if w as usize == u {
159                        continue; // Skip immediate back-edge
160                    }
161
162                    // Look for x such that w -> x -> u forms a 4-cycle
163                    for &x in graph.neighbors(w) {
164                        if x as usize != u && x != v && neighbors_u.contains(&x) {
165                            // Found 4-cycle: u -> v -> w -> x -> u
166                            cycle_counts[u] += 1;
167                        }
168                    }
169                }
170            }
171        }
172
173        // Each 4-cycle is counted 4 times (once per node), normalize
174        for count in &mut cycle_counts {
175            *count /= 4;
176        }
177
178        cycle_counts
179    }
180
181    /// Detect 4-cycles using sampling for large graphs.
182    fn detect_4_cycles_sampled(graph: &CsrGraph, sample_rate: f64) -> Vec<u32> {
183        let n = graph.num_nodes;
184        let mut cycle_counts = vec![0u32; n];
185
186        let sample_count = (n as f64 * sample_rate).max(1.0) as usize;
187        let step = (n / sample_count).max(1);
188
189        for u in (0..n).step_by(step) {
190            let neighbors_u: HashSet<u64> = graph.neighbors(u as u64).iter().copied().collect();
191
192            for &v in graph.neighbors(u as u64) {
193                for &w in graph.neighbors(v) {
194                    if w as usize == u {
195                        continue;
196                    }
197
198                    for &x in graph.neighbors(w) {
199                        if x as usize != u && x != v && neighbors_u.contains(&x) {
200                            cycle_counts[u] += 1;
201                        }
202                    }
203                }
204            }
205        }
206
207        // Scale up for sampling
208        let scale = 1.0 / sample_rate;
209        for count in &mut cycle_counts {
210            *count = (*count as f64 * scale) as u32 / 4;
211        }
212
213        cycle_counts
214    }
215
216    /// Compute full cycle participation for all nodes.
217    pub fn compute_all(graph: &CsrGraph) -> Vec<CycleParticipationResult> {
218        let cycles_2 = Self::detect_2_cycles(graph);
219        let cycles_3 = Self::detect_3_cycles(graph);
220        let cycles_4 = Self::detect_4_cycles(graph);
221
222        let n = graph.num_nodes;
223
224        (0..n)
225            .map(|i| {
226                let c2 = cycles_2[i];
227                let c3 = cycles_3[i];
228                let c4 = cycles_4[i];
229
230                let degree = graph.out_degree(i as u64);
231                let cycle_ratio = if degree > 0 {
232                    (c2 + c3 + c4) as f64 / degree as f64
233                } else {
234                    0.0
235                };
236
237                let risk_level = Self::calculate_risk_level(c2, c3, c4);
238
239                CycleParticipationResult {
240                    node_index: i,
241                    cycle_count_2hop: c2,
242                    cycle_count_3hop: c3,
243                    cycle_count_4hop: c4,
244                    total_cycle_weight: 0.0, // Would need weighted graph
245                    cycle_ratio,
246                    risk_level,
247                }
248            })
249            .collect()
250    }
251
252    /// Calculate risk level based on cycle participation.
253    fn calculate_risk_level(cycles_2: u32, cycles_3: u32, cycles_4: u32) -> CycleRiskLevel {
254        if cycles_4 > 3 {
255            CycleRiskLevel::Critical
256        } else if cycles_4 > 0 {
257            CycleRiskLevel::High
258        } else if cycles_3 >= 3 {
259            CycleRiskLevel::Medium
260        } else if cycles_3 >= 1 || cycles_2 >= 3 {
261            CycleRiskLevel::Low
262        } else {
263            CycleRiskLevel::Baseline
264        }
265    }
266
267    /// Find high-risk nodes based on cycle participation.
268    pub fn find_high_risk_nodes(graph: &CsrGraph) -> Vec<CycleParticipationResult> {
269        Self::compute_all(graph)
270            .into_iter()
271            .filter(|r| {
272                matches!(
273                    r.risk_level,
274                    CycleRiskLevel::High | CycleRiskLevel::Critical
275                )
276            })
277            .collect()
278    }
279
280    /// Find nodes with any 4-cycle participation.
281    pub fn find_4_cycle_nodes(graph: &CsrGraph) -> Vec<CycleParticipationResult> {
282        Self::compute_all(graph)
283            .into_iter()
284            .filter(|r| r.cycle_count_4hop > 0)
285            .collect()
286    }
287
288    /// Count total triangles in the graph.
289    pub fn count_triangles(graph: &CsrGraph) -> u64 {
290        let cycles_3 = Self::detect_3_cycles(graph);
291        // Each triangle is counted 3 times (once per vertex)
292        cycles_3.iter().map(|&c| c as u64).sum::<u64>() / 3
293    }
294}
295
296impl GpuKernel for ShortCycleParticipation {
297    fn metadata(&self) -> &KernelMetadata {
298        &self.metadata
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn create_triangle_graph() -> CsrGraph {
307        // Undirected triangle: 0 - 1 - 2 - 0 (bidirectional edges)
308        CsrGraph::from_edges(3, &[(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)])
309    }
310
311    fn create_square_graph() -> CsrGraph {
312        // Square: 0 -> 1 -> 2 -> 3 -> 0
313        CsrGraph::from_edges(4, &[(0, 1), (1, 2), (2, 3), (3, 0)])
314    }
315
316    fn create_reciprocal_graph() -> CsrGraph {
317        // Bidirectional edges: 0 <-> 1, 1 <-> 2
318        CsrGraph::from_edges(3, &[(0, 1), (1, 0), (1, 2), (2, 1)])
319    }
320
321    fn create_complex_graph() -> CsrGraph {
322        // Graph with two triangles (undirected)
323        CsrGraph::from_edges(
324            5,
325            &[
326                // Triangle 0-1-2
327                (0, 1),
328                (1, 0),
329                (1, 2),
330                (2, 1),
331                (0, 2),
332                (2, 0),
333                // Triangle 2-3-4
334                (2, 3),
335                (3, 2),
336                (3, 4),
337                (4, 3),
338                (2, 4),
339                (4, 2),
340            ],
341        )
342    }
343
344    #[test]
345    fn test_cycle_participation_metadata() {
346        let kernel = ShortCycleParticipation::new();
347        assert_eq!(kernel.metadata().id, "graph/cycle-participation");
348        assert_eq!(kernel.metadata().domain, Domain::GraphAnalytics);
349    }
350
351    #[test]
352    fn test_detect_2_cycles() {
353        let graph = create_reciprocal_graph();
354        let counts = ShortCycleParticipation::detect_2_cycles(&graph);
355
356        // Node 1 has reciprocal edges with both 0 and 2
357        assert!(counts[1] >= 2);
358    }
359
360    #[test]
361    fn test_detect_3_cycles() {
362        let graph = create_triangle_graph();
363        let counts = ShortCycleParticipation::detect_3_cycles(&graph);
364
365        // All three nodes participate in one triangle
366        assert!(
367            counts[0] >= 1,
368            "Node 0 should participate in triangle: got {}",
369            counts[0]
370        );
371        assert!(
372            counts[1] >= 1,
373            "Node 1 should participate in triangle: got {}",
374            counts[1]
375        );
376        assert!(
377            counts[2] >= 1,
378            "Node 2 should participate in triangle: got {}",
379            counts[2]
380        );
381    }
382
383    #[test]
384    fn test_count_triangles() {
385        let graph = create_triangle_graph();
386        let count = ShortCycleParticipation::count_triangles(&graph);
387
388        assert_eq!(
389            count, 1,
390            "Should find 1 triangle in undirected triangle graph"
391        );
392    }
393
394    #[test]
395    fn test_complex_graph_triangles() {
396        let graph = create_complex_graph();
397        let count = ShortCycleParticipation::count_triangles(&graph);
398
399        assert_eq!(count, 2, "Should find 2 triangles"); // Two triangles
400    }
401
402    #[test]
403    fn test_risk_level_baseline() {
404        let level = ShortCycleParticipation::calculate_risk_level(0, 0, 0);
405        assert_eq!(level, CycleRiskLevel::Baseline);
406    }
407
408    #[test]
409    fn test_risk_level_low() {
410        let level = ShortCycleParticipation::calculate_risk_level(0, 1, 0);
411        assert_eq!(level, CycleRiskLevel::Low);
412    }
413
414    #[test]
415    fn test_risk_level_medium() {
416        let level = ShortCycleParticipation::calculate_risk_level(0, 3, 0);
417        assert_eq!(level, CycleRiskLevel::Medium);
418    }
419
420    #[test]
421    fn test_risk_level_high() {
422        let level = ShortCycleParticipation::calculate_risk_level(0, 0, 1);
423        assert_eq!(level, CycleRiskLevel::High);
424    }
425
426    #[test]
427    fn test_risk_level_critical() {
428        let level = ShortCycleParticipation::calculate_risk_level(0, 0, 5);
429        assert_eq!(level, CycleRiskLevel::Critical);
430    }
431
432    #[test]
433    fn test_find_high_risk_nodes() {
434        let graph = create_triangle_graph();
435        let high_risk = ShortCycleParticipation::find_high_risk_nodes(&graph);
436
437        // No 4-cycles, so no high-risk nodes
438        assert!(high_risk.is_empty());
439    }
440
441    #[test]
442    fn test_compute_all() {
443        let graph = create_triangle_graph();
444        let results = ShortCycleParticipation::compute_all(&graph);
445
446        assert_eq!(results.len(), 3);
447        for result in &results {
448            assert!(
449                result.cycle_count_3hop >= 1,
450                "Each node should have at least 1 triangle participation"
451            );
452            assert_eq!(result.cycle_count_4hop, 0);
453        }
454    }
455}