1use 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#[derive(Debug, Clone, PartialEq)]
50pub struct HitsScores {
51 pub hub: Vec<f64>,
53 pub authority: Vec<f64>,
55 pub eigenvalue: f64,
59}
60
61pub 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 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 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 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 #[allow(clippy::cast_precision_loss)]
167 let mut h: Vec<f64> = out_adj.iter().map(|nei| nei.len() as f64).collect();
168 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 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 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 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 for slot in &mut h {
215 if *slot < 0.0 {
216 *slot = 0.0;
217 }
218 }
219
220 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#[allow(clippy::too_many_lines)] pub 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 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 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 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 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 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 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
465fn 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 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 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
557fn 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
583fn 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 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 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 assert!((s.eigenvalue - 4.0).abs() < 1e-6);
667 }
668
669 #[test]
670 fn directed_triangle_uniform_one() {
671 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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}