use crate::exact::bigint::BigInt;
use crate::graph::core::Graph;
use crate::linalg::eigen::eigen_symmetric;
use crate::linalg::matrix::Matrix;
use crate::monte_carlo::Rng;
const EIG_TOL: f64 = 1e-9;
#[must_use]
pub fn laplacian_matrix(g: &Graph) -> Matrix {
assert!(!g.directed, "the Laplacian here is for undirected graphs");
let n = g.n;
let mut l = Matrix::zeros(n, n);
for (u, v, w) in g.edges() {
if u == v {
continue;
}
l.set(u, u, l.get(u, u) + w);
l.set(v, v, l.get(v, v) + w);
l.set(u, v, l.get(u, v) - w);
l.set(v, u, l.get(v, u) - w);
}
l
}
#[must_use]
pub fn weighted_degrees(g: &Graph) -> Vec<f64> {
let mut d = vec![0.0; g.n];
for (u, v, w) in g.edges() {
if u == v {
continue;
}
d[u] += w;
if !g.directed {
d[v] += w;
}
}
d
}
#[must_use]
pub fn normalized_laplacian(g: &Graph) -> Matrix {
assert!(!g.directed, "the Laplacian here is for undirected graphs");
let n = g.n;
let deg = weighted_degrees(g);
let mut m = Matrix::zeros(n, n);
for v in 0..n {
if deg[v] > 0.0 {
m.set(v, v, 1.0);
}
}
for (u, v, w) in g.edges() {
if u == v || deg[u] <= 0.0 || deg[v] <= 0.0 {
continue;
}
let s = w / (deg[u] * deg[v]).sqrt();
m.set(u, v, m.get(u, v) - s);
m.set(v, u, m.get(v, u) - s);
}
m
}
#[must_use]
pub fn adjacency_spectrum(g: &Graph) -> Vec<f64> {
assert!(!g.directed, "the adjacency spectrum here is for undirected graphs");
let mut a = g.to_adjacency_matrix();
for v in 0..g.n {
a.set(v, v, 0.0);
}
ascending_eigenvalues(&a)
}
#[must_use]
pub fn laplacian_spectrum(g: &Graph) -> Vec<f64> {
ascending_eigenvalues(&laplacian_matrix(g))
}
#[must_use]
pub fn normalized_laplacian_spectrum(g: &Graph) -> Vec<f64> {
ascending_eigenvalues(&normalized_laplacian(g))
}
fn ascending_eigenvalues(m: &Matrix) -> Vec<f64> {
let e = eigen_symmetric(m, 1e-12, 200).expect("Jacobi converges on a symmetric matrix");
let mut v = e.values;
v.reverse();
v
}
#[must_use]
pub fn algebraic_connectivity(g: &Graph) -> f64 {
if g.n < 2 {
return 0.0;
}
let s = laplacian_spectrum(g);
s[1].max(0.0)
}
#[must_use]
pub fn fiedler_vector(g: &Graph) -> Vec<f64> {
assert!(g.n >= 2, "the Fiedler vector needs at least two vertices");
let l = laplacian_matrix(g);
let e = eigen_symmetric(&l, 1e-12, 200).expect("Jacobi converges on a symmetric matrix");
let col = g.n - 2;
let mut v: Vec<f64> = (0..g.n).map(|r| e.vectors.get(r, col)).collect();
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 0.0 {
v.iter_mut().for_each(|x| *x /= norm);
}
if let Some(&first) = v.iter().find(|x| x.abs() > EIG_TOL) {
if first < 0.0 {
v.iter_mut().for_each(|x| *x = -*x);
}
}
v
}
#[must_use]
pub fn spectral_bisection(g: &Graph) -> Vec<bool> {
fiedler_vector(g).into_iter().map(|x| x >= 0.0).collect()
}
#[must_use]
pub fn spectral_clustering(g: &Graph, k: usize, rng: &mut Rng) -> Vec<usize> {
assert!(k > 0 && k <= g.n, "k must satisfy 1 <= k <= n");
if k == 1 {
return vec![0; g.n];
}
let l = laplacian_matrix(g);
let e = eigen_symmetric(&l, 1e-12, 200).expect("Jacobi converges on a symmetric matrix");
let coords: Vec<Vec<f64>> = (0..g.n)
.map(|v| (0..k).map(|j| e.vectors.get(v, g.n - 1 - j)).collect())
.collect();
kmeans(&coords, k, rng)
}
fn kmeans(points: &[Vec<f64>], k: usize, rng: &mut Rng) -> Vec<usize> {
let n = points.len();
if n == 0 {
return Vec::new();
}
let dim = points[0].len();
let dist2 = |a: &[f64], b: &[f64]| -> f64 {
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
};
let first = ((u128::from(rng.next_u64()) * n as u128) >> 64) as usize;
let mut centres: Vec<Vec<f64>> = vec![points[first].clone()];
while centres.len() < k {
let d: Vec<f64> = points
.iter()
.map(|p| centres.iter().map(|c| dist2(p, c)).fold(f64::INFINITY, f64::min))
.collect();
let total: f64 = d.iter().sum();
let pick = if total <= 0.0 {
((u128::from(rng.next_u64()) * n as u128) >> 64) as usize
} else {
let target = rng.next_f64() * total;
let mut acc = 0.0;
let mut idx = n - 1;
for (i, &x) in d.iter().enumerate() {
acc += x;
if acc >= target {
idx = i;
break;
}
}
idx
};
centres.push(points[pick].clone());
}
let mut label = vec![0usize; n];
for _ in 0..100 {
let mut changed = false;
for (i, p) in points.iter().enumerate() {
let best = (0..k)
.min_by(|&a, &b| dist2(p, ¢res[a]).total_cmp(&dist2(p, ¢res[b])))
.unwrap();
if label[i] != best {
label[i] = best;
changed = true;
}
}
for c in 0..k {
let members: Vec<&Vec<f64>> =
(0..n).filter(|&i| label[i] == c).map(|i| &points[i]).collect();
if members.is_empty() {
continue;
}
for j in 0..dim {
centres[c][j] = members.iter().map(|p| p[j]).sum::<f64>() / members.len() as f64;
}
}
if !changed {
break;
}
}
label
}
#[must_use]
pub fn number_spanning_trees(g: &Graph) -> f64 {
if g.n == 0 {
return 0.0;
}
if g.n == 1 {
return 1.0;
}
let s = laplacian_spectrum(g);
let scale = s.last().copied().unwrap_or(1.0).max(1.0);
if s[1] <= EIG_TOL * scale {
return 0.0;
}
s[1..].iter().product::<f64>() / g.n as f64
}
#[must_use]
pub fn number_spanning_trees_exact(g: &Graph) -> BigInt {
crate::graph::core::spanning_tree_count_exact(g)
}
#[must_use]
pub fn pagerank(g: &Graph, damping: f64, tol: f64) -> Vec<f64> {
assert!((0.0..1.0).contains(&damping), "damping must be in [0, 1)");
assert!(tol > 0.0, "tol must be positive");
let n = g.n;
if n == 0 {
return Vec::new();
}
let out: Vec<f64> = (0..n)
.map(|v| g.adj[v].iter().filter(|&&(t, _)| t != v).map(|&(_, w)| w).sum())
.collect();
let mut rank = vec![1.0 / n as f64; n];
for _ in 0..1_000 {
let mut next = vec![(1.0 - damping) / n as f64; n];
let dangling: f64 = (0..n).filter(|&v| out[v] <= 0.0).map(|v| rank[v]).sum();
for x in next.iter_mut() {
*x += damping * dangling / n as f64;
}
for u in 0..n {
if out[u] <= 0.0 {
continue;
}
for &(v, w) in &g.adj[u] {
if v != u {
next[v] += damping * rank[u] * w / out[u];
}
}
}
let delta: f64 = (0..n).map(|v| (next[v] - rank[v]).abs()).sum();
rank = next;
if delta < tol {
break;
}
}
rank
}
#[must_use]
pub fn hits(g: &Graph, tol: f64) -> (Vec<f64>, Vec<f64>) {
assert!(tol > 0.0, "tol must be positive");
let n = g.n;
let mut hub = vec![1.0; n];
let mut auth = vec![1.0; n];
for _ in 0..1_000 {
let mut new_auth = vec![0.0; n];
for u in 0..n {
for &(v, w) in &g.adj[u] {
new_auth[v] += hub[u] * w;
}
}
let mut new_hub = vec![0.0; n];
for u in 0..n {
for &(v, w) in &g.adj[u] {
new_hub[u] += new_auth[v] * w;
}
}
normalize(&mut new_auth);
normalize(&mut new_hub);
let delta: f64 = (0..n)
.map(|v| (new_auth[v] - auth[v]).abs() + (new_hub[v] - hub[v]).abs())
.sum();
auth = new_auth;
hub = new_hub;
if delta < tol {
break;
}
}
(hub, auth)
}
fn normalize(v: &mut [f64]) {
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 0.0 {
v.iter_mut().for_each(|x| *x /= norm);
}
}
#[must_use]
pub fn eigenvector_centrality(g: &Graph, tol: f64) -> Vec<f64> {
assert!(tol > 0.0, "tol must be positive");
let n = g.n;
if n == 0 {
return Vec::new();
}
let mut shift = 1.0f64;
for u in 0..n {
shift = shift.max(g.adj[u].iter().map(|&(_, w)| w.abs()).sum::<f64>());
}
let mut x = vec![1.0 / (n as f64).sqrt(); n];
for _ in 0..10_000 {
let mut next: Vec<f64> = x.iter().map(|v| v * shift).collect();
for u in 0..n {
for &(v, w) in &g.adj[u] {
next[v] += x[u] * w;
}
}
normalize(&mut next);
let delta: f64 = (0..n).map(|v| (next[v] - x[v]).abs()).sum();
x = next;
if delta < tol {
break;
}
}
x
}
#[must_use]
pub fn katz_centrality(g: &Graph, alpha: f64) -> Vec<f64> {
assert!(alpha > 0.0, "alpha must be positive");
let n = g.n;
let mut x = vec![0.0; n];
for _ in 0..10_000 {
let mut next = vec![1.0; n];
for u in 0..n {
for &(v, w) in &g.adj[u] {
next[v] += alpha * x[u] * w;
}
}
let delta: f64 = (0..n).map(|v| (next[v] - x[v]).abs()).sum();
x = next;
if delta < 1e-12 {
break;
}
}
x
}
#[must_use]
pub fn betweenness_centrality(g: &Graph) -> Vec<f64> {
let n = g.n;
let mut score = vec![0.0; n];
for s in 0..n {
let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut sigma = vec![0.0f64; n];
let mut dist = vec![usize::MAX; n];
let mut order: Vec<usize> = Vec::new();
sigma[s] = 1.0;
dist[s] = 0;
let mut queue = std::collections::VecDeque::from(vec![s]);
while let Some(v) = queue.pop_front() {
order.push(v);
for &(w, _) in &g.adj[v] {
if dist[w] == usize::MAX {
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];
for &w in order.iter().rev() {
for &v in &preds[w] {
delta[v] += sigma[v] / sigma[w] * (1.0 + delta[w]);
}
if w != s {
score[w] += delta[w];
}
}
}
if !g.directed {
score.iter_mut().for_each(|x| *x /= 2.0);
}
score
}
#[must_use]
pub fn closeness_centrality(g: &Graph) -> Vec<f64> {
let n = g.n;
(0..n)
.map(|v| {
let d = g.bfs(v);
let reached: Vec<usize> = d.iter().filter_map(|x| *x).filter(|&x| x > 0).collect();
let total: usize = reached.iter().sum();
if total == 0 {
return 0.0;
}
let r = reached.len() as f64;
(r / total as f64) * (r / (n as f64 - 1.0))
})
.collect()
}
#[must_use]
pub fn harmonic_centrality(g: &Graph) -> Vec<f64> {
(0..g.n)
.map(|v| {
g.bfs(v)
.iter()
.enumerate()
.filter(|&(u, _)| u != v)
.filter_map(|(_, d)| d.map(|x| 1.0 / x as f64))
.sum()
})
.collect()
}
fn laplacian_pseudoinverse(g: &Graph) -> Matrix {
let n = g.n;
let l = laplacian_matrix(g);
let e = eigen_symmetric(&l, 1e-12, 200).expect("Jacobi converges on a symmetric matrix");
let scale = e.values.iter().fold(0.0f64, |a, &b| a.max(b.abs())).max(1.0);
let mut p = Matrix::zeros(n, n);
for k in 0..n {
let lambda = e.values[k];
if lambda.abs() <= EIG_TOL * scale {
continue;
}
for i in 0..n {
for j in 0..n {
let add = e.vectors.get(i, k) * e.vectors.get(j, k) / lambda;
p.set(i, j, p.get(i, j) + add);
}
}
}
p
}
#[must_use]
pub fn effective_resistance(g: &Graph, u: usize, v: usize) -> f64 {
assert!(u < g.n && v < g.n, "endpoints must be vertices");
if u == v {
return 0.0;
}
if g.bfs(u)[v].is_none() {
return f64::INFINITY;
}
let p = laplacian_pseudoinverse(g);
p.get(u, u) + p.get(v, v) - 2.0 * p.get(u, v)
}
#[must_use]
pub fn resistance_matrix(g: &Graph) -> Matrix {
let n = g.n;
let p = laplacian_pseudoinverse(g);
let mut r = Matrix::zeros(n, n);
for i in 0..n {
for j in 0..n {
if i == j {
continue;
}
let value = if g.bfs(i)[j].is_none() {
f64::INFINITY
} else {
p.get(i, i) + p.get(j, j) - 2.0 * p.get(i, j)
};
r.set(i, j, value);
}
}
r
}
#[must_use]
pub fn commute_time(g: &Graph, u: usize, v: usize) -> f64 {
let total: f64 = g.edges().iter().filter(|&&(a, b, _)| a != b).map(|&(_, _, w)| w).sum();
2.0 * total * effective_resistance(g, u, v)
}
#[must_use]
pub fn random_walk_stationary(g: &Graph) -> Vec<f64> {
assert!(!g.directed, "this stationary form is for undirected graphs");
let deg = weighted_degrees(g);
let total: f64 = deg.iter().sum();
if total <= 0.0 {
return vec![0.0; g.n];
}
deg.into_iter().map(|d| d / total).collect()
}
#[must_use]
pub fn mixing_time_estimate(g: &Graph, eps: f64) -> f64 {
assert!((0.0..1.0).contains(&eps) && eps > 0.0, "eps must be in (0, 1)");
let pi = random_walk_stationary(g);
let pi_min = pi.iter().copied().fold(f64::INFINITY, f64::min);
if pi_min <= 0.0 {
return f64::INFINITY;
}
let mut s = normalized_laplacian_spectrum(g);
if s.is_empty() {
return f64::INFINITY;
}
s.remove(0);
let second = s.iter().map(|&mu| (1.0 - mu).abs()).fold(0.0f64, f64::max);
if second >= 1.0 - EIG_TOL {
return f64::INFINITY;
}
(1.0 / (eps * pi_min)).ln() / (1.0 - second)
}
#[must_use]
pub fn cheeger_bound(g: &Graph) -> (f64, f64) {
assert!(g.n >= 2, "conductance needs at least two vertices");
let s = normalized_laplacian_spectrum(g);
let mu = s[1].max(0.0);
(mu / 2.0, (2.0 * mu).sqrt())
}
#[must_use]
pub fn expander_check(g: &Graph, target_gap: f64) -> bool {
g.n >= 2 && algebraic_connectivity(g) >= target_gap
}
#[must_use]
pub fn graph_energy(g: &Graph) -> f64 {
adjacency_spectrum(g).iter().map(|x| x.abs()).sum()
}
#[must_use]
pub fn estrada_index(g: &Graph) -> f64 {
adjacency_spectrum(g).iter().map(|x| x.exp()).sum()
}
#[must_use]
pub fn isospectral_check(g: &Graph, h: &Graph, tol: f64) -> bool {
if g.n != h.n {
return false;
}
let a = adjacency_spectrum(g);
let b = adjacency_spectrum(h);
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
}
fn modularity_degrees(g: &Graph) -> Vec<f64> {
let mut d = vec![0.0; g.n];
for (u, v, w) in g.edges() {
d[u] += w;
d[v] += w;
}
d
}
#[must_use]
pub fn modularity(g: &Graph, communities: &[usize]) -> f64 {
assert!(!g.directed, "modularity here is for undirected graphs");
assert_eq!(communities.len(), g.n, "one label per vertex is required");
let deg = modularity_degrees(g);
let two_m: f64 = deg.iter().sum();
if two_m <= 0.0 {
return 0.0;
}
let mut inside = 0.0;
for (u, v, w) in g.edges() {
if communities[u] != communities[v] {
continue;
}
inside += 2.0 * w;
}
let labels: std::collections::BTreeSet<usize> = communities.iter().copied().collect();
let expected: f64 = labels
.iter()
.map(|&c| {
let d: f64 = (0..g.n).filter(|&v| communities[v] == c).map(|v| deg[v]).sum();
(d / two_m) * (d / two_m)
})
.sum();
inside / two_m - expected
}
#[must_use]
pub fn community_louvain(g: &Graph, rng: &mut Rng) -> Vec<usize> {
assert!(!g.directed, "Louvain here is for undirected graphs");
let n = g.n;
if n == 0 {
return Vec::new();
}
let mut assignment: Vec<usize> = (0..n).collect();
let mut work = g.clone();
for _ in 0..20 {
let m = work.n;
let deg = modularity_degrees(&work);
let two_m: f64 = deg.iter().sum();
if two_m <= 0.0 {
break;
}
let mut comm: Vec<usize> = (0..m).collect();
let mut ctot: Vec<f64> = deg.clone();
let mut improved = false;
for _ in 0..20 {
let mut moved = false;
let order = crate::discrete::combinatorics::random_permutation(m, rng);
for &v in &order {
let old = comm[v];
ctot[old] -= deg[v];
let mut links: std::collections::BTreeMap<usize, f64> =
std::collections::BTreeMap::new();
for &(w, weight) in &work.adj[v] {
if w != v {
*links.entry(comm[w]).or_insert(0.0) += weight;
}
}
let mut best = old;
let mut best_gain = links.get(&old).copied().unwrap_or(0.0)
- deg[v] * ctot[old] / two_m;
for (&c, &k_in) in &links {
let gain = k_in - deg[v] * ctot[c] / two_m;
if gain > best_gain + 1e-12 {
best_gain = gain;
best = c;
}
}
ctot[best] += deg[v];
if best != old {
comm[v] = best;
moved = true;
improved = true;
}
}
if !moved {
break;
}
}
if !improved {
break;
}
let mut relabel: std::collections::BTreeMap<usize, usize> =
std::collections::BTreeMap::new();
for &c in &comm {
let next = relabel.len();
relabel.entry(c).or_insert(next);
}
let compact: Vec<usize> = comm.iter().map(|c| relabel[c]).collect();
for a in assignment.iter_mut() {
*a = compact[*a];
}
let mut next_graph = Graph::new(relabel.len(), false);
let mut merged: std::collections::BTreeMap<(usize, usize), f64> =
std::collections::BTreeMap::new();
for (u, v, w) in work.edges() {
let (a, b) = (compact[u], compact[v]);
*merged.entry((a.min(b), a.max(b))).or_insert(0.0) += w;
}
for ((a, b), w) in merged {
next_graph.add_edge(a, b, w);
}
if next_graph.n == work.n {
break;
}
work = next_graph;
}
renumber(&assignment)
}
#[must_use]
pub fn label_propagation(g: &Graph, rng: &mut Rng) -> Vec<usize> {
assert!(!g.directed, "label propagation here is for undirected graphs");
let n = g.n;
let mut label: Vec<usize> = (0..n).collect();
for _ in 0..100 {
let mut changed = false;
for &v in &crate::discrete::combinatorics::random_permutation(n, rng) {
let mut weight: std::collections::BTreeMap<usize, f64> =
std::collections::BTreeMap::new();
for &(w, x) in &g.adj[v] {
if w != v {
*weight.entry(label[w]).or_insert(0.0) += x;
}
}
if weight.is_empty() {
continue;
}
let best = weight
.values()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let tied: Vec<usize> = weight
.iter()
.filter(|(_, &w)| (w - best).abs() < 1e-12)
.map(|(&l, _)| l)
.collect();
let pick = tied[((u128::from(rng.next_u64()) * tied.len() as u128) >> 64) as usize];
if pick != label[v] {
label[v] = pick;
changed = true;
}
}
if !changed {
break;
}
}
renumber(&label)
}
fn renumber(labels: &[usize]) -> Vec<usize> {
let mut map: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
labels
.iter()
.map(|&l| {
let next = map.len();
*map.entry(l).or_insert(next)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::core::{
complete_bipartite, complete_graph, cycle_graph, hypercube_graph, path_graph,
petersen_graph, star_graph,
};
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-7 * a.abs().max(b.abs()).max(1.0)
}
fn random_graph(n: usize, p: f64, rng: &mut Rng) -> Graph {
let mut g = Graph::new(n, false);
for u in 0..n {
for v in u + 1..n {
if rng.next_f64() < p {
g.add_edge(u, v, 1.0);
}
}
}
g
}
#[test]
fn laplacian_has_its_defining_structure() {
let mut rng = Rng::new(0x_1A81);
for n in 1..=9usize {
for _ in 0..15 {
let g = random_graph(n, 0.4, &mut rng);
let l = laplacian_matrix(&g);
for i in 0..n {
for j in 0..n {
assert!(close(l.get(i, j), l.get(j, i)), "not symmetric");
}
let row: f64 = (0..n).map(|j| l.get(i, j)).sum();
assert!(row.abs() < 1e-9, "row {i} sums to {row}");
assert!(close(l.get(i, i), g.degree(i) as f64));
}
let s = laplacian_spectrum(&g);
assert!(s.iter().all(|&x| x > -1e-9), "negative eigenvalue in {s:?}");
assert!(s[0].abs() < 1e-9, "smallest eigenvalue is {}", s[0]);
let trace: f64 = s.iter().sum();
assert!(close(trace, 2.0 * g.edge_count() as f64), "trace is wrong");
}
}
}
#[test]
fn zero_eigenvalue_multiplicity_is_the_component_count() {
let mut rng = Rng::new(0x_C0AF);
for n in 1..=9usize {
for _ in 0..25 {
let g = random_graph(n, 0.25, &mut rng);
let s = laplacian_spectrum(&g);
let scale = s.last().copied().unwrap_or(1.0).max(1.0);
let zeros = s.iter().filter(|&&x| x.abs() <= 1e-9 * scale).count();
assert_eq!(
zeros,
g.connected_components().len(),
"n = {n}, spectrum {s:?}"
);
let connected = g.is_connected();
assert_eq!(
algebraic_connectivity(&g) > 1e-9 * scale,
connected && n >= 2,
"connectivity disagrees at n = {n}"
);
}
}
}
#[test]
fn laplacian_spectra_match_their_closed_forms() {
for n in 2..=8usize {
let s = laplacian_spectrum(&complete_graph(n));
assert!(s[0].abs() < 1e-9);
for &x in &s[1..] {
assert!(close(x, n as f64), "K{n} gave {x}");
}
assert!(close(algebraic_connectivity(&complete_graph(n)), n as f64));
}
for n in 3..=9usize {
let s = laplacian_spectrum(&cycle_graph(n));
let mut want: Vec<f64> = (0..n)
.map(|k| 2.0 - 2.0 * (std::f64::consts::TAU * k as f64 / n as f64).cos())
.collect();
want.sort_by(f64::total_cmp);
for (a, b) in s.iter().zip(&want) {
assert!(close(*a, *b), "C{n}: {a} vs {b}");
}
}
for n in 2..=9usize {
let s = laplacian_spectrum(&path_graph(n));
let mut want: Vec<f64> = (0..n)
.map(|k| 2.0 - 2.0 * (std::f64::consts::PI * k as f64 / n as f64).cos())
.collect();
want.sort_by(f64::total_cmp);
for (a, b) in s.iter().zip(&want) {
assert!(close(*a, *b), "P{n}: {a} vs {b}");
}
}
for n in 3..=8usize {
let s = laplacian_spectrum(&star_graph(n));
assert!(s[0].abs() < 1e-9);
for &x in &s[1..n - 1] {
assert!(close(x, 1.0), "star {n} gave {x}");
}
assert!(close(s[n - 1], n as f64));
}
for d in 1..=4u32 {
let s = laplacian_spectrum(&hypercube_graph(d));
for k in 0..=d {
let want = 2.0 * k as f64;
let count = s.iter().filter(|&&x| close(x, want)).count();
let expect = crate::discrete::combinatorics::binomial_u64(
u64::from(d),
u64::from(k),
)
.unwrap() as usize;
assert_eq!(count, expect, "Q{d} eigenvalue {want}");
}
}
}
#[test]
fn normalized_spectrum_is_bounded_and_detects_bipartiteness() {
let mut rng = Rng::new(0x_0A1F);
for n in 2..=9usize {
for _ in 0..20 {
let g = random_graph(n, 0.4, &mut rng);
if g.edge_count() == 0 {
continue;
}
let s = normalized_laplacian_spectrum(&g);
for &x in &s {
assert!(
(-1e-9..=2.0 + 1e-9).contains(&x),
"eigenvalue {x} is outside [0, 2]"
);
}
let has_two = s.iter().any(|&x| (x - 2.0).abs() < 1e-7);
let bipartite_component = g
.connected_components()
.iter()
.filter(|c| c.len() > 1)
.any(|c| g.subgraph(c).is_bipartite().is_some());
assert_eq!(has_two, bipartite_component, "n = {n}, spectrum {s:?}");
}
}
for (m, n) in [(2usize, 3usize), (3, 3), (1, 4)] {
let s = normalized_laplacian_spectrum(&complete_bipartite(m, n));
assert!(s.iter().any(|&x| (x - 2.0).abs() < 1e-7), "K_{{{m},{n}}}");
}
let s = normalized_laplacian_spectrum(&cycle_graph(5));
assert!(s.iter().all(|&x| x < 2.0 - 1e-6), "C5 should not reach 2");
}
#[test]
fn fiedler_vector_is_the_second_eigenvector() {
let mut rng = Rng::new(0x_F1ED);
for n in 2..=9usize {
for _ in 0..20 {
let g = random_graph(n, 0.45, &mut rng);
if !g.is_connected() {
continue;
}
let v = fiedler_vector(&g);
let l = laplacian_matrix(&g);
let lambda = algebraic_connectivity(&g);
for i in 0..n {
let lv: f64 = (0..n).map(|j| l.get(i, j) * v[j]).sum();
assert!(
(lv - lambda * v[i]).abs() < 1e-6,
"not an eigenvector at {i}: {lv} vs {}",
lambda * v[i]
);
}
let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
assert!(close(norm, 1.0), "not normalized: {norm}");
let dot: f64 = v.iter().sum();
assert!(dot.abs() < 1e-6, "not orthogonal to ones: {dot}");
assert_eq!(fiedler_vector(&g), v, "not reproducible");
}
}
let v = fiedler_vector(&path_graph(8));
let increasing = v.windows(2).all(|w| w[0] <= w[1] + 1e-9);
let decreasing = v.windows(2).all(|w| w[0] >= w[1] - 1e-9);
assert!(increasing || decreasing, "not monotone on a path: {v:?}");
let mut barbell = Graph::new(8, false);
for (u, v) in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)] {
barbell.add_edge(u, v, 1.0);
}
for (u, v) in [(4, 5), (4, 6), (4, 7), (5, 6), (5, 7), (6, 7)] {
barbell.add_edge(u, v, 1.0);
}
barbell.add_edge(3, 4, 1.0);
let side = spectral_bisection(&barbell);
assert!(
(0..4).all(|i| side[i] == side[0]) && (4..8).all(|i| side[i] == side[4]),
"bisection did not find the barbell's halves: {side:?}"
);
assert_ne!(side[0], side[4], "both halves on the same side");
}
#[test]
fn spectral_clustering_recovers_planted_blocks() {
let mut rng = Rng::new(0x_C1A5);
let mut g = Graph::new(12, false);
for b in 0..3 {
for i in 0..4 {
for j in i + 1..4 {
g.add_edge(b * 4 + i, b * 4 + j, 1.0);
}
}
}
g.add_edge(3, 4, 1.0);
g.add_edge(7, 8, 1.0);
let labels = spectral_clustering(&g, 3, &mut rng);
for b in 0..3 {
let first = labels[b * 4];
for i in 1..4 {
assert_eq!(labels[b * 4 + i], first, "block {b} was split");
}
}
let distinct: std::collections::BTreeSet<usize> = labels.iter().copied().collect();
assert_eq!(distinct.len(), 3, "the three blocks were not separated");
assert_eq!(spectral_clustering(&g, 1, &mut rng), vec![0; 12]);
let all = spectral_clustering(&g, 12, &mut rng);
assert_eq!(all.len(), 12);
}
#[test]
fn spectral_spanning_tree_count_matches_the_exact_one() {
let mut rng = Rng::new(0x_71EE);
for n in 1..=8usize {
for _ in 0..20 {
let g = random_graph(n, 0.5, &mut rng);
let exact = number_spanning_trees_exact(&g);
let approx = number_spanning_trees(&g);
assert!(
(approx - exact.to_f64()).abs() < 1e-6 * exact.to_f64().max(1.0),
"n = {n}: spectral {approx} vs exact {exact}"
);
}
}
for n in 2..=9u64 {
let want = (n as f64).powi(n as i32 - 2);
assert!(
close(number_spanning_trees(&complete_graph(n as usize)), want),
"K{n}"
);
}
assert!(close(number_spanning_trees(&petersen_graph()), 2000.0));
assert!(close(number_spanning_trees(&path_graph(6)), 1.0));
assert!(close(number_spanning_trees(&cycle_graph(7)), 7.0));
assert_eq!(number_spanning_trees(&Graph::new(4, false)), 0.0);
}
#[test]
fn pagerank_sums_to_one_and_is_stationary() {
let mut rng = Rng::new(0x_9A6E);
for n in 1..=9usize {
for _ in 0..12 {
let mut g = Graph::new(n, true);
for u in 0..n {
for v in 0..n {
if u != v && rng.next_f64() < 0.3 {
g.add_edge(u, v, 1.0);
}
}
}
let r = pagerank(&g, 0.85, 1e-12);
let total: f64 = r.iter().sum();
assert!(close(total, 1.0), "n = {n}: sums to {total}");
assert!(r.iter().all(|&x| x >= 0.0), "negative rank");
let out: Vec<f64> = (0..n)
.map(|v| g.adj[v].iter().filter(|&&(t, _)| t != v).count() as f64)
.collect();
let dangling: f64 = (0..n).filter(|&v| out[v] <= 0.0).map(|v| r[v]).sum();
let mut next = vec![0.15 / n as f64 + 0.85 * dangling / n as f64; n];
for u in 0..n {
if out[u] <= 0.0 {
continue;
}
for &(v, _) in &g.adj[u] {
if v != u {
next[v] += 0.85 * r[u] / out[u];
}
}
}
for v in 0..n {
assert!((next[v] - r[v]).abs() < 1e-6, "not stationary at {v}");
}
}
}
for g in [cycle_graph(7), complete_graph(6), petersen_graph()] {
let r = pagerank(&g, 0.85, 1e-14);
let first = r[0];
for (v, &x) in r.iter().enumerate() {
assert!(close(x, first), "vertex {v} differs on a regular graph");
}
}
let r = pagerank(&path_graph(5), 0.0, 1e-14);
assert!(r.iter().all(|&x| close(x, 0.2)));
}
#[test]
fn centralities_match_closed_forms_on_named_graphs() {
for n in 3..=9usize {
let b = betweenness_centrality(&star_graph(n));
let want = (n - 1) as f64 * (n - 2) as f64 / 2.0;
assert!(close(b[0], want), "star {n} centre: {} vs {want}", b[0]);
for (v, &x) in b.iter().enumerate().skip(1) {
assert!(x.abs() < 1e-9, "leaf {v} has betweenness {x}");
}
}
for n in 2..=7usize {
let b = betweenness_centrality(&complete_graph(n));
assert!(b.iter().all(|&x| x.abs() < 1e-9), "K{n} has betweenness");
}
for n in 2..=8usize {
let b = betweenness_centrality(&path_graph(n));
for i in 0..n {
let want = (i * (n - 1 - i)) as f64;
assert!(close(b[i], want), "P{n} at {i}: {} vs {want}", b[i]);
}
}
for n in 2..=7usize {
let c = closeness_centrality(&complete_graph(n));
assert!(c.iter().all(|&x| close(x, 1.0)), "K{n} closeness");
let h = harmonic_centrality(&complete_graph(n));
assert!(h.iter().all(|&x| close(x, (n - 1) as f64)), "K{n} harmonic");
}
let mut split = Graph::new(4, false);
split.add_edge(0, 1, 1.0);
split.add_edge(2, 3, 1.0);
let h = harmonic_centrality(&split);
assert!(h.iter().all(|&x| close(x, 1.0)), "got {h:?}");
for g in [cycle_graph(6), complete_graph(5), petersen_graph()] {
let e = eigenvector_centrality(&g, 1e-12);
let first = e[0];
assert!(e.iter().all(|&x| close(x, first)), "regular graph is not uniform");
let norm: f64 = e.iter().map(|x| x * x).sum::<f64>().sqrt();
assert!(close(norm, 1.0));
}
}
#[test]
fn eigenvector_centrality_solves_its_equation() {
let mut rng = Rng::new(0x_E16E);
for n in 2..=8usize {
for _ in 0..15 {
let g = random_graph(n, 0.5, &mut rng);
if !g.is_connected() {
continue;
}
let x = eigenvector_centrality(&g, 1e-14);
let a = g.to_adjacency_matrix();
let ax: Vec<f64> = (0..n)
.map(|i| (0..n).map(|j| a.get(i, j) * x[j]).sum())
.collect();
let lambda: f64 = (0..n).map(|i| x[i] * ax[i]).sum();
for i in 0..n {
assert!(
(ax[i] - lambda * x[i]).abs() < 1e-5,
"not an eigenvector at {i}"
);
}
assert!(x.iter().all(|&v| v >= -1e-9), "negative entry");
let top = *adjacency_spectrum(&g).last().unwrap();
assert!(close(lambda, top), "lambda {lambda} vs top {top}");
}
}
}
#[test]
fn hits_scores_satisfy_their_recurrence() {
let mut rng = Rng::new(0x_417);
for n in 2..=8usize {
for _ in 0..10 {
let mut g = Graph::new(n, true);
for u in 0..n {
for v in 0..n {
if u != v && rng.next_f64() < 0.4 {
g.add_edge(u, v, 1.0);
}
}
}
if g.edge_count() == 0 {
continue;
}
let (hub, auth) = hits(&g, 1e-14);
for x in [&hub, &auth] {
let norm: f64 = x.iter().map(|v| v * v).sum::<f64>().sqrt();
assert!(close(norm, 1.0) || norm < 1e-12, "not normalized: {norm}");
assert!(x.iter().all(|&v| v >= -1e-9), "negative score");
}
for v in 0..n {
if g.in_degree(v) == 0 {
assert!(auth[v].abs() < 1e-9, "vertex {v} has authority from nothing");
}
if g.out_degree(v) == 0 {
assert!(hub[v].abs() < 1e-9, "vertex {v} hubs nothing");
}
}
}
}
let g = Graph::from_edges(
4,
&[(0, 2, 1.0), (0, 3, 1.0), (1, 2, 1.0), (1, 3, 1.0)],
true,
);
let (hub, auth) = hits(&g, 1e-14);
assert!(hub[0] > 0.5 && hub[1] > 0.5, "0 and 1 should be hubs");
assert!(hub[2].abs() < 1e-9 && hub[3].abs() < 1e-9);
assert!(auth[2] > 0.5 && auth[3] > 0.5, "2 and 3 should be authorities");
assert!(auth[0].abs() < 1e-9 && auth[1].abs() < 1e-9);
}
#[test]
fn effective_resistance_behaves_like_a_circuit() {
for n in 2..=8usize {
let r = effective_resistance(&path_graph(n), 0, n - 1);
assert!(close(r, (n - 1) as f64), "P{n}: {r}");
}
for k in 1..=5usize {
let mut g = Graph::new(2, false);
for _ in 0..k {
g.add_edge(0, 1, 1.0);
}
let r = effective_resistance(&g, 0, 1);
assert!(close(r, 1.0 / k as f64), "{k} parallel: {r}");
}
for n in 3..=8usize {
let c = cycle_graph(n);
for a in 1..n {
let want = (a * (n - a)) as f64 / n as f64;
let r = effective_resistance(&c, 0, a);
assert!(close(r, want), "C{n} at {a}: {r} vs {want}");
}
}
for n in 2..=8usize {
let k = complete_graph(n);
for u in 0..n {
for v in u + 1..n {
let r = effective_resistance(&k, u, v);
assert!(close(r, 2.0 / n as f64), "K{n}: {r}");
}
}
}
assert_eq!(effective_resistance(&path_graph(4), 2, 2), 0.0);
let mut split = Graph::new(4, false);
split.add_edge(0, 1, 1.0);
split.add_edge(2, 3, 1.0);
assert!(effective_resistance(&split, 0, 2).is_infinite());
let mut rng = Rng::new(0x_2E51);
for n in 2..=7usize {
for _ in 0..15 {
let g = random_graph(n, 0.6, &mut rng);
if !g.is_connected() {
continue;
}
let m = resistance_matrix(&g);
for i in 0..n {
assert!(m.get(i, i).abs() < 1e-9);
for j in 0..n {
assert!(close(m.get(i, j), m.get(j, i)), "not symmetric");
assert!(m.get(i, j) >= -1e-9, "negative resistance");
for k in 0..n {
assert!(
m.get(i, k) <= m.get(i, j) + m.get(j, k) + 1e-6,
"triangle inequality fails at ({i}, {j}, {k})"
);
}
}
}
let foster: f64 = g.edges().iter().map(|&(u, v, _)| m.get(u, v)).sum();
assert!(
close(foster, (n - 1) as f64),
"Foster's theorem: {foster} vs {}",
n - 1
);
}
}
}
#[test]
fn commute_time_matches_the_resistance_identity() {
for g in [path_graph(5), cycle_graph(6), complete_graph(5), petersen_graph()] {
let m: f64 = g.edges().iter().map(|&(_, _, w)| w).sum();
for u in 0..g.n {
for v in 0..g.n {
let c = commute_time(&g, u, v);
let r = effective_resistance(&g, u, v);
assert!(close(c, 2.0 * m * r), "commute {c} vs 2mR {}", 2.0 * m * r);
}
}
}
for n in 2..=7usize {
let c = commute_time(&path_graph(n), 0, n - 1);
let want = 2.0 * (n - 1) as f64 * (n - 1) as f64;
assert!(close(c, want), "P{n}: {c} vs {want}");
}
}
#[test]
fn random_walk_stationary_is_the_degree_distribution() {
let mut rng = Rng::new(0x_2A1C);
for n in 2..=9usize {
for _ in 0..15 {
let g = random_graph(n, 0.5, &mut rng);
if g.edge_count() == 0 {
continue;
}
let pi = random_walk_stationary(&g);
assert!(close(pi.iter().sum::<f64>(), 1.0), "does not sum to one");
let deg = weighted_degrees(&g);
let mut next = vec![0.0; n];
for u in 0..n {
if deg[u] <= 0.0 {
continue;
}
for &(v, w) in &g.adj[u] {
if u != v {
next[v] += pi[u] * w / deg[u];
}
}
}
for v in 0..n {
if deg[v] > 0.0 {
assert!((next[v] - pi[v]).abs() < 1e-9, "not stationary at {v}");
}
}
}
}
for g in [cycle_graph(8), complete_graph(6), petersen_graph()] {
let pi = random_walk_stationary(&g);
assert!(pi.iter().all(|&x| close(x, 1.0 / g.n as f64)));
}
}
#[test]
fn cheeger_bounds_bracket_the_true_conductance() {
let mut rng = Rng::new(0x_C4EE);
for n in 2..=7usize {
for _ in 0..15 {
let g = random_graph(n, 0.5, &mut rng);
if !g.is_connected() || g.edge_count() == 0 {
continue;
}
let (lo, hi) = cheeger_bound(&g);
let h = brute_conductance(&g);
assert!(
h >= lo - 1e-7 && h <= hi + 1e-7,
"n = {n}: conductance {h} outside [{lo}, {hi}]"
);
}
}
assert!(expander_check(&complete_graph(8), 4.0));
assert!(!expander_check(&path_graph(8), 1.0));
assert!(!expander_check(&Graph::new(4, false), 0.1), "disconnected");
}
fn brute_conductance(g: &Graph) -> f64 {
let n = g.n;
let deg = weighted_degrees(g);
let total: f64 = deg.iter().sum();
let mut best = f64::INFINITY;
for mask in 1u64..(1u64 << n) - 1 {
let inside: Vec<bool> = (0..n).map(|v| mask >> v & 1 == 1).collect();
let vol: f64 = (0..n).filter(|&v| inside[v]).map(|v| deg[v]).sum();
let vol_c = total - vol;
if vol <= 0.0 || vol_c <= 0.0 {
continue;
}
let cut: f64 = g
.edges()
.iter()
.filter(|&&(u, v, _)| inside[u] != inside[v])
.map(|&(_, _, w)| w)
.sum();
best = best.min(cut / vol.min(vol_c));
}
best
}
#[test]
fn mixing_time_is_finite_exactly_when_the_walk_converges() {
for g in [complete_graph(6), cycle_graph(7), petersen_graph()] {
let t = mixing_time_estimate(&g, 0.01);
assert!(t.is_finite() && t > 0.0, "expected finite, got {t}");
}
for g in [cycle_graph(6), path_graph(5), complete_bipartite(3, 3)] {
assert!(
mixing_time_estimate(&g, 0.01).is_infinite(),
"a bipartite walk should not mix"
);
}
let mut split = Graph::new(4, false);
split.add_edge(0, 1, 1.0);
assert!(mixing_time_estimate(&split, 0.01).is_infinite());
let fast = mixing_time_estimate(&complete_graph(10), 0.01);
let slow = mixing_time_estimate(&cycle_graph(11), 0.01);
assert!(fast < slow, "K10 ({fast}) should mix faster than C11 ({slow})");
}
#[test]
fn spectral_invariants_match_their_definitions() {
for n in 2..=8usize {
let e = graph_energy(&complete_graph(n));
assert!(close(e, 2.0 * (n - 1) as f64), "K{n} energy: {e}");
}
let mut rng = Rng::new(0x_5AEC);
for n in 1..=8usize {
for _ in 0..15 {
let g = random_graph(n, 0.5, &mut rng);
let s = adjacency_spectrum(&g);
assert!(s.iter().sum::<f64>().abs() < 1e-9, "trace is not zero");
let sq: f64 = s.iter().map(|x| x * x).sum();
assert!(close(sq, 2.0 * g.edge_count() as f64), "trace of A^2");
let est = estrada_index(&g);
assert!(est >= n as f64 - 1e-9, "Estrada {est} below {n}");
}
}
let mut rng = Rng::new(0x_1505);
for n in 1..=7usize {
let g = random_graph(n, 0.5, &mut rng);
let perm = crate::discrete::combinatorics::random_permutation(n, &mut rng);
let mut h = Graph::new(n, false);
for (u, v, w) in g.edges() {
h.add_edge(perm[u], perm[v], w);
}
assert!(isospectral_check(&g, &h, 1e-9), "relabelling changed the spectrum");
}
let star = star_graph(5);
let mut c4_plus = Graph::new(5, false);
for (u, v) in [(0, 1), (1, 2), (2, 3), (3, 0)] {
c4_plus.add_edge(u, v, 1.0);
}
assert!(
isospectral_check(&star, &c4_plus, 1e-9),
"the classic cospectral pair should match"
);
assert!(
!crate::graph::core::is_isomorphic_small(&star, &c4_plus),
"but they are not isomorphic"
);
assert!(!isospectral_check(&complete_graph(3), &complete_graph(4), 1e-9));
}
#[test]
fn modularity_matches_its_definition() {
let mut rng = Rng::new(0x_10D);
for n in 2..=8usize {
for _ in 0..20 {
let g = random_graph(n, 0.5, &mut rng);
if g.edge_count() == 0 {
continue;
}
let labels: Vec<usize> = (0..n)
.map(|_| ((u128::from(rng.next_u64()) * 3) >> 64) as usize)
.collect();
let q = modularity(&g, &labels);
let a = g.to_adjacency_matrix();
let deg = weighted_degrees(&g);
let two_m: f64 = deg.iter().sum();
let mut want = 0.0;
for i in 0..n {
for j in 0..n {
if labels[i] == labels[j] {
want += a.get(i, j) - deg[i] * deg[j] / two_m;
}
}
}
want /= two_m;
assert!(close(q, want), "n = {n}: {q} vs {want}");
assert!(q <= 1.0 + 1e-9, "modularity {q} exceeds one");
}
}
for g in [complete_graph(6), cycle_graph(7), petersen_graph()] {
assert!(close(modularity(&g, &vec![0; g.n]), 0.0));
}
}
#[test]
fn modularity_survives_contracting_a_partition() {
let mut rng = Rng::new(0x_C047);
for _ in 0..80 {
let n = 2 + ((u128::from(rng.next_u64()) * 8) >> 64) as usize;
let g = random_graph(n, 0.4, &mut rng);
if g.edge_count() == 0 {
continue;
}
let k = 1 + ((u128::from(rng.next_u64()) * n as u128) >> 64) as usize;
let labels: Vec<usize> = (0..n)
.map(|_| ((u128::from(rng.next_u64()) * k as u128) >> 64) as usize)
.collect();
let before = modularity(&g, &labels);
let compact = renumber(&labels);
let c = compact.iter().copied().max().unwrap() + 1;
let mut merged: std::collections::BTreeMap<(usize, usize), f64> =
std::collections::BTreeMap::new();
for (u, v, w) in g.edges() {
let (a, b) = (compact[u], compact[v]);
*merged.entry((a.min(b), a.max(b))).or_insert(0.0) += w;
}
let mut h = Graph::new(c, false);
for ((a, b), w) in merged {
h.add_edge(a, b, w);
}
let after = modularity(&h, &(0..c).collect::<Vec<_>>());
assert!(
close(before, after),
"contraction changed modularity: {before} to {after}"
);
}
let mut g = Graph::new(2, false);
g.add_edge(0, 1, 1.0);
g.add_edge(0, 0, 3.0);
assert!(close(modularity(&g, &[0, 0]), 0.0));
let split = 6.0 / 8.0 - ((7.0 / 8.0f64).powi(2) + (1.0 / 8.0f64).powi(2));
assert!(close(modularity(&g, &[0, 1]), split), "loop weight is not counted");
}
#[test]
fn community_detection_recovers_a_planted_partition() {
let mut rng = Rng::new(0x_10AF);
let mut g = Graph::new(20, false);
for b in 0..4 {
for i in 0..5 {
for j in i + 1..5 {
g.add_edge(b * 5 + i, b * 5 + j, 1.0);
}
}
}
for b in 0..3 {
g.add_edge(b * 5 + 4, (b + 1) * 5, 1.0);
}
let louvain = community_louvain(&g, &mut rng);
for b in 0..4 {
let first = louvain[b * 5];
for i in 1..5 {
assert_eq!(louvain[b * 5 + i], first, "Louvain split block {b}");
}
}
let q = modularity(&g, &louvain);
assert!(q > 0.6, "Louvain modularity {q} is too low for a planted partition");
assert!(q > modularity(&g, &[0; 20]), "worse than one community");
let lp = label_propagation(&g, &mut rng);
for b in 0..4 {
let first = lp[b * 5];
for i in 1..5 {
assert_eq!(lp[b * 5 + i], first, "label propagation split block {b}");
}
}
assert!(modularity(&g, &lp) > 0.6);
for labels in [&louvain, &lp] {
let distinct: std::collections::BTreeSet<usize> = labels.iter().copied().collect();
assert_eq!(
distinct,
(0..distinct.len()).collect::<std::collections::BTreeSet<_>>(),
"labels are not compact"
);
}
let k = complete_graph(8);
let single = community_louvain(&k, &mut rng);
assert!(
modularity(&k, &single) <= 1e-9,
"K8 should have no positive-modularity partition"
);
}
}