rustkernel_graph/
topology.rs

1//! Graph topology analysis kernels.
2//!
3//! This module provides topology analysis:
4//! - Degree ratio (in/out degree analysis)
5//! - Star topology score (hub-and-spoke detection)
6
7use crate::types::CsrGraph;
8use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
9use std::collections::HashSet;
10
11// ============================================================================
12// Degree Ratio Kernel
13// ============================================================================
14
15/// Degree ratio result for a node.
16#[derive(Debug, Clone)]
17pub struct DegreeRatioResult {
18    /// Node index.
19    pub node_index: usize,
20    /// In-degree (incoming edges).
21    pub in_degree: u32,
22    /// Out-degree (outgoing edges).
23    pub out_degree: u32,
24    /// In/out ratio (infinity if out-degree=0).
25    pub ratio: f64,
26    /// Degree variance among neighbors.
27    pub variance: f64,
28    /// Classification based on ratio.
29    pub classification: NodeClassification,
30}
31
32/// Node classification based on degree ratio.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum NodeClassification {
35    /// Source node (ratio ≈ 0, mostly outgoing).
36    Source,
37    /// Balanced node (ratio ≈ 1, equal in/out).
38    Balanced,
39    /// Sink node (ratio > threshold, mostly incoming).
40    Sink,
41    /// Isolated node (no edges).
42    Isolated,
43}
44
45/// Degree ratio kernel.
46///
47/// Calculates in-degree/out-degree ratio for source/sink classification.
48/// Critical for cash flow analysis and account role identification.
49#[derive(Debug, Clone)]
50pub struct DegreeRatio {
51    metadata: KernelMetadata,
52}
53
54impl Default for DegreeRatio {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl DegreeRatio {
61    /// Create a new degree ratio kernel.
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            metadata: KernelMetadata::ring("graph/degree-ratio", Domain::GraphAnalytics)
66                .with_description("In/out degree ratio for node classification")
67                .with_throughput(1_000_000)
68                .with_latency_us(0.3),
69        }
70    }
71
72    /// Compute degree ratio for a single node.
73    ///
74    /// # Arguments
75    /// * `graph` - Input graph (CSR format)
76    /// * `in_degrees` - Precomputed in-degrees (or None to compute on-the-fly)
77    /// * `node` - Node index
78    pub fn compute_single(
79        graph: &CsrGraph,
80        in_degrees: Option<&[u32]>,
81        node: usize,
82    ) -> DegreeRatioResult {
83        let out_degree = graph.out_degree(node as u64) as u32;
84
85        let in_degree = if let Some(in_deg) = in_degrees {
86            in_deg.get(node).copied().unwrap_or(0)
87        } else {
88            // Compute in-degree by scanning all edges
89            Self::compute_in_degree(graph, node)
90        };
91
92        let (ratio, classification) = if out_degree == 0 && in_degree == 0 {
93            (0.0, NodeClassification::Isolated)
94        } else if out_degree == 0 {
95            (f64::INFINITY, NodeClassification::Sink)
96        } else {
97            let r = in_degree as f64 / out_degree as f64;
98            let class = if r < 0.2 {
99                NodeClassification::Source
100            } else if r > 5.0 {
101                NodeClassification::Sink
102            } else {
103                NodeClassification::Balanced
104            };
105            (r, class)
106        };
107
108        // Compute degree variance among neighbors
109        let variance = Self::compute_neighbor_degree_variance(graph, node);
110
111        DegreeRatioResult {
112            node_index: node,
113            in_degree,
114            out_degree,
115            ratio,
116            variance,
117            classification,
118        }
119    }
120
121    /// Compute in-degree for a node by scanning all edges.
122    fn compute_in_degree(graph: &CsrGraph, target: usize) -> u32 {
123        let mut in_deg = 0u32;
124        for source in 0..graph.num_nodes {
125            for &neighbor in graph.neighbors(source as u64) {
126                if neighbor as usize == target {
127                    in_deg += 1;
128                }
129            }
130        }
131        in_deg
132    }
133
134    /// Compute degree variance among neighbors.
135    fn compute_neighbor_degree_variance(graph: &CsrGraph, node: usize) -> f64 {
136        let neighbors = graph.neighbors(node as u64);
137        if neighbors.is_empty() {
138            return 0.0;
139        }
140
141        let degrees: Vec<f64> = neighbors
142            .iter()
143            .map(|&n| graph.out_degree(n) as f64)
144            .collect();
145
146        let mean = degrees.iter().sum::<f64>() / degrees.len() as f64;
147        let variance =
148            degrees.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / degrees.len() as f64;
149
150        variance
151    }
152
153    /// Compute degree ratios for all nodes.
154    pub fn compute_batch(graph: &CsrGraph) -> Vec<DegreeRatioResult> {
155        // Precompute in-degrees for efficiency
156        let in_degrees = Self::compute_all_in_degrees(graph);
157
158        (0..graph.num_nodes)
159            .map(|node| Self::compute_single(graph, Some(&in_degrees), node))
160            .collect()
161    }
162
163    /// Compute in-degrees for all nodes.
164    pub fn compute_all_in_degrees(graph: &CsrGraph) -> Vec<u32> {
165        let mut in_degrees = vec![0u32; graph.num_nodes];
166
167        for source in 0..graph.num_nodes {
168            for &target in graph.neighbors(source as u64) {
169                in_degrees[target as usize] += 1;
170            }
171        }
172
173        in_degrees
174    }
175
176    /// Classify nodes by their role (source, balanced, sink).
177    pub fn classify_nodes(graph: &CsrGraph) -> NodeRoleDistribution {
178        let results = Self::compute_batch(graph);
179
180        let mut sources = Vec::new();
181        let mut balanced = Vec::new();
182        let mut sinks = Vec::new();
183        let mut isolated = Vec::new();
184
185        for result in results {
186            match result.classification {
187                NodeClassification::Source => sources.push(result.node_index),
188                NodeClassification::Balanced => balanced.push(result.node_index),
189                NodeClassification::Sink => sinks.push(result.node_index),
190                NodeClassification::Isolated => isolated.push(result.node_index),
191            }
192        }
193
194        NodeRoleDistribution {
195            sources,
196            balanced,
197            sinks,
198            isolated,
199        }
200    }
201}
202
203impl GpuKernel for DegreeRatio {
204    fn metadata(&self) -> &KernelMetadata {
205        &self.metadata
206    }
207}
208
209/// Distribution of node roles in the graph.
210#[derive(Debug, Clone)]
211pub struct NodeRoleDistribution {
212    /// Source nodes (mostly outgoing edges).
213    pub sources: Vec<usize>,
214    /// Balanced nodes (equal in/out).
215    pub balanced: Vec<usize>,
216    /// Sink nodes (mostly incoming edges).
217    pub sinks: Vec<usize>,
218    /// Isolated nodes (no edges).
219    pub isolated: Vec<usize>,
220}
221
222// ============================================================================
223// Star Topology Score Kernel
224// ============================================================================
225
226/// Star type classification.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum StarType {
229    /// Not a star (low score or low degree).
230    None,
231    /// In-Star: Most edges point TO this node (collection pattern).
232    InStar,
233    /// Out-Star: Most edges point FROM this node (distribution pattern).
234    OutStar,
235    /// Mixed-Star: Both incoming and outgoing edges (money mule hub).
236    Mixed,
237}
238
239/// Star topology result for a node.
240#[derive(Debug, Clone)]
241pub struct StarTopologyResult {
242    /// Node index.
243    pub node_index: usize,
244    /// Star score `[0,1]` - 1.0 = perfect star, 0.0 = not a star.
245    pub star_score: f64,
246    /// Type of star topology.
247    pub star_type: StarType,
248    /// Number of spokes (neighbors).
249    pub spoke_count: usize,
250    /// Number of inter-spoke edges (ideally 0 for perfect star).
251    pub inter_spoke_edges: usize,
252}
253
254/// Star topology score kernel.
255///
256/// Detects and scores star/hub-and-spoke topology patterns.
257/// Critical for AML (smurfing detection) and fraud (money mule identification).
258#[derive(Debug, Clone)]
259pub struct StarTopologyScore {
260    metadata: KernelMetadata,
261    /// Minimum degree to be considered a potential hub.
262    pub min_degree: usize,
263}
264
265impl Default for StarTopologyScore {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271impl StarTopologyScore {
272    /// Create a new star topology score kernel.
273    #[must_use]
274    pub fn new() -> Self {
275        Self {
276            metadata: KernelMetadata::batch("graph/star-topology", Domain::GraphAnalytics)
277                .with_description("Star/hub-and-spoke topology score")
278                .with_throughput(20_000)
279                .with_latency_us(150.0),
280            min_degree: 3,
281        }
282    }
283
284    /// Create with custom minimum degree threshold.
285    #[must_use]
286    pub fn with_min_degree(min_degree: usize) -> Self {
287        Self {
288            min_degree,
289            ..Self::new()
290        }
291    }
292
293    /// Compute star topology score for a single node.
294    pub fn compute_single(
295        graph: &CsrGraph,
296        in_degrees: Option<&[u32]>,
297        node: usize,
298        min_degree: usize,
299    ) -> StarTopologyResult {
300        let neighbors = graph.neighbors(node as u64);
301        let spoke_count = neighbors.len();
302
303        // Check minimum degree
304        if spoke_count < min_degree {
305            return StarTopologyResult {
306                node_index: node,
307                star_score: 0.0,
308                star_type: StarType::None,
309                spoke_count,
310                inter_spoke_edges: 0,
311            };
312        }
313
314        // Create spoke set for O(1) lookup
315        let spoke_set: HashSet<u64> = neighbors.iter().copied().collect();
316
317        // Count inter-spoke edges
318        let mut inter_spoke_edges = 0usize;
319        for &spoke in neighbors {
320            for &neighbor_of_spoke in graph.neighbors(spoke) {
321                if neighbor_of_spoke != node as u64 && spoke_set.contains(&neighbor_of_spoke) {
322                    inter_spoke_edges += 1;
323                }
324            }
325        }
326        // Each edge counted twice
327        inter_spoke_edges /= 2;
328
329        // Maximum possible inter-spoke edges (complete graph among spokes)
330        let max_inter_spoke = (spoke_count * (spoke_count - 1)) / 2;
331
332        // Star score: 1.0 = perfect star (no inter-spoke edges), 0.0 = complete graph
333        let star_score = if max_inter_spoke == 0 {
334            0.0
335        } else {
336            1.0 - (inter_spoke_edges as f64 / max_inter_spoke as f64)
337        };
338
339        // Classify star type based on edge directionality
340        let out_degree = graph.out_degree(node as u64) as usize;
341        let in_degree = if let Some(in_deg) = in_degrees {
342            in_deg.get(node).copied().unwrap_or(0) as usize
343        } else {
344            DegreeRatio::compute_in_degree(graph, node) as usize
345        };
346
347        let total_edges = in_degree + out_degree;
348        let star_type = if total_edges == 0 {
349            StarType::None
350        } else if in_degree as f64 > 0.9 * total_edges as f64 {
351            StarType::InStar
352        } else if out_degree as f64 > 0.9 * total_edges as f64 {
353            StarType::OutStar
354        } else {
355            StarType::Mixed
356        };
357
358        StarTopologyResult {
359            node_index: node,
360            star_score,
361            star_type,
362            spoke_count,
363            inter_spoke_edges,
364        }
365    }
366
367    /// Compute star topology scores for all nodes.
368    pub fn compute_batch(&self, graph: &CsrGraph) -> Vec<StarTopologyResult> {
369        let in_degrees = DegreeRatio::compute_all_in_degrees(graph);
370
371        (0..graph.num_nodes)
372            .map(|node| Self::compute_single(graph, Some(&in_degrees), node, self.min_degree))
373            .collect()
374    }
375
376    /// Find top-K star hubs.
377    pub fn top_k_hubs(&self, graph: &CsrGraph, k: usize) -> Vec<StarTopologyResult> {
378        let mut results = self.compute_batch(graph);
379
380        // Sort by star score descending
381        results.sort_by(|a, b| {
382            b.star_score
383                .partial_cmp(&a.star_score)
384                .unwrap_or(std::cmp::Ordering::Equal)
385        });
386
387        results.into_iter().take(k).collect()
388    }
389
390    /// Find in-star hubs (collection accounts).
391    pub fn find_in_stars(&self, graph: &CsrGraph, min_score: f64) -> Vec<StarTopologyResult> {
392        self.compute_batch(graph)
393            .into_iter()
394            .filter(|r| r.star_type == StarType::InStar && r.star_score >= min_score)
395            .collect()
396    }
397
398    /// Find out-star hubs (distribution accounts - potential smurfing).
399    pub fn find_out_stars(&self, graph: &CsrGraph, min_score: f64) -> Vec<StarTopologyResult> {
400        self.compute_batch(graph)
401            .into_iter()
402            .filter(|r| r.star_type == StarType::OutStar && r.star_score >= min_score)
403            .collect()
404    }
405}
406
407impl GpuKernel for StarTopologyScore {
408    fn metadata(&self) -> &KernelMetadata {
409        &self.metadata
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn create_star_graph() -> CsrGraph {
418        // Star graph: node 0 is hub, connected to 1,2,3,4
419        // 0 -> 1, 0 -> 2, 0 -> 3, 0 -> 4 (out-star)
420        CsrGraph::from_edges(5, &[(0, 1), (0, 2), (0, 3), (0, 4)])
421    }
422
423    fn create_bidirectional_star() -> CsrGraph {
424        // Bidirectional star: node 0 is hub, connected to 1,2,3,4 in both directions
425        CsrGraph::from_edges(
426            5,
427            &[
428                (0, 1),
429                (0, 2),
430                (0, 3),
431                (0, 4),
432                (1, 0),
433                (2, 0),
434                (3, 0),
435                (4, 0),
436            ],
437        )
438    }
439
440    fn create_partial_star() -> CsrGraph {
441        // Partial star with some inter-spoke edges
442        CsrGraph::from_edges(
443            5,
444            &[
445                (0, 1),
446                (0, 2),
447                (0, 3),
448                (0, 4), // Hub edges
449                (1, 2),
450                (2, 1), // Inter-spoke edge (bidirectional)
451            ],
452        )
453    }
454
455    #[test]
456    fn test_degree_ratio_metadata() {
457        let kernel = DegreeRatio::new();
458        assert_eq!(kernel.metadata().id, "graph/degree-ratio");
459        assert_eq!(kernel.metadata().domain, Domain::GraphAnalytics);
460    }
461
462    #[test]
463    fn test_degree_ratio_out_star() {
464        let graph = create_star_graph();
465        let result = DegreeRatio::compute_single(&graph, None, 0);
466
467        assert_eq!(result.out_degree, 4);
468        assert_eq!(result.in_degree, 0);
469        assert_eq!(result.ratio, 0.0);
470        assert_eq!(result.classification, NodeClassification::Source);
471    }
472
473    #[test]
474    fn test_degree_ratio_bidirectional() {
475        let graph = create_bidirectional_star();
476        let result = DegreeRatio::compute_single(&graph, None, 0);
477
478        assert_eq!(result.out_degree, 4);
479        assert_eq!(result.in_degree, 4);
480        assert!((result.ratio - 1.0).abs() < 0.01);
481        assert_eq!(result.classification, NodeClassification::Balanced);
482    }
483
484    #[test]
485    fn test_star_topology_perfect_star() {
486        let graph = create_star_graph();
487        let result = StarTopologyScore::compute_single(&graph, None, 0, 3);
488
489        assert_eq!(result.spoke_count, 4);
490        assert_eq!(result.inter_spoke_edges, 0);
491        assert!((result.star_score - 1.0).abs() < 0.01);
492        assert_eq!(result.star_type, StarType::OutStar);
493    }
494
495    #[test]
496    fn test_star_topology_bidirectional() {
497        let graph = create_bidirectional_star();
498        let result = StarTopologyScore::compute_single(&graph, None, 0, 3);
499
500        // Bidirectional star - should be Mixed type
501        assert_eq!(result.spoke_count, 4);
502        assert_eq!(result.inter_spoke_edges, 0);
503        assert!(
504            (result.star_score - 1.0).abs() < 0.01,
505            "Star score should be 1.0 for perfect star"
506        );
507        assert_eq!(
508            result.star_type,
509            StarType::Mixed,
510            "Bidirectional star should be Mixed type"
511        );
512    }
513
514    #[test]
515    fn test_star_topology_partial() {
516        let graph = create_partial_star();
517        let result = StarTopologyScore::compute_single(&graph, None, 0, 3);
518
519        assert_eq!(result.spoke_count, 4);
520        // One bidirectional inter-spoke edge between 1 and 2
521        assert!(
522            result.inter_spoke_edges >= 1,
523            "Should detect inter-spoke edge"
524        );
525        // 6 possible inter-spoke edges, 1 exists -> score = 1 - 1/6 ≈ 0.83
526        assert!(
527            result.star_score > 0.5 && result.star_score < 1.0,
528            "Star score {} should be between 0.5 and 1.0",
529            result.star_score
530        );
531    }
532
533    #[test]
534    fn test_star_topology_top_k() {
535        let graph = create_star_graph();
536        let kernel = StarTopologyScore::with_min_degree(2);
537        let top = kernel.top_k_hubs(&graph, 2);
538
539        assert!(!top.is_empty());
540        assert_eq!(top[0].node_index, 0); // Hub should be first
541    }
542
543    #[test]
544    fn test_node_classification() {
545        let graph = create_star_graph();
546        let roles = DegreeRatio::classify_nodes(&graph);
547
548        assert!(roles.sources.contains(&0)); // Hub is source
549        assert!(roles.sinks.len() + roles.isolated.len() >= 1); // Spoke nodes
550    }
551}