Skip to main content

bixverse_methods/graph/
graph_structures.rs

1//! Structures for graphs within bixverse.
2
3#![allow(dead_code)]
4
5use faer::{Mat, MatRef};
6use rayon::prelude::*;
7use rustc_hash::FxHashMap;
8use std::clone::Clone;
9use std::collections::BinaryHeap;
10
11use crate::prelude::*;
12use bixverse_core::core::math::sparse::coo_to_csr;
13
14////////////
15// Consts //
16////////////
17
18/// Rows of the similarity matrix processed per parallel task in the kNN build
19const KNN_ROW_BLOCK: usize = 32;
20
21/// Tile edge for the symmetrisation pass
22const KNN_SYM_TILE: usize = 64;
23
24///////////////
25// Union-find //
26///////////////
27
28/// Disjoint-set forest with path halving and union by size.
29pub struct UnionFind {
30    /// Parent pointer per element; a root points at itself.
31    parent: Vec<u32>,
32    /// Size of the tree rooted at each element. Only meaningful at roots.
33    size: Vec<u32>,
34}
35
36impl UnionFind {
37    /// Create `n` singleton sets.
38    ///
39    /// ### Params
40    ///
41    /// * `n` - Number of elements.
42    ///
43    /// ### Returns
44    ///
45    /// The initialised forest.
46    pub fn new(n: usize) -> Self {
47        Self {
48            parent: (0..n as u32).collect(),
49            size: vec![1; n],
50        }
51    }
52
53    /// Find the root of `x`, halving the path on the way up.
54    ///
55    /// ### Params
56    ///
57    /// * `x` - Element to look up.
58    ///
59    /// ### Returns
60    ///
61    /// The root of `x`'s set.
62    pub fn find(&mut self, mut x: u32) -> u32 {
63        while self.parent[x as usize] != x {
64            let grandparent = self.parent[self.parent[x as usize] as usize];
65            self.parent[x as usize] = grandparent;
66            x = grandparent;
67        }
68        x
69    }
70
71    /// Merge the sets containing `a` and `b`, smaller tree under larger.
72    ///
73    /// ### Params
74    ///
75    /// * `a` - First element.
76    /// * `b` - Second element.
77    ///
78    /// ### Returns
79    ///
80    /// `true` when the two were in different sets and have just been merged,
81    /// `false` when they already shared a root. Kruskal needs that answer to
82    /// decide whether an edge joins two components or closes a cycle.
83    pub fn union(&mut self, a: u32, b: u32) -> bool {
84        let (mut ra, mut rb) = (self.find(a), self.find(b));
85        if ra == rb {
86            return false;
87        }
88        if self.size[ra as usize] < self.size[rb as usize] {
89            std::mem::swap(&mut ra, &mut rb);
90        }
91        self.parent[rb as usize] = ra;
92        self.size[ra as usize] += self.size[rb as usize];
93        true
94    }
95}
96
97/////////////////////////////
98// Sparse graph structures //
99/////////////////////////////
100
101/// Structure representation of a sparse graph
102///
103/// ### Fields
104///
105/// * `adjacency` - Sparse CSR representation of the graph with `f16` as
106///   weights.
107/// * `num_nodes` - Number of nodes represented in the graph.
108/// * `directed` - Whether the graph is directed or not.
109#[derive(Clone, Debug)]
110pub struct SparseGraph<T: Clone> {
111    /// Adjacency matrix in CSR (symmetric for undirected graphs)
112    adjacency: CompressedSparseData2<T>,
113    num_nodes: usize,
114    directed: bool,
115}
116
117impl<T> SparseGraph<T>
118where
119    T: Clone + BixverseFloat + std::iter::Sum,
120{
121    /// Generate a new SparseGraph
122    ///
123    /// ### Params
124    ///
125    /// * `num_nodes` - Number of nodes in the graph
126    /// * `adjacency` - CompressedSparseData2 containing the adjacency
127    ///   information
128    /// * `directed` - Is this a directed or undirected graph
129    ///
130    /// ### Returns
131    ///
132    /// Initialised self
133    pub fn new(num_nodes: usize, adjacency: CompressedSparseData2<T>, directed: bool) -> Self {
134        Self {
135            adjacency,
136            num_nodes,
137            directed,
138        }
139    }
140
141    /// Helper function to get the neighbours and weights
142    ///
143    /// ### Params
144    ///
145    /// * `node` - Index of the node for which to get the neighbours
146    ///
147    /// ### Return
148    ///
149    /// Tuple of `(neighbour_indices, edge_weights)`
150    #[inline]
151    pub fn get_neighbours(&self, node: usize) -> (Vec<usize>, &[T]) {
152        let start = self.adjacency.indptr[node] as usize;
153        let end = self.adjacency.indptr[node + 1] as usize;
154        (
155            self.adjacency.indices[start..end].index_cast(),
156            &self.adjacency.data[start..end],
157        )
158    }
159
160    /// Get the neighbours and weights without allocating
161    ///
162    /// ### Params
163    ///
164    /// * `node` - Index of the node for which to get the neighbours
165    ///
166    /// ### Return
167    ///
168    /// Tuple of `(neighbour_indices, edge_weights)`, borrowed from the CSR
169    #[inline]
170    pub fn get_neighbours_raw(&self, node: usize) -> (&[u32], &[T]) {
171        let start = self.adjacency.indptr[node] as usize;
172        let end = self.adjacency.indptr[node + 1] as usize;
173        (
174            &self.adjacency.indices[start..end],
175            &self.adjacency.data[start..end],
176        )
177    }
178
179    /// Get the node degree
180    ///
181    /// ### Params
182    ///
183    /// * `node` - Index of the node to get the node degree from
184    ///
185    /// ### Return
186    ///
187    /// The node degree for this node.
188    #[inline]
189    pub fn get_node_degree(&self, node: usize) -> usize {
190        (self.adjacency.indptr[node + 1] - self.adjacency.indptr[node]) as usize
191    }
192
193    /// Get total weight
194    ///
195    /// ### Params
196    ///
197    /// * `undirected` - Is the graph undirected or not.
198    ///
199    /// ### Returns
200    ///
201    /// The total edge weight
202    pub fn total_weight(&self) -> T {
203        let mut total = T::zero();
204        for i in 0..self.num_nodes {
205            let (_, weights) = self.get_neighbours(i);
206            total += weights.iter().copied().sum::<T>();
207        }
208        let half = T::from_f64(0.5).unwrap();
209        if !self.directed { total * half } else { total }
210    }
211
212    /// Expose the number of nodes
213    ///
214    /// ### Returns
215    ///
216    /// The number of nodes in the graph
217    pub fn get_node_number(&self) -> usize {
218        self.num_nodes
219    }
220
221    /// Expose if graph is directed
222    ///
223    /// ### Returns
224    ///
225    /// Boolean indicating if graph is directed
226    pub fn is_directed(&self) -> bool {
227        self.directed
228    }
229}
230
231//////////////////////////////////
232// Hierachical graph structures //
233//////////////////////////////////
234
235/// Represents one level in the hierarchy
236///
237/// ### Fields
238///
239/// * `graph` - The sparse graph
240/// * `node_map` - Maps fine node -> coarse node
241/// * `coarse_weights` - Number of fine nodes that mapped to each coarse node
242#[allow(dead_code)]
243pub struct CoarseLevel<T>
244where
245    T: Clone,
246{
247    graph: SparseGraph<T>,
248    node_map: Vec<usize>,
249    coarse_weights: Vec<usize>,
250}
251
252/// Multi-level graph hierarchy
253///
254/// ### Fields
255///
256/// * `finest` - Original finest level graph
257/// * `levels` - Coarse levels
258pub struct GraphHierarchy<T>
259where
260    T: Clone,
261{
262    finest: SparseGraph<T>,
263    levels: Vec<CoarseLevel<T>>,
264}
265
266impl<T> GraphHierarchy<T>
267where
268    T: Clone + BixverseFloat + std::iter::Sum + std::default::Default,
269{
270    /// Build a Graph hierarchy based on an initial graph
271    ///
272    /// This one assumes undirected graphs!!!
273    ///
274    /// ### Params
275    ///
276    /// * `graph` - The initial SparseGraph
277    /// * `reduction_ratio` - By how much to reduce per level the number of
278    ///   nodes. Typically 0.5.
279    /// * `max_levels` - Maximum depth in terms of coarsion.
280    ///
281    /// ### Returns
282    ///
283    /// Initialised self.
284    pub fn build(graph: SparseGraph<T>, reduction_ratio: T, max_levels: usize) -> Self {
285        assert!(
286            !graph.is_directed(),
287            "This is implemented for undirected graphs!"
288        );
289        let mut levels = Vec::new();
290        let mut current_graph = graph.clone();
291
292        let min_nodes = T::from_usize(100).unwrap();
293
294        for _ in 0..max_levels {
295            let coarse_level = Self::coarsen_level(&current_graph);
296
297            let current_nodes = T::from_usize(current_graph.num_nodes).unwrap();
298            let coarse_nodes = T::from_usize(coarse_level.graph.num_nodes).unwrap();
299            let reduction = coarse_nodes / current_nodes;
300
301            if reduction > reduction_ratio || coarse_nodes < min_nodes {
302                break;
303            }
304
305            current_graph = coarse_level.graph.clone();
306            levels.push(coarse_level);
307        }
308
309        Self {
310            finest: graph,
311            levels,
312        }
313    }
314
315    /// Coarsen one level using heavy-edge matching
316    ///
317    /// Generates a CoarseLevel of a given graph via heavy edge matching.
318    ///
319    /// ### Params
320    ///
321    /// * `graph` - The SparseGraph to coarsen
322    ///
323    /// ### Returns
324    ///
325    /// CoarseLevel containing the coarsened graph and mapping information
326    fn coarsen_level(graph: &SparseGraph<T>) -> CoarseLevel<T> {
327        let matching = Self::heavy_edge_matching(graph);
328        let (coarse_graph, node_map, coarse_weights) = Self::contract_graph(graph, &matching);
329
330        CoarseLevel {
331            graph: coarse_graph,
332            node_map,
333            coarse_weights,
334        }
335    }
336
337    /// Match nodes via heavy edges
338    ///
339    /// Finds pairs of nodes to match based on heaviest edge weights. Each node
340    /// is matched with its heaviest unmatched neighbour.
341    ///
342    /// ### Params
343    ///
344    /// * `graph` - SparseGraph for which to identify the matching nodes
345    ///
346    /// ### Returns
347    ///
348    /// Vector where each element is either None (unmatched) or Some(node_id)
349    /// indicating which node this node is matched with
350    fn heavy_edge_matching(graph: &SparseGraph<T>) -> Vec<Option<usize>> {
351        let n = graph.num_nodes;
352        let mut matching = vec![None; n];
353        let mut matched = vec![false; n];
354
355        for i in 0..n {
356            if matched[i] {
357                continue;
358            }
359
360            let (neighbour, weights) = graph.get_neighbours(i);
361
362            // Find heaviest unmatched neighbour
363            let mut best_neighbour = None;
364            let mut best_weight = T::zero();
365
366            for (&j, &w) in neighbour.iter().zip(weights.iter()) {
367                if !matched[j] && j != i && w > best_weight {
368                    best_weight = w;
369                    best_neighbour = Some(j)
370                }
371            }
372
373            if let Some(j) = best_neighbour {
374                matching[i] = Some(j);
375                matching[j] = Some(i);
376                matched[i] = true;
377                matched[j] = true;
378            }
379        }
380
381        matching
382    }
383
384    /// Contract graph based on matching
385    ///
386    /// Creates a coarser graph by merging matched nodes. Edges between matched
387    /// nodes are removed, and edges to external nodes are combined.
388    ///
389    /// ### Params
390    ///
391    /// * `graph` - The fine-level graph to contract
392    /// * `matching` - Node matching information from heavy_edge_matching
393    ///
394    /// ### Returns
395    ///
396    /// Tuple of (coarse_graph, node_map, coarse_weights) where node_map
397    /// indicates which coarse node each fine node maps to, and coarse_weights
398    /// counts how many fine nodes were merged into each coarse node
399    fn contract_graph(
400        graph: &SparseGraph<T>,
401        matching: &[Option<usize>],
402    ) -> (SparseGraph<T>, Vec<usize>, Vec<usize>) {
403        let n = graph.num_nodes;
404        let mut node_map = vec![0; n];
405        let mut coarse_id = 0;
406
407        // assign coarse node IDs
408        for i in 0..n {
409            if node_map[i] == 0 || i == 0 {
410                match matching[i] {
411                    Some(j) if j > i => {
412                        node_map[i] = coarse_id;
413                        node_map[j] = coarse_id;
414                        coarse_id += 1;
415                    }
416                    None => {
417                        // i unchanged
418                        node_map[i] = coarse_id;
419                        coarse_id += 1;
420                    }
421                    _ => {} // already assigned
422                }
423            }
424        }
425
426        let num_coarse = coarse_id;
427        let mut coarse_weights = vec![0; num_coarse];
428
429        for i in 0..n {
430            coarse_weights[node_map[i]] += 1;
431        }
432
433        // Build coarse graph edges
434        let mut rows = Vec::new();
435        let mut cols = Vec::new();
436        let mut vals = Vec::new();
437
438        for i in 0..n {
439            let ci = node_map[i];
440            let (neighbours, weights) = graph.get_neighbours(i);
441
442            for (&j, &w) in neighbours.iter().zip(weights.iter()) {
443                let cj = node_map[j];
444                if ci != cj {
445                    rows.push(ci);
446                    cols.push(cj);
447                    vals.push(w);
448                }
449            }
450        }
451
452        // Sort to group duplicates
453        let mut edges: Vec<(usize, usize, T)> = rows
454            .into_iter()
455            .zip(cols)
456            .zip(vals)
457            .map(|((r, c), v)| (r, c, v))
458            .collect();
459        edges.sort_unstable_by_key(|(r, c, _)| (*r, *c));
460
461        // Sum duplicates
462        let mut deduped_rows = Vec::new();
463        let mut deduped_cols = Vec::new();
464        let mut deduped_vals = Vec::new();
465
466        if !edges.is_empty() {
467            let (mut curr_r, mut curr_c, mut curr_v) = edges[0];
468
469            for &(r, c, v) in &edges[1..] {
470                if r == curr_r && c == curr_c {
471                    curr_v += v;
472                } else {
473                    deduped_rows.push(curr_r);
474                    deduped_cols.push(curr_c);
475                    deduped_vals.push(curr_v);
476                    (curr_r, curr_c, curr_v) = (r, c, v);
477                }
478            }
479            deduped_rows.push(curr_r);
480            deduped_cols.push(curr_c);
481            deduped_vals.push(curr_v);
482        }
483
484        let coarse_csr = coo_to_csr(
485            &deduped_rows.index_cast(),
486            &deduped_cols.index_cast(),
487            &deduped_vals,
488            (num_coarse, num_coarse),
489        );
490
491        let coarse_graph = SparseGraph::new(num_coarse, coarse_csr, false);
492
493        (coarse_graph, node_map, coarse_weights)
494    }
495
496    /// Project coarse solution to fine level
497    ///
498    /// ### Params
499    ///
500    /// * `level` - Which coarse level (0 = first coarsening)
501    /// * `coarse_labels` - Labels at coarse level
502    ///
503    /// ### Returns
504    ///
505    /// Vector of labels at the fine level
506    pub fn prolong(&self, level: usize, coarse_labels: &[usize]) -> Vec<usize> {
507        if level >= self.levels.len() {
508            return coarse_labels.to_vec();
509        }
510
511        let node_map = &self.levels[level].node_map;
512        node_map.iter().map(|&ci| coarse_labels[ci]).collect()
513    }
514
515    /// Get coarsest graph
516    ///
517    /// ### Returns
518    ///
519    /// Reference to the coarsest graph in the hierarchy
520    pub fn coarsest(&self) -> &SparseGraph<T> {
521        self.levels.last().map(|l| &l.graph).unwrap_or(&self.finest)
522    }
523}
524
525/////////////////////////
526// Heterogenous graphs //
527/////////////////////////
528
529/// Directed edge list with a type per node and a type and weight per edge,
530/// for constrained personalised PageRank.
531#[derive(Debug, Clone)]
532pub struct AttributedGraph<'a, T> {
533    /// Type per node
534    pub node_types: Vec<&'a str>,
535    /// 0-based source node per edge
536    pub from: Vec<usize>,
537    /// 0-based target node per edge
538    pub to: Vec<usize>,
539    /// Type per edge
540    pub edge_types: Vec<&'a str>,
541    /// Weight per edge
542    pub weights: Vec<T>,
543}
544
545/// Generate a weighted, typed graph from node and edge lists
546///
547/// ### Params
548///
549/// * `nodes` - Slice of node names.
550/// * `node_types` - Slice of the node types.
551/// * `from` - Slice of the names of the from nodes.
552/// * `to` - Slice of the names of the to nodes.
553/// * `edge_types` - Slice of the edge types.
554/// * `edge_weights` - Slice of the edge weights.
555///
556/// ### Returns
557///
558/// An `AttributedGraph` for subsequent usage in constrained personalised
559/// page-rank iteration.
560///
561/// ### Panics
562///
563/// Function will panic if `nodes` and `nodes_types` do not have the same
564/// length, when `from`, `to`, `edge_types` and `edge_weights` do not have the
565/// same length, or when an edge names a node that is not in `nodes`.
566pub fn graph_from_strings_with_attributes<'a, T>(
567    nodes: &'a [String],
568    node_types: &'a [String],
569    from: &'a [String],
570    to: &'a [String],
571    edge_types: &'a [String],
572    edge_weights: &'a [T],
573) -> AttributedGraph<'a, T>
574where
575    T: BixverseFloat,
576{
577    assert_same_len!(nodes, node_types);
578    assert_same_len!(from, to, edge_types, edge_weights);
579
580    let name_to_idx: FxHashMap<&String, usize> = nodes
581        .iter()
582        .enumerate()
583        .map(|(idx, name)| (name, idx))
584        .collect();
585    let lookup = |name: &String| {
586        *name_to_idx
587            .get(name)
588            .unwrap_or_else(|| panic!("Node '{name}' not found in nodes list"))
589    };
590
591    AttributedGraph {
592        node_types: node_types.iter().map(String::as_str).collect(),
593        from: from.iter().map(lookup).collect(),
594        to: to.iter().map(lookup).collect(),
595        edge_types: edge_types.iter().map(String::as_str).collect(),
596        weights: edge_weights.to_vec(),
597    }
598}
599
600///////////////////////////////
601// Structure transformations //
602///////////////////////////////
603
604/// Build a CSR adjacency from an edge list via counting sort.
605///
606/// Duplicate `(row, col)` pairs are kept as separate entries, in input order.
607///
608/// ### Params
609///
610/// * `n` - Number of rows (nodes)
611/// * `rows` - Row index per edge
612/// * `cols` - Column index per edge
613/// * `vals` - Value per edge
614///
615/// ### Returns
616///
617/// Tuple of `(indptr, indices, values)`.
618pub(crate) fn edges_to_csr<T>(
619    n: usize,
620    rows: &[u32],
621    cols: &[u32],
622    vals: &[T],
623) -> (Vec<usize>, Vec<u32>, Vec<T>)
624where
625    T: BixverseFloat,
626{
627    let mut indptr = vec![0usize; n + 1];
628    for &r in rows {
629        indptr[r as usize + 1] += 1;
630    }
631    for i in 0..n {
632        indptr[i + 1] += indptr[i];
633    }
634
635    let mut next = indptr[..n].to_vec();
636    let mut indices = vec![0u32; rows.len()];
637    let mut values = vec![T::zero(); rows.len()];
638    for ((&r, &c), &v) in rows.iter().zip(cols).zip(vals) {
639        let pos = next[r as usize];
640        indices[pos] = c;
641        values[pos] = v;
642        next[r as usize] += 1;
643    }
644
645    (indptr, indices, values)
646}
647
648/// Generate a KNN graph adjacency matrix from a similarity matrix
649///
650/// ### Params
651///
652/// * `similarities` - The symmetric similarity matrix.
653/// * `k` - Number of neighbours to take
654///
655/// ### Returns
656///
657/// The KNN adjacency matrix
658pub fn get_knn_graph_adj<T>(similarities: &MatRef<T>, k: usize) -> Mat<T>
659where
660    T: BixverseFloat,
661{
662    assert_symmetric_mat!(similarities);
663    let n = similarities.nrows();
664    let mut adjacency: Mat<T> = Mat::zeros(n, n);
665
666    // Row blocks, walking each column once per block so the reads are
667    // contiguous in the column-major matrix.
668    let blocks: Vec<usize> = (0..n).step_by(KNN_ROW_BLOCK).collect();
669    let rows: Vec<Vec<(usize, T)>> = blocks
670        .par_iter()
671        .flat_map_iter(|&start| {
672            let end = (start + KNN_ROW_BLOCK).min(n);
673            let mut heaps: Vec<BinaryHeap<(RevOrderedFloat<T>, usize)>> = (start..end)
674                .map(|_| BinaryHeap::with_capacity(k + 1))
675                .collect();
676
677            for j in 0..n {
678                let col = similarities.col(j);
679                for (heap, i) in heaps.iter_mut().zip(start..end) {
680                    if i == j {
681                        continue;
682                    }
683                    let entry = (RevOrderedFloat(col[i]), j);
684                    // The entry that would be pushed and immediately popped
685                    if heap.len() == k && heap.peek().is_some_and(|top| entry >= *top) {
686                        continue;
687                    }
688                    heap.push(entry);
689                    if heap.len() > k {
690                        heap.pop();
691                    }
692                }
693            }
694
695            heaps.into_iter().map(|heap| {
696                heap.into_iter()
697                    .map(|(ordered_sim, index)| (index, ordered_sim.0))
698                    .collect::<Vec<_>>()
699            })
700        })
701        .collect();
702
703    // Fill adjacency matrix
704    for (i, neighbors) in rows.iter().enumerate() {
705        for &(j, sim) in neighbors {
706            adjacency[(i, j)] = sim;
707        }
708    }
709
710    // Symmetrise tile by tile to avoid strided walks
711    let two = T::from_f64(2.0).unwrap();
712    for ib in (0..n).step_by(KNN_SYM_TILE) {
713        for jb in (ib..n).step_by(KNN_SYM_TILE) {
714            for j in jb..(jb + KNN_SYM_TILE).min(n) {
715                for i in ib..(ib + KNN_SYM_TILE).min(n).min(j) {
716                    let val = (adjacency[(i, j)] + adjacency[(j, i)]) / two;
717                    adjacency[(i, j)] = val;
718                    adjacency[(j, i)] = val;
719                }
720            }
721        }
722    }
723
724    adjacency
725}
726
727/// Generate a Laplacian matrix from an adjacency matrix
728///
729/// ### Params
730///
731/// * `adjacency` - The symmetric adjacency matrix.
732///
733/// ### Returns
734///
735/// The Laplacian matrix
736pub fn adjacency_to_laplacian<T>(adjacency: &MatRef<T>, normalise: bool) -> Mat<T>
737where
738    T: BixverseFloat,
739{
740    assert_symmetric_mat!(adjacency);
741    let n = adjacency.nrows();
742
743    // Column-wise accumulation, in the same order as a row sum
744    let mut degrees = vec![T::zero(); n];
745    for j in 0..n {
746        for (d, &x) in degrees.iter_mut().zip(adjacency.col(j).iter()) {
747            *d += x;
748        }
749    }
750
751    if !normalise {
752        let mut laplacian = adjacency.cloned();
753        for j in 0..n {
754            for i in 0..n {
755                if i != j {
756                    laplacian[(i, j)] = -adjacency[(i, j)];
757                }
758            }
759            laplacian[(j, j)] = degrees[j] - adjacency[(j, j)];
760        }
761        laplacian
762    } else {
763        let inv_sqrt_d: Vec<T> = degrees
764            .iter()
765            .map(|&d| {
766                if d > T::from_f64(1e-10).unwrap() {
767                    T::one() / d.sqrt()
768                } else {
769                    T::zero()
770                }
771            })
772            .collect();
773        let mut laplacian = Mat::zeros(n, n);
774        for j in 0..n {
775            laplacian[(j, j)] = T::one();
776            for i in 0..n {
777                laplacian[(i, j)] -= inv_sqrt_d[i] * adjacency[(i, j)] * inv_sqrt_d[j];
778            }
779        }
780        laplacian
781    }
782}
783
784/// Build an undirected SparseGraph from a flat edge-pair list.
785///
786/// Counting-sort CSR build, then a parallel per-row sort of the column
787/// indices. Duplicate edges are kept as separate entries (algorithms that scan
788/// rows sum them), and a self-loop lands twice in its row, as in igraph.
789///
790/// ### Params
791///
792/// * `edges` - Flat edge list, pairs `[u, v, u, v, ...]`, 0-based
793/// * `weights` - Edge weights, one per pair
794/// * `n_nodes` - Number of nodes in the graph
795///
796/// ### Returns
797///
798/// Undirected graph with a symmetric, row-sorted CSR
799pub fn edge_pairs_to_sparse_graph<T>(
800    edges: &[usize],
801    weights: &[T],
802    n_nodes: usize,
803) -> SparseGraph<T>
804where
805    T: BixverseFloat + std::iter::Sum + Send + Sync,
806{
807    let n_edges = weights.len();
808
809    let mut indptr = vec![0usize; n_nodes + 1];
810    for e in 0..n_edges {
811        indptr[edges[2 * e] + 1] += 1;
812        indptr[edges[2 * e + 1] + 1] += 1;
813    }
814    for i in 1..=n_nodes {
815        indptr[i] += indptr[i - 1];
816    }
817
818    let nnz = indptr[n_nodes];
819    let mut indices = vec![0usize; nnz];
820    let mut data = vec![T::zero(); nnz];
821    let mut cursor = indptr.clone();
822
823    for e in 0..n_edges {
824        let i = edges[2 * e];
825        let j = edges[2 * e + 1];
826        let w = weights[e];
827
828        indices[cursor[i]] = j;
829        data[cursor[i]] = w;
830        cursor[i] += 1;
831
832        indices[cursor[j]] = i;
833        data[cursor[j]] = w;
834        cursor[j] += 1;
835    }
836
837    // sort column indices within each row, keeping data aligned
838    let mut rows: Vec<(&mut [usize], &mut [T])> = Vec::with_capacity(n_nodes);
839    let (mut idx_rest, mut data_rest) = (indices.as_mut_slice(), data.as_mut_slice());
840    for node in 0..n_nodes {
841        let len = indptr[node + 1] - indptr[node];
842        let (idx_row, idx_tail) = idx_rest.split_at_mut(len);
843        let (data_row, data_tail) = data_rest.split_at_mut(len);
844        rows.push((idx_row, data_row));
845        idx_rest = idx_tail;
846        data_rest = data_tail;
847    }
848
849    rows.par_iter_mut()
850        .for_each_init(Vec::<(usize, T)>::new, |scratch, (idx_row, data_row)| {
851            if idx_row.len() < 2 {
852                return;
853            }
854            scratch.clear();
855            scratch.extend(idx_row.iter().copied().zip(data_row.iter().copied()));
856            scratch.sort_unstable_by_key(|&(node, _)| node);
857            for ((i, d), &(node, w)) in idx_row.iter_mut().zip(data_row.iter_mut()).zip(&*scratch) {
858                *i = node;
859                *d = w;
860            }
861        });
862
863    let adjacency = CompressedSparseData2 {
864        data,
865        indices: indices.index_cast(),
866        indptr: indptr.index_cast(),
867        cs_type: CompressedSparseFormat::Csr,
868        data_2: None,
869        shape: (n_nodes, n_nodes),
870    };
871
872    SparseGraph::new(n_nodes, adjacency, false)
873}
874
875///////////
876// Tests //
877///////////
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use bixverse_core::core::math::sparse::CompressedSparseData2;
883    use faer::Mat;
884
885    /// Degrees, total weight and neighbour slices read straight off the CSR.
886    #[test]
887    fn test_sparse_graph_properties() {
888        // Simple line graph: 0 <-> 1 <-> 2
889        let data = vec![1.0, 1.0, 1.0, 1.0];
890        let indices = vec![1, 0, 2, 1]; // neighbors
891        let indptr = vec![0, 1, 3, 4]; // node offsets
892        let csr =
893            CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, (3, 3));
894
895        let graph = SparseGraph::new(3, csr, false);
896
897        assert_eq!(graph.get_node_degree(0), 1);
898        assert_eq!(graph.get_node_degree(1), 2);
899        assert_eq!(graph.total_weight(), 2.0); // 2 undirected edges of weight 1.0
900
901        let (n0_neighbors, n0_weights) = graph.get_neighbours(0);
902        assert_eq!(n0_neighbors, &[1]);
903        assert_eq!(n0_weights, &[1.0]);
904    }
905
906    /// The unnormalised Laplacian is `D - A`, degrees on the diagonal.
907    #[test]
908    fn test_adjacency_to_laplacian() {
909        // Adjacency for 0 <-> 1
910        let mut adj: Mat<f64> = Mat::zeros(2, 2);
911        adj[(0, 1)] = 1.0;
912        adj[(1, 0)] = 1.0;
913
914        // Unnormalized Laplacian: L = D - A
915        // D is [[1, 0], [0, 1]]
916        let lap = adjacency_to_laplacian(&adj.as_ref(), false);
917
918        assert!((lap[(0, 0)] - 1.0).abs() < 1e-6);
919        assert!((lap[(1, 1)] - 1.0).abs() < 1e-6);
920        assert!((lap[(0, 1)] - (-1.0)).abs() < 1e-6);
921        assert!((lap[(1, 0)] - (-1.0)).abs() < 1e-6);
922    }
923
924    /// String node names map onto indices without dropping or duplicating edges.
925    #[test]
926    fn test_graph_from_strings_with_attributes() {
927        let nodes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
928        let node_types = vec!["x".to_string(), "y".to_string(), "x".to_string()];
929        let from = vec!["A".to_string(), "B".to_string(), "C".to_string()];
930        let to = vec!["B".to_string(), "C".to_string(), "B".to_string()];
931        let edge_types = vec!["e".to_string(), "f".to_string(), "e".to_string()];
932        let weights = vec![1.5, 2.5, 0.5];
933
934        let graph = graph_from_strings_with_attributes(
935            &nodes,
936            &node_types,
937            &from,
938            &to,
939            &edge_types,
940            &weights,
941        );
942
943        assert_eq!(graph.node_types, vec!["x", "y", "x"]);
944        assert_eq!(graph.from, vec![0, 1, 2]);
945        assert_eq!(graph.to, vec![1, 2, 1]);
946        assert_eq!(graph.edge_types, vec!["e", "f", "e"]);
947        assert_eq!(graph.weights, weights);
948    }
949}