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        degrees.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / degrees.len() as f64
148    }
149
150    /// Compute degree ratios for all nodes.
151    pub fn compute_batch(graph: &CsrGraph) -> Vec<DegreeRatioResult> {
152        // Precompute in-degrees for efficiency
153        let in_degrees = Self::compute_all_in_degrees(graph);
154
155        (0..graph.num_nodes)
156            .map(|node| Self::compute_single(graph, Some(&in_degrees), node))
157            .collect()
158    }
159
160    /// Compute in-degrees for all nodes.
161    pub fn compute_all_in_degrees(graph: &CsrGraph) -> Vec<u32> {
162        let mut in_degrees = vec![0u32; graph.num_nodes];
163
164        for source in 0..graph.num_nodes {
165            for &target in graph.neighbors(source as u64) {
166                in_degrees[target as usize] += 1;
167            }
168        }
169
170        in_degrees
171    }
172
173    /// Classify nodes by their role (source, balanced, sink).
174    pub fn classify_nodes(graph: &CsrGraph) -> NodeRoleDistribution {
175        let results = Self::compute_batch(graph);
176
177        let mut sources = Vec::new();
178        let mut balanced = Vec::new();
179        let mut sinks = Vec::new();
180        let mut isolated = Vec::new();
181
182        for result in results {
183            match result.classification {
184                NodeClassification::Source => sources.push(result.node_index),
185                NodeClassification::Balanced => balanced.push(result.node_index),
186                NodeClassification::Sink => sinks.push(result.node_index),
187                NodeClassification::Isolated => isolated.push(result.node_index),
188            }
189        }
190
191        NodeRoleDistribution {
192            sources,
193            balanced,
194            sinks,
195            isolated,
196        }
197    }
198}
199
200impl GpuKernel for DegreeRatio {
201    fn metadata(&self) -> &KernelMetadata {
202        &self.metadata
203    }
204}
205
206/// Distribution of node roles in the graph.
207#[derive(Debug, Clone)]
208pub struct NodeRoleDistribution {
209    /// Source nodes (mostly outgoing edges).
210    pub sources: Vec<usize>,
211    /// Balanced nodes (equal in/out).
212    pub balanced: Vec<usize>,
213    /// Sink nodes (mostly incoming edges).
214    pub sinks: Vec<usize>,
215    /// Isolated nodes (no edges).
216    pub isolated: Vec<usize>,
217}
218
219// ============================================================================
220// Star Topology Score Kernel
221// ============================================================================
222
223/// Star type classification.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum StarType {
226    /// Not a star (low score or low degree).
227    None,
228    /// In-Star: Most edges point TO this node (collection pattern).
229    InStar,
230    /// Out-Star: Most edges point FROM this node (distribution pattern).
231    OutStar,
232    /// Mixed-Star: Both incoming and outgoing edges (money mule hub).
233    Mixed,
234}
235
236/// Star topology result for a node.
237#[derive(Debug, Clone)]
238pub struct StarTopologyResult {
239    /// Node index.
240    pub node_index: usize,
241    /// Star score `[0,1]` - 1.0 = perfect star, 0.0 = not a star.
242    pub star_score: f64,
243    /// Type of star topology.
244    pub star_type: StarType,
245    /// Number of spokes (neighbors).
246    pub spoke_count: usize,
247    /// Number of inter-spoke edges (ideally 0 for perfect star).
248    pub inter_spoke_edges: usize,
249}
250
251/// Star topology score kernel.
252///
253/// Detects and scores star/hub-and-spoke topology patterns.
254/// Critical for AML (smurfing detection) and fraud (money mule identification).
255#[derive(Debug, Clone)]
256pub struct StarTopologyScore {
257    metadata: KernelMetadata,
258    /// Minimum degree to be considered a potential hub.
259    pub min_degree: usize,
260}
261
262impl Default for StarTopologyScore {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268impl StarTopologyScore {
269    /// Create a new star topology score kernel.
270    #[must_use]
271    pub fn new() -> Self {
272        Self {
273            metadata: KernelMetadata::batch("graph/star-topology", Domain::GraphAnalytics)
274                .with_description("Star/hub-and-spoke topology score")
275                .with_throughput(20_000)
276                .with_latency_us(150.0),
277            min_degree: 3,
278        }
279    }
280
281    /// Create with custom minimum degree threshold.
282    #[must_use]
283    pub fn with_min_degree(min_degree: usize) -> Self {
284        Self {
285            min_degree,
286            ..Self::new()
287        }
288    }
289
290    /// Compute star topology score for a single node.
291    pub fn compute_single(
292        graph: &CsrGraph,
293        in_degrees: Option<&[u32]>,
294        node: usize,
295        min_degree: usize,
296    ) -> StarTopologyResult {
297        let neighbors = graph.neighbors(node as u64);
298        let spoke_count = neighbors.len();
299
300        // Check minimum degree
301        if spoke_count < min_degree {
302            return StarTopologyResult {
303                node_index: node,
304                star_score: 0.0,
305                star_type: StarType::None,
306                spoke_count,
307                inter_spoke_edges: 0,
308            };
309        }
310
311        // Create spoke set for O(1) lookup
312        let spoke_set: HashSet<u64> = neighbors.iter().copied().collect();
313
314        // Count inter-spoke edges
315        let mut inter_spoke_edges = 0usize;
316        for &spoke in neighbors {
317            for &neighbor_of_spoke in graph.neighbors(spoke) {
318                if neighbor_of_spoke != node as u64 && spoke_set.contains(&neighbor_of_spoke) {
319                    inter_spoke_edges += 1;
320                }
321            }
322        }
323        // Each edge counted twice
324        inter_spoke_edges /= 2;
325
326        // Maximum possible inter-spoke edges (complete graph among spokes)
327        let max_inter_spoke = (spoke_count * (spoke_count - 1)) / 2;
328
329        // Star score: 1.0 = perfect star (no inter-spoke edges), 0.0 = complete graph
330        let star_score = if max_inter_spoke == 0 {
331            0.0
332        } else {
333            1.0 - (inter_spoke_edges as f64 / max_inter_spoke as f64)
334        };
335
336        // Classify star type based on edge directionality
337        let out_degree = graph.out_degree(node as u64) as usize;
338        let in_degree = if let Some(in_deg) = in_degrees {
339            in_deg.get(node).copied().unwrap_or(0) as usize
340        } else {
341            DegreeRatio::compute_in_degree(graph, node) as usize
342        };
343
344        let total_edges = in_degree + out_degree;
345        let star_type = if total_edges == 0 {
346            StarType::None
347        } else if in_degree as f64 > 0.9 * total_edges as f64 {
348            StarType::InStar
349        } else if out_degree as f64 > 0.9 * total_edges as f64 {
350            StarType::OutStar
351        } else {
352            StarType::Mixed
353        };
354
355        StarTopologyResult {
356            node_index: node,
357            star_score,
358            star_type,
359            spoke_count,
360            inter_spoke_edges,
361        }
362    }
363
364    /// Compute star topology scores for all nodes.
365    pub fn compute_batch(&self, graph: &CsrGraph) -> Vec<StarTopologyResult> {
366        let in_degrees = DegreeRatio::compute_all_in_degrees(graph);
367
368        (0..graph.num_nodes)
369            .map(|node| Self::compute_single(graph, Some(&in_degrees), node, self.min_degree))
370            .collect()
371    }
372
373    /// Find top-K star hubs.
374    pub fn top_k_hubs(&self, graph: &CsrGraph, k: usize) -> Vec<StarTopologyResult> {
375        let mut results = self.compute_batch(graph);
376
377        // Sort by star score descending
378        results.sort_by(|a, b| {
379            b.star_score
380                .partial_cmp(&a.star_score)
381                .unwrap_or(std::cmp::Ordering::Equal)
382        });
383
384        results.into_iter().take(k).collect()
385    }
386
387    /// Find in-star hubs (collection accounts).
388    pub fn find_in_stars(&self, graph: &CsrGraph, min_score: f64) -> Vec<StarTopologyResult> {
389        self.compute_batch(graph)
390            .into_iter()
391            .filter(|r| r.star_type == StarType::InStar && r.star_score >= min_score)
392            .collect()
393    }
394
395    /// Find out-star hubs (distribution accounts - potential smurfing).
396    pub fn find_out_stars(&self, graph: &CsrGraph, min_score: f64) -> Vec<StarTopologyResult> {
397        self.compute_batch(graph)
398            .into_iter()
399            .filter(|r| r.star_type == StarType::OutStar && r.star_score >= min_score)
400            .collect()
401    }
402}
403
404impl GpuKernel for StarTopologyScore {
405    fn metadata(&self) -> &KernelMetadata {
406        &self.metadata
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    fn create_star_graph() -> CsrGraph {
415        // Star graph: node 0 is hub, connected to 1,2,3,4
416        // 0 -> 1, 0 -> 2, 0 -> 3, 0 -> 4 (out-star)
417        CsrGraph::from_edges(5, &[(0, 1), (0, 2), (0, 3), (0, 4)])
418    }
419
420    fn create_bidirectional_star() -> CsrGraph {
421        // Bidirectional star: node 0 is hub, connected to 1,2,3,4 in both directions
422        CsrGraph::from_edges(
423            5,
424            &[
425                (0, 1),
426                (0, 2),
427                (0, 3),
428                (0, 4),
429                (1, 0),
430                (2, 0),
431                (3, 0),
432                (4, 0),
433            ],
434        )
435    }
436
437    fn create_partial_star() -> CsrGraph {
438        // Partial star with some inter-spoke edges
439        CsrGraph::from_edges(
440            5,
441            &[
442                (0, 1),
443                (0, 2),
444                (0, 3),
445                (0, 4), // Hub edges
446                (1, 2),
447                (2, 1), // Inter-spoke edge (bidirectional)
448            ],
449        )
450    }
451
452    #[test]
453    fn test_degree_ratio_metadata() {
454        let kernel = DegreeRatio::new();
455        assert_eq!(kernel.metadata().id, "graph/degree-ratio");
456        assert_eq!(kernel.metadata().domain, Domain::GraphAnalytics);
457    }
458
459    #[test]
460    fn test_degree_ratio_out_star() {
461        let graph = create_star_graph();
462        let result = DegreeRatio::compute_single(&graph, None, 0);
463
464        assert_eq!(result.out_degree, 4);
465        assert_eq!(result.in_degree, 0);
466        assert_eq!(result.ratio, 0.0);
467        assert_eq!(result.classification, NodeClassification::Source);
468    }
469
470    #[test]
471    fn test_degree_ratio_bidirectional() {
472        let graph = create_bidirectional_star();
473        let result = DegreeRatio::compute_single(&graph, None, 0);
474
475        assert_eq!(result.out_degree, 4);
476        assert_eq!(result.in_degree, 4);
477        assert!((result.ratio - 1.0).abs() < 0.01);
478        assert_eq!(result.classification, NodeClassification::Balanced);
479    }
480
481    #[test]
482    fn test_star_topology_perfect_star() {
483        let graph = create_star_graph();
484        let result = StarTopologyScore::compute_single(&graph, None, 0, 3);
485
486        assert_eq!(result.spoke_count, 4);
487        assert_eq!(result.inter_spoke_edges, 0);
488        assert!((result.star_score - 1.0).abs() < 0.01);
489        assert_eq!(result.star_type, StarType::OutStar);
490    }
491
492    #[test]
493    fn test_star_topology_bidirectional() {
494        let graph = create_bidirectional_star();
495        let result = StarTopologyScore::compute_single(&graph, None, 0, 3);
496
497        // Bidirectional star - should be Mixed type
498        assert_eq!(result.spoke_count, 4);
499        assert_eq!(result.inter_spoke_edges, 0);
500        assert!(
501            (result.star_score - 1.0).abs() < 0.01,
502            "Star score should be 1.0 for perfect star"
503        );
504        assert_eq!(
505            result.star_type,
506            StarType::Mixed,
507            "Bidirectional star should be Mixed type"
508        );
509    }
510
511    #[test]
512    fn test_star_topology_partial() {
513        let graph = create_partial_star();
514        let result = StarTopologyScore::compute_single(&graph, None, 0, 3);
515
516        assert_eq!(result.spoke_count, 4);
517        // One bidirectional inter-spoke edge between 1 and 2
518        assert!(
519            result.inter_spoke_edges >= 1,
520            "Should detect inter-spoke edge"
521        );
522        // 6 possible inter-spoke edges, 1 exists -> score = 1 - 1/6 ≈ 0.83
523        assert!(
524            result.star_score > 0.5 && result.star_score < 1.0,
525            "Star score {} should be between 0.5 and 1.0",
526            result.star_score
527        );
528    }
529
530    #[test]
531    fn test_star_topology_top_k() {
532        let graph = create_star_graph();
533        let kernel = StarTopologyScore::with_min_degree(2);
534        let top = kernel.top_k_hubs(&graph, 2);
535
536        assert!(!top.is_empty());
537        assert_eq!(top[0].node_index, 0); // Hub should be first
538    }
539
540    #[test]
541    fn test_node_classification() {
542        let graph = create_star_graph();
543        let roles = DegreeRatio::classify_nodes(&graph);
544
545        assert!(roles.sources.contains(&0)); // Hub is source
546        assert!(roles.sinks.len() + roles.isolated.len() >= 1); // Spoke nodes
547    }
548}