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 let variance =
148 degrees.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / degrees.len() as f64;
149
150 variance
151 }
152
153 pub fn compute_batch(graph: &CsrGraph) -> Vec<DegreeRatioResult> {
155 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 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 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#[derive(Debug, Clone)]
211pub struct NodeRoleDistribution {
212 pub sources: Vec<usize>,
214 pub balanced: Vec<usize>,
216 pub sinks: Vec<usize>,
218 pub isolated: Vec<usize>,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum StarType {
229 None,
231 InStar,
233 OutStar,
235 Mixed,
237}
238
239#[derive(Debug, Clone)]
241pub struct StarTopologyResult {
242 pub node_index: usize,
244 pub star_score: f64,
246 pub star_type: StarType,
248 pub spoke_count: usize,
250 pub inter_spoke_edges: usize,
252}
253
254#[derive(Debug, Clone)]
259pub struct StarTopologyScore {
260 metadata: KernelMetadata,
261 pub min_degree: usize,
263}
264
265impl Default for StarTopologyScore {
266 fn default() -> Self {
267 Self::new()
268 }
269}
270
271impl StarTopologyScore {
272 #[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 #[must_use]
286 pub fn with_min_degree(min_degree: usize) -> Self {
287 Self {
288 min_degree,
289 ..Self::new()
290 }
291 }
292
293 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 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 let spoke_set: HashSet<u64> = neighbors.iter().copied().collect();
316
317 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 inter_spoke_edges /= 2;
328
329 let max_inter_spoke = (spoke_count * (spoke_count - 1)) / 2;
331
332 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 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 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 pub fn top_k_hubs(&self, graph: &CsrGraph, k: usize) -> Vec<StarTopologyResult> {
378 let mut results = self.compute_batch(graph);
379
380 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 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 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 CsrGraph::from_edges(5, &[(0, 1), (0, 2), (0, 3), (0, 4)])
421 }
422
423 fn create_bidirectional_star() -> CsrGraph {
424 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 CsrGraph::from_edges(
443 5,
444 &[
445 (0, 1),
446 (0, 2),
447 (0, 3),
448 (0, 4), (1, 2),
450 (2, 1), ],
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 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 assert!(
522 result.inter_spoke_edges >= 1,
523 "Should detect inter-spoke edge"
524 );
525 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); }
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)); assert!(roles.sinks.len() + roles.isolated.len() >= 1); }
551}