Skip to main content

torsh_graph/utils/
memory_efficient.rs

1//! Memory-efficient graph operations.
2//!
3//! This module provides utilities that keep memory usage proportional to the
4//! number of *edges* rather than the number of *node pairs*:
5//!
6//! - [`SparseGraph`](crate::utils::memory_efficient::SparseGraph) stores a
7//!   graph in coordinate (COO) form, extracting only the non-negligible
8//!   entries of a dense adjacency matrix.
9//! - [`sparse_laplacian`](crate::utils::memory_efficient::sparse_laplacian)
10//!   builds the (optionally symmetric-normalized) graph Laplacian directly in
11//!   COO form without ever materializing the dense `num_nodes x num_nodes`
12//!   matrix.
13//! - [`adaptive_coarsening`](crate::utils::memory_efficient::adaptive_coarsening)
14//!   reduces a graph to a target number of supernodes using greedy
15//!   edge-contraction (union-find), averaging the node features of each
16//!   contracted cluster.
17//! - [`chunked_neighbor_aggregation`](crate::utils::memory_efficient::chunked_neighbor_aggregation)
18//!   performs mean neighbor aggregation using a sparse adjacency list
19//!   (`O(E)` memory) and processes destination nodes in bounded-size chunks
20//!   for cache locality.
21
22use 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/// Sparse coordinate (COO) representation of a graph or graph operator.
34///
35/// Each stored entry is a `(row, col)` coordinate together with an associated
36/// weight. This is used both for sparsified adjacency matrices (see
37/// [`SparseGraph::from_dense`]) and for sparse operators such as the graph
38/// Laplacian (see [`sparse_laplacian`]).
39#[derive(Debug, Clone)]
40pub struct SparseGraph {
41    /// Stored `(row, col)` coordinates of non-zero entries.
42    pub edge_list: Vec<(usize, usize)>,
43    /// Optional dense node-feature matrix associated with the graph.
44    pub node_features: Option<Tensor>,
45    /// Weight associated with each coordinate in `edge_list` (same length).
46    pub edge_weights: Option<Vec<f32>>,
47    /// Number of nodes the operator is defined over.
48    pub num_nodes: usize,
49    /// Number of stored entries (`edge_list.len()`).
50    pub num_edges: usize,
51}
52
53impl SparseGraph {
54    /// Build a sparse graph from a dense adjacency matrix, keeping only entries
55    /// whose magnitude strictly exceeds `threshold`.
56    ///
57    /// The matrix is interpreted as a `rows x cols` tensor; `num_nodes` is taken
58    /// from the number of rows. Entries are scanned in row-major order so the
59    /// resulting `edge_list` is sorted by `(row, col)`.
60    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    /// Convert the stored coordinates into a `[2, num_edges]` edge-index tensor.
94    ///
95    /// The first row holds source indices and the second holds destinations,
96    /// matching the convention used by [`GraphData`]. An empty graph yields a
97    /// `[2, 0]` tensor.
98    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    /// Total memory occupied by this representation, in bytes.
119    ///
120    /// This counts the inline size of the struct itself plus all heap
121    /// allocations it owns (the coordinate list, the optional node-feature
122    /// tensor, and the optional weight vector). The value is therefore always
123    /// strictly positive, even for a graph with no stored edges, because the
124    /// structure's own fields still occupy memory.
125    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    /// Fraction of possible directed entries that are actually stored.
143    ///
144    /// Returns `num_edges / num_nodes^2`, or `0.0` for an empty node set.
145    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
154/// Build the graph Laplacian directly in sparse COO form.
155///
156/// With `normalized == false` this returns the combinatorial Laplacian
157/// `L = D - A`; with `normalized == true` it returns the symmetric normalized
158/// Laplacian `L = I - D^{-1/2} A D^{-1/2}`.
159///
160/// The adjacency is symmetrized and de-duplicated first, and node degrees are
161/// the row sums of that adjacency, so an undirected edge listed once and the
162/// same edge listed in both directions produce the same operator. The result
163/// matches the dense [`graph_laplacian`](super::graph_laplacian) entry for
164/// entry while only storing the non-zero entries.
165///
166/// # Errors
167/// Returns an error when `edge_index` is not a `[2, num_edges]` tensor.
168pub 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    // De-duplicated, symmetric adjacency; degrees are the resulting row sums so
176    // listing an undirected edge once or in both directions gives the same
177    // operator (matching the dense `graph_laplacian`).
178    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
237/// Find the representative of `x` with path compression.
238fn uf_find(parent: &mut [usize], x: usize) -> usize {
239    let mut root = x;
240    while parent[root] != root {
241        root = parent[root];
242    }
243    // Path compression: point every node on the path directly at the root.
244    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
253/// Union the two *roots* `a` and `b` using union-by-rank.
254fn 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
268/// Coarsen `graph` down to at most `target_nodes` supernodes.
269///
270/// Coarsening uses greedy edge-contraction: adjacent components are repeatedly
271/// merged (via union-find) until the component count reaches the target. For
272/// disconnected graphs where edge-contraction alone cannot reach the target,
273/// the remaining components are merged deterministically. Each resulting
274/// supernode's feature vector is the **mean** of the original node features it
275/// absorbed, so finite inputs always yield finite outputs.
276///
277/// If `graph.num_nodes <= target_nodes` the graph is returned unchanged. A
278/// `target_nodes` of `0` is treated as `1` so the result is never empty.
279pub fn adaptive_coarsening(graph: &GraphData, target_nodes: usize) -> Result<GraphData> {
280    let n = graph.num_nodes;
281    // Never collapse to an empty graph.
282    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    // --- Greedy edge-contraction via union-find -------------------------------
291    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            // Disconnected: no adjacent components remain to contract.
320            break;
321        }
322    }
323
324    // Force-merge leftover components for disconnected graphs so the output
325    // always honors `num_nodes <= target`.
326    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    // --- Assign contiguous cluster ids (first-appearance order) ---------------
348    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    // --- Mean-aggregate features within each cluster --------------------------
359    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    // --- Build coarsened edges (undirected, de-duplicated, no self-loops) -----
381    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
422/// Mean-aggregate each node's neighbor features.
423///
424/// This uses a sparse adjacency list (`O(E)` memory) instead of a dense
425/// `O(N^2)` adjacency matrix, and processes destination nodes in batches of
426/// `chunk_size` (clamped to at least `1`) to bound the per-step working set and
427/// improve cache locality. A node with no neighbors retains its own features so
428/// the output is always well defined and finite for finite inputs.
429///
430/// The returned tensor has shape `[num_nodes, num_features]`.
431pub 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    /// Undirected 4-cycle (0-1, 1-2, 2-3, 3-0), each edge listed once.
479    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,1) = 0.5
493                0.0, 0.0, 0.05, // (1,2) = 0.05 (below threshold)
494                -0.8, 0.0, 0.0, // (2,0) = -0.8
495            ],
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        // An all-zero adjacency produces no stored edges, but the structure
515        // itself still occupies memory.
516        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, //
529                1.0, 0.0, 1.0, 1.0, //
530                1.0, 1.0, 0.0, 1.0, //
531                1.0, 1.0, 1.0, 0.0, //
532            ],
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, //
550                0.0, 0.0, 1.0, //
551                1.0, 0.0, 0.0, //
552            ],
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        // Triangle 0-1-2 listed in both directions plus a self-loop on node 0.
574        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        // Reconstruct the dense matrix from COO and compare against the
617        // independent dense implementation.
618        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        // Features chosen so the cluster means are exactly predictable.
663        let x = from_vec(
664            vec![
665                0.0, 0.0, // node 0
666                2.0, 4.0, // node 1
667                10.0, 10.0, // node 2
668                4.0, 8.0, // node 3
669            ],
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        // Greedy contraction merges {0,1,3} and leaves {2}, with cluster 0
683        // appearing first. mean(node0,1,3) = (2.0, 4.0); node 2 = (10, 10).
684        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        // Four isolated nodes: edge-contraction cannot reach the target on its
703        // own, so the force-merge path must still produce exactly two nodes.
704        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, //
718                2.0, 2.0, //
719                3.0, 3.0, //
720                4.0, 4.0, //
721            ],
722            &[4, 2],
723            DeviceType::Cpu,
724        )
725        .unwrap();
726        let graph = GraphData::new(x, cycle4_edge_index());
727
728        // adjacency: 0:{1,3} 1:{0,2} 2:{1,3} 3:{0,2}
729        let naive = vec![
730            3.0, 3.0, // node 0: mean(2,4)
731            2.0, 2.0, // node 1: mean(1,3)
732            3.0, 3.0, // node 2: mean(2,4)
733            2.0, 2.0, // node 3: mean(1,3)
734        ];
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}