use crate::core::{Graph, IgraphError, IgraphResult};
const DEFAULT_EPS: f64 = 1e-14;
const DEFAULT_MAX_ITER: usize = 5000;
const SHIFTED_EPS: f64 = 1e-12;
const SHIFTED_MAX_ITER: usize = 5000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EigenvectorMode {
Out,
In,
All,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EigenvectorScores {
pub vector: Vec<f64>,
pub eigenvalue: f64,
}
pub fn eigenvector_centrality(graph: &Graph) -> IgraphResult<Vec<f64>> {
if graph.is_directed() {
return Err(IgraphError::Unsupported(
"directed eigenvector_centrality — use eigenvector_centrality_directed or _full",
));
}
undirected_unweighted(graph).map(|s| s.vector)
}
pub fn eigenvector_centrality_weighted(
graph: &Graph,
weights: &[f64],
) -> IgraphResult<EigenvectorScores> {
if graph.is_directed() {
return Err(IgraphError::Unsupported(
"directed eigenvector_centrality_weighted — use eigenvector_centrality_directed_weighted",
));
}
validate_weights(graph, weights)?;
undirected_weighted(graph, Some(weights))
}
pub fn eigenvector_centrality_directed(
graph: &Graph,
mode: EigenvectorMode,
) -> IgraphResult<EigenvectorScores> {
eigenvector_centrality_full(graph, mode, None)
}
pub fn eigenvector_centrality_directed_weighted(
graph: &Graph,
mode: EigenvectorMode,
weights: &[f64],
) -> IgraphResult<EigenvectorScores> {
eigenvector_centrality_full(graph, mode, Some(weights))
}
pub fn eigenvector_centrality_full(
graph: &Graph,
mode: EigenvectorMode,
weights: Option<&[f64]>,
) -> IgraphResult<EigenvectorScores> {
if let Some(w) = weights {
validate_weights(graph, w)?;
}
let effective_mode = if graph.is_directed() {
mode
} else {
EigenvectorMode::All
};
match effective_mode {
EigenvectorMode::All => match weights {
None => undirected_unweighted(graph),
Some(w) => undirected_weighted(graph, Some(w)),
},
EigenvectorMode::Out | EigenvectorMode::In => directed_path(graph, effective_mode, weights),
}
}
fn validate_weights(graph: &Graph, weights: &[f64]) -> IgraphResult<()> {
let m = graph.ecount();
if weights.len() != m {
return Err(IgraphError::InvalidArgument(format!(
"weights vector length ({}) not equal to number of edges ({})",
weights.len(),
m
)));
}
Ok(())
}
fn undirected_unweighted(graph: &Graph) -> IgraphResult<EigenvectorScores> {
let n = graph.vcount();
let n_us = n as usize;
if n == 0 {
return Ok(EigenvectorScores {
vector: Vec::new(),
eigenvalue: 0.0,
});
}
if graph.ecount() == 0 {
return Ok(EigenvectorScores {
vector: vec![1.0; n_us],
eigenvalue: 0.0,
});
}
if n == 1 {
return Ok(EigenvectorScores {
vector: vec![1.0],
eigenvalue: 0.0,
});
}
let mut adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
for v in 0..n {
adj.push(graph.neighbors(v)?);
}
let mut x = vec![1.0_f64; n_us];
let mut x_new = vec![0.0_f64; n_us];
for _ in 0..DEFAULT_MAX_ITER {
for v in 0..n_us {
let mut sum = x[v];
for &u in &adj[v] {
sum += x[u as usize];
}
x_new[v] = sum;
}
let max = x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
if max > 0.0 {
for slot in &mut x_new {
*slot /= max;
}
}
let mut diff = 0.0_f64;
for v in 0..n_us {
diff += (x_new[v] - x[v]).abs();
}
std::mem::swap(&mut x, &mut x_new);
if diff < DEFAULT_EPS {
break;
}
}
let mut ax = vec![0.0_f64; n_us];
for v in 0..n_us {
let mut sum = 0.0_f64;
for &u in &adj[v] {
sum += x[u as usize];
}
ax[v] = sum;
}
let num: f64 = x.iter().zip(ax.iter()).map(|(a, b)| a * b).sum();
let den: f64 = x.iter().map(|a| a * a).sum();
let eigenvalue = if den > 0.0 { num / den } else { 0.0 };
for slot in &mut x {
if *slot < 0.0 {
*slot = 0.0;
}
}
Ok(EigenvectorScores {
vector: x,
eigenvalue,
})
}
#[allow(
clippy::too_many_lines,
clippy::many_single_char_names,
clippy::cast_possible_truncation
)]
fn undirected_weighted(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<EigenvectorScores> {
let n = graph.vcount();
let n_us = n as usize;
let m = graph.ecount();
if n == 0 {
return Ok(EigenvectorScores {
vector: Vec::new(),
eigenvalue: 0.0,
});
}
if m == 0 {
return Ok(EigenvectorScores {
vector: vec![1.0; n_us],
eigenvalue: 0.0,
});
}
if let Some(w) = weights {
if w.iter().all(|&x| x == 0.0) {
return Ok(EigenvectorScores {
vector: vec![1.0; n_us],
eigenvalue: 0.0,
});
}
}
if n == 1 {
return Ok(EigenvectorScores {
vector: vec![1.0],
eigenvalue: 0.0,
});
}
let negative_weights = weights.is_some_and(|w| w.iter().any(|&x| x < 0.0));
let mut inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
for v in 0..n {
inc.push(graph.incident(v)?);
}
let mut x = vec![0.0_f64; n_us];
for v in 0..n {
let v_us = v as usize;
let mut strength = 0.0_f64;
for &e in &inc[v_us] {
let w = weights.map_or(1.0, |ws| ws[e as usize]);
strength += w;
}
if strength != 0.0 {
x[v_us] = strength.abs();
} else if !negative_weights {
x[v_us] = 0.0;
} else {
x[v_us] = if inc[v_us].is_empty() { 0.0 } else { 1.0 };
}
}
if x.iter().all(|&y| y == 0.0) {
x.fill(1.0);
}
let max0 = x.iter().fold(0.0_f64, |acc, &y| acc.max(y.abs()));
if max0 > 0.0 {
for slot in &mut x {
*slot /= max0;
}
}
let shift = 1.0_f64;
let mut x_new = vec![0.0_f64; n_us];
for _ in 0..SHIFTED_MAX_ITER {
for v in 0..n_us {
let mut sum = shift * x[v];
for &e in &inc[v] {
let other = graph.edge_other(e, v as u32)?;
let w = weights.map_or(1.0, |ws| ws[e as usize]);
sum += w * x[other as usize];
}
x_new[v] = sum;
}
let pivot = if negative_weights {
let mut best = 0.0_f64;
for &v in &x_new {
if v.abs() > best.abs() {
best = v;
}
}
best
} else {
x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
};
if pivot != 0.0 {
for slot in &mut x_new {
*slot /= pivot;
}
}
let mut diff = 0.0_f64;
for v in 0..n_us {
diff += (x_new[v] - x[v]).abs();
}
std::mem::swap(&mut x, &mut x_new);
if diff < SHIFTED_EPS {
break;
}
}
let mut wx = vec![0.0_f64; n_us];
for v in 0..n_us {
let mut sum = 0.0_f64;
for &e in &inc[v] {
let other = graph.edge_other(e, v as u32)?;
let w = weights.map_or(1.0, |ws| ws[e as usize]);
sum += w * x[other as usize];
}
wx[v] = sum;
}
let num: f64 = x.iter().zip(wx.iter()).map(|(a, b)| a * b).sum();
let den: f64 = x.iter().map(|a| a * a).sum();
let eigenvalue = if den > 0.0 { num / den } else { 0.0 };
let max = x.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
if max > 0.0 {
for slot in &mut x {
*slot /= max;
}
}
if !negative_weights {
for slot in &mut x {
if *slot < 0.0 {
*slot = 0.0;
}
}
}
Ok(EigenvectorScores {
vector: x,
eigenvalue,
})
}
#[allow(
clippy::too_many_lines,
clippy::many_single_char_names,
clippy::cast_possible_truncation,
clippy::needless_range_loop
)]
fn directed_path(
graph: &Graph,
mode: EigenvectorMode,
weights: Option<&[f64]>,
) -> IgraphResult<EigenvectorScores> {
let n = graph.vcount();
let n_us = n as usize;
let m = graph.ecount();
if n == 0 {
return Ok(EigenvectorScores {
vector: Vec::new(),
eigenvalue: 0.0,
});
}
if m == 0 {
return Ok(EigenvectorScores {
vector: vec![1.0; n_us],
eigenvalue: 0.0,
});
}
if let Some(w) = weights {
if w.iter().all(|&x| x == 0.0) {
return Ok(EigenvectorScores {
vector: vec![1.0; n_us],
eigenvalue: 0.0,
});
}
}
let negative_weights = weights.is_some_and(|w| w.iter().any(|&x| x < 0.0));
if !negative_weights && crate::algorithms::properties::is_dag::is_dag(graph) {
return dag_sentinel(graph, mode, weights);
}
let mut walk: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_us];
let m_u32 = u32::try_from(m)
.map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
match mode {
EigenvectorMode::Out => {
for e in 0..m_u32 {
let (u, v) = graph.edge(e)?;
walk[v as usize].push((e, u));
}
}
EigenvectorMode::In => {
for e in 0..m_u32 {
let (u, v) = graph.edge(e)?;
walk[u as usize].push((e, v));
}
}
EigenvectorMode::All => unreachable!("All is dispatched to undirected_*"),
}
let mut x = vec![0.0_f64; n_us];
for v in 0..n_us {
let mut strength = 0.0_f64;
for &(e, _) in &walk[v] {
let w = weights.map_or(1.0, |ws| ws[e as usize]);
strength += w;
}
if strength != 0.0 {
x[v] = strength.abs();
} else if !negative_weights {
x[v] = 0.0;
} else {
x[v] = if walk[v].is_empty() { 0.0 } else { 1.0 };
}
}
if x.iter().all(|&y| y == 0.0) {
x.fill(1.0);
}
let max0 = x.iter().fold(0.0_f64, |acc, &y| acc.max(y.abs()));
if max0 > 0.0 {
for slot in &mut x {
*slot /= max0;
}
}
let mut row_norm: f64 = 0.0;
for v in 0..n_us {
let mut s = 0.0_f64;
for &(e, _) in &walk[v] {
let w = weights.map_or(1.0, |ws| ws[e as usize]);
s += w.abs();
}
if s > row_norm {
row_norm = s;
}
}
let shift = row_norm.max(1.0) + 1.0;
let mut x_new = vec![0.0_f64; n_us];
for _ in 0..SHIFTED_MAX_ITER {
for v in 0..n_us {
let mut sum = shift * x[v];
for &(e, nei) in &walk[v] {
let w = weights.map_or(1.0, |ws| ws[e as usize]);
sum += w * x[nei as usize];
}
x_new[v] = sum;
}
let pivot = if negative_weights {
let mut best = 0.0_f64;
for &v in &x_new {
if v.abs() > best.abs() {
best = v;
}
}
best
} else {
x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
};
if pivot != 0.0 {
for slot in &mut x_new {
*slot /= pivot;
}
}
let mut diff = 0.0_f64;
for v in 0..n_us {
diff += (x_new[v] - x[v]).abs();
}
std::mem::swap(&mut x, &mut x_new);
if diff < SHIFTED_EPS {
break;
}
}
let mut mx = vec![0.0_f64; n_us];
for v in 0..n_us {
let mut sum = 0.0_f64;
for &(e, nei) in &walk[v] {
let w = weights.map_or(1.0, |ws| ws[e as usize]);
sum += w * x[nei as usize];
}
mx[v] = sum;
}
let num: f64 = x.iter().zip(mx.iter()).map(|(a, b)| a * b).sum();
let den: f64 = x.iter().map(|a| a * a).sum();
let mut eigenvalue = if den > 0.0 { num / den } else { 0.0 };
if !negative_weights && eigenvalue <= SHIFTED_EPS {
eigenvalue = 0.0;
x.fill(0.0);
} else {
let max = x.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
if max > 0.0 {
for slot in &mut x {
*slot /= max;
}
}
if !negative_weights {
for slot in &mut x {
if *slot < 0.0 {
*slot = 0.0;
}
}
}
}
Ok(EigenvectorScores {
vector: x,
eigenvalue,
})
}
#[allow(clippy::many_single_char_names)]
fn dag_sentinel(
graph: &Graph,
mode: EigenvectorMode,
weights: Option<&[f64]>,
) -> IgraphResult<EigenvectorScores> {
let n = graph.vcount();
let n_us = n as usize;
let m = graph.ecount();
let mut strength = vec![0.0_f64; n_us];
let m_u32 = u32::try_from(m)
.map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
for e in 0..m_u32 {
let (u, v) = graph.edge(e)?;
let w = weights.map_or(1.0, |ws| ws[e as usize]);
match mode {
EigenvectorMode::Out => strength[u as usize] += w,
EigenvectorMode::In => strength[v as usize] += w,
EigenvectorMode::All => unreachable!(),
}
}
let vector: Vec<f64> = strength
.iter()
.map(|&s| if s == 0.0 { 1.0 } else { 0.0 })
.collect();
Ok(EigenvectorScores {
vector,
eigenvalue: 0.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: &[f64], expected: &[f64], tol: f64) {
assert_eq!(actual.len(), expected.len(), "length mismatch");
for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
}
}
#[test]
fn empty_graph_yields_empty() {
let g = Graph::with_vertices(0);
assert!(eigenvector_centrality(&g).unwrap().is_empty());
}
#[test]
fn singleton_yields_one() {
let g = Graph::with_vertices(1);
assert_eq!(eigenvector_centrality(&g).unwrap(), vec![1.0]);
}
#[test]
fn isolated_vertices_yield_uniform_one() {
let g = Graph::with_vertices(3);
let ec = eigenvector_centrality(&g).unwrap();
close(&ec, &[1.0, 1.0, 1.0], 1e-9);
}
#[test]
fn triangle_uniform_one() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
g.add_edge(2, 0).unwrap();
let ec = eigenvector_centrality(&g).unwrap();
close(&ec, &[1.0, 1.0, 1.0], 1e-9);
}
#[test]
fn star_4_centre_one_leaves_inverse_sqrt_three() {
let mut g = Graph::with_vertices(4);
for v in 1..4 {
g.add_edge(0, v).unwrap();
}
let ec = eigenvector_centrality(&g).unwrap();
let inv_sqrt3 = 1.0 / 3f64.sqrt();
close(&ec, &[1.0, inv_sqrt3, inv_sqrt3, inv_sqrt3], 1e-9);
}
#[test]
fn k4_uniform_one() {
let mut g = Graph::with_vertices(4);
for u in 0..4u32 {
for v in (u + 1)..4 {
g.add_edge(u, v).unwrap();
}
}
let ec = eigenvector_centrality(&g).unwrap();
close(&ec, &[1.0, 1.0, 1.0, 1.0], 1e-9);
}
#[test]
fn directed_graph_returns_unsupported() {
let mut g = Graph::new(3, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(eigenvector_centrality(&g).is_err());
}
#[test]
fn weighted_star_unit_matches_unweighted() {
let mut g = Graph::with_vertices(5);
for v in 1..5 {
g.add_edge(0, v).unwrap();
}
let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
close(&s.vector, &[1.0, 0.5, 0.5, 0.5, 0.5], 1e-9);
assert!((s.eigenvalue - 2.0).abs() < 1e-6);
}
#[test]
fn weighted_k5_unit_eigenvalue_four() {
let mut g = Graph::with_vertices(5);
for u in 0..5u32 {
for v in (u + 1)..5 {
g.add_edge(u, v).unwrap();
}
}
let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
close(&s.vector, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-9);
assert!((s.eigenvalue - 4.0).abs() < 1e-6);
}
#[test]
fn weighted_empty_returns_ones() {
let g = Graph::with_vertices(4);
let s = eigenvector_centrality_weighted(&g, &[]).unwrap();
close(&s.vector, &[1.0; 4], 1e-15);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn weighted_all_zero_returns_ones() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
let s = eigenvector_centrality_weighted(&g, &[0.0, 0.0]).unwrap();
close(&s.vector, &[1.0; 3], 1e-15);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn weighted_length_mismatch_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
assert!(eigenvector_centrality_weighted(&g, &[1.0]).is_err());
}
#[test]
fn directed_full_eigenvalue_n_minus_one() {
let mut g = Graph::new(5, true).unwrap();
for u in 0..5u32 {
for v in 0..5u32 {
if u != v {
g.add_edge(u, v).unwrap();
}
}
}
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
close(&s.vector, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-9);
assert!((s.eigenvalue - 4.0).abs() < 1e-6);
}
#[test]
fn directed_dag_out_star_returns_leaves_one() {
let mut g = Graph::new(5, true).unwrap();
for v in 1..5u32 {
g.add_edge(0, v).unwrap();
}
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
close(&s.vector, &[0.0, 1.0, 1.0, 1.0, 1.0], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn directed_dag_in_star_in_mode_marks_leaves() {
let mut g = Graph::new(5, true).unwrap();
for v in 1..5u32 {
g.add_edge(v, 0).unwrap();
}
let s = eigenvector_centrality_directed(&g, EigenvectorMode::In).unwrap();
close(&s.vector, &[0.0, 1.0, 1.0, 1.0, 1.0], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn directed_dag_in_star_out_mode_marks_centre() {
let mut g = Graph::new(5, true).unwrap();
for v in 1..5u32 {
g.add_edge(v, 0).unwrap();
}
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
close(&s.vector, &[1.0, 0.0, 0.0, 0.0, 0.0], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn directed_small_cycle_chord_eigenvalue_matches_arpack() {
let mut g = Graph::new(4, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)])
.unwrap();
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
assert!(
(s.eigenvalue - 1.220_744).abs() < 1e-3,
"expected ≈ 1.22074, got {}",
s.eigenvalue
);
let max_idx = s
.vector
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap()
.0;
assert_eq!(max_idx, 3);
}
#[test]
fn directed_empty_directed_returns_ones() {
let g = Graph::new(5, true).unwrap();
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
close(&s.vector, &[1.0; 5], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn full_undirected_with_all_mode_works() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
g.add_edge(2, 0).unwrap();
let s = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
close(&s.vector, &[1.0, 1.0, 1.0], 1e-9);
}
#[test]
fn full_undirected_forces_all_mode() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
g.add_edge(2, 0).unwrap();
let a = eigenvector_centrality_full(&g, EigenvectorMode::Out, None).unwrap();
let b = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
close(&a.vector, &b.vector, 1e-12);
assert!((a.eigenvalue - b.eigenvalue).abs() < 1e-12);
}
#[test]
fn directed_cycle_50_converges_to_unit_eigenvalue() {
let n: u32 = 50;
let mut g = Graph::new(n, true).unwrap();
for i in 0..n {
g.add_edge(i, (i + 1) % n).unwrap();
}
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
assert!(
(s.eigenvalue - 1.0).abs() < 1e-9,
"expected λ ≈ 1.0, got {}",
s.eigenvalue
);
let mx = s.vector.iter().copied().fold(0.0_f64, f64::max);
let mn = s.vector.iter().copied().fold(f64::INFINITY, f64::min);
assert!(mx - mn < 1e-9, "vec spread {} too large", mx - mn);
}
#[test]
fn directed_k10_eigenvalue_n_minus_one() {
let n: u32 = 10;
let mut g = Graph::new(n, true).unwrap();
for u in 0..n {
for v in 0..n {
if u != v {
g.add_edge(u, v).unwrap();
}
}
}
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
assert!(
(s.eigenvalue - 9.0).abs() < 1e-9,
"expected λ = 9, got {}",
s.eigenvalue
);
close(&s.vector, &[1.0; 10], 1e-9);
}
#[test]
fn directed_cycle_chord_eigenvector_components_match_arpack() {
let mut g = Graph::new(4, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)])
.unwrap();
let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
assert!((s.eigenvalue - 1.220_744).abs() < 1e-4);
close(
&s.vector,
&[0.819_173, 0.671_044, 0.549_700, 1.000_000],
1e-3,
);
}
}