facett-graphview 0.1.12

facett — a vello-backed, domain-agnostic scalable 2D graph render engine. Runtime-selects vello (GPU/wgpu) when a usable GPU exists, vello_cpu (multithreaded SIMD) as the no-GPU fallback. The eventual home for every graph surface (dep/arch/release dashboards, korp, graph-DB browsing).
Documentation
//! **Graph analysis** — the pure-algorithm core of the graph-DB IDE (V.G / G.V()).
//!
//! Every algorithm works on the **index-based** representation `(n, edges)` — `n`
//! nodes numbered `0..n` and `edges: &[(usize, usize)]` (directed `from → to`) — the
//! same shape [`crate::community::louvain`] already uses, so a caller maps its
//! `GraphModel` (String ids) to indices once and runs the whole toolbox. Results are
//! plain `Vec`s keyed by node index. No egui, no GPU, no RNG — deterministic and
//! headless-testable.
//!
//! Groups:
//! - [`centrality`] — degree / betweenness / closeness / pagerank / eigenvector.
//! - [`community`] — Louvain (re-exported) + label propagation.
//! - [`pathfinding`] — BFS / DFS / shortest path / all simple paths.
//! - [`cycles`] — cycle detection + topological sort.
//! - [`components`] — connected (undirected) + strongly-connected (Tarjan).
//! - [`kcore`] — k-core decomposition (core numbers).
//! - [`similarity`] — Jaccard / common-neighbours / Adamic–Adar / cosine.
//! - [`stats`] — whole-graph clustering coefficient + diameter / average path length.

pub mod centrality;
pub mod community;
pub mod components;
pub mod cycles;
pub mod kcore;
pub mod pathfinding;
pub mod similarity;
pub mod stats;

/// A built adjacency over `n` indexed nodes: directed out / in neighbour lists plus an
/// undirected view, each carrying an edge weight (`1.0` for unweighted input). Built
/// once from `(n, edges)` and shared by the analysis algorithms.
#[derive(Clone, Debug)]
pub struct Adjacency {
    /// Node count (indices `0..n`).
    pub n: usize,
    /// `out[i]` = directed out-neighbours of `i` as `(j, weight)`.
    pub out: Vec<Vec<(usize, f32)>>,
    /// `inc[i]` = directed in-neighbours of `i` as `(j, weight)`.
    pub inc: Vec<Vec<(usize, f32)>>,
    /// `und[i]` = undirected neighbours of `i` as `(j, weight)` (each edge both ways).
    pub und: Vec<Vec<(usize, f32)>>,
}

impl Adjacency {
    /// Build from unweighted directed edges (each `(a, b)` has weight `1.0`).
    /// Out-of-range endpoints and self-loops are dropped.
    #[must_use]
    pub fn from_edges(n: usize, edges: &[(usize, usize)]) -> Self {
        let weighted: Vec<(usize, usize, f32)> = edges.iter().map(|&(a, b)| (a, b, 1.0)).collect();
        Self::from_weighted(n, &weighted)
    }

    /// Build from weighted directed edges. Out-of-range endpoints and self-loops are
    /// dropped; parallel edges accumulate their weight.
    #[must_use]
    pub fn from_weighted(n: usize, edges: &[(usize, usize, f32)]) -> Self {
        let mut out = vec![Vec::new(); n];
        let mut inc = vec![Vec::new(); n];
        let mut und = vec![Vec::new(); n];
        for &(a, b, w) in edges {
            if a == b || a >= n || b >= n {
                continue;
            }
            accumulate(&mut out[a], b, w);
            accumulate(&mut inc[b], a, w);
            accumulate(&mut und[a], b, w);
            accumulate(&mut und[b], a, w);
        }
        Self { n, out, inc, und }
    }

    /// Out-degree of `i` (count of distinct out-neighbours).
    #[must_use]
    pub fn out_degree(&self, i: usize) -> usize {
        self.out.get(i).map_or(0, Vec::len)
    }
    /// In-degree of `i`.
    #[must_use]
    pub fn in_degree(&self, i: usize) -> usize {
        self.inc.get(i).map_or(0, Vec::len)
    }
    /// Undirected degree of `i`.
    #[must_use]
    pub fn degree(&self, i: usize) -> usize {
        self.und.get(i).map_or(0, Vec::len)
    }
}

/// Add `w` to node `j`'s weight in a neighbour list, inserting if absent.
fn accumulate(list: &mut Vec<(usize, f32)>, j: usize, w: f32) {
    if let Some(e) = list.iter_mut().find(|(k, _)| *k == j) {
        e.1 += w;
    } else {
        list.push((j, w));
    }
}

/// A tiny directed test fixture reused across the analysis unit tests:
///
/// ```text
/// 0 → 1 → 2 → 0   (a 3-cycle)     3 → 4   (a separate 2-chain)
/// ```
#[cfg(test)]
pub(crate) fn fixture() -> (usize, Vec<(usize, usize)>) {
    (5, vec![(0, 1), (1, 2), (2, 0), (3, 4)])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn adjacency_builds_directed_and_undirected_views() {
        let (n, edges) = fixture();
        let g = Adjacency::from_edges(n, &edges);
        assert_eq!(g.n, 5);
        assert_eq!(g.out_degree(0), 1); // 0→1
        assert_eq!(g.in_degree(0), 1); // 2→0
        assert_eq!(g.degree(0), 2); // undirected: 1 and 2
        assert_eq!(g.degree(3), 1);
        assert_eq!(g.degree(4), 1);
    }

    #[test]
    fn self_loops_and_out_of_range_dropped_parallel_accumulate() {
        let g = Adjacency::from_weighted(3, &[(0, 0, 1.0), (0, 5, 1.0), (0, 1, 2.0), (0, 1, 3.0)]);
        assert_eq!(g.out_degree(0), 1); // only 0→1 survives
        assert_eq!(g.out[0][0], (1, 5.0)); // parallel weights accumulate
    }
}