use std::collections::{BTreeMap, BTreeSet};
pub type Weight = f32;
pub fn connected_components<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
) -> Vec<(N, usize)> {
let mut universe: BTreeSet<N> = BTreeSet::new();
for n in nodes {
universe.insert(n.clone());
}
for (a, b, _w) in edges {
universe.insert(a.clone());
universe.insert(b.clone());
}
let ordered: Vec<N> = universe.into_iter().collect();
let mut index_of: BTreeMap<&N, usize> = BTreeMap::new();
for (i, n) in ordered.iter().enumerate() {
index_of.insert(n, i);
}
let mut parent: Vec<usize> = (0..ordered.len()).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
while parent[x] != x {
parent[x] = parent[parent[x]];
x = parent[x];
}
x
}
for (a, b, _w) in edges {
let ia = index_of[a];
let ib = index_of[b];
let ra = find(&mut parent, ia);
let rb = find(&mut parent, ib);
if ra != rb {
let (lo, hi) = if ra < rb { (ra, rb) } else { (rb, ra) };
parent[hi] = lo;
}
}
let mut island_of_root: BTreeMap<usize, usize> = BTreeMap::new();
let mut next_island: usize = 0;
let mut result: Vec<(N, usize)> = Vec::with_capacity(ordered.len());
for (i, n) in ordered.iter().enumerate() {
let root = find(&mut parent, i);
let island = *island_of_root.entry(root).or_insert_with(|| {
let id = next_island;
next_island += 1;
id
});
result.push((n.clone(), island));
}
result
}
fn node_universe<N: Clone + Ord>(nodes: &[N], edges: &[(N, N, Weight)]) -> Vec<N> {
let mut universe: BTreeSet<N> = BTreeSet::new();
for n in nodes {
universe.insert(n.clone());
}
for (a, b, _w) in edges {
universe.insert(a.clone());
universe.insert(b.clone());
}
universe.into_iter().collect()
}
fn modularity_of(
n: usize,
adj: &[Vec<(usize, f64)>],
selfloop: &[f64],
degree: &[f64],
m: f64,
comm: &[usize],
resolution: f64,
) -> f64 {
if m <= 0.0 {
return 0.0;
}
let two_m = 2.0 * m;
let mut w_in: BTreeMap<usize, f64> = BTreeMap::new();
let mut tot: BTreeMap<usize, f64> = BTreeMap::new();
for i in 0..n {
*tot.entry(comm[i]).or_insert(0.0) += degree[i];
if selfloop[i] != 0.0 {
*w_in.entry(comm[i]).or_insert(0.0) += selfloop[i];
}
for &(j, w) in &adj[i] {
if comm[i] == comm[j] {
*w_in.entry(comm[i]).or_insert(0.0) += w / 2.0;
}
}
}
let mut q = 0.0;
for (c, &win) in &w_in {
let st = *tot.get(c).unwrap_or(&0.0);
q += win / m - resolution * (st / two_m) * (st / two_m);
}
for (c, &st) in &tot {
if !w_in.contains_key(c) {
q += -resolution * (st / two_m) * (st / two_m);
}
}
q
}
struct Level {
n: usize,
adj: Vec<Vec<(usize, f64)>>,
selfloop: Vec<f64>,
degree: Vec<f64>,
m: f64,
}
impl Level {
fn base<N: Clone + Ord>(ordered: &[N], edges: &[(N, N, Weight)]) -> Self {
let n = ordered.len();
let mut index_of: BTreeMap<&N, usize> = BTreeMap::new();
for (i, node) in ordered.iter().enumerate() {
index_of.insert(node, i);
}
let mut pair_weight: BTreeMap<(usize, usize), f64> = BTreeMap::new();
let mut selfloop = vec![0.0f64; n];
for (a, b, w) in edges {
let ia = index_of[a];
let ib = index_of[b];
let w = *w as f64;
if ia == ib {
selfloop[ia] += w;
} else {
let key = if ia < ib { (ia, ib) } else { (ib, ia) };
*pair_weight.entry(key).or_insert(0.0) += w;
}
}
Self::from_parts(n, pair_weight, selfloop)
}
fn from_parts(
n: usize,
pair_weight: BTreeMap<(usize, usize), f64>,
selfloop: Vec<f64>,
) -> Self {
let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
let mut degree = vec![0.0f64; n];
let mut m = 0.0;
for (&(lo, hi), &w) in &pair_weight {
adj[lo].push((hi, w));
adj[hi].push((lo, w));
degree[lo] += w;
degree[hi] += w;
m += w;
}
for (i, &sl) in selfloop.iter().enumerate() {
degree[i] += 2.0 * sl;
m += sl;
}
Level {
n,
adj,
selfloop,
degree,
m,
}
}
fn local_moving(&self, resolution: f64) -> Vec<usize> {
let mut comm: Vec<usize> = (0..self.n).collect();
let mut sigma_tot: Vec<f64> = self.degree.clone();
if self.m <= 0.0 {
return comm; }
let two_m = 2.0 * self.m;
loop {
let mut improved = false;
for i in 0..self.n {
let ci = comm[i];
let mut w_to: BTreeMap<usize, f64> = BTreeMap::new();
for &(j, w) in &self.adj[i] {
*w_to.entry(comm[j]).or_insert(0.0) += w;
}
sigma_tot[ci] -= self.degree[i];
let ki = self.degree[i];
let gain = |c: usize| -> f64 {
w_to.get(&c).copied().unwrap_or(0.0) - resolution * sigma_tot[c] * ki / two_m
};
let mut best_comm = ci;
let mut best_gain = gain(ci);
for &c in w_to.keys() {
let g = gain(c);
if g > best_gain {
best_gain = g;
best_comm = c;
}
}
sigma_tot[best_comm] += self.degree[i];
if best_comm != ci {
comm[i] = best_comm;
improved = true;
}
}
if !improved {
break;
}
}
comm
}
fn aggregate(&self, comm: &[usize]) -> (Level, BTreeMap<usize, usize>) {
let mut renumber: BTreeMap<usize, usize> = BTreeMap::new();
for &c in comm {
let next = renumber.len();
renumber.entry(c).or_insert(next);
}
let nc = renumber.len();
let mut pair_weight: BTreeMap<(usize, usize), f64> = BTreeMap::new();
let mut selfloop = vec![0.0f64; nc];
for i in 0..self.n {
if self.selfloop[i] != 0.0 {
selfloop[renumber[&comm[i]]] += self.selfloop[i];
}
}
for i in 0..self.n {
let ci = renumber[&comm[i]];
for &(j, w) in &self.adj[i] {
let cj = renumber[&comm[j]];
if ci == cj {
selfloop[ci] += w / 2.0;
} else {
let key = if ci < cj { (ci, cj) } else { (cj, ci) };
*pair_weight.entry(key).or_insert(0.0) += w / 2.0;
}
}
}
(Self::from_parts(nc, pair_weight, selfloop), renumber)
}
}
pub fn louvain<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
resolution: f64,
) -> Vec<(N, usize)> {
let ordered = node_universe(nodes, edges);
let n = ordered.len();
if n == 0 {
return Vec::new();
}
let base = Level::base(&ordered, edges);
let mut membership: Vec<usize> = (0..n).collect();
let mut level = base;
loop {
let comm = level.local_moving(resolution);
let (next_level, renumber) = level.aggregate(&comm);
for c in membership.iter_mut() {
*c = renumber[&comm[*c]];
}
if next_level.n == level.n {
break;
}
level = next_level;
}
let mut label_of: BTreeMap<usize, usize> = BTreeMap::new();
let mut next_label = 0usize;
let mut result: Vec<(N, usize)> = Vec::with_capacity(n);
for (i, node) in ordered.iter().enumerate() {
let label = *label_of.entry(membership[i]).or_insert_with(|| {
let id = next_label;
next_label += 1;
id
});
result.push((node.clone(), label));
}
result
}
pub fn modularity<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
assignment: &[(N, usize)],
resolution: f64,
) -> f64 {
let ordered = node_universe(nodes, edges);
let n = ordered.len();
if n == 0 {
return 0.0;
}
let mut index_of: BTreeMap<&N, usize> = BTreeMap::new();
for (i, node) in ordered.iter().enumerate() {
index_of.insert(node, i);
}
let level = Level::base(&ordered, edges);
let assigned: BTreeMap<N, usize> = assignment.iter().cloned().collect();
let mut comm = vec![0usize; n];
for (node, &i) in &index_of {
comm[i] = *assigned.get(node).unwrap_or(&i);
}
modularity_of(
n,
&level.adj,
&level.selfloop,
&level.degree,
level.m,
&comm,
resolution,
)
}
pub fn shortest_path<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
src: &N,
dst: &N,
max_hops: Option<usize>,
) -> Option<Vec<(N, f64)>> {
let ordered = node_universe(nodes, edges);
let n = ordered.len();
if n == 0 {
return None;
}
let mut index_of: BTreeMap<&N, usize> = BTreeMap::new();
for (i, node) in ordered.iter().enumerate() {
index_of.insert(node, i);
}
let si = match index_of.get(src) {
Some(&i) => i,
None => return None,
};
let di = match index_of.get(dst) {
Some(&i) => i,
None => return None,
};
let mut adj: Vec<BTreeMap<usize, f64>> = vec![BTreeMap::new(); n];
for (a, b, w) in edges {
let ia = index_of[a];
let ib = index_of[b];
if ia == ib {
continue; }
let w = *w as f64;
let e = adj[ia].entry(ib).or_insert(f64::INFINITY);
if w < *e {
*e = w;
}
let e = adj[ib].entry(ia).or_insert(f64::INFINITY);
if w < *e {
*e = w;
}
}
let rounds = max_hops.unwrap_or(n.saturating_sub(1));
let mut dist = vec![f64::INFINITY; n];
dist[si] = 0.0;
let mut pred: Vec<Option<usize>> = vec![None; n];
for _ in 0..rounds {
let mut next = dist.clone();
let mut changed = false;
for u in 0..n {
if !dist[u].is_finite() {
continue;
}
for (&v, &w) in &adj[u] {
let cand = dist[u] + w;
if cand < next[v] {
next[v] = cand;
pred[v] = Some(u);
changed = true;
}
}
}
dist = next;
if !changed {
break; }
}
if !dist[di].is_finite() {
return None; }
let mut path_idx = Vec::new();
let mut seen = BTreeSet::new();
let mut cur = di;
loop {
if !seen.insert(cur) {
return None; }
path_idx.push(cur);
if cur == si {
break;
}
match pred[cur] {
Some(p) => cur = p,
None => return None, }
}
path_idx.reverse();
let mut result = Vec::with_capacity(path_idx.len());
let mut cum = 0.0;
for (i, &idx) in path_idx.iter().enumerate() {
if i > 0 {
let prev = path_idx[i - 1];
cum += adj[prev][&idx];
}
result.push((ordered[idx].clone(), cum));
}
Some(result)
}
fn undirected_adjacency<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
) -> (Vec<N>, Vec<Vec<usize>>) {
let ordered = node_universe(nodes, edges);
let n = ordered.len();
let mut index_of: BTreeMap<&N, usize> = BTreeMap::new();
for (i, node) in ordered.iter().enumerate() {
index_of.insert(node, i);
}
let mut sets: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
for (a, b, _w) in edges {
let ia = index_of[a];
let ib = index_of[b];
if ia == ib {
continue; }
sets[ia].insert(ib);
sets[ib].insert(ia);
}
let adj: Vec<Vec<usize>> = sets.into_iter().map(|s| s.into_iter().collect()).collect();
(ordered, adj)
}
pub fn betweenness<N: Clone + Ord>(nodes: &[N], edges: &[(N, N, Weight)]) -> Vec<(N, f64)> {
let (ordered, adj) = undirected_adjacency(nodes, edges);
let n = ordered.len();
let mut cb = vec![0.0f64; n];
for s in 0..n {
let mut stack: Vec<usize> = Vec::with_capacity(n);
let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut sigma = vec![0.0f64; n]; let mut dist = vec![-1i64; n];
sigma[s] = 1.0;
dist[s] = 0;
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
queue.push_back(s);
while let Some(v) = queue.pop_front() {
stack.push(v);
for &w in &adj[v] {
if dist[w] < 0 {
dist[w] = dist[v] + 1;
queue.push_back(w);
}
if dist[w] == dist[v] + 1 {
sigma[w] += sigma[v];
preds[w].push(v);
}
}
}
let mut delta = vec![0.0f64; n];
while let Some(w) = stack.pop() {
for &v in &preds[w] {
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w]);
}
if w != s {
cb[w] += delta[w];
}
}
}
let mut result: Vec<(N, f64)> = Vec::with_capacity(n);
for (i, node) in ordered.iter().enumerate() {
result.push((node.clone(), cb[i] / 2.0));
}
result
}
pub fn eigenvector<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
max_iterations: usize,
tolerance: f64,
) -> Vec<(N, f64)> {
let (ordered, adj) = undirected_adjacency(nodes, edges);
let n = ordered.len();
if n == 0 {
return Vec::new();
}
let has_edges = adj.iter().any(|row| !row.is_empty());
let mut x = vec![1.0f64 / (n as f64).sqrt(); n];
if !has_edges {
return ordered.into_iter().zip(x).collect();
}
for _ in 0..max_iterations {
let mut y = vec![0.0f64; n];
for i in 0..n {
let mut acc = x[i];
for &j in &adj[i] {
acc += x[j];
}
y[i] = acc;
}
let norm: f64 = y.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm == 0.0 {
break;
}
for v in y.iter_mut() {
*v /= norm;
}
let diff: f64 = x.iter().zip(&y).map(|(a, b)| (a - b).abs()).sum();
x = y;
if diff < tolerance {
break;
}
}
ordered.into_iter().zip(x).collect()
}
pub fn pagerank<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
damping: f64,
max_iterations: usize,
) -> Vec<(N, f64)> {
let (ordered, adj) = undirected_adjacency(nodes, edges);
let n = ordered.len();
if n == 0 {
return Vec::new();
}
let nf = n as f64;
let degree: Vec<f64> = adj.iter().map(|row| row.len() as f64).collect();
let mut rank = vec![1.0f64 / nf; n];
let teleport = (1.0 - damping) / nf;
for _ in 0..max_iterations {
let dangling: f64 = (0..n).filter(|&i| degree[i] == 0.0).map(|i| rank[i]).sum();
let mut next = vec![teleport + damping * dangling / nf; n];
for i in 0..n {
if degree[i] == 0.0 {
continue;
}
let share = damping * rank[i] / degree[i];
for &j in &adj[i] {
next[j] += share;
}
}
rank = next;
}
let total: f64 = rank.iter().sum();
if total > 0.0 {
for r in rank.iter_mut() {
*r /= total;
}
}
ordered.into_iter().zip(rank).collect()
}
pub fn spectral_embedding<N: Clone + Ord>(
nodes: &[N],
edges: &[(N, N, Weight)],
max_iterations: usize,
tolerance: f64,
) -> Vec<(N, (f64, f64))> {
let (ordered, adj) = undirected_adjacency(nodes, edges);
let n = ordered.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return ordered.into_iter().map(|node| (node, (0.5, 0.5))).collect();
}
let degree: Vec<f64> = adj.iter().map(|row| row.len() as f64).collect();
let max_degree = degree.iter().cloned().fold(0.0_f64, f64::max);
let c = 2.0 * max_degree + 1.0;
let apply_m = |v: &[f64]| -> Vec<f64> {
let mut out = vec![0.0f64; n];
for i in 0..n {
let mut acc = (c - degree[i]) * v[i];
for &j in &adj[i] {
acc += v[j];
}
out[i] = acc;
}
out
};
let inv_sqrt_n = 1.0 / (n as f64).sqrt();
let v0 = vec![inv_sqrt_n; n];
let seed_x: Vec<f64> = (0..n).map(|i| (i as f64) + 1.0).collect();
let seed_y: Vec<f64> = (0..n).map(|i| ((i as f64) + 1.0).powi(2)).collect();
let mut basis: Vec<Vec<f64>> = vec![v0];
let vx = dominant_orthogonal(&apply_m, &seed_x, &basis, max_iterations, tolerance);
basis.push(vx.clone());
let vy = dominant_orthogonal(&apply_m, &seed_y, &basis, max_iterations, tolerance);
let x = normalize_unit_interval(&vx);
let y = normalize_unit_interval(&vy);
ordered
.into_iter()
.enumerate()
.map(|(i, node)| (node, (x[i], y[i])))
.collect()
}
fn l2_norm(v: &[f64]) -> f64 {
v.iter().map(|x| x * x).sum::<f64>().sqrt()
}
fn project_out(v: &mut [f64], basis: &[Vec<f64>]) {
for b in basis {
let dot: f64 = v.iter().zip(b).map(|(x, y)| x * y).sum();
for (x, &bi) in v.iter_mut().zip(b) {
*x -= dot * bi;
}
}
}
fn dominant_orthogonal(
apply: &impl Fn(&[f64]) -> Vec<f64>,
seed: &[f64],
basis: &[Vec<f64>],
max_iterations: usize,
tolerance: f64,
) -> Vec<f64> {
let mut v = seed.to_vec();
project_out(&mut v, basis);
let norm = l2_norm(&v);
if norm == 0.0 {
return v; }
for x in v.iter_mut() {
*x /= norm;
}
for _ in 0..max_iterations {
let mut next = apply(&v);
project_out(&mut next, basis);
let norm = l2_norm(&next);
if norm == 0.0 {
break;
}
for x in next.iter_mut() {
*x /= norm;
}
let diff: f64 = v.iter().zip(&next).map(|(a, b)| (a - b).abs()).sum();
v = next;
if diff < tolerance {
break;
}
}
v
}
fn normalize_unit_interval(v: &[f64]) -> Vec<f64> {
let min = v.iter().cloned().fold(f64::INFINITY, f64::min);
let max = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let span = max - min;
if !span.is_finite() || span <= 0.0 {
return vec![0.5; v.len()];
}
v.iter().map(|x| (x - min) / span).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
fn w(a: &str, b: &str) -> (String, String, Weight) {
(a.to_string(), b.to_string(), 1.0)
}
#[test]
fn golden_two_disjoint_triangles() {
let nodes: Vec<String> = ["a", "b", "c", "d", "e", "f"]
.iter()
.map(|s| s.to_string())
.collect();
let edges = vec![
w("a", "b"),
w("b", "c"),
w("c", "a"),
w("d", "e"),
w("e", "f"),
w("f", "d"),
];
let got = connected_components(&nodes, &edges);
let map: BTreeMap<String, usize> = got.into_iter().collect();
assert_eq!(map["a"], 0);
assert_eq!(map["b"], 0);
assert_eq!(map["c"], 0);
assert_eq!(map["d"], 1);
assert_eq!(map["e"], 1);
assert_eq!(map["f"], 1);
}
#[test]
fn dumbbell_is_one_component() {
let nodes: Vec<String> = ["a", "b", "c", "d", "e", "f"]
.iter()
.map(|s| s.to_string())
.collect();
let edges = vec![
w("a", "b"),
w("b", "c"),
w("c", "a"),
w("d", "e"),
w("e", "f"),
w("f", "d"),
w("c", "d"),
];
let got = connected_components(&nodes, &edges);
let islands: BTreeSet<usize> = got.iter().map(|(_, i)| *i).collect();
assert_eq!(islands.len(), 1);
assert!(got.iter().all(|(_, i)| *i == 0));
}
#[test]
fn isolated_nodes_each_their_own_island() {
let nodes: Vec<String> = ["x", "y", "z"].iter().map(|s| s.to_string()).collect();
let edges: Vec<(String, String, Weight)> = vec![];
let got = connected_components(&nodes, &edges);
let map: BTreeMap<String, usize> = got.into_iter().collect();
assert_eq!(map["x"], 0);
assert_eq!(map["y"], 1);
assert_eq!(map["z"], 2);
}
#[test]
fn edge_endpoints_not_in_node_list_are_included() {
let nodes: Vec<String> = vec!["a".to_string()];
let edges = vec![w("a", "g")];
let got = connected_components(&nodes, &edges);
assert_eq!(got.len(), 2);
let map: BTreeMap<String, usize> = got.into_iter().collect();
assert_eq!(map["a"], map["g"]);
assert_eq!(map["a"], 0);
}
#[test]
fn determinism_repeated_runs_identical() {
let nodes: Vec<String> = ["n3", "n1", "n2", "n4"]
.iter()
.map(|s| s.to_string())
.collect();
let edges = vec![w("n1", "n2"), w("n3", "n4")];
let a = connected_components(&nodes, &edges);
let b = connected_components(&nodes, &edges);
assert_eq!(a, b);
let ordered: Vec<String> = a.iter().map(|(n, _)| n.clone()).collect();
let mut sorted = ordered.clone();
sorted.sort();
assert_eq!(ordered, sorted);
}
fn graph_strategy() -> impl Strategy<Value = (Vec<String>, Vec<(String, String, Weight)>)> {
(1usize..8usize).prop_flat_map(|n| {
let nodes: Vec<String> = (0..n).map(|i| format!("n{i:02}")).collect();
let ids = nodes.clone();
let edge = (0..n, 0..n)
.prop_map(move |(a, b)| (format!("n{a:02}"), format!("n{b:02}"), 1.0f32));
let edges = prop::collection::vec(edge, 0..16);
(Just(ids), edges)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn every_node_exactly_one_island((nodes, edges) in graph_strategy()) {
let result = connected_components(&nodes, &edges);
let mut universe: BTreeSet<String> = BTreeSet::new();
for n in &nodes { universe.insert(n.clone()); }
for (a, b, _) in &edges { universe.insert(a.clone()); universe.insert(b.clone()); }
prop_assert_eq!(result.len(), universe.len());
let assigned: BTreeMap<String, usize> = result.iter().cloned().collect();
prop_assert_eq!(assigned.len(), universe.len());
for n in &universe {
prop_assert!(assigned.contains_key(n));
}
}
#[test]
fn islands_are_connected((nodes, edges) in graph_strategy()) {
let result = connected_components(&nodes, &edges);
let assigned: BTreeMap<String, usize> = result.iter().cloned().collect();
let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for n in assigned.keys() {
adj.entry(n.clone()).or_default();
}
for (a, b, _) in &edges {
adj.entry(a.clone()).or_default().insert(b.clone());
adj.entry(b.clone()).or_default().insert(a.clone());
}
let mut by_island: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
for (n, isl) in &assigned {
by_island.entry(*isl).or_default().insert(n.clone());
}
for members in by_island.values() {
let start = members.iter().next().unwrap().clone();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut q: VecDeque<String> = VecDeque::new();
q.push_back(start.clone());
seen.insert(start);
while let Some(cur) = q.pop_front() {
if let Some(ns) = adj.get(&cur) {
for nb in ns {
if seen.insert(nb.clone()) {
q.push_back(nb.clone());
}
}
}
}
prop_assert_eq!(&seen, members);
}
let k = by_island.len();
for id in 0..k {
prop_assert!(by_island.contains_key(&id));
}
}
#[test]
fn determinism_property((nodes, edges) in graph_strategy()) {
let a = connected_components(&nodes, &edges);
let b = connected_components(&nodes, &edges);
prop_assert_eq!(a, b);
}
}
fn community_count(assignment: &[(String, usize)]) -> usize {
assignment
.iter()
.map(|(_, c)| *c)
.collect::<BTreeSet<usize>>()
.len()
}
fn singleton_partition(assignment: &[(String, usize)]) -> Vec<(String, usize)> {
assignment
.iter()
.enumerate()
.map(|(i, (n, _))| (n.clone(), i))
.collect()
}
fn two_cliques_bridge() -> (Vec<String>, Vec<(String, String, Weight)>) {
let nodes: Vec<String> = ["a0", "a1", "a2", "a3", "a4", "b0", "b1", "b2", "b3", "b4"]
.iter()
.map(|s| s.to_string())
.collect();
let clique = |p: &str| -> Vec<(String, String, Weight)> {
let mut e = Vec::new();
for i in 0..5 {
for j in (i + 1)..5 {
e.push((format!("{p}{i}"), format!("{p}{j}"), 1.0));
}
}
e
};
let mut edges = clique("a");
edges.extend(clique("b"));
edges.push(("a0".to_string(), "b0".to_string(), 1.0));
(nodes, edges)
}
#[test]
fn louvain_golden_two_cliques_form_two_communities() {
let (nodes, edges) = two_cliques_bridge();
let assignment = louvain(&nodes, &edges, 1.0);
let map: BTreeMap<String, usize> = assignment.iter().cloned().collect();
assert_eq!(community_count(&assignment), 2, "expected two communities");
for i in 1..5 {
assert_eq!(map[&format!("a{i}")], map["a0"], "clique A coheres");
assert_eq!(map[&format!("b{i}")], map["b0"], "clique B coheres");
}
assert_ne!(map["a0"], map["b0"], "the two cliques are distinct");
assert_eq!(map["a0"], 0);
}
fn karate_club() -> (Vec<String>, Vec<(String, String, Weight)>) {
let pairs: &[(u32, u32)] = &[
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(0, 5),
(0, 6),
(0, 7),
(0, 8),
(0, 10),
(0, 11),
(0, 12),
(0, 13),
(0, 17),
(0, 19),
(0, 21),
(0, 31),
(1, 2),
(1, 3),
(1, 7),
(1, 13),
(1, 17),
(1, 19),
(1, 21),
(1, 30),
(2, 3),
(2, 7),
(2, 8),
(2, 9),
(2, 13),
(2, 27),
(2, 28),
(2, 32),
(3, 7),
(3, 12),
(3, 13),
(4, 6),
(4, 10),
(5, 6),
(5, 10),
(5, 16),
(6, 16),
(8, 30),
(8, 32),
(8, 33),
(9, 33),
(13, 33),
(14, 32),
(14, 33),
(15, 32),
(15, 33),
(18, 32),
(18, 33),
(19, 33),
(20, 32),
(20, 33),
(22, 32),
(22, 33),
(23, 25),
(23, 27),
(23, 29),
(23, 32),
(23, 33),
(24, 25),
(24, 27),
(24, 31),
(25, 31),
(26, 29),
(26, 33),
(27, 33),
(28, 31),
(28, 33),
(29, 32),
(29, 33),
(30, 32),
(30, 33),
(31, 32),
(31, 33),
(32, 33),
];
let nodes: Vec<String> = (0..34).map(|i| format!("n{i:02}")).collect();
let edges = pairs
.iter()
.map(|(a, b)| (format!("n{a:02}"), format!("n{b:02}"), 1.0f32))
.collect();
(nodes, edges)
}
#[test]
fn louvain_karate_club_recovers_high_modularity_communities() {
let (nodes, edges) = karate_club();
let assignment = louvain(&nodes, &edges, 1.0);
let map: BTreeMap<String, usize> = assignment.iter().cloned().collect();
let k = community_count(&assignment);
assert!((2..=5).contains(&k), "expected 2..=5 communities, got {k}");
let q = modularity(&nodes, &edges, &assignment, 1.0);
assert!(
q >= 0.38,
"modularity {q} should clear the 0.38 literature bar"
);
assert_ne!(
map["n00"], map["n33"],
"the two faction leaders land in different communities"
);
}
#[test]
fn louvain_resolution_higher_yields_more_communities() {
let (nodes, edges) = two_cliques_bridge();
let low = community_count(&louvain(&nodes, &edges, 0.1));
let high = community_count(&louvain(&nodes, &edges, 2.0));
assert!(
high >= low,
"higher resolution must not reduce community count ({low} -> {high})"
);
}
#[test]
fn louvain_determinism_100_runs_identical() {
let (nodes, edges) = karate_club();
let first = louvain(&nodes, &edges, 1.0);
for _ in 0..100 {
assert_eq!(louvain(&nodes, &edges, 1.0), first);
}
}
#[test]
fn louvain_empty_and_isolated_nodes() {
let empty: Vec<String> = Vec::new();
assert!(louvain(&empty, &[], 1.0).is_empty());
let nodes: Vec<String> = ["x", "y", "z"].iter().map(|s| s.to_string()).collect();
let got = louvain(&nodes, &[], 1.0);
let map: BTreeMap<String, usize> = got.into_iter().collect();
assert_eq!(map["x"], 0);
assert_eq!(map["y"], 1);
assert_eq!(map["z"], 2);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn louvain_beats_singleton_and_covers_all((nodes, edges) in graph_strategy()) {
let assignment = louvain(&nodes, &edges, 1.0);
let mut universe: BTreeSet<String> = BTreeSet::new();
for n in &nodes { universe.insert(n.clone()); }
for (a, b, _) in &edges { universe.insert(a.clone()); universe.insert(b.clone()); }
prop_assert_eq!(assignment.len(), universe.len());
let assigned: BTreeMap<String, usize> = assignment.iter().cloned().collect();
prop_assert_eq!(assigned.len(), universe.len());
let ids: BTreeSet<usize> = assignment.iter().map(|(_, c)| *c).collect();
let k = ids.len();
for id in 0..k {
prop_assert!(ids.contains(&id), "community ids contiguous 0..k");
}
let q = modularity(&nodes, &edges, &assignment, 1.0);
let q0 = modularity(&nodes, &edges, &singleton_partition(&assignment), 1.0);
prop_assert!(q + 1e-9 >= q0, "louvain Q {} >= singleton Q {}", q, q0);
}
#[test]
fn louvain_determinism_property((nodes, edges) in graph_strategy()) {
let a = louvain(&nodes, &edges, 1.0);
let b = louvain(&nodes, &edges, 1.0);
prop_assert_eq!(a, b);
}
}
fn path_weight(path: &Option<Vec<(String, f64)>>) -> Option<f64> {
path.as_ref()
.map(|p| p.last().map(|(_, w)| *w).unwrap_or(0.0))
}
#[test]
fn shortest_path_golden_known_path() {
let nodes: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
let edges = vec![
("a".to_string(), "b".to_string(), 1.0f32),
("a".to_string(), "c".to_string(), 4.0),
("b".to_string(), "c".to_string(), 1.0),
("c".to_string(), "d".to_string(), 1.0),
("b".to_string(), "d".to_string(), 5.0),
];
let path = shortest_path(&nodes, &edges, &"a".to_string(), &"d".to_string(), None)
.expect("a reaches d");
let route: Vec<String> = path.iter().map(|(n, _)| n.clone()).collect();
assert_eq!(route, vec!["a", "b", "c", "d"], "min-weight route");
assert_eq!(path[0], ("a".to_string(), 0.0));
assert_eq!(path[1].1, 1.0); assert_eq!(path[2].1, 2.0); assert_eq!(path[3].1, 3.0); }
#[test]
fn shortest_path_self_is_zero_length() {
let nodes: Vec<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();
let edges = vec![("a".to_string(), "b".to_string(), 1.0f32)];
let path = shortest_path(&nodes, &edges, &"a".to_string(), &"a".to_string(), None)
.expect("self path exists");
assert_eq!(path, vec![("a".to_string(), 0.0)]);
}
#[test]
fn shortest_path_unreachable_is_none() {
let nodes: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
let edges = vec![
("a".to_string(), "b".to_string(), 1.0f32),
("c".to_string(), "d".to_string(), 1.0),
];
assert!(shortest_path(&nodes, &edges, &"a".to_string(), &"d".to_string(), None).is_none());
}
#[test]
fn shortest_path_missing_endpoint_is_none() {
let nodes: Vec<String> = vec!["a".to_string()];
let edges: Vec<(String, String, Weight)> = vec![];
assert!(shortest_path(&nodes, &edges, &"a".to_string(), &"z".to_string(), None).is_none());
}
#[test]
fn shortest_path_max_hops_caps_edge_count() {
let nodes: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
let edges = vec![
("a".to_string(), "b".to_string(), 1.0f32),
("b".to_string(), "c".to_string(), 1.0),
("c".to_string(), "d".to_string(), 1.0),
("a".to_string(), "d".to_string(), 10.0),
];
let unbounded = shortest_path(&nodes, &edges, &"a".to_string(), &"d".to_string(), None);
assert_eq!(path_weight(&unbounded), Some(3.0));
let capped = shortest_path(&nodes, &edges, &"a".to_string(), &"d".to_string(), Some(1));
assert_eq!(path_weight(&capped), Some(10.0));
assert!(
shortest_path(&nodes, &edges, &"a".to_string(), &"d".to_string(), Some(0)).is_none()
);
}
const EIG_ITERS: usize = 200;
const EIG_TOL: f64 = 1e-10;
const PR_DAMPING: f64 = 0.85;
const PR_ITERS: usize = 200;
fn map_of(rows: Vec<(String, f64)>) -> BTreeMap<String, f64> {
rows.into_iter().collect()
}
fn star() -> (Vec<String>, Vec<(String, String, Weight)>) {
let nodes: Vec<String> = ["c", "l1", "l2", "l3"]
.iter()
.map(|s| s.to_string())
.collect();
let edges = vec![w("c", "l1"), w("c", "l2"), w("c", "l3")];
(nodes, edges)
}
fn path3() -> (Vec<String>, Vec<(String, String, Weight)>) {
let nodes: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
let edges = vec![w("a", "b"), w("b", "c")];
(nodes, edges)
}
fn triangle() -> (Vec<String>, Vec<(String, String, Weight)>) {
let nodes: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
let edges = vec![w("a", "b"), w("b", "c"), w("c", "a")];
(nodes, edges)
}
#[test]
fn betweenness_golden_star_centre_carries_all_pairs() {
let (nodes, edges) = star();
let got = map_of(betweenness(&nodes, &edges));
assert!(
(got["c"] - 3.0).abs() < 1e-9,
"centre = 3.0, got {}",
got["c"]
);
for leaf in ["l1", "l2", "l3"] {
assert!(got[leaf].abs() < 1e-9, "{leaf} = 0.0, got {}", got[leaf]);
}
}
#[test]
fn betweenness_golden_path_middle_is_one() {
let (nodes, edges) = path3();
let got = map_of(betweenness(&nodes, &edges));
assert!(
(got["b"] - 1.0).abs() < 1e-9,
"middle = 1.0, got {}",
got["b"]
);
assert!(got["a"].abs() < 1e-9);
assert!(got["c"].abs() < 1e-9);
}
#[test]
fn betweenness_isolated_node_scores_zero() {
let nodes: Vec<String> = ["a", "b", "iso"].iter().map(|s| s.to_string()).collect();
let edges = vec![w("a", "b")];
let got = map_of(betweenness(&nodes, &edges));
assert!(got["iso"].abs() < 1e-9, "isolated node scores 0.0");
assert!(got["a"].abs() < 1e-9);
assert!(got["b"].abs() < 1e-9);
}
#[test]
fn betweenness_empty_graph_is_empty() {
let empty: Vec<String> = Vec::new();
assert!(betweenness(&empty, &[]).is_empty());
}
#[test]
fn eigenvector_golden_path3_known_vector() {
let (nodes, edges) = path3();
let got = map_of(eigenvector(&nodes, &edges, EIG_ITERS, EIG_TOL));
let inv_sqrt2 = 1.0 / 2.0_f64.sqrt();
assert!(
(got["b"] - inv_sqrt2).abs() < 1e-6,
"centre {} ~ {inv_sqrt2}",
got["b"]
);
assert!(
(got["a"] - 0.5).abs() < 1e-6,
"endpoint a {} ~ 0.5",
got["a"]
);
assert!(
(got["c"] - 0.5).abs() < 1e-6,
"endpoint c {} ~ 0.5",
got["c"]
);
let sumsq: f64 = got.values().map(|v| v * v).sum();
assert!((sumsq - 1.0).abs() < 1e-6, "unit L2 norm, got {sumsq}");
}
#[test]
fn eigenvector_golden_triangle_all_equal() {
let (nodes, edges) = triangle();
let got = map_of(eigenvector(&nodes, &edges, EIG_ITERS, EIG_TOL));
let expect = 1.0 / 3.0_f64.sqrt();
for k in ["a", "b", "c"] {
assert!((got[k] - expect).abs() < 1e-6, "{k} = 1/√3, got {}", got[k]);
}
}
#[test]
fn eigenvector_isolated_node_scores_zero() {
let nodes: Vec<String> = ["a", "b", "iso"].iter().map(|s| s.to_string()).collect();
let edges = vec![w("a", "b")];
let got = map_of(eigenvector(&nodes, &edges, EIG_ITERS, EIG_TOL));
assert!(
got["iso"].abs() < 1e-9,
"isolated node scores 0.0, got {}",
got["iso"]
);
}
#[test]
fn shortest_path_parallel_edges_take_minimum() {
let nodes: Vec<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();
let edges = vec![
("a".to_string(), "b".to_string(), 5.0f32),
("a".to_string(), "b".to_string(), 2.0),
];
let path = shortest_path(&nodes, &edges, &"a".to_string(), &"b".to_string(), None);
assert_eq!(path_weight(&path), Some(2.0));
}
fn weighted_graph_strategy(
) -> impl Strategy<Value = (Vec<String>, Vec<(String, String, Weight)>)> {
(2usize..7usize).prop_flat_map(|n| {
let nodes: Vec<String> = (0..n).map(|i| format!("n{i:02}")).collect();
let ids = nodes.clone();
let edge = (0..n, 0..n, 1u32..10u32)
.prop_map(move |(a, b, w)| (format!("n{a:02}"), format!("n{b:02}"), w as f32));
let edges = prop::collection::vec(edge, 0..14);
(Just(ids), edges)
})
}
#[test]
fn eigenvector_edgeless_graph_is_uniform_unit() {
let nodes: Vec<String> = ["x", "y", "z", "q"].iter().map(|s| s.to_string()).collect();
let got = map_of(eigenvector(&nodes, &[], EIG_ITERS, EIG_TOL));
let expect = 1.0 / 4.0_f64.sqrt();
for k in ["x", "y", "z", "q"] {
assert!((got[k] - expect).abs() < 1e-9, "uniform 1/√n");
}
}
#[test]
fn pagerank_golden_triangle_all_equal() {
let (nodes, edges) = triangle();
let got = map_of(pagerank(&nodes, &edges, PR_DAMPING, PR_ITERS));
for k in ["a", "b", "c"] {
assert!(
(got[k] - 1.0 / 3.0).abs() < 1e-9,
"{k} = 1/3, got {}",
got[k]
);
}
let sum: f64 = got.values().sum();
assert!((sum - 1.0).abs() < 1e-9, "sums to 1");
}
#[test]
fn pagerank_golden_star_centre_ranks_highest() {
let (nodes, edges) = star();
let got = map_of(pagerank(&nodes, &edges, PR_DAMPING, PR_ITERS));
for leaf in ["l1", "l2", "l3"] {
assert!(got["c"] > got[leaf], "centre outranks {leaf}");
assert!((got[leaf] - got["l1"]).abs() < 1e-9, "leaves share rank");
}
let sum: f64 = got.values().sum();
assert!((sum - 1.0).abs() < 1e-9, "sums to 1");
}
#[test]
fn pagerank_isolated_node_gets_teleport_floor() {
let nodes: Vec<String> = ["a", "b", "iso"].iter().map(|s| s.to_string()).collect();
let edges = vec![w("a", "b")];
let got = map_of(pagerank(&nodes, &edges, PR_DAMPING, PR_ITERS));
let sum: f64 = got.values().sum();
assert!((sum - 1.0).abs() < 1e-9, "sums to 1");
let floor = (1.0 - PR_DAMPING) / 3.0;
for v in got.values() {
assert!(*v + 1e-12 >= floor, "score {v} >= floor {floor}");
}
assert!(got["iso"] < got["a"]);
assert!(got["iso"] < got["b"]);
}
#[test]
fn centrality_empty_graph_all_empty() {
let empty: Vec<String> = Vec::new();
assert!(eigenvector(&empty, &[], EIG_ITERS, EIG_TOL).is_empty());
assert!(pagerank(&empty, &[], PR_DAMPING, PR_ITERS).is_empty());
}
const SPECTRAL_ITERS: usize = 200;
const SPECTRAL_TOL: f64 = 1e-9;
fn hint_map(rows: Vec<(String, (f64, f64))>) -> BTreeMap<String, (f64, f64)> {
rows.into_iter().collect()
}
#[test]
fn spectral_embedding_coords_in_unit_interval() {
let (nodes, edges) = two_cliques_bridge();
let rows = spectral_embedding(&nodes, &edges, SPECTRAL_ITERS, SPECTRAL_TOL);
for (node, (x, y)) in &rows {
assert!(
(0.0..=1.0).contains(x) && (0.0..=1.0).contains(y),
"node {node} hint ({x}, {y}) must be normalised to [0, 1]²"
);
}
let xs: Vec<f64> = rows.iter().map(|(_, (x, _))| *x).collect();
let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(min_x.abs() < 1e-9, "x min anchored at 0, got {min_x}");
assert!(
(max_x - 1.0).abs() < 1e-9,
"x max anchored at 1, got {max_x}"
);
}
#[test]
fn spectral_embedding_determinism_100_runs_identical() {
let (nodes, edges) = karate_club();
let first = spectral_embedding(&nodes, &edges, SPECTRAL_ITERS, SPECTRAL_TOL);
for _ in 0..100 {
assert_eq!(
spectral_embedding(&nodes, &edges, SPECTRAL_ITERS, SPECTRAL_TOL),
first,
"spectral embedding must be bit-for-bit reproducible"
);
}
}
#[test]
fn spectral_embedding_node_order_independent() {
let (nodes, edges) = two_cliques_bridge();
let mut shuffled_nodes = nodes.clone();
shuffled_nodes.reverse();
let mut shuffled_edges = edges.clone();
shuffled_edges.reverse();
let a = hint_map(spectral_embedding(
&nodes,
&edges,
SPECTRAL_ITERS,
SPECTRAL_TOL,
));
let b = hint_map(spectral_embedding(
&shuffled_nodes,
&shuffled_edges,
SPECTRAL_ITERS,
SPECTRAL_TOL,
));
assert_eq!(a, b, "hint is keyed on node identity, not input order");
}
#[test]
fn spectral_embedding_separates_disconnected_components() {
let nodes: Vec<String> = ["a", "b", "c", "d", "e", "f"]
.iter()
.map(|s| s.to_string())
.collect();
let edges = vec![
w("a", "b"),
w("b", "c"),
w("c", "a"),
w("d", "e"),
w("e", "f"),
w("f", "d"),
];
let map = hint_map(spectral_embedding(
&nodes,
&edges,
SPECTRAL_ITERS,
SPECTRAL_TOL,
));
let left = (map["a"].0 + map["b"].0 + map["c"].0) / 3.0;
let right = (map["d"].0 + map["e"].0 + map["f"].0) / 3.0;
assert!(
(left - right).abs() > 0.25,
"disconnected components should separate on the layout (Δx = {})",
(left - right).abs()
);
}
#[test]
fn spectral_embedding_degenerate_graphs() {
let empty: Vec<String> = Vec::new();
assert!(spectral_embedding(&empty, &[], SPECTRAL_ITERS, SPECTRAL_TOL).is_empty());
let one = vec!["solo".to_string()];
let rows = spectral_embedding(&one, &[], SPECTRAL_ITERS, SPECTRAL_TOL);
assert_eq!(rows, vec![("solo".to_string(), (0.5, 0.5))]);
let nodes: Vec<String> = ["x", "y", "z"].iter().map(|s| s.to_string()).collect();
for (_, (x, y)) in spectral_embedding(&nodes, &[], SPECTRAL_ITERS, SPECTRAL_TOL) {
assert!(x.is_finite() && y.is_finite(), "edgeless hint stays finite");
assert!((0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn shortest_path_is_symmetric((nodes, edges) in weighted_graph_strategy()) {
let universe = node_universe(&nodes, &edges);
for a in &universe {
for b in &universe {
let fwd = shortest_path(&nodes, &edges, a, b, None);
let rev = shortest_path(&nodes, &edges, b, a, None);
prop_assert_eq!(
path_weight(&fwd), path_weight(&rev),
"distance {} <-> {} must be symmetric", a, b
);
}
}
}
#[test]
fn shortest_path_triangle_inequality((nodes, edges) in weighted_graph_strategy()) {
let universe = node_universe(&nodes, &edges);
for a in &universe {
for b in &universe {
for c in &universe {
let ab = path_weight(&shortest_path(&nodes, &edges, a, b, None));
let bc = path_weight(&shortest_path(&nodes, &edges, b, c, None));
let ac = path_weight(&shortest_path(&nodes, &edges, a, c, None));
if let (Some(ab), Some(bc), Some(ac)) = (ab, bc, ac) {
prop_assert!(
ac <= ab + bc + 1e-9,
"triangle: d({},{})={} > d({},{})={} + d({},{})={}",
a, c, ac, a, b, ab, b, c, bc
);
}
}
}
}
}
#[test]
fn shortest_path_unreachable_across_components(
(left, right) in (1usize..4usize, 1usize..4usize)
) {
let mut nodes = Vec::new();
let mut edges: Vec<(String, String, Weight)> = Vec::new();
for i in 0..left { nodes.push(format!("l{i}")); }
for i in 0..right { nodes.push(format!("r{i}")); }
for i in 0..left {
for j in (i + 1)..left {
edges.push((format!("l{i}"), format!("l{j}"), 1.0));
}
}
for i in 0..right {
for j in (i + 1)..right {
edges.push((format!("r{i}"), format!("r{j}"), 1.0));
}
}
prop_assert!(
shortest_path(&nodes, &edges, &"l0".to_string(), &"r0".to_string(), None).is_none()
);
prop_assert!(
shortest_path(&nodes, &edges, &"r0".to_string(), &"l0".to_string(), None).is_none()
);
}
#[test]
fn shortest_path_determinism((nodes, edges) in weighted_graph_strategy()) {
let universe = node_universe(&nodes, &edges);
if universe.len() >= 2 {
let a = &universe[0];
let b = &universe[universe.len() - 1];
let p1 = shortest_path(&nodes, &edges, a, b, None);
let p2 = shortest_path(&nodes, &edges, a, b, None);
prop_assert_eq!(p1, p2);
}
}
#[test]
fn betweenness_range_isolation_and_determinism((nodes, edges) in graph_strategy()) {
let rows = betweenness(&nodes, &edges);
let mut universe: BTreeSet<String> = BTreeSet::new();
for n in &nodes { universe.insert(n.clone()); }
for (a, b, _) in &edges { universe.insert(a.clone()); universe.insert(b.clone()); }
prop_assert_eq!(rows.len(), universe.len());
let mut deg: BTreeMap<String, usize> = BTreeMap::new();
for n in &universe { deg.insert(n.clone(), 0); }
for (a, b, _) in &edges {
if a != b {
*deg.get_mut(a).unwrap() += 1;
*deg.get_mut(b).unwrap() += 1;
}
}
for (node, score) in &rows {
prop_assert!(*score >= -1e-9, "score {} >= 0", score);
if deg[node] == 0 {
prop_assert!(score.abs() < 1e-9, "isolated {} scores 0", node);
}
}
prop_assert_eq!(betweenness(&nodes, &edges), rows);
}
#[test]
fn eigenvector_unit_norm_range_and_isolation((nodes, edges) in graph_strategy()) {
let rows = eigenvector(&nodes, &edges, EIG_ITERS, EIG_TOL);
prop_assume!(!rows.is_empty());
let sumsq: f64 = rows.iter().map(|(_, v)| v * v).sum();
prop_assert!((sumsq - 1.0).abs() < 1e-6, "unit L2 norm, got {}", sumsq);
let mut deg: BTreeMap<String, usize> = BTreeMap::new();
for (n, _) in &rows { deg.insert(n.clone(), 0); }
for (a, b, _) in &edges {
if a != b {
*deg.get_mut(a).unwrap() += 1;
*deg.get_mut(b).unwrap() += 1;
}
}
let has_edges = deg.values().any(|d| *d > 0);
for (node, score) in &rows {
prop_assert!(*score >= -1e-9 && *score <= 1.0 + 1e-9, "score {} in [0,1]", score);
if has_edges && deg[node] == 0 {
prop_assert!(score.abs() < 1e-6, "isolated {} ~ 0", node);
}
}
prop_assert_eq!(eigenvector(&nodes, &edges, EIG_ITERS, EIG_TOL), rows);
}
#[test]
fn pagerank_sums_to_one_floor_and_determinism((nodes, edges) in graph_strategy()) {
let rows = pagerank(&nodes, &edges, PR_DAMPING, PR_ITERS);
prop_assume!(!rows.is_empty());
let sum: f64 = rows.iter().map(|(_, v)| v).sum();
prop_assert!((sum - 1.0).abs() < 1e-9, "PageRank sums to 1, got {}", sum);
let floor = (1.0 - PR_DAMPING) / rows.len() as f64;
for (_, score) in &rows {
prop_assert!(*score + 1e-12 >= floor, "score {} >= floor {}", score, floor);
}
prop_assert_eq!(pagerank(&nodes, &edges, PR_DAMPING, PR_ITERS), rows);
}
#[test]
fn spectral_embedding_range_coverage_and_determinism((nodes, edges) in graph_strategy()) {
let rows = spectral_embedding(&nodes, &edges, SPECTRAL_ITERS, SPECTRAL_TOL);
let mut universe: BTreeSet<String> = BTreeSet::new();
for n in &nodes { universe.insert(n.clone()); }
for (a, b, _) in &edges { universe.insert(a.clone()); universe.insert(b.clone()); }
prop_assert_eq!(rows.len(), universe.len());
for (node, (x, y)) in &rows {
prop_assert!(x.is_finite() && y.is_finite(), "node {} hint is finite", node);
prop_assert!(
*x >= -1e-9 && *x <= 1.0 + 1e-9 && *y >= -1e-9 && *y <= 1.0 + 1e-9,
"node {} hint ({}, {}) in [0,1]^2", node, x, y
);
}
prop_assert_eq!(spectral_embedding(&nodes, &edges, SPECTRAL_ITERS, SPECTRAL_TOL), rows);
}
}
}