Skip to main content

rust_igraph/algorithms/properties/
hits.rs

1//! Kleinberg's hub and authority scores — HITS (ALGO-PR-017 / PR-017b).
2//!
3//! Counterpart of `igraph_hub_and_authority_scores()` from
4//! `references/igraph/src/centrality/hub_authority.c`. The hub score
5//! `h[v]` is the `v`-th component of the principal eigenvector of
6//! `A·Aᵀ`; the authority score `a[v]` is the principal eigenvector of
7//! `Aᵀ·A`. The two are tied by `a = Aᵀ·h` and `h = A·a` once both have
8//! converged.
9//!
10//! ## Unweighted ([`hub_and_authority_scores`])
11//! - **Directed**: power iteration on `A·Aᵀ` (symmetric PSD ⇒ no
12//!   bipartite ±λ shift trick needed; non-negative initial vector
13//!   preserves Perron-Frobenius non-negativity).
14//! - **Undirected**: delegate to [`eigenvector_centrality`], matching
15//!   upstream's documented behaviour ("In undirected graphs, both the
16//!   hub and authority scores are equal to the eigenvector
17//!   centrality"). The reported eigenvalue is `λ²` because if
18//!   `A·v = λ·v` then `A²·v = λ²·v`.
19//! - **Empty edges**: vectors filled with `1.0`, eigenvalue `0`,
20//!   matching upstream.
21//!
22//! ## Weighted ([`hub_and_authority_scores_weighted`])
23//! - **Directed**: same power iteration, with weighted matrix
24//!   `W[i,j] = Σ_{e: i→j} w_e`. Two-step `tmp = Wᵀ·h`, `h_new = W·tmp`,
25//!   walking edge-incidence lists rather than vertex-neighbour lists.
26//! - **Undirected**: shifted power iteration on `(W + I)` where
27//!   `W` is the symmetric weighted adjacency. Self-rolled (avoids
28//!   waiting on PR-012b's weighted eigenvector centrality); reported
29//!   eigenvalue is squared per upstream convention.
30//! - **Negative weights**: allowed but a warning is appropriate
31//!   (Perron-Frobenius doesn't apply, sign of entries is no longer
32//!   guaranteed). We don't zero-clip negatives in this case.
33//! - **Length / all-zero / empty-edge** validation mirrors upstream.
34//!
35//! ARPACK backend ships later (PR-017c) once the LA-IRLM scaffolding
36//! lands, paralleling PR-011c.
37
38use crate::algorithms::properties::eigenvector::eigenvector_centrality;
39use crate::core::{Graph, IgraphError, IgraphResult};
40
41const DEFAULT_EPS: f64 = 1e-12;
42const DEFAULT_MAX_ITER: usize = 5000;
43
44/// Output of [`hub_and_authority_scores`]: scaled hub and authority
45/// vectors and the dominant eigenvalue of `A·Aᵀ`.
46///
47/// Both vectors are normalised so that their max-absolute element is
48/// exactly `1.0`, matching python-igraph's reporting convention.
49#[derive(Debug, Clone, PartialEq)]
50pub struct HitsScores {
51    /// Hub score per vertex, length `vcount()`. Max-absolute element is `1.0`.
52    pub hub: Vec<f64>,
53    /// Authority score per vertex, length `vcount()`. Max-absolute element is `1.0`.
54    pub authority: Vec<f64>,
55    /// Dominant eigenvalue of `A·Aᵀ` (= square of dominant `A`-eigenvalue
56    /// for the undirected delegation path). Returned as `0.0` for the
57    /// empty-edge case.
58    pub eigenvalue: f64,
59}
60
61/// Compute Kleinberg's hub and authority scores.
62///
63/// Returns `Ok(HitsScores)` containing both vectors and the dominant
64/// eigenvalue of `A·Aᵀ`. The empty graph yields empty vectors and a
65/// `0.0` eigenvalue.
66///
67/// On undirected graphs the routine delegates to
68/// [`eigenvector_centrality`], so `hub == authority` exactly and the
69/// reported `eigenvalue` is `λ²` (the square of the dominant
70/// adjacency-matrix eigenvalue).
71///
72/// Counterpart of `igraph_hub_and_authority_scores(g, h, a, &val,
73/// /*weights=*/NULL, /*options=*/NULL)` from `references/igraph/src/centrality/hub_authority.c`.
74///
75/// # Examples
76///
77/// Pure hub / authority partition on a bipartite directed graph:
78///
79/// ```
80/// use rust_igraph::{Graph, hub_and_authority_scores};
81///
82/// // 0,1 → 2,3 — vertices 0,1 are pure hubs, 2,3 pure authorities.
83/// let mut g = Graph::new(4, true).unwrap();
84/// g.add_edges(vec![(0u32, 2u32), (0, 3), (1, 2), (1, 3)]).unwrap();
85/// let s = hub_and_authority_scores(&g).unwrap();
86/// assert!((s.hub[0] - 1.0).abs() < 1e-9);
87/// assert!((s.hub[1] - 1.0).abs() < 1e-9);
88/// assert!(s.hub[2].abs() < 1e-9);
89/// assert!(s.hub[3].abs() < 1e-9);
90/// assert!((s.authority[2] - 1.0).abs() < 1e-9);
91/// assert!((s.authority[3] - 1.0).abs() < 1e-9);
92/// // Largest eigenvalue of A·Aᵀ for this 2x2 hub-authority block is 4.
93/// assert!((s.eigenvalue - 4.0).abs() < 1e-6);
94/// ```
95///
96/// Cross-relation invariant `h ∝ A·a` after convergence:
97///
98/// ```
99/// use rust_igraph::{Graph, hub_and_authority_scores};
100///
101/// let mut g = Graph::new(5, true).unwrap();
102/// g.add_edges(vec![(0u32, 1u32), (0, 3), (1, 2), (1, 3), (2, 0), (2, 4), (3, 2), (4, 0), (4, 1)])
103///     .unwrap();
104/// let s = hub_and_authority_scores(&g).unwrap();
105///
106/// // Compute A·authority by walking the edge list, then verify it lines
107/// // up with hub after max-norming.
108/// let n = g.vcount() as usize;
109/// let mut a_auth = vec![0.0_f64; n];
110/// for e in 0..g.ecount() {
111///     let (u, v) = g.edge(e as u32).unwrap();
112///     a_auth[u as usize] += s.authority[v as usize];
113/// }
114/// let max = a_auth.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
115/// for x in &mut a_auth {
116///     *x /= max;
117/// }
118/// for u in 0..n {
119///     assert!((a_auth[u] - s.hub[u]).abs() < 1e-6);
120/// }
121/// ```
122pub fn hub_and_authority_scores(graph: &Graph) -> IgraphResult<HitsScores> {
123    let n = graph.vcount();
124    let n_us = n as usize;
125    if n == 0 {
126        return Ok(HitsScores {
127            hub: Vec::new(),
128            authority: Vec::new(),
129            eigenvalue: 0.0,
130        });
131    }
132
133    // Undirected → eigenvector centrality (mode = ALL); hub = auth.
134    if !graph.is_directed() {
135        let ec = eigenvector_centrality(graph)?;
136        let lambda = dominant_eigenvalue_undirected(graph, &ec);
137        return Ok(HitsScores {
138            hub: ec.clone(),
139            authority: ec,
140            eigenvalue: lambda * lambda,
141        });
142    }
143
144    // Empty-edge directed graph → fill with 1.0, eigenvalue 0.
145    if graph.ecount() == 0 {
146        return Ok(HitsScores {
147            hub: vec![1.0_f64; n_us],
148            authority: vec![1.0_f64; n_us],
149            eigenvalue: 0.0,
150        });
151    }
152
153    // Pre-cache out- and in-neighbour lists; both are O(V + E).
154    let mut out_adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
155    let mut in_adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
156    for v in 0..n {
157        out_adj.push(graph.out_neighbors_vec(v)?);
158        in_adj.push(graph.in_neighbors_vec(v)?);
159    }
160
161    // Seed the hub vector with out-degrees, mirroring upstream — this
162    // is correlated with the dominant A·Aᵀ eigenvector and gives faster
163    // convergence than a uniform start. Vertices with zero out-degree
164    // (sinks) start at 0 and stay 0 through iteration: a sink can
165    // never be a hub.
166    #[allow(clippy::cast_precision_loss)]
167    let mut h: Vec<f64> = out_adj.iter().map(|nei| nei.len() as f64).collect();
168    // Normalise initial seed so the first iteration's eigenvalue
169    // estimate is meaningful.
170    rescale_max_abs(&mut h);
171    let mut tmp = vec![0.0_f64; n_us];
172    let mut h_new = vec![0.0_f64; n_us];
173
174    let mut eigenvalue = 0.0_f64;
175    for _ in 0..DEFAULT_MAX_ITER {
176        // tmp = Aᵀ h  →  tmp[v] = Σ_{u ∈ in(v)} h[u].
177        for v in 0..n_us {
178            let mut s = 0.0_f64;
179            for &u in &in_adj[v] {
180                s += h[u as usize];
181            }
182            tmp[v] = s;
183        }
184        // h_new = A tmp  →  h_new[u] = Σ_{v ∈ out(u)} tmp[v].
185        for u in 0..n_us {
186            let mut s = 0.0_f64;
187            for &v in &out_adj[u] {
188                s += tmp[v as usize];
189            }
190            h_new[u] = s;
191        }
192
193        // Rayleigh-style estimate: with `max|h| = 1`, the unnormalised
194        // `max|A·Aᵀ·h|` equals the dominant eigenvalue at convergence.
195        let max = h_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
196        if max > 0.0 {
197            eigenvalue = max;
198            for slot in &mut h_new {
199                *slot /= max;
200            }
201        }
202
203        let mut diff = 0.0_f64;
204        for v in 0..n_us {
205            diff += (h_new[v] - h[v]).abs();
206        }
207        std::mem::swap(&mut h, &mut h_new);
208        if diff < DEFAULT_EPS {
209            break;
210        }
211    }
212
213    // Eliminate -0.0 from numerical drift.
214    for slot in &mut h {
215        if *slot < 0.0 {
216            *slot = 0.0;
217        }
218    }
219
220    // authority = Aᵀ · h, then rescale.
221    let mut authority = vec![0.0_f64; n_us];
222    for v in 0..n_us {
223        let mut s = 0.0_f64;
224        for &u in &in_adj[v] {
225            s += h[u as usize];
226        }
227        authority[v] = s;
228    }
229    rescale_max_abs(&mut authority);
230    for slot in &mut authority {
231        if *slot < 0.0 {
232            *slot = 0.0;
233        }
234    }
235
236    Ok(HitsScores {
237        hub: h,
238        authority,
239        eigenvalue,
240    })
241}
242
243/// Weighted Kleinberg hub and authority scores.
244///
245/// `weights[e]` is the weight of edge id `e`; must have length
246/// `graph.ecount()` or this returns [`IgraphError::InvalidArgument`].
247/// The weighted adjacency matrix is `W[i,j] = Σ_{e: i→j} w_e`; the
248/// returned hub/authority vectors approximate the principal
249/// eigenvectors of `W·Wᵀ` and `Wᵀ·W` respectively.
250///
251/// Behaviour mirrors upstream `igraph_hub_and_authority_scores` when
252/// `weights` is non-NULL:
253/// - Length must match `ecount()`.
254/// - All-zero weights → both vectors filled with `1.0`, eigenvalue `0`.
255/// - Empty edges → same as the unweighted empty case.
256/// - Negative weights are *accepted*: the sign-cleanup pass that
257///   normally zeros tiny negative drifts is skipped, since
258///   Perron-Frobenius no longer guarantees a non-negative eigenvector.
259/// - Undirected graphs delegate to a self-rolled shifted power
260///   iteration on `(W + I)`, and the reported eigenvalue is `λ²`
261///   (square of the dominant adjacency eigenvalue).
262///
263/// Counterpart of `igraph_hub_and_authority_scores(g, h, a, &val,
264/// weights, /*options=*/NULL)` from
265/// `references/igraph/src/centrality/hub_authority.c`.
266///
267/// # Examples
268///
269/// Weighted 2×2 bipartite block where weights tilt the authority:
270///
271/// ```
272/// use rust_igraph::{Graph, hub_and_authority_scores_weighted};
273///
274/// // 0→2 (w=1), 0→3 (w=4), 1→2 (w=2), 1→3 (w=3).
275/// let mut g = Graph::new(4, true).unwrap();
276/// g.add_edges(vec![(0u32, 2u32), (0, 3), (1, 2), (1, 3)]).unwrap();
277/// let weights = vec![1.0, 4.0, 2.0, 3.0];
278/// let s = hub_and_authority_scores_weighted(&g, &weights).unwrap();
279/// // Both vertex 0 and 1 are hubs; vertex 2,3 are authorities.
280/// assert!(s.hub[2].abs() < 1e-9);
281/// assert!(s.hub[3].abs() < 1e-9);
282/// assert!(s.authority[0].abs() < 1e-9);
283/// assert!(s.authority[1].abs() < 1e-9);
284/// // Authority 3 has heavier in-weight than authority 2.
285/// assert!(s.authority[3] > s.authority[2]);
286/// ```
287#[allow(clippy::too_many_lines)] // Mirrors upstream's tightly-coupled two-step power iter.
288pub fn hub_and_authority_scores_weighted(
289    graph: &Graph,
290    weights: &[f64],
291) -> IgraphResult<HitsScores> {
292    let n = graph.vcount();
293    let n_us = n as usize;
294    let m = graph.ecount();
295
296    if weights.len() != m {
297        return Err(IgraphError::InvalidArgument(format!(
298            "weights length {} does not match edge count {}",
299            weights.len(),
300            m
301        )));
302    }
303
304    if n == 0 {
305        return Ok(HitsScores {
306            hub: Vec::new(),
307            authority: Vec::new(),
308            eigenvalue: 0.0,
309        });
310    }
311
312    if m == 0 {
313        return Ok(HitsScores {
314            hub: vec![1.0_f64; n_us],
315            authority: vec![1.0_f64; n_us],
316            eigenvalue: 0.0,
317        });
318    }
319
320    let (min_w, max_w) = weights
321        .iter()
322        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &w| {
323            (lo.min(w), hi.max(w))
324        });
325    let negative_weights = min_w < 0.0;
326    if min_w == 0.0 && max_w == 0.0 {
327        return Ok(HitsScores {
328            hub: vec![1.0_f64; n_us],
329            authority: vec![1.0_f64; n_us],
330            eigenvalue: 0.0,
331        });
332    }
333
334    if !graph.is_directed() {
335        let ec = weighted_undirected_eigenvector(graph, weights, negative_weights)?;
336        let lambda = weighted_rayleigh_undirected(graph, weights, &ec);
337        return Ok(HitsScores {
338            hub: ec.clone(),
339            authority: ec,
340            eigenvalue: lambda * lambda,
341        });
342    }
343
344    // Cache out- and in-incident edge ids; both are O(V + E).
345    let mut out_inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
346    let mut in_inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
347    for v in 0..n {
348        out_inc.push(graph.incident(v)?);
349        in_inc.push(graph.incident_in(v)?);
350    }
351
352    // Seed with out-strengths; sinks (zero out-strength, non-negative
353    // weights) stay 0. With negative weights, fall back to degree-based
354    // sentinel to avoid spurious zeros.
355    let mut h: Vec<f64> = (0..n_us)
356        .map(|u| {
357            let strength: f64 = out_inc[u].iter().map(|&e| weights[e as usize]).sum();
358            if strength != 0.0 {
359                strength
360            } else if negative_weights && !out_inc[u].is_empty() {
361                1.0
362            } else {
363                0.0
364            }
365        })
366        .collect();
367    rescale_max_abs(&mut h);
368
369    let mut tmp = vec![0.0_f64; n_us];
370    let mut h_new = vec![0.0_f64; n_us];
371
372    let mut eigenvalue = 0.0_f64;
373    for _ in 0..DEFAULT_MAX_ITER {
374        // tmp = Wᵀ h  →  tmp[v] = Σ_{e=(u→v)} w_e · h[u]
375        for v in 0..n {
376            let v_us = v as usize;
377            let mut s = 0.0_f64;
378            for &e in &in_inc[v_us] {
379                let other = graph.edge_other(e, v)?;
380                s += weights[e as usize] * h[other as usize];
381            }
382            tmp[v_us] = s;
383        }
384        // h_new = W tmp  →  h_new[u] = Σ_{e=(u→v)} w_e · tmp[v]
385        for u in 0..n {
386            let u_us = u as usize;
387            let mut s = 0.0_f64;
388            for &e in &out_inc[u_us] {
389                let other = graph.edge_other(e, u)?;
390                s += weights[e as usize] * tmp[other as usize];
391            }
392            h_new[u_us] = s;
393        }
394
395        // Dominant eigenvalue estimate: with max|h|=1, max|W·Wᵀ·h| ≈ λ
396        // at convergence. For negative weights we track the signed
397        // component of greatest magnitude so we converge on the
398        // *largest* (not largest-magnitude) eigenvalue, matching the
399        // C side's `which = 'LA'` setting.
400        let scale = if negative_weights {
401            let (mut best_mag, mut signed) = (0.0_f64, 0.0_f64);
402            for &v in &h_new {
403                let mag = v.abs();
404                if mag > best_mag {
405                    best_mag = mag;
406                    signed = v;
407                }
408            }
409            signed
410        } else {
411            h_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
412        };
413        if scale != 0.0 {
414            eigenvalue = scale;
415            for slot in &mut h_new {
416                *slot /= scale;
417            }
418        }
419
420        let mut diff = 0.0_f64;
421        for v in 0..n_us {
422            diff += (h_new[v] - h[v]).abs();
423        }
424        std::mem::swap(&mut h, &mut h_new);
425        if diff < DEFAULT_EPS {
426            break;
427        }
428    }
429
430    if !negative_weights {
431        for slot in &mut h {
432            if *slot < 0.0 {
433                *slot = 0.0;
434            }
435        }
436    }
437
438    // authority = Wᵀ · h, then rescale.
439    let mut authority = vec![0.0_f64; n_us];
440    for v in 0..n {
441        let v_us = v as usize;
442        let mut s = 0.0_f64;
443        for &e in &in_inc[v_us] {
444            let other = graph.edge_other(e, v)?;
445            s += weights[e as usize] * h[other as usize];
446        }
447        authority[v_us] = s;
448    }
449    rescale_max_abs(&mut authority);
450    if !negative_weights {
451        for slot in &mut authority {
452            if *slot < 0.0 {
453                *slot = 0.0;
454            }
455        }
456    }
457
458    Ok(HitsScores {
459        hub: h,
460        authority,
461        eigenvalue,
462    })
463}
464
465/// Shifted power iteration on `(W + I)` for the symmetric weighted
466/// adjacency `W`. Returns the max-norm-scaled principal eigenvector.
467/// Stand-in for the not-yet-landed weighted
468/// `eigenvector_centrality_weighted` (PR-012b); kept private so it can
469/// later be promoted.
470fn weighted_undirected_eigenvector(
471    graph: &Graph,
472    weights: &[f64],
473    negative_weights: bool,
474) -> IgraphResult<Vec<f64>> {
475    let n = graph.vcount();
476    let n_us = n as usize;
477
478    let mut inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
479    for v in 0..n {
480        inc.push(graph.incident(v)?);
481    }
482
483    // Seed with vertex strengths (sum of incident-edge weights) so the
484    // first iteration is already in the dominant subspace. Loops
485    // contribute once (incident lists count them once on undirected
486    // graphs in this codebase; mirror that).
487    let mut x: Vec<f64> = (0..n_us)
488        .map(|v| {
489            let strength: f64 = inc[v].iter().map(|&e| weights[e as usize]).sum();
490            if strength != 0.0 {
491                strength
492            } else if negative_weights && !inc[v].is_empty() {
493                1.0
494            } else {
495                0.0
496            }
497        })
498        .collect();
499    if x.iter().all(|&v| v == 0.0) {
500        x.fill(1.0);
501    }
502    rescale_max_abs(&mut x);
503
504    let mut x_new = vec![0.0_f64; n_us];
505
506    for _ in 0..DEFAULT_MAX_ITER {
507        // x_new = (W + I) x
508        for v in 0..n {
509            let v_us = v as usize;
510            let mut s = x[v_us];
511            for &e in &inc[v_us] {
512                let other = graph.edge_other(e, v)?;
513                s += weights[e as usize] * x[other as usize];
514            }
515            x_new[v_us] = s;
516        }
517
518        let scale = if negative_weights {
519            let (mut best_mag, mut signed) = (0.0_f64, 0.0_f64);
520            for &v in &x_new {
521                let mag = v.abs();
522                if mag > best_mag {
523                    best_mag = mag;
524                    signed = v;
525                }
526            }
527            signed
528        } else {
529            x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
530        };
531        if scale != 0.0 {
532            for slot in &mut x_new {
533                *slot /= scale;
534            }
535        }
536
537        let mut diff = 0.0_f64;
538        for v in 0..n_us {
539            diff += (x_new[v] - x[v]).abs();
540        }
541        std::mem::swap(&mut x, &mut x_new);
542        if diff < DEFAULT_EPS {
543            break;
544        }
545    }
546
547    if !negative_weights {
548        for slot in &mut x {
549            if *slot < 0.0 {
550                *slot = 0.0;
551            }
552        }
553    }
554    Ok(x)
555}
556
557/// Rayleigh quotient `vᵀ·W·v / vᵀ·v` on the symmetric weighted
558/// adjacency, used to recover the eigenvalue along the undirected
559/// fallback path.
560fn weighted_rayleigh_undirected(graph: &Graph, weights: &[f64], v: &[f64]) -> f64 {
561    let n = graph.vcount();
562    if n == 0 {
563        return 0.0;
564    }
565    let mut numer = 0.0_f64;
566    let mut denom = 0.0_f64;
567    for u in 0..n {
568        let vu = v[u as usize];
569        denom += vu * vu;
570        if let Ok(inc) = graph.incident(u) {
571            let mut acc = 0.0_f64;
572            for &e in &inc {
573                if let Ok(other) = graph.edge_other(e, u) {
574                    acc += weights[e as usize] * v[other as usize];
575                }
576            }
577            numer += vu * acc;
578        }
579    }
580    if denom > 0.0 { numer / denom } else { 0.0 }
581}
582
583/// Rayleigh quotient `vᵀ·A·v / vᵀ·v` on the underlying undirected
584/// adjacency, used to recover the eigenvalue along the undirected
585/// fallback path.
586fn dominant_eigenvalue_undirected(graph: &Graph, v: &[f64]) -> f64 {
587    let n = graph.vcount();
588    if n == 0 {
589        return 0.0;
590    }
591    let mut numer = 0.0_f64;
592    let mut denom = 0.0_f64;
593    for u in 0..n {
594        let vu = v[u as usize];
595        denom += vu * vu;
596        if let Ok(nei) = graph.neighbors(u) {
597            let mut acc = 0.0_f64;
598            for &w in &nei {
599                acc += v[w as usize];
600            }
601            numer += vu * acc;
602        }
603    }
604    if denom > 0.0 { numer / denom } else { 0.0 }
605}
606
607fn rescale_max_abs(v: &mut [f64]) {
608    let max = v.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
609    if max > 0.0 {
610        for slot in v {
611            *slot /= max;
612        }
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    fn close(actual: &[f64], expected: &[f64], tol: f64) {
621        assert_eq!(actual.len(), expected.len(), "length mismatch");
622        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
623            assert!((a - e).abs() < tol, "index {i}: actual={a} expected={e}");
624        }
625    }
626
627    #[test]
628    fn empty_graph() {
629        let g = Graph::new(0, true).unwrap();
630        let s = hub_and_authority_scores(&g).unwrap();
631        assert!(s.hub.is_empty());
632        assert!(s.authority.is_empty());
633        assert!(s.eigenvalue.abs() < f64::EPSILON);
634    }
635
636    #[test]
637    fn directed_no_edges_fills_ones() {
638        let g = Graph::new(3, true).unwrap();
639        let s = hub_and_authority_scores(&g).unwrap();
640        close(&s.hub, &[1.0, 1.0, 1.0], 1e-12);
641        close(&s.authority, &[1.0, 1.0, 1.0], 1e-12);
642        assert!(s.eigenvalue.abs() < f64::EPSILON);
643    }
644
645    #[test]
646    fn single_directed_edge() {
647        // 0 → 1: 0 is a pure hub (auth=0), 1 is a pure authority (hub=0).
648        let mut g = Graph::new(2, true).unwrap();
649        g.add_edge(0, 1).unwrap();
650        let s = hub_and_authority_scores(&g).unwrap();
651        close(&s.hub, &[1.0, 0.0], 1e-9);
652        close(&s.authority, &[0.0, 1.0], 1e-9);
653        assert!((s.eigenvalue - 1.0).abs() < 1e-6);
654    }
655
656    #[test]
657    fn two_to_two_bipartite_hub_auth() {
658        // Doctest scenario: 0,1 → 2,3.
659        let mut g = Graph::new(4, true).unwrap();
660        g.add_edges(vec![(0u32, 2u32), (0, 3), (1, 2), (1, 3)])
661            .unwrap();
662        let s = hub_and_authority_scores(&g).unwrap();
663        close(&s.hub, &[1.0, 1.0, 0.0, 0.0], 1e-9);
664        close(&s.authority, &[0.0, 0.0, 1.0, 1.0], 1e-9);
665        // Largest eigenvalue of A·Aᵀ for this 2x2 block is 4.
666        assert!((s.eigenvalue - 4.0).abs() < 1e-6);
667    }
668
669    #[test]
670    fn directed_triangle_uniform_one() {
671        // 0→1→2→0: every vertex is symmetrically a hub and an authority.
672        let mut g = Graph::new(3, true).unwrap();
673        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
674        let s = hub_and_authority_scores(&g).unwrap();
675        close(&s.hub, &[1.0, 1.0, 1.0], 1e-9);
676        close(&s.authority, &[1.0, 1.0, 1.0], 1e-9);
677        assert!((s.eigenvalue - 1.0).abs() < 1e-6);
678    }
679
680    #[test]
681    fn undirected_delegates_to_eigenvector() {
682        // Undirected triangle: hub == auth == eigenvector centrality.
683        let mut g = Graph::with_vertices(3);
684        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
685        let s = hub_and_authority_scores(&g).unwrap();
686        close(&s.hub, &[1.0, 1.0, 1.0], 1e-9);
687        close(&s.authority, &s.hub, 1e-15);
688    }
689
690    #[test]
691    fn undirected_star_hub_equals_eigenvector() {
692        // Undirected 4-star: centre = 1, leaves = 1/sqrt(3).
693        let mut g = Graph::with_vertices(4);
694        for v in 1..4 {
695            g.add_edge(0, v).unwrap();
696        }
697        let s = hub_and_authority_scores(&g).unwrap();
698        let inv_sqrt3 = 1.0 / 3f64.sqrt();
699        close(&s.hub, &[1.0, inv_sqrt3, inv_sqrt3, inv_sqrt3], 1e-9);
700        close(&s.authority, &s.hub, 1e-15);
701    }
702
703    #[test]
704    fn sink_has_zero_hub() {
705        // 0→2, 1→2: 2 is a sink (out-degree 0) → hub[2] = 0.
706        let mut g = Graph::new(3, true).unwrap();
707        g.add_edges(vec![(0u32, 2u32), (1, 2)]).unwrap();
708        let s = hub_and_authority_scores(&g).unwrap();
709        assert!(s.hub[2].abs() < 1e-9);
710        // 2 is the only authority.
711        assert!((s.authority[2] - 1.0).abs() < 1e-9);
712        assert!(s.authority[0].abs() < 1e-9);
713        assert!(s.authority[1].abs() < 1e-9);
714    }
715
716    #[test]
717    fn source_has_zero_authority() {
718        // 0→1, 0→2: 0 is a source (in-degree 0) → authority[0] = 0.
719        let mut g = Graph::new(3, true).unwrap();
720        g.add_edges(vec![(0u32, 1u32), (0, 2)]).unwrap();
721        let s = hub_and_authority_scores(&g).unwrap();
722        assert!(s.authority[0].abs() < 1e-9);
723        assert!((s.hub[0] - 1.0).abs() < 1e-9);
724    }
725
726    #[test]
727    fn formula_h_eq_a_a_authority() {
728        // After convergence, h ∝ A · authority. Verify on a small
729        // directed graph using the returned (normalised) vectors and
730        // the eigenvalue.
731        let mut g = Graph::new(5, true).unwrap();
732        g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (1, 4)])
733            .unwrap();
734        let s = hub_and_authority_scores(&g).unwrap();
735        // Verify: A·a should be parallel to h (up to normalisation by max).
736        let n = g.vcount();
737        let mut a_a = vec![0.0_f64; n as usize];
738        for u in 0..n {
739            let mut acc = 0.0_f64;
740            for &v in &g.out_neighbors_vec(u).unwrap() {
741                acc += s.authority[v as usize];
742            }
743            a_a[u as usize] = acc;
744        }
745        let max = a_a.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
746        if max > 0.0 {
747            for slot in &mut a_a {
748                *slot /= max;
749            }
750        }
751        for (u, &val) in a_a.iter().enumerate() {
752            assert!(
753                (val - s.hub[u]).abs() < 1e-6,
754                "vertex {u}: A·a={val} hub={}",
755                s.hub[u]
756            );
757        }
758    }
759
760    // -------- PR-017b: weighted variant --------
761
762    #[test]
763    fn weighted_length_mismatch_errors() {
764        let mut g = Graph::new(3, true).unwrap();
765        g.add_edges(vec![(0u32, 1u32), (1, 2)]).unwrap();
766        let result = hub_and_authority_scores_weighted(&g, &[1.0]);
767        assert!(matches!(result, Err(IgraphError::InvalidArgument(_))));
768    }
769
770    #[test]
771    fn weighted_empty_graph() {
772        let g = Graph::new(0, true).unwrap();
773        let s = hub_and_authority_scores_weighted(&g, &[]).unwrap();
774        assert!(s.hub.is_empty());
775        assert!(s.authority.is_empty());
776        assert!(s.eigenvalue.abs() < f64::EPSILON);
777    }
778
779    #[test]
780    fn weighted_no_edges_fills_ones() {
781        let g = Graph::new(3, true).unwrap();
782        let s = hub_and_authority_scores_weighted(&g, &[]).unwrap();
783        close(&s.hub, &[1.0, 1.0, 1.0], 1e-12);
784        close(&s.authority, &[1.0, 1.0, 1.0], 1e-12);
785        assert!(s.eigenvalue.abs() < f64::EPSILON);
786    }
787
788    #[test]
789    fn weighted_all_zero_weights_fills_ones() {
790        let mut g = Graph::new(3, true).unwrap();
791        g.add_edges(vec![(0u32, 1u32), (1, 2)]).unwrap();
792        let s = hub_and_authority_scores_weighted(&g, &[0.0, 0.0]).unwrap();
793        close(&s.hub, &[1.0, 1.0, 1.0], 1e-12);
794        close(&s.authority, &[1.0, 1.0, 1.0], 1e-12);
795        assert!(s.eigenvalue.abs() < f64::EPSILON);
796    }
797
798    #[test]
799    fn weighted_uniform_matches_unweighted() {
800        // With unit weights, weighted and unweighted HITS must agree.
801        let mut g = Graph::new(5, true).unwrap();
802        g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (1, 4)])
803            .unwrap();
804        let unweighted = hub_and_authority_scores(&g).unwrap();
805        let weighted = hub_and_authority_scores_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
806        close(&weighted.hub, &unweighted.hub, 1e-6);
807        close(&weighted.authority, &unweighted.authority, 1e-6);
808        assert!((weighted.eigenvalue - unweighted.eigenvalue).abs() < 1e-6);
809    }
810
811    #[test]
812    fn weighted_bipartite_authority_tilt() {
813        // 0,1 → 2,3 with weights tilting authority 3.
814        let mut g = Graph::new(4, true).unwrap();
815        g.add_edges(vec![(0u32, 2u32), (0, 3), (1, 2), (1, 3)])
816            .unwrap();
817        let s = hub_and_authority_scores_weighted(&g, &[1.0, 4.0, 2.0, 3.0]).unwrap();
818        // Hubs are the source partition, authorities are the sink one.
819        assert!(s.hub[2].abs() < 1e-9);
820        assert!(s.hub[3].abs() < 1e-9);
821        assert!(s.authority[0].abs() < 1e-9);
822        assert!(s.authority[1].abs() < 1e-9);
823        // Authority 3 receives heavier weights (4+3 = 7) than 2 (1+2 = 3).
824        assert!(s.authority[3] > s.authority[2]);
825    }
826
827    #[test]
828    #[allow(clippy::many_single_char_names, clippy::cast_possible_truncation)]
829    fn weighted_cross_relation_invariant() {
830        // After convergence, hub ∝ W·authority (max-normalised both sides).
831        let mut g = Graph::new(5, true).unwrap();
832        g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (4, 0)])
833            .unwrap();
834        let weights = vec![1.5, 0.5, 2.0, 1.0, 0.75, 1.25];
835        let s = hub_and_authority_scores_weighted(&g, &weights).unwrap();
836
837        let n = g.vcount() as usize;
838        let mut w_auth = vec![0.0_f64; n];
839        for (e, &w) in weights.iter().enumerate() {
840            let (u, v) = g.edge(e as u32).unwrap();
841            w_auth[u as usize] += w * s.authority[v as usize];
842        }
843        let max = w_auth.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
844        if max > 0.0 {
845            for slot in &mut w_auth {
846                *slot /= max;
847            }
848        }
849        for (u, &val) in w_auth.iter().enumerate() {
850            assert!(
851                (val - s.hub[u]).abs() < 1e-6,
852                "vertex {u}: W·a={val} hub={}",
853                s.hub[u]
854            );
855        }
856    }
857
858    #[test]
859    fn weighted_undirected_matches_unweighted_under_unit_weights() {
860        // Undirected delegation: unit weights ⇒ same as unweighted path.
861        let mut g = Graph::with_vertices(4);
862        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (0, 3)])
863            .unwrap();
864        let unweighted = hub_and_authority_scores(&g).unwrap();
865        let weighted = hub_and_authority_scores_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
866        close(&weighted.hub, &unweighted.hub, 1e-6);
867        close(&weighted.authority, &unweighted.authority, 1e-6);
868        assert!((weighted.eigenvalue - unweighted.eigenvalue).abs() < 1e-6);
869    }
870
871    #[test]
872    fn weighted_negative_weights_no_zero_clip() {
873        // With negative weights, sign-cleanup is skipped: at least one
874        // vector entry may be < 0 (and we don't reject the input).
875        let mut g = Graph::new(3, true).unwrap();
876        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
877        let s = hub_and_authority_scores_weighted(&g, &[1.0, -1.0, 1.0]).unwrap();
878        // Result must still be finite and one component must hit ±1.
879        assert!(s.hub.iter().all(|x| x.is_finite()));
880        assert!(s.authority.iter().all(|x| x.is_finite()));
881        let max_hub = s.hub.iter().fold(0.0_f64, |a, &x| a.max(x.abs()));
882        assert!((max_hub - 1.0).abs() < 1e-6);
883    }
884
885    #[test]
886    fn weighted_sink_has_zero_hub() {
887        // 0→2 (w=2), 1→2 (w=3): 2 is a sink → hub[2] = 0.
888        let mut g = Graph::new(3, true).unwrap();
889        g.add_edges(vec![(0u32, 2u32), (1, 2)]).unwrap();
890        let s = hub_and_authority_scores_weighted(&g, &[2.0, 3.0]).unwrap();
891        assert!(s.hub[2].abs() < 1e-9);
892        assert!((s.authority[2] - 1.0).abs() < 1e-9);
893    }
894}