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