1use std::collections::{HashMap, HashSet};
23
24use super::tensor_to_vec2;
25use crate::GraphData;
26use torsh_core::device::DeviceType;
27use torsh_core::error::Result;
28use torsh_tensor::{
29 creation::{from_vec, zeros},
30 Tensor,
31};
32
33#[derive(Debug, Clone)]
40pub struct SparseGraph {
41 pub edge_list: Vec<(usize, usize)>,
43 pub node_features: Option<Tensor>,
45 pub edge_weights: Option<Vec<f32>>,
47 pub num_nodes: usize,
49 pub num_edges: usize,
51}
52
53impl SparseGraph {
54 pub fn from_dense(adjacency: &Tensor, threshold: f32) -> Result<Self> {
61 let shape = adjacency.shape();
62 let dims = shape.dims();
63 let rows = dims.first().copied().unwrap_or(0);
64 let cols = if dims.len() > 1 { dims[1] } else { 1 };
65 let num_nodes = rows;
66
67 let data = adjacency.to_vec()?;
68
69 let mut edge_list = Vec::new();
70 let mut edge_weights = Vec::new();
71
72 for i in 0..rows {
73 let row_base = i * cols;
74 for j in 0..cols {
75 let weight = data[row_base + j];
76 if weight.abs() > threshold {
77 edge_list.push((i, j));
78 edge_weights.push(weight);
79 }
80 }
81 }
82
83 let num_edges = edge_list.len();
84 Ok(Self {
85 edge_list,
86 node_features: None,
87 edge_weights: Some(edge_weights),
88 num_nodes,
89 num_edges,
90 })
91 }
92
93 pub fn to_edge_index(&self) -> Result<Tensor> {
99 if self.edge_list.is_empty() {
100 return Ok(zeros(&[2, 0])?);
101 }
102
103 let mut edge_vec = Vec::with_capacity(2 * self.edge_list.len());
104 for &(src, _) in &self.edge_list {
105 edge_vec.push(src as f32);
106 }
107 for &(_, dst) in &self.edge_list {
108 edge_vec.push(dst as f32);
109 }
110
111 Ok(from_vec(
112 edge_vec,
113 &[2, self.edge_list.len()],
114 DeviceType::Cpu,
115 )?)
116 }
117
118 pub fn memory_footprint(&self) -> usize {
126 let base = std::mem::size_of::<Self>();
127 let edge_list_bytes = self.edge_list.capacity() * std::mem::size_of::<(usize, usize)>();
128 let feature_bytes = self
129 .node_features
130 .as_ref()
131 .map(|t| t.numel() * std::mem::size_of::<f32>())
132 .unwrap_or(0);
133 let weight_bytes = self
134 .edge_weights
135 .as_ref()
136 .map(|w| w.capacity() * std::mem::size_of::<f32>())
137 .unwrap_or(0);
138
139 base + edge_list_bytes + feature_bytes + weight_bytes
140 }
141
142 pub fn density(&self) -> f32 {
146 if self.num_nodes == 0 {
147 return 0.0;
148 }
149 let possible = self.num_nodes as f32 * self.num_nodes as f32;
150 self.num_edges as f32 / possible
151 }
152}
153
154pub fn sparse_laplacian(
169 edge_index: &Tensor,
170 num_nodes: usize,
171 normalized: bool,
172) -> Result<SparseGraph> {
173 let (src_row, dst_row) = super::edge_rows(edge_index)?;
174
175 let mut neighbors: Vec<HashSet<usize>> = vec![HashSet::new(); num_nodes];
179 for (&src, &dst) in src_row.iter().zip(dst_row.iter()) {
180 if src < 0.0 || dst < 0.0 {
181 continue;
182 }
183 let s = src as usize;
184 let d = dst as usize;
185 if s < num_nodes && d < num_nodes {
186 neighbors[s].insert(d);
187 neighbors[d].insert(s);
188 }
189 }
190
191 let degrees: Vec<f32> = neighbors.iter().map(|row| row.len() as f32).collect();
192
193 let mut edges: Vec<(usize, usize)> = Vec::new();
194 let mut values: Vec<f32> = Vec::new();
195
196 for i in 0..num_nodes {
197 let self_loop = if neighbors[i].contains(&i) { 1.0 } else { 0.0 };
198 let diagonal = if normalized {
199 if degrees[i] > 0.0 {
200 1.0 - self_loop / degrees[i]
201 } else {
202 1.0
203 }
204 } else {
205 degrees[i] - self_loop
206 };
207 edges.push((i, i));
208 values.push(diagonal);
209
210 let mut row: Vec<usize> = neighbors[i].iter().copied().filter(|&j| j != i).collect();
211 row.sort_unstable();
212 for j in row {
213 let weight = if normalized {
214 if degrees[i] > 0.0 && degrees[j] > 0.0 {
215 -1.0 / (degrees[i].sqrt() * degrees[j].sqrt())
216 } else {
217 0.0
218 }
219 } else {
220 -1.0
221 };
222 edges.push((i, j));
223 values.push(weight);
224 }
225 }
226
227 let num_edges = edges.len();
228 Ok(SparseGraph {
229 edge_list: edges,
230 node_features: None,
231 edge_weights: Some(values),
232 num_nodes,
233 num_edges,
234 })
235}
236
237fn uf_find(parent: &mut [usize], x: usize) -> usize {
239 let mut root = x;
240 while parent[root] != root {
241 root = parent[root];
242 }
243 let mut cursor = x;
245 while parent[cursor] != root {
246 let next = parent[cursor];
247 parent[cursor] = root;
248 cursor = next;
249 }
250 root
251}
252
253fn uf_union(parent: &mut [usize], rank: &mut [u8], a: usize, b: usize) {
255 if a == b {
256 return;
257 }
258 match rank[a].cmp(&rank[b]) {
259 std::cmp::Ordering::Less => parent[a] = b,
260 std::cmp::Ordering::Greater => parent[b] = a,
261 std::cmp::Ordering::Equal => {
262 parent[b] = a;
263 rank[a] += 1;
264 }
265 }
266}
267
268pub fn adaptive_coarsening(graph: &GraphData, target_nodes: usize) -> Result<GraphData> {
280 let n = graph.num_nodes;
281 let target = target_nodes.max(1);
283
284 if n <= target {
285 return Ok(graph.clone());
286 }
287
288 let num_features = graph.x.shape().dims()[1];
289
290 let adjacency = super::connectivity::build_adjacency_list(&graph.edge_index, n)?;
292 let mut parent: Vec<usize> = (0..n).collect();
293 let mut rank: Vec<u8> = vec![0; n];
294 let mut num_components = n;
295
296 loop {
297 if num_components <= target {
298 break;
299 }
300 let mut progressed = false;
301 for (u, neighbors) in adjacency.iter().enumerate() {
302 if num_components <= target {
303 break;
304 }
305 for &v in neighbors {
306 if num_components <= target {
307 break;
308 }
309 let ru = uf_find(&mut parent, u);
310 let rv = uf_find(&mut parent, v);
311 if ru != rv {
312 uf_union(&mut parent, &mut rank, ru, rv);
313 num_components -= 1;
314 progressed = true;
315 }
316 }
317 }
318 if !progressed {
319 break;
321 }
322 }
323
324 if num_components > target {
327 let mut representatives: Vec<usize> = Vec::new();
328 for node in 0..n {
329 let root = uf_find(&mut parent, node);
330 if !representatives.contains(&root) {
331 representatives.push(root);
332 }
333 }
334 let anchor = representatives.first().copied().unwrap_or(0);
335 let mut idx = representatives.len();
336 while num_components > target && idx > 1 {
337 idx -= 1;
338 let root = uf_find(&mut parent, representatives[idx]);
339 let anchor_root = uf_find(&mut parent, anchor);
340 if root != anchor_root {
341 uf_union(&mut parent, &mut rank, root, anchor_root);
342 num_components -= 1;
343 }
344 }
345 }
346
347 let mut root_to_cluster: HashMap<usize, usize> = HashMap::new();
349 let mut node_to_cluster = vec![0usize; n];
350 for (node, slot) in node_to_cluster.iter_mut().enumerate() {
351 let root = uf_find(&mut parent, node);
352 let next_id = root_to_cluster.len();
353 let cluster_id = *root_to_cluster.entry(root).or_insert(next_id);
354 *slot = cluster_id;
355 }
356 let num_coarse = root_to_cluster.len();
357
358 let x_flat = graph.x.to_vec()?;
360 let mut coarse_features = vec![0.0f32; num_coarse * num_features];
361 let mut cluster_sizes = vec![0usize; num_coarse];
362 for (node, &cluster_id) in node_to_cluster.iter().enumerate() {
363 cluster_sizes[cluster_id] += 1;
364 let src_base = node * num_features;
365 let dst_base = cluster_id * num_features;
366 for f in 0..num_features {
367 coarse_features[dst_base + f] += x_flat[src_base + f];
368 }
369 }
370 for (cluster_id, &size) in cluster_sizes.iter().enumerate() {
371 if size > 1 {
372 let inv = 1.0 / size as f32;
373 let base = cluster_id * num_features;
374 for value in &mut coarse_features[base..base + num_features] {
375 *value *= inv;
376 }
377 }
378 }
379
380 let edge_data = tensor_to_vec2::<f32>(&graph.edge_index)?;
382 let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
383 if edge_data.len() >= 2 {
384 for (&src, &dst) in edge_data[0].iter().zip(edge_data[1].iter()) {
385 let s = src as usize;
386 let d = dst as usize;
387 if s < n && d < n {
388 let cs = node_to_cluster[s];
389 let cd = node_to_cluster[d];
390 if cs != cd {
391 edge_set.insert((cs.min(cd), cs.max(cd)));
392 }
393 }
394 }
395 }
396 let mut coarse_edges: Vec<(usize, usize)> = edge_set.into_iter().collect();
397 coarse_edges.sort_unstable();
398 let num_coarse_edges = coarse_edges.len();
399
400 let coarse_x = from_vec(
401 coarse_features,
402 &[num_coarse, num_features],
403 DeviceType::Cpu,
404 )?;
405
406 let coarse_edge_index = if num_coarse_edges > 0 {
407 let mut edge_vec = Vec::with_capacity(2 * num_coarse_edges);
408 for &(src, _) in &coarse_edges {
409 edge_vec.push(src as f32);
410 }
411 for &(_, dst) in &coarse_edges {
412 edge_vec.push(dst as f32);
413 }
414 from_vec(edge_vec, &[2, num_coarse_edges], DeviceType::Cpu)?
415 } else {
416 zeros(&[2, 0])?
417 };
418
419 Ok(GraphData::new(coarse_x, coarse_edge_index))
420}
421
422pub fn chunked_neighbor_aggregation(graph: &GraphData, chunk_size: usize) -> Result<Tensor> {
432 let n = graph.num_nodes;
433 let num_features = if n == 0 { 0 } else { graph.x.shape().dims()[1] };
434
435 let x_flat = graph.x.to_vec()?;
436 let adjacency = super::connectivity::build_adjacency_list(&graph.edge_index, n)?;
437 let chunk = chunk_size.max(1);
438 let mut out = vec![0.0f32; n * num_features];
439
440 let mut start = 0;
441 while start < n {
442 let end = (start + chunk).min(n);
443 for (offset, neighbors) in adjacency[start..end].iter().enumerate() {
444 let node = start + offset;
445 let out_base = node * num_features;
446 if neighbors.is_empty() {
447 let in_base = node * num_features;
448 out[out_base..out_base + num_features]
449 .copy_from_slice(&x_flat[in_base..in_base + num_features]);
450 } else {
451 for &neighbor in neighbors {
452 let nb_base = neighbor * num_features;
453 for f in 0..num_features {
454 out[out_base + f] += x_flat[nb_base + f];
455 }
456 }
457 let inv = 1.0 / neighbors.len() as f32;
458 for value in &mut out[out_base..out_base + num_features] {
459 *value *= inv;
460 }
461 }
462 }
463 start = end;
464 }
465
466 Ok(from_vec(out, &[n, num_features], DeviceType::Cpu)?)
467}
468
469#[cfg(test)]
470mod tests {
471 use super::{adaptive_coarsening, chunked_neighbor_aggregation, sparse_laplacian, SparseGraph};
472 use crate::utils::{graph_laplacian, tensor_to_vec2};
473 use crate::GraphData;
474 use torsh_core::device::DeviceType;
475 use torsh_tensor::creation::{from_vec, zeros};
476 use torsh_tensor::Tensor;
477
478 fn cycle4_edge_index() -> Tensor {
480 from_vec(
481 vec![0.0, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0, 0.0],
482 &[2, 4],
483 DeviceType::Cpu,
484 )
485 .unwrap()
486 }
487
488 #[test]
489 fn from_dense_extracts_entries_above_threshold() {
490 let dense = from_vec(
491 vec![
492 0.0, 0.5, 0.0, 0.0, 0.0, 0.05, -0.8, 0.0, 0.0, ],
496 &[3, 3],
497 DeviceType::Cpu,
498 )
499 .unwrap();
500
501 let sparse = SparseGraph::from_dense(&dense, 0.1).expect("operation should succeed");
502 assert_eq!(sparse.num_nodes, 3);
503 assert_eq!(sparse.num_edges, 2);
504 assert_eq!(sparse.edge_list, vec![(0, 1), (2, 0)]);
505
506 let weights = sparse.edge_weights.as_ref().unwrap();
507 assert_eq!(weights.len(), 2);
508 assert!((weights[0] - 0.5).abs() < 1e-6);
509 assert!((weights[1] + 0.8).abs() < 1e-6);
510 }
511
512 #[test]
513 fn empty_sparse_graph_still_has_positive_footprint() {
514 let dense = zeros(&[4, 4]).unwrap();
517 let sparse = SparseGraph::from_dense(&dense, 0.1).expect("operation should succeed");
518 assert_eq!(sparse.num_edges, 0);
519 assert!(sparse.memory_footprint() > 0);
520 assert_eq!(sparse.density(), 0.0);
521 }
522
523 #[test]
524 fn footprint_increases_with_stored_edges() {
525 let empty = SparseGraph::from_dense(&zeros(&[4, 4]).unwrap(), 0.1);
526 let dense = from_vec(
527 vec![
528 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, ],
533 &[4, 4],
534 DeviceType::Cpu,
535 )
536 .unwrap();
537 let full = SparseGraph::from_dense(&dense, 0.1).expect("operation should succeed");
538 assert_eq!(full.num_edges, 12);
539 assert!(
540 full.memory_footprint() > empty.expect("operation should succeed").memory_footprint()
541 );
542 assert!((full.density() - 12.0 / 16.0).abs() < 1e-6);
543 }
544
545 #[test]
546 fn to_edge_index_round_trips_coordinates() {
547 let dense = from_vec(
548 vec![
549 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, ],
553 &[3, 3],
554 DeviceType::Cpu,
555 )
556 .unwrap();
557 let sparse = SparseGraph::from_dense(&dense, 0.5).unwrap();
558 let edge_index = sparse.to_edge_index().unwrap();
559 assert_eq!(edge_index.shape().dims(), &[2, 3]);
560 let rows = tensor_to_vec2::<f32>(&edge_index).unwrap();
561 assert_eq!(rows[0], vec![0.0, 1.0, 2.0]);
562 assert_eq!(rows[1], vec![1.0, 2.0, 0.0]);
563 }
564
565 #[test]
566 fn to_edge_index_empty_is_two_by_zero() {
567 let sparse = SparseGraph::from_dense(&zeros(&[3, 3]).unwrap(), 0.5).unwrap();
568 let edge_index = sparse.to_edge_index().unwrap();
569 assert_eq!(edge_index.shape().dims(), &[2, 0]);
570 }
571
572 fn self_loop_edge_index() -> Tensor {
573 from_vec(
575 vec![
576 0.0, 1.0, 1.0, 2.0, 2.0, 0.0, 0.0, 1.0, 0.0, 2.0, 1.0, 0.0, 2.0, 0.0,
577 ],
578 &[2, 7],
579 DeviceType::Cpu,
580 )
581 .unwrap()
582 }
583
584 fn assert_sparse_matches_dense(edge_index: &Tensor, num_nodes: usize, normalized: bool) {
585 let sparse = sparse_laplacian(edge_index, num_nodes, normalized).unwrap();
586 let weights = sparse.edge_weights.as_ref().unwrap();
587 assert!(weights.iter().all(|w| w.is_finite()));
588
589 let mut reconstructed = vec![0.0f32; num_nodes * num_nodes];
590 for (&(row, col), &value) in sparse.edge_list.iter().zip(weights.iter()) {
591 reconstructed[row * num_nodes + col] += value;
592 }
593 let reference = graph_laplacian(edge_index, num_nodes, normalized)
594 .unwrap()
595 .to_vec()
596 .unwrap();
597 for (got, want) in reconstructed.iter().zip(reference.iter()) {
598 assert!((got - want).abs() < 1e-6, "sparse {got} vs dense {want}");
599 }
600 }
601
602 #[test]
603 fn sparse_laplacian_matches_dense_with_self_loops() {
604 let edge_index = self_loop_edge_index();
605 assert_sparse_matches_dense(&edge_index, 3, false);
606 assert_sparse_matches_dense(&edge_index, 3, true);
607 }
608
609 #[test]
610 fn sparse_unnormalized_laplacian_matches_dense_reference() {
611 let edge_index = cycle4_edge_index();
612 let sparse = sparse_laplacian(&edge_index, 4, false).unwrap();
613 let weights = sparse.edge_weights.as_ref().unwrap();
614 assert!(weights.iter().all(|w| w.is_finite()));
615
616 let mut reconstructed = vec![0.0f32; 16];
619 for (&(row, col), &value) in sparse.edge_list.iter().zip(weights.iter()) {
620 reconstructed[row * 4 + col] += value;
621 }
622 let reference = graph_laplacian(&edge_index, 4, false)
623 .unwrap()
624 .to_vec()
625 .unwrap();
626 for (got, want) in reconstructed.iter().zip(reference.iter()) {
627 assert!((got - want).abs() < 1e-6, "sparse {got} vs dense {want}");
628 }
629 }
630
631 #[test]
632 fn sparse_normalized_laplacian_matches_dense_reference() {
633 let edge_index = cycle4_edge_index();
634 let sparse = sparse_laplacian(&edge_index, 4, true).unwrap();
635 let weights = sparse.edge_weights.as_ref().unwrap();
636 assert!(weights.iter().all(|w| w.is_finite()));
637
638 let mut reconstructed = vec![0.0f32; 16];
639 for (&(row, col), &value) in sparse.edge_list.iter().zip(weights.iter()) {
640 reconstructed[row * 4 + col] += value;
641 }
642 let reference = graph_laplacian(&edge_index, 4, true)
643 .unwrap()
644 .to_vec()
645 .unwrap();
646 for (got, want) in reconstructed.iter().zip(reference.iter()) {
647 assert!((got - want).abs() < 1e-6, "sparse {got} vs dense {want}");
648 }
649 }
650
651 #[test]
652 fn coarsening_is_noop_when_already_at_or_below_target() {
653 let x = from_vec(vec![1.0, 2.0, 3.0, 4.0], &[2, 2], DeviceType::Cpu).unwrap();
654 let edge_index = from_vec(vec![0.0, 1.0], &[2, 1], DeviceType::Cpu).unwrap();
655 let graph = GraphData::new(x, edge_index);
656 let coarsened = adaptive_coarsening(&graph, 5).unwrap();
657 assert_eq!(coarsened.num_nodes, 2);
658 }
659
660 #[test]
661 fn coarsening_reaches_target_and_averages_features() {
662 let x = from_vec(
664 vec![
665 0.0, 0.0, 2.0, 4.0, 10.0, 10.0, 4.0, 8.0, ],
670 &[4, 2],
671 DeviceType::Cpu,
672 )
673 .unwrap();
674 let graph = GraphData::new(x, cycle4_edge_index());
675 let coarsened = adaptive_coarsening(&graph, 2).unwrap();
676
677 assert_eq!(coarsened.num_nodes, 2);
678 let vals = coarsened.x.to_vec().unwrap();
679 assert_eq!(vals.len(), 4);
680 assert!(vals.iter().all(|v| v.is_finite()));
681
682 assert!((vals[0] - 2.0).abs() < 1e-6);
685 assert!((vals[1] - 4.0).abs() < 1e-6);
686 assert!((vals[2] - 10.0).abs() < 1e-6);
687 assert!((vals[3] - 10.0).abs() < 1e-6);
688 }
689
690 #[test]
691 fn coarsening_to_one_node_averages_everything() {
692 let x = from_vec(vec![1.0, 3.0, 5.0, 7.0], &[4, 1], DeviceType::Cpu).unwrap();
693 let graph = GraphData::new(x, cycle4_edge_index());
694 let coarsened = adaptive_coarsening(&graph, 1).unwrap();
695 assert_eq!(coarsened.num_nodes, 1);
696 let vals = coarsened.x.to_vec().unwrap();
697 assert!((vals[0] - 4.0).abs() < 1e-6);
698 }
699
700 #[test]
701 fn coarsening_force_merges_disconnected_graph() {
702 let x = from_vec(vec![1.0, 2.0, 3.0, 4.0], &[4, 1], DeviceType::Cpu).unwrap();
705 let edge_index = zeros(&[2, 0]).unwrap();
706 let graph = GraphData::new(x, edge_index);
707 let coarsened = adaptive_coarsening(&graph, 2).unwrap();
708 assert_eq!(coarsened.num_nodes, 2);
709 let vals = coarsened.x.to_vec().unwrap();
710 assert!(vals.iter().all(|v| v.is_finite()));
711 }
712
713 #[test]
714 fn chunked_aggregation_matches_naive_for_all_chunk_sizes() {
715 let x = from_vec(
716 vec![
717 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, ],
722 &[4, 2],
723 DeviceType::Cpu,
724 )
725 .unwrap();
726 let graph = GraphData::new(x, cycle4_edge_index());
727
728 let naive = vec![
730 3.0, 3.0, 2.0, 2.0, 3.0, 3.0, 2.0, 2.0, ];
735
736 for chunk in [1usize, 2, 3, 100] {
737 let out = chunked_neighbor_aggregation(&graph, chunk)
738 .unwrap()
739 .to_vec()
740 .unwrap();
741 assert_eq!(out.len(), naive.len());
742 for (got, want) in out.iter().zip(naive.iter()) {
743 assert!((got - want).abs() < 1e-6, "chunk {chunk}: {got} vs {want}");
744 }
745 }
746 }
747
748 #[test]
749 fn chunked_aggregation_isolated_node_keeps_own_features() {
750 let x = from_vec(vec![5.0, 9.0], &[2, 1], DeviceType::Cpu).unwrap();
751 let edge_index = zeros(&[2, 0]).unwrap();
752 let graph = GraphData::new(x, edge_index);
753 let out = chunked_neighbor_aggregation(&graph, 1)
754 .unwrap()
755 .to_vec()
756 .unwrap();
757 assert_eq!(out, vec![5.0, 9.0]);
758 }
759}