1use crate::types::CsrGraph;
8use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
9use std::collections::HashSet;
10
11#[derive(Debug, Clone)]
17pub struct DegreeRatioResult {
18 pub node_index: usize,
20 pub in_degree: u32,
22 pub out_degree: u32,
24 pub ratio: f64,
26 pub variance: f64,
28 pub classification: NodeClassification,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum NodeClassification {
35 Source,
37 Balanced,
39 Sink,
41 Isolated,
43}
44
45#[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 #[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 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 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 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 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 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 pub fn compute_batch(graph: &CsrGraph) -> Vec<DegreeRatioResult> {
152 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 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 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#[derive(Debug, Clone)]
208pub struct NodeRoleDistribution {
209 pub sources: Vec<usize>,
211 pub balanced: Vec<usize>,
213 pub sinks: Vec<usize>,
215 pub isolated: Vec<usize>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum StarType {
226 None,
228 InStar,
230 OutStar,
232 Mixed,
234}
235
236#[derive(Debug, Clone)]
238pub struct StarTopologyResult {
239 pub node_index: usize,
241 pub star_score: f64,
243 pub star_type: StarType,
245 pub spoke_count: usize,
247 pub inter_spoke_edges: usize,
249}
250
251#[derive(Debug, Clone)]
256pub struct StarTopologyScore {
257 metadata: KernelMetadata,
258 pub min_degree: usize,
260}
261
262impl Default for StarTopologyScore {
263 fn default() -> Self {
264 Self::new()
265 }
266}
267
268impl StarTopologyScore {
269 #[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 #[must_use]
283 pub fn with_min_degree(min_degree: usize) -> Self {
284 Self {
285 min_degree,
286 ..Self::new()
287 }
288 }
289
290 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 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 let spoke_set: HashSet<u64> = neighbors.iter().copied().collect();
313
314 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 inter_spoke_edges /= 2;
325
326 let max_inter_spoke = (spoke_count * (spoke_count - 1)) / 2;
328
329 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 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 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 pub fn top_k_hubs(&self, graph: &CsrGraph, k: usize) -> Vec<StarTopologyResult> {
375 let mut results = self.compute_batch(graph);
376
377 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 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 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 CsrGraph::from_edges(5, &[(0, 1), (0, 2), (0, 3), (0, 4)])
418 }
419
420 fn create_bidirectional_star() -> CsrGraph {
421 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 CsrGraph::from_edges(
440 5,
441 &[
442 (0, 1),
443 (0, 2),
444 (0, 3),
445 (0, 4), (1, 2),
447 (2, 1), ],
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 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 assert!(
519 result.inter_spoke_edges >= 1,
520 "Should detect inter-spoke edge"
521 );
522 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); }
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)); assert!(roles.sinks.len() + roles.isolated.len() >= 1); }
548}