use crate::algorithms::properties::eigenvector::eigenvector_centrality;
use crate::core::{Graph, IgraphError, IgraphResult};
const DEFAULT_EPS: f64 = 1e-12;
const DEFAULT_MAX_ITER: usize = 5000;
#[derive(Debug, Clone, PartialEq)]
pub struct HitsScores {
pub hub: Vec<f64>,
pub authority: Vec<f64>,
pub eigenvalue: f64,
}
pub fn hub_and_authority_scores(graph: &Graph) -> IgraphResult<HitsScores> {
let n = graph.vcount();
let n_us = n as usize;
if n == 0 {
return Ok(HitsScores {
hub: Vec::new(),
authority: Vec::new(),
eigenvalue: 0.0,
});
}
if !graph.is_directed() {
let ec = eigenvector_centrality(graph)?;
let lambda = dominant_eigenvalue_undirected(graph, &ec);
return Ok(HitsScores {
hub: ec.clone(),
authority: ec,
eigenvalue: lambda * lambda,
});
}
if graph.ecount() == 0 {
return Ok(HitsScores {
hub: vec![1.0_f64; n_us],
authority: vec![1.0_f64; n_us],
eigenvalue: 0.0,
});
}
let mut out_adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
let mut in_adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
for v in 0..n {
out_adj.push(graph.out_neighbors_vec(v)?);
in_adj.push(graph.in_neighbors_vec(v)?);
}
#[allow(clippy::cast_precision_loss)]
let mut h: Vec<f64> = out_adj.iter().map(|nei| nei.len() as f64).collect();
rescale_max_abs(&mut h);
let mut tmp = vec![0.0_f64; n_us];
let mut h_new = vec![0.0_f64; n_us];
let mut eigenvalue = 0.0_f64;
for _ in 0..DEFAULT_MAX_ITER {
for v in 0..n_us {
let mut s = 0.0_f64;
for &u in &in_adj[v] {
s += h[u as usize];
}
tmp[v] = s;
}
for u in 0..n_us {
let mut s = 0.0_f64;
for &v in &out_adj[u] {
s += tmp[v as usize];
}
h_new[u] = s;
}
let max = h_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
if max > 0.0 {
eigenvalue = max;
for slot in &mut h_new {
*slot /= max;
}
}
let mut diff = 0.0_f64;
for v in 0..n_us {
diff += (h_new[v] - h[v]).abs();
}
std::mem::swap(&mut h, &mut h_new);
if diff < DEFAULT_EPS {
break;
}
}
for slot in &mut h {
if *slot < 0.0 {
*slot = 0.0;
}
}
let mut authority = vec![0.0_f64; n_us];
for v in 0..n_us {
let mut s = 0.0_f64;
for &u in &in_adj[v] {
s += h[u as usize];
}
authority[v] = s;
}
rescale_max_abs(&mut authority);
for slot in &mut authority {
if *slot < 0.0 {
*slot = 0.0;
}
}
Ok(HitsScores {
hub: h,
authority,
eigenvalue,
})
}
#[allow(clippy::too_many_lines)] pub fn hub_and_authority_scores_weighted(
graph: &Graph,
weights: &[f64],
) -> IgraphResult<HitsScores> {
let n = graph.vcount();
let n_us = n as usize;
let m = graph.ecount();
if weights.len() != m {
return Err(IgraphError::InvalidArgument(format!(
"weights length {} does not match edge count {}",
weights.len(),
m
)));
}
if n == 0 {
return Ok(HitsScores {
hub: Vec::new(),
authority: Vec::new(),
eigenvalue: 0.0,
});
}
if m == 0 {
return Ok(HitsScores {
hub: vec![1.0_f64; n_us],
authority: vec![1.0_f64; n_us],
eigenvalue: 0.0,
});
}
let (min_w, max_w) = weights
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &w| {
(lo.min(w), hi.max(w))
});
let negative_weights = min_w < 0.0;
if min_w == 0.0 && max_w == 0.0 {
return Ok(HitsScores {
hub: vec![1.0_f64; n_us],
authority: vec![1.0_f64; n_us],
eigenvalue: 0.0,
});
}
if !graph.is_directed() {
let ec = weighted_undirected_eigenvector(graph, weights, negative_weights)?;
let lambda = weighted_rayleigh_undirected(graph, weights, &ec);
return Ok(HitsScores {
hub: ec.clone(),
authority: ec,
eigenvalue: lambda * lambda,
});
}
let mut out_inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
let mut in_inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
for v in 0..n {
out_inc.push(graph.incident(v)?);
in_inc.push(graph.incident_in(v)?);
}
let mut h: Vec<f64> = (0..n_us)
.map(|u| {
let strength: f64 = out_inc[u].iter().map(|&e| weights[e as usize]).sum();
if strength != 0.0 {
strength
} else if negative_weights && !out_inc[u].is_empty() {
1.0
} else {
0.0
}
})
.collect();
rescale_max_abs(&mut h);
let mut tmp = vec![0.0_f64; n_us];
let mut h_new = vec![0.0_f64; n_us];
let mut eigenvalue = 0.0_f64;
for _ in 0..DEFAULT_MAX_ITER {
for v in 0..n {
let v_us = v as usize;
let mut s = 0.0_f64;
for &e in &in_inc[v_us] {
let other = graph.edge_other(e, v)?;
s += weights[e as usize] * h[other as usize];
}
tmp[v_us] = s;
}
for u in 0..n {
let u_us = u as usize;
let mut s = 0.0_f64;
for &e in &out_inc[u_us] {
let other = graph.edge_other(e, u)?;
s += weights[e as usize] * tmp[other as usize];
}
h_new[u_us] = s;
}
let scale = if negative_weights {
let (mut best_mag, mut signed) = (0.0_f64, 0.0_f64);
for &v in &h_new {
let mag = v.abs();
if mag > best_mag {
best_mag = mag;
signed = v;
}
}
signed
} else {
h_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
};
if scale != 0.0 {
eigenvalue = scale;
for slot in &mut h_new {
*slot /= scale;
}
}
let mut diff = 0.0_f64;
for v in 0..n_us {
diff += (h_new[v] - h[v]).abs();
}
std::mem::swap(&mut h, &mut h_new);
if diff < DEFAULT_EPS {
break;
}
}
if !negative_weights {
for slot in &mut h {
if *slot < 0.0 {
*slot = 0.0;
}
}
}
let mut authority = vec![0.0_f64; n_us];
for v in 0..n {
let v_us = v as usize;
let mut s = 0.0_f64;
for &e in &in_inc[v_us] {
let other = graph.edge_other(e, v)?;
s += weights[e as usize] * h[other as usize];
}
authority[v_us] = s;
}
rescale_max_abs(&mut authority);
if !negative_weights {
for slot in &mut authority {
if *slot < 0.0 {
*slot = 0.0;
}
}
}
Ok(HitsScores {
hub: h,
authority,
eigenvalue,
})
}
fn weighted_undirected_eigenvector(
graph: &Graph,
weights: &[f64],
negative_weights: bool,
) -> IgraphResult<Vec<f64>> {
let n = graph.vcount();
let n_us = n as usize;
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<f64> = (0..n_us)
.map(|v| {
let strength: f64 = inc[v].iter().map(|&e| weights[e as usize]).sum();
if strength != 0.0 {
strength
} else if negative_weights && !inc[v].is_empty() {
1.0
} else {
0.0
}
})
.collect();
if x.iter().all(|&v| v == 0.0) {
x.fill(1.0);
}
rescale_max_abs(&mut x);
let mut x_new = vec![0.0_f64; n_us];
for _ in 0..DEFAULT_MAX_ITER {
for v in 0..n {
let v_us = v as usize;
let mut s = x[v_us];
for &e in &inc[v_us] {
let other = graph.edge_other(e, v)?;
s += weights[e as usize] * x[other as usize];
}
x_new[v_us] = s;
}
let scale = if negative_weights {
let (mut best_mag, mut signed) = (0.0_f64, 0.0_f64);
for &v in &x_new {
let mag = v.abs();
if mag > best_mag {
best_mag = mag;
signed = v;
}
}
signed
} else {
x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
};
if scale != 0.0 {
for slot in &mut x_new {
*slot /= scale;
}
}
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;
}
}
if !negative_weights {
for slot in &mut x {
if *slot < 0.0 {
*slot = 0.0;
}
}
}
Ok(x)
}
fn weighted_rayleigh_undirected(graph: &Graph, weights: &[f64], v: &[f64]) -> f64 {
let n = graph.vcount();
if n == 0 {
return 0.0;
}
let mut numer = 0.0_f64;
let mut denom = 0.0_f64;
for u in 0..n {
let vu = v[u as usize];
denom += vu * vu;
if let Ok(inc) = graph.incident(u) {
let mut acc = 0.0_f64;
for &e in &inc {
if let Ok(other) = graph.edge_other(e, u) {
acc += weights[e as usize] * v[other as usize];
}
}
numer += vu * acc;
}
}
if denom > 0.0 { numer / denom } else { 0.0 }
}
fn dominant_eigenvalue_undirected(graph: &Graph, v: &[f64]) -> f64 {
let n = graph.vcount();
if n == 0 {
return 0.0;
}
let mut numer = 0.0_f64;
let mut denom = 0.0_f64;
for u in 0..n {
let vu = v[u as usize];
denom += vu * vu;
if let Ok(nei) = graph.neighbors(u) {
let mut acc = 0.0_f64;
for &w in &nei {
acc += v[w as usize];
}
numer += vu * acc;
}
}
if denom > 0.0 { numer / denom } else { 0.0 }
}
fn rescale_max_abs(v: &mut [f64]) {
let max = v.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
if max > 0.0 {
for slot in v {
*slot /= max;
}
}
}
#[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, "index {i}: actual={a} expected={e}");
}
}
#[test]
fn empty_graph() {
let g = Graph::new(0, true).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
assert!(s.hub.is_empty());
assert!(s.authority.is_empty());
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn directed_no_edges_fills_ones() {
let g = Graph::new(3, true).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
close(&s.hub, &[1.0, 1.0, 1.0], 1e-12);
close(&s.authority, &[1.0, 1.0, 1.0], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn single_directed_edge() {
let mut g = Graph::new(2, true).unwrap();
g.add_edge(0, 1).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
close(&s.hub, &[1.0, 0.0], 1e-9);
close(&s.authority, &[0.0, 1.0], 1e-9);
assert!((s.eigenvalue - 1.0).abs() < 1e-6);
}
#[test]
fn two_to_two_bipartite_hub_auth() {
let mut g = Graph::new(4, true).unwrap();
g.add_edges(vec![(0u32, 2u32), (0, 3), (1, 2), (1, 3)])
.unwrap();
let s = hub_and_authority_scores(&g).unwrap();
close(&s.hub, &[1.0, 1.0, 0.0, 0.0], 1e-9);
close(&s.authority, &[0.0, 0.0, 1.0, 1.0], 1e-9);
assert!((s.eigenvalue - 4.0).abs() < 1e-6);
}
#[test]
fn directed_triangle_uniform_one() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
close(&s.hub, &[1.0, 1.0, 1.0], 1e-9);
close(&s.authority, &[1.0, 1.0, 1.0], 1e-9);
assert!((s.eigenvalue - 1.0).abs() < 1e-6);
}
#[test]
fn undirected_delegates_to_eigenvector() {
let mut g = Graph::with_vertices(3);
g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
close(&s.hub, &[1.0, 1.0, 1.0], 1e-9);
close(&s.authority, &s.hub, 1e-15);
}
#[test]
fn undirected_star_hub_equals_eigenvector() {
let mut g = Graph::with_vertices(4);
for v in 1..4 {
g.add_edge(0, v).unwrap();
}
let s = hub_and_authority_scores(&g).unwrap();
let inv_sqrt3 = 1.0 / 3f64.sqrt();
close(&s.hub, &[1.0, inv_sqrt3, inv_sqrt3, inv_sqrt3], 1e-9);
close(&s.authority, &s.hub, 1e-15);
}
#[test]
fn sink_has_zero_hub() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 2u32), (1, 2)]).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
assert!(s.hub[2].abs() < 1e-9);
assert!((s.authority[2] - 1.0).abs() < 1e-9);
assert!(s.authority[0].abs() < 1e-9);
assert!(s.authority[1].abs() < 1e-9);
}
#[test]
fn source_has_zero_authority() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (0, 2)]).unwrap();
let s = hub_and_authority_scores(&g).unwrap();
assert!(s.authority[0].abs() < 1e-9);
assert!((s.hub[0] - 1.0).abs() < 1e-9);
}
#[test]
fn formula_h_eq_a_a_authority() {
let mut g = Graph::new(5, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (1, 4)])
.unwrap();
let s = hub_and_authority_scores(&g).unwrap();
let n = g.vcount();
let mut a_a = vec![0.0_f64; n as usize];
for u in 0..n {
let mut acc = 0.0_f64;
for &v in &g.out_neighbors_vec(u).unwrap() {
acc += s.authority[v as usize];
}
a_a[u as usize] = acc;
}
let max = a_a.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
if max > 0.0 {
for slot in &mut a_a {
*slot /= max;
}
}
for (u, &val) in a_a.iter().enumerate() {
assert!(
(val - s.hub[u]).abs() < 1e-6,
"vertex {u}: A·a={val} hub={}",
s.hub[u]
);
}
}
#[test]
fn weighted_length_mismatch_errors() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (1, 2)]).unwrap();
let result = hub_and_authority_scores_weighted(&g, &[1.0]);
assert!(matches!(result, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn weighted_empty_graph() {
let g = Graph::new(0, true).unwrap();
let s = hub_and_authority_scores_weighted(&g, &[]).unwrap();
assert!(s.hub.is_empty());
assert!(s.authority.is_empty());
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn weighted_no_edges_fills_ones() {
let g = Graph::new(3, true).unwrap();
let s = hub_and_authority_scores_weighted(&g, &[]).unwrap();
close(&s.hub, &[1.0, 1.0, 1.0], 1e-12);
close(&s.authority, &[1.0, 1.0, 1.0], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn weighted_all_zero_weights_fills_ones() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (1, 2)]).unwrap();
let s = hub_and_authority_scores_weighted(&g, &[0.0, 0.0]).unwrap();
close(&s.hub, &[1.0, 1.0, 1.0], 1e-12);
close(&s.authority, &[1.0, 1.0, 1.0], 1e-12);
assert!(s.eigenvalue.abs() < f64::EPSILON);
}
#[test]
fn weighted_uniform_matches_unweighted() {
let mut g = Graph::new(5, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (1, 4)])
.unwrap();
let unweighted = hub_and_authority_scores(&g).unwrap();
let weighted = hub_and_authority_scores_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
close(&weighted.hub, &unweighted.hub, 1e-6);
close(&weighted.authority, &unweighted.authority, 1e-6);
assert!((weighted.eigenvalue - unweighted.eigenvalue).abs() < 1e-6);
}
#[test]
fn weighted_bipartite_authority_tilt() {
let mut g = Graph::new(4, true).unwrap();
g.add_edges(vec![(0u32, 2u32), (0, 3), (1, 2), (1, 3)])
.unwrap();
let s = hub_and_authority_scores_weighted(&g, &[1.0, 4.0, 2.0, 3.0]).unwrap();
assert!(s.hub[2].abs() < 1e-9);
assert!(s.hub[3].abs() < 1e-9);
assert!(s.authority[0].abs() < 1e-9);
assert!(s.authority[1].abs() < 1e-9);
assert!(s.authority[3] > s.authority[2]);
}
#[test]
#[allow(clippy::many_single_char_names, clippy::cast_possible_truncation)]
fn weighted_cross_relation_invariant() {
let mut g = Graph::new(5, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (4, 0)])
.unwrap();
let weights = vec![1.5, 0.5, 2.0, 1.0, 0.75, 1.25];
let s = hub_and_authority_scores_weighted(&g, &weights).unwrap();
let n = g.vcount() as usize;
let mut w_auth = vec![0.0_f64; n];
for (e, &w) in weights.iter().enumerate() {
let (u, v) = g.edge(e as u32).unwrap();
w_auth[u as usize] += w * s.authority[v as usize];
}
let max = w_auth.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
if max > 0.0 {
for slot in &mut w_auth {
*slot /= max;
}
}
for (u, &val) in w_auth.iter().enumerate() {
assert!(
(val - s.hub[u]).abs() < 1e-6,
"vertex {u}: W·a={val} hub={}",
s.hub[u]
);
}
}
#[test]
fn weighted_undirected_matches_unweighted_under_unit_weights() {
let mut g = Graph::with_vertices(4);
g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (0, 3)])
.unwrap();
let unweighted = hub_and_authority_scores(&g).unwrap();
let weighted = hub_and_authority_scores_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
close(&weighted.hub, &unweighted.hub, 1e-6);
close(&weighted.authority, &unweighted.authority, 1e-6);
assert!((weighted.eigenvalue - unweighted.eigenvalue).abs() < 1e-6);
}
#[test]
fn weighted_negative_weights_no_zero_clip() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
let s = hub_and_authority_scores_weighted(&g, &[1.0, -1.0, 1.0]).unwrap();
assert!(s.hub.iter().all(|x| x.is_finite()));
assert!(s.authority.iter().all(|x| x.is_finite()));
let max_hub = s.hub.iter().fold(0.0_f64, |a, &x| a.max(x.abs()));
assert!((max_hub - 1.0).abs() < 1e-6);
}
#[test]
fn weighted_sink_has_zero_hub() {
let mut g = Graph::new(3, true).unwrap();
g.add_edges(vec![(0u32, 2u32), (1, 2)]).unwrap();
let s = hub_and_authority_scores_weighted(&g, &[2.0, 3.0]).unwrap();
assert!(s.hub[2].abs() < 1e-9);
assert!((s.authority[2] - 1.0).abs() < 1e-9);
}
}