1#![allow(
85 clippy::cast_possible_truncation,
86 clippy::cast_sign_loss,
87 clippy::cast_precision_loss,
88 clippy::too_many_arguments,
89 clippy::too_many_lines,
90 clippy::needless_range_loop
91)]
92
93use crate::core::rng::SplitMix64;
94use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
95
96struct PsumTree {
102 n: usize,
103 bit: Vec<f64>,
104 values: Vec<f64>,
105 total: f64,
106}
107
108impl PsumTree {
109 fn new(n: usize) -> Self {
110 Self {
111 n,
112 bit: vec![0.0; n + 1],
113 values: vec![0.0; n],
114 total: 0.0,
115 }
116 }
117
118 fn set(&mut self, i: usize, v: f64) {
119 let delta = v - self.values[i];
120 self.values[i] = v;
121 self.total += delta;
122 let mut k = i + 1;
123 while k <= self.n {
124 self.bit[k] += delta;
125 k += k & k.wrapping_neg();
126 }
127 }
128
129 fn total(&self) -> f64 {
130 self.total
131 }
132
133 fn search_bounded(&self, target: f64, bound: usize) -> usize {
135 if bound == 0 {
136 return 0;
137 }
138 let mut idx: usize = 0;
139 let mut remaining = target;
140 let mut step = 1usize;
141 while step.saturating_mul(2) <= bound {
142 step *= 2;
143 }
144 while step > 0 {
145 let next = idx + step;
146 if next <= bound && self.bit[next] <= remaining {
147 idx = next;
148 remaining -= self.bit[next];
149 }
150 step >>= 1;
151 }
152 idx.min(bound - 1)
153 }
154}
155
156fn attraction_aging(
162 degree: u32,
163 age: u32,
164 pa_exp: f64,
165 aging_exp: f64,
166 zero_deg_appeal: f64,
167 zero_age_appeal: f64,
168 deg_coef: f64,
169 age_coef: f64,
170) -> f64 {
171 let dp = if pa_exp == 0.0 {
172 1.0
173 } else if degree == 0 {
174 if pa_exp > 0.0 { 0.0 } else { f64::INFINITY }
175 } else {
176 f64::from(degree).powf(pa_exp)
177 };
178 let ap = if age == 0 {
179 if aging_exp == 0.0 {
182 1.0
183 } else if aging_exp > 0.0 {
184 0.0
185 } else {
186 f64::INFINITY
187 }
188 } else {
189 f64::from(age).powf(aging_exp)
190 };
191 (deg_coef * dp + zero_deg_appeal) * (age_coef * ap + zero_age_appeal)
192}
193
194fn validate(
195 nodes: u32,
196 m: u32,
197 outseq: Option<&[u32]>,
198 pa_exp: f64,
199 aging_exp: f64,
200 aging_bins: u32,
201 zero_deg_appeal: f64,
202 zero_age_appeal: f64,
203 deg_coef: f64,
204 age_coef: f64,
205) -> IgraphResult<()> {
206 if !pa_exp.is_finite() {
207 return Err(IgraphError::InvalidArgument(format!(
208 "pa_exp must be finite (got {pa_exp})"
209 )));
210 }
211 if !aging_exp.is_finite() {
212 return Err(IgraphError::InvalidArgument(format!(
213 "aging_exp must be finite (got {aging_exp})"
214 )));
215 }
216 if aging_bins == 0 {
217 return Err(IgraphError::InvalidArgument(
218 "aging_bins must be positive (got 0)".into(),
219 ));
220 }
221 if !deg_coef.is_finite() || deg_coef < 0.0 {
222 return Err(IgraphError::InvalidArgument(format!(
223 "deg_coef must be finite and non-negative (got {deg_coef})"
224 )));
225 }
226 if !age_coef.is_finite() || age_coef < 0.0 {
227 return Err(IgraphError::InvalidArgument(format!(
228 "age_coef must be finite and non-negative (got {age_coef})"
229 )));
230 }
231 if !zero_deg_appeal.is_finite() || zero_deg_appeal < 0.0 {
232 return Err(IgraphError::InvalidArgument(format!(
233 "zero_deg_appeal must be finite and non-negative (got {zero_deg_appeal})"
234 )));
235 }
236 if !zero_age_appeal.is_finite() || zero_age_appeal < 0.0 {
237 return Err(IgraphError::InvalidArgument(format!(
238 "zero_age_appeal must be finite and non-negative (got {zero_age_appeal})"
239 )));
240 }
241 if let Some(seq) = outseq {
242 if seq.len() != nodes as usize {
243 return Err(IgraphError::InvalidArgument(format!(
244 "outseq length ({}) must equal nodes ({nodes})",
245 seq.len()
246 )));
247 }
248 }
249 let _ = m;
250 Ok(())
251}
252
253fn edge_capacity(nodes: u32, m: u32, outseq: Option<&[u32]>) -> usize {
254 let n = nodes as usize;
255 match outseq {
256 Some(seq) => seq.iter().skip(1).map(|&x| x as usize).sum(),
257 None => n.saturating_sub(1).saturating_mul(m as usize),
258 }
259}
260
261pub fn barabasi_aging_game(
293 nodes: u32,
294 m: u32,
295 outseq: Option<&[u32]>,
296 outpref: bool,
297 pa_exp: f64,
298 aging_exp: f64,
299 aging_bins: u32,
300 zero_deg_appeal: f64,
301 zero_age_appeal: f64,
302 deg_coef: f64,
303 age_coef: f64,
304 directed: bool,
305 seed: u64,
306) -> IgraphResult<Graph> {
307 validate(
308 nodes,
309 m,
310 outseq,
311 pa_exp,
312 aging_exp,
313 aging_bins,
314 zero_deg_appeal,
315 zero_age_appeal,
316 deg_coef,
317 age_coef,
318 )?;
319
320 let mut graph = Graph::new(nodes, directed)?;
321 if nodes < 2 {
322 return Ok(graph);
323 }
324 if outseq.is_none() && m == 0 {
325 return Ok(graph);
326 }
327 if let Some(seq) = outseq {
328 let after_zero: u64 = seq.iter().skip(1).map(|&x| u64::from(x)).sum();
329 if after_zero == 0 {
330 return Ok(graph);
331 }
332 }
333
334 let n = nodes as usize;
335 let binwidth = (n / aging_bins as usize) + 1;
336 let mut rng = SplitMix64::new(seed);
337 let mut psum = PsumTree::new(n);
338 let mut degree: Vec<u32> = vec![0; n];
339 let capacity = edge_capacity(nodes, m, outseq);
340 let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(capacity);
341
342 psum.set(0, 1.0);
345
346 let mut picks: Vec<usize> = Vec::with_capacity(m as usize);
347
348 for i in 1..n {
349 let no_of_neighbors = if let Some(seq) = outseq {
350 seq[i] as usize
351 } else {
352 m as usize
353 };
354
355 picks.clear();
356
357 let sum_snapshot = psum.total();
362 for _ in 0..no_of_neighbors {
363 let to = if sum_snapshot > 0.0 {
364 let target = rng.gen_unit() * sum_snapshot;
365 psum.search_bounded(target, i)
366 } else {
367 let span = i as u64;
368 (rng.next_u64() % span) as usize
369 };
370 degree[to] += 1;
371 edges.push((
372 u32::try_from(i)
373 .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
374 u32::try_from(to)
375 .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
376 ));
377 picks.push(to);
378 }
379
380 for &nn in &picks {
384 let age = (i - nn) / binwidth + 1;
385 let w = attraction_aging(
386 degree[nn],
387 age as u32,
388 pa_exp,
389 aging_exp,
390 zero_deg_appeal,
391 zero_age_appeal,
392 deg_coef,
393 age_coef,
394 );
395 psum.set(nn, w);
396 }
397
398 if outpref {
400 degree[i] += no_of_neighbors as u32;
401 let w = attraction_aging(
402 degree[i],
403 1,
404 pa_exp,
405 aging_exp,
406 zero_deg_appeal,
407 zero_age_appeal,
408 deg_coef,
409 age_coef,
410 );
411 psum.set(i, w);
412 } else {
413 let w = attraction_aging(
414 0,
415 1,
416 pa_exp,
417 aging_exp,
418 zero_deg_appeal,
419 zero_age_appeal,
420 deg_coef,
421 age_coef,
422 );
423 psum.set(i, w);
424 }
425
426 let mut k = 1usize;
430 loop {
431 let offset = binwidth.saturating_mul(k);
432 if offset > i {
433 break;
434 }
435 let shnode = i - offset;
436 let deg = degree[shnode];
437 let new_age = (k + 2) as u32;
438 let w = attraction_aging(
439 deg,
440 new_age,
441 pa_exp,
442 aging_exp,
443 zero_deg_appeal,
444 zero_age_appeal,
445 deg_coef,
446 age_coef,
447 );
448 psum.set(shnode, w);
449 k += 1;
450 }
451 }
452
453 graph.add_edges(edges)?;
454 Ok(graph)
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use std::collections::HashSet;
461
462 fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
463 let n = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
464 (0..n)
465 .map(|eid| g.edge(eid).expect("edge id in bounds"))
466 .collect()
467 }
468
469 fn default_call(
470 nodes: u32,
471 m: u32,
472 outpref: bool,
473 pa_exp: f64,
474 aging_exp: f64,
475 directed: bool,
476 seed: u64,
477 ) -> IgraphResult<Graph> {
478 barabasi_aging_game(
479 nodes, m, None, outpref, pa_exp, aging_exp, 10, 1.0, 1.0, 1.0, 1.0, directed, seed,
480 )
481 }
482
483 #[test]
484 fn aging_n_zero_returns_empty() {
485 let g = default_call(0, 2, false, 1.0, 0.0, true, 1).unwrap();
486 assert_eq!(g.vcount(), 0);
487 assert_eq!(g.ecount(), 0);
488 }
489
490 #[test]
491 fn aging_n_one_singleton() {
492 let g = default_call(1, 2, false, 1.0, 0.0, true, 1).unwrap();
493 assert_eq!(g.vcount(), 1);
494 assert_eq!(g.ecount(), 0);
495 }
496
497 #[test]
498 fn aging_m_zero_edgeless() {
499 let g = default_call(10, 0, false, 1.0, 0.0, true, 1).unwrap();
500 assert_eq!(g.ecount(), 0);
501 }
502
503 #[test]
504 fn aging_ecount_exact_constant_m() {
505 let g = default_call(50, 3, false, 1.0, 0.0, true, 42).unwrap();
506 assert_eq!(g.vcount(), 50);
507 assert_eq!(g.ecount(), 49 * 3);
508 }
509
510 #[test]
511 fn aging_ecount_exact_outseq() {
512 let outseq: Vec<u32> = (0..20)
513 .map(|i| if i == 0 { 0 } else { (i % 3) + 1 })
514 .collect();
515 let expected_edges: usize = outseq.iter().skip(1).map(|&x| x as usize).sum();
516 let g = barabasi_aging_game(
517 20,
518 0,
519 Some(&outseq),
520 false,
521 1.0,
522 -0.5,
523 5,
524 1.0,
525 1.0,
526 1.0,
527 1.0,
528 true,
529 7,
530 )
531 .unwrap();
532 assert_eq!(g.vcount(), 20);
533 assert_eq!(g.ecount(), expected_edges);
534 }
535
536 #[test]
537 fn aging_no_self_loops() {
538 let g = default_call(80, 4, false, 1.0, -1.0, true, 123).unwrap();
539 for (s, d) in collect_edges(&g) {
540 assert_ne!(s, d, "self-loop at edge ({s}, {d})");
541 }
542 }
543
544 #[test]
545 fn aging_source_is_step_index() {
546 let m: u32 = 3;
549 let n: u32 = 30;
550 let g = default_call(n, m, false, 1.0, -0.5, true, 99).unwrap();
551 let edges = collect_edges(&g);
552 for (idx, (s, _d)) in edges.iter().enumerate() {
553 let expected_src = (idx as u32 / m) + 1;
554 assert_eq!(
555 *s, expected_src,
556 "edge {idx}: src {s} != expected {expected_src}"
557 );
558 }
559 }
560
561 #[test]
562 fn aging_target_in_zero_to_step_minus_one() {
563 let m: u32 = 2;
564 let n: u32 = 40;
565 let g = default_call(n, m, false, 1.0, -0.5, true, 7).unwrap();
566 let edges = collect_edges(&g);
567 for (idx, (s, d)) in edges.iter().enumerate() {
568 assert!(*d < *s, "edge {idx}: target {d} should be < source {s}");
569 }
570 }
571
572 #[test]
573 fn aging_deterministic_same_seed() {
574 let g1 = default_call(40, 3, false, 1.0, -0.5, true, 11).unwrap();
575 let g2 = default_call(40, 3, false, 1.0, -0.5, true, 11).unwrap();
576 assert_eq!(collect_edges(&g1), collect_edges(&g2));
577 }
578
579 #[test]
580 fn aging_seed_divergence() {
581 let g1 = default_call(40, 3, false, 1.0, -0.5, true, 11).unwrap();
582 let g2 = default_call(40, 3, false, 1.0, -0.5, true, 12).unwrap();
583 assert_ne!(collect_edges(&g1), collect_edges(&g2));
584 }
585
586 #[test]
587 fn aging_directed_flag_propagates() {
588 let g_dir = default_call(20, 2, false, 1.0, 0.0, true, 1).unwrap();
589 let g_undir = default_call(20, 2, false, 1.0, 0.0, false, 1).unwrap();
590 assert!(g_dir.is_directed());
591 assert!(!g_undir.is_directed());
592 }
593
594 #[test]
595 fn aging_outpref_changes_graph() {
596 let g_off = default_call(30, 2, false, 1.0, -1.0, true, 5).unwrap();
597 let g_on = default_call(30, 2, true, 1.0, -1.0, true, 5).unwrap();
598 assert_ne!(collect_edges(&g_off), collect_edges(&g_on));
601 }
602
603 #[test]
604 fn aging_strong_aging_lifts_young_share_vs_no_aging() {
605 let make = |aging_exp: f64| {
609 barabasi_aging_game(
610 200, 2, None, false, 1.0, aging_exp, 50, 1.0, 0.0, 1.0, 1.0, true, 33,
611 )
612 .unwrap()
613 };
614 let indeg = |g: &Graph| -> Vec<u32> {
615 let mut v = vec![0u32; 200];
616 for (_s, d) in collect_edges(g) {
617 v[d as usize] += 1;
618 }
619 v
620 };
621 let aged = indeg(&make(-5.0));
622 let flat = indeg(&make(0.0));
623 let young_aged: u32 = aged[100..].iter().sum();
624 let young_flat: u32 = flat[100..].iter().sum();
625 assert!(
626 young_aged > young_flat,
627 "young-in under strong aging ({young_aged}) should exceed young-in under no aging ({young_flat})"
628 );
629 }
630
631 #[test]
632 fn aging_classical_matches_classical_ba_shape() {
633 let g = barabasi_aging_game(
639 100, 2, None, false, 1.0, 0.0, 10, 1.0, 1.0, 1.0, 0.0, true, 17,
640 )
641 .unwrap();
642 assert_eq!(g.ecount(), 99 * 2);
643 let mut indeg = vec![0u32; 100];
644 for (_s, d) in collect_edges(&g) {
645 indeg[d as usize] += 1;
646 }
647 let max_idx = (0..100).max_by_key(|&i| indeg[i]).expect("100 candidates");
648 assert!(
650 max_idx < 10,
651 "max-indegree vertex should be among the oldest 10 (got {max_idx})"
652 );
653 }
654
655 #[test]
656 fn aging_validation_aging_bins_zero() {
657 let err = barabasi_aging_game(10, 2, None, false, 1.0, 0.0, 0, 1.0, 1.0, 1.0, 1.0, true, 1)
658 .unwrap_err();
659 assert!(matches!(err, IgraphError::InvalidArgument(_)));
660 }
661
662 #[test]
663 fn aging_validation_negative_deg_coef() {
664 let err = barabasi_aging_game(
665 10, 2, None, false, 1.0, 0.0, 5, 1.0, 1.0, -1.0, 1.0, true, 1,
666 )
667 .unwrap_err();
668 assert!(matches!(err, IgraphError::InvalidArgument(_)));
669 }
670
671 #[test]
672 fn aging_validation_negative_age_coef() {
673 let err = barabasi_aging_game(
674 10, 2, None, false, 1.0, 0.0, 5, 1.0, 1.0, 1.0, -1.0, true, 1,
675 )
676 .unwrap_err();
677 assert!(matches!(err, IgraphError::InvalidArgument(_)));
678 }
679
680 #[test]
681 fn aging_validation_negative_zero_deg_appeal() {
682 let err = barabasi_aging_game(
683 10, 2, None, false, 1.0, 0.0, 5, -1.0, 1.0, 1.0, 1.0, true, 1,
684 )
685 .unwrap_err();
686 assert!(matches!(err, IgraphError::InvalidArgument(_)));
687 }
688
689 #[test]
690 fn aging_validation_negative_zero_age_appeal() {
691 let err = barabasi_aging_game(
692 10, 2, None, false, 1.0, 0.0, 5, 1.0, -1.0, 1.0, 1.0, true, 1,
693 )
694 .unwrap_err();
695 assert!(matches!(err, IgraphError::InvalidArgument(_)));
696 }
697
698 #[test]
699 fn aging_validation_pa_exp_nan() {
700 let err = barabasi_aging_game(
701 10,
702 2,
703 None,
704 false,
705 f64::NAN,
706 0.0,
707 5,
708 1.0,
709 1.0,
710 1.0,
711 1.0,
712 true,
713 1,
714 )
715 .unwrap_err();
716 assert!(matches!(err, IgraphError::InvalidArgument(_)));
717 }
718
719 #[test]
720 fn aging_validation_outseq_wrong_length() {
721 let outseq = vec![0u32; 5];
722 let err = barabasi_aging_game(
723 10,
724 0,
725 Some(&outseq),
726 false,
727 1.0,
728 0.0,
729 5,
730 1.0,
731 1.0,
732 1.0,
733 1.0,
734 true,
735 1,
736 )
737 .unwrap_err();
738 assert!(matches!(err, IgraphError::InvalidArgument(_)));
739 }
740
741 #[test]
742 fn attraction_aging_pa_exp_zero_returns_deg_constant_one() {
743 let w0 = attraction_aging(0, 1, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
745 let w_big = attraction_aging(1000, 1, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
746 assert!((w0 - 2.0).abs() < 1e-12);
748 assert!((w_big - 2.0).abs() < 1e-12);
749 }
750
751 #[test]
752 fn aging_targets_are_unique_when_strongly_aged() {
753 let g = default_call(40, 2, false, 1.0, -3.0, true, 21).unwrap();
757 let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
758 for e in collect_edges(&g) {
759 assert_ne!(e.0, e.1);
760 seen.insert(e); }
762 }
763}
764
765#[cfg(all(test, feature = "proptest-harness"))]
766mod proptests {
767 use super::*;
768 use proptest::prelude::*;
769
770 proptest! {
771 #[test]
772 fn aging_ecount_exact_constant_m(
773 n in 2u32..40,
774 m in 1u32..6,
775 pa_exp in -1.0_f64..2.0,
776 aging_exp in -2.0_f64..1.0,
777 aging_bins in 1u32..20,
778 outpref: bool,
779 directed: bool,
780 seed in 0u64..1_000_000,
781 ) {
782 let g = barabasi_aging_game(
783 n, m, None, outpref,
784 pa_exp, aging_exp, aging_bins,
785 1.0, 1.0, 1.0, 1.0,
786 directed, seed,
787 ).unwrap();
788 prop_assert_eq!(g.vcount(), n);
789 prop_assert_eq!(g.ecount() as u32, (n - 1) * m);
790 }
791
792 #[test]
793 fn aging_no_self_loops(
794 n in 2u32..40,
795 m in 1u32..6,
796 pa_exp in -1.0_f64..2.0,
797 aging_exp in -2.0_f64..1.0,
798 aging_bins in 1u32..20,
799 outpref: bool,
800 directed: bool,
801 seed in 0u64..1_000_000,
802 ) {
803 let g = barabasi_aging_game(
804 n, m, None, outpref,
805 pa_exp, aging_exp, aging_bins,
806 1.0, 1.0, 1.0, 1.0,
807 directed, seed,
808 ).unwrap();
809 let m_edges = u32::try_from(g.ecount()).unwrap();
810 for eid in 0..m_edges {
811 let (s, d) = g.edge(eid).unwrap();
812 prop_assert_ne!(s, d);
813 }
814 }
815
816 #[test]
817 fn aging_source_is_step_index(
818 n in 2u32..40,
819 m in 1u32..6,
820 seed in 0u64..1_000_000,
821 ) {
822 let g = barabasi_aging_game(
823 n, m, None, false,
824 1.0, -0.5, 5,
825 1.0, 1.0, 1.0, 1.0,
826 true, seed,
827 ).unwrap();
828 let m_edges = u32::try_from(g.ecount()).unwrap();
829 for eid in 0..m_edges {
830 let (s, _d) = g.edge(eid).unwrap();
831 let expected_src = (eid / m) + 1;
832 prop_assert_eq!(s, expected_src);
833 }
834 }
835
836 #[test]
837 fn aging_targets_below_source(
838 n in 2u32..40,
839 m in 1u32..6,
840 aging_exp in -2.0_f64..1.0,
841 seed in 0u64..1_000_000,
842 ) {
843 let g = barabasi_aging_game(
844 n, m, None, false,
845 1.0, aging_exp, 5,
846 1.0, 1.0, 1.0, 1.0,
847 true, seed,
848 ).unwrap();
849 let m_edges = u32::try_from(g.ecount()).unwrap();
850 for eid in 0..m_edges {
851 let (s, d) = g.edge(eid).unwrap();
852 prop_assert!(d < s);
853 }
854 }
855
856 #[test]
857 fn aging_determinism(
858 n in 2u32..30,
859 m in 1u32..5,
860 pa_exp in -1.0_f64..2.0,
861 aging_exp in -2.0_f64..1.0,
862 outpref: bool,
863 seed in 0u64..1_000_000,
864 ) {
865 let g1 = barabasi_aging_game(
866 n, m, None, outpref,
867 pa_exp, aging_exp, 5,
868 1.0, 1.0, 1.0, 1.0,
869 true, seed,
870 ).unwrap();
871 let g2 = barabasi_aging_game(
872 n, m, None, outpref,
873 pa_exp, aging_exp, 5,
874 1.0, 1.0, 1.0, 1.0,
875 true, seed,
876 ).unwrap();
877 let m_edges = u32::try_from(g1.ecount()).unwrap();
878 for eid in 0..m_edges {
879 prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
880 }
881 }
882 }
883}