1#![allow(
114 clippy::cast_possible_truncation,
115 clippy::cast_sign_loss,
116 clippy::cast_precision_loss,
117 clippy::cast_lossless,
118 clippy::too_many_arguments,
119 clippy::too_many_lines,
120 clippy::needless_range_loop,
121 clippy::similar_names,
122 clippy::many_single_char_names
123)]
124
125use std::collections::VecDeque;
126
127use crate::core::rng::SplitMix64;
128use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
129
130struct PsumTree {
137 n: usize,
138 bit: Vec<f64>,
139 values: Vec<f64>,
140 total: f64,
141}
142
143impl PsumTree {
144 fn new(n: usize) -> Self {
145 Self {
146 n,
147 bit: vec![0.0; n + 1],
148 values: vec![0.0; n],
149 total: 0.0,
150 }
151 }
152
153 fn set(&mut self, i: usize, v: f64) {
154 let delta = v - self.values[i];
155 self.values[i] = v;
156 self.total += delta;
157 let mut k = i + 1;
158 while k <= self.n {
159 self.bit[k] += delta;
160 k += k & k.wrapping_neg();
161 }
162 }
163
164 fn total(&self) -> f64 {
165 self.total
166 }
167
168 fn search_bounded(&self, target: f64, bound: usize) -> usize {
170 if bound == 0 {
171 return 0;
172 }
173 let mut idx: usize = 0;
174 let mut remaining = target;
175 let mut step = 1usize;
176 while step.saturating_mul(2) <= bound {
177 step *= 2;
178 }
179 while step > 0 {
180 let next = idx + step;
181 if next <= bound && self.bit[next] <= remaining {
182 idx = next;
183 remaining -= self.bit[next];
184 }
185 step >>= 1;
186 }
187 idx.min(bound - 1)
188 }
189}
190
191type HistoryEntry = Option<u32>;
194
195fn degree_term(degree: u32, pa_exp: f64, zero_appeal: f64) -> f64 {
198 let term = if degree == 0 {
199 if pa_exp == 0.0 {
200 1.0
201 } else if pa_exp > 0.0 {
202 0.0
203 } else {
204 f64::INFINITY
205 }
206 } else {
207 f64::from(degree).powf(pa_exp)
208 };
209 term + zero_appeal
210}
211
212fn age_term(age: u32, aging_exp: f64) -> f64 {
216 if age == 0 {
217 if aging_exp == 0.0 {
218 1.0
219 } else if aging_exp > 0.0 {
220 0.0
221 } else {
222 f64::INFINITY
223 }
224 } else {
225 f64::from(age).powf(aging_exp)
226 }
227}
228
229fn weight(degree: u32, age: u32, pa_exp: f64, aging_exp: f64, zero_appeal: f64) -> f64 {
230 degree_term(degree, pa_exp, zero_appeal) * age_term(age, aging_exp)
231}
232
233fn validate(
234 nodes: u32,
235 m: u32,
236 outseq: Option<&[u32]>,
237 pa_exp: f64,
238 aging_exp: f64,
239 aging_bins: u32,
240 zero_appeal: f64,
241) -> IgraphResult<()> {
242 if !pa_exp.is_finite() {
243 return Err(IgraphError::InvalidArgument(format!(
244 "pa_exp must be finite (got {pa_exp})"
245 )));
246 }
247 if !aging_exp.is_finite() {
248 return Err(IgraphError::InvalidArgument(format!(
249 "aging_exp must be finite (got {aging_exp})"
250 )));
251 }
252 if aging_bins == 0 {
253 return Err(IgraphError::InvalidArgument(
254 "aging_bins must be positive (got 0)".into(),
255 ));
256 }
257 if !zero_appeal.is_finite() || zero_appeal < 0.0 {
258 return Err(IgraphError::InvalidArgument(format!(
259 "zero_appeal must be finite and non-negative (got {zero_appeal})"
260 )));
261 }
262 if let Some(seq) = outseq {
263 if seq.len() != nodes as usize {
264 return Err(IgraphError::InvalidArgument(format!(
265 "outseq length ({}) must equal nodes ({nodes})",
266 seq.len()
267 )));
268 }
269 }
270 let _ = m;
271 Ok(())
272}
273
274fn edge_capacity(nodes: u32, m: u32, outseq: Option<&[u32]>) -> usize {
275 let n = nodes as usize;
276 match outseq {
277 Some(seq) => seq.iter().skip(1).map(|&x| x as usize).sum(),
278 None => n.saturating_sub(1).saturating_mul(m as usize),
279 }
280}
281
282pub fn recent_degree_aging_game(
309 nodes: u32,
310 m: u32,
311 outseq: Option<&[u32]>,
312 outpref: bool,
313 pa_exp: f64,
314 aging_exp: f64,
315 aging_bins: u32,
316 time_window: u32,
317 zero_appeal: f64,
318 directed: bool,
319 seed: u64,
320) -> IgraphResult<Graph> {
321 validate(nodes, m, outseq, pa_exp, aging_exp, aging_bins, zero_appeal)?;
322
323 let mut graph = Graph::new(nodes, directed)?;
324 if nodes < 2 {
325 return Ok(graph);
326 }
327 if outseq.is_none() && m == 0 {
328 return Ok(graph);
329 }
330 if let Some(seq) = outseq {
331 let after_zero: u64 = seq.iter().skip(1).map(|&x| u64::from(x)).sum();
332 if after_zero == 0 {
333 return Ok(graph);
334 }
335 }
336
337 let n = nodes as usize;
338 let binwidth = (n / aging_bins as usize) + 1;
339 let mut rng = SplitMix64::new(seed);
340 let mut psum = PsumTree::new(n);
341 let mut degree: Vec<u32> = vec![0; n];
342 let mut history: VecDeque<HistoryEntry> = VecDeque::new();
343 let capacity = edge_capacity(nodes, m, outseq);
344 let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(capacity);
345
346 psum.set(0, zero_appeal);
351 history.push_back(None); for i in 1..n {
354 let no_of_neighbors = if let Some(seq) = outseq {
355 seq[i] as usize
356 } else {
357 m as usize
358 };
359
360 if i >= time_window as usize {
364 while let Some(entry) = history.pop_front() {
365 match entry {
366 None => break,
367 Some(j) => {
368 let ju = j as usize;
369 degree[ju] = degree[ju].saturating_sub(1);
370 let age = ((i - ju) / binwidth + 1) as u32;
371 let w = weight(degree[ju], age, pa_exp, aging_exp, zero_appeal);
372 psum.set(ju, w);
373 }
374 }
375 }
376 }
377
378 let src =
384 u32::try_from(i).map_err(|_| IgraphError::Internal("vertex index overflows u32"))?;
385 for _ in 0..no_of_neighbors {
386 let sum = psum.total();
387 let to = if sum > 0.0 {
388 let target = rng.gen_unit() * sum;
389 psum.search_bounded(target, i)
390 } else {
391 let span = i as u64;
392 (rng.next_u64() % span) as usize
393 };
394 let dst = u32::try_from(to)
395 .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?;
396 degree[to] = degree[to]
397 .checked_add(1)
398 .ok_or_else(|| IgraphError::InvalidArgument("degree overflow".into()))?;
399 edges.push((src, dst));
400 history.push_back(Some(dst));
401 }
402 history.push_back(None);
404
405 let start = edges.len() - no_of_neighbors;
407 for edge in &edges[start..] {
408 let nn = edge.1 as usize;
409 let age = ((i - nn) / binwidth + 1) as u32;
410 let w = weight(degree[nn], age, pa_exp, aging_exp, zero_appeal);
411 psum.set(nn, w);
412 }
413
414 if outpref {
416 let bump = u32::try_from(no_of_neighbors)
420 .map_err(|_| IgraphError::InvalidArgument("neighbors count overflow".into()))?;
421 degree[i] = degree[i]
422 .checked_add(bump)
423 .ok_or_else(|| IgraphError::InvalidArgument("degree overflow".into()))?;
424 psum.set(i, degree_term(degree[i], pa_exp, zero_appeal));
429 } else {
430 psum.set(i, zero_appeal);
432 }
433
434 let mut k = 1usize;
438 loop {
439 let offset = binwidth.saturating_mul(k);
440 if offset > i {
441 break;
442 }
443 let shnode = i - offset;
444 let deg = degree[shnode];
445 let new_age = (k + 2) as u32;
446 let w = weight(deg, new_age, pa_exp, aging_exp, zero_appeal);
447 psum.set(shnode, w);
448 k += 1;
449 }
450 }
451
452 graph.add_edges(edges)?;
453 Ok(graph)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::collections::HashSet;
460
461 fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
462 let n = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
463 (0..n)
464 .map(|eid| g.edge(eid).expect("edge id in bounds"))
465 .collect()
466 }
467
468 fn default_call(
469 nodes: u32,
470 m: u32,
471 outpref: bool,
472 pa_exp: f64,
473 aging_exp: f64,
474 time_window: u32,
475 directed: bool,
476 seed: u64,
477 ) -> IgraphResult<Graph> {
478 recent_degree_aging_game(
479 nodes,
480 m,
481 None,
482 outpref,
483 pa_exp,
484 aging_exp,
485 10,
486 time_window,
487 1.0,
488 directed,
489 seed,
490 )
491 }
492
493 #[test]
494 fn rdaging_n_zero_returns_empty() {
495 let g = default_call(0, 2, false, 1.0, 0.0, 5, true, 1).unwrap();
496 assert_eq!(g.vcount(), 0);
497 assert_eq!(g.ecount(), 0);
498 }
499
500 #[test]
501 fn rdaging_n_one_singleton() {
502 let g = default_call(1, 2, false, 1.0, 0.0, 5, true, 1).unwrap();
503 assert_eq!(g.vcount(), 1);
504 assert_eq!(g.ecount(), 0);
505 }
506
507 #[test]
508 fn rdaging_m_zero_edgeless() {
509 let g = default_call(10, 0, false, 1.0, 0.0, 5, true, 1).unwrap();
510 assert_eq!(g.ecount(), 0);
511 }
512
513 #[test]
514 fn rdaging_ecount_exact_constant_m() {
515 let g = default_call(50, 3, false, 1.0, 0.0, 8, true, 42).unwrap();
516 assert_eq!(g.vcount(), 50);
517 assert_eq!(g.ecount(), 49 * 3);
518 }
519
520 #[test]
521 fn rdaging_ecount_exact_outseq() {
522 let outseq: Vec<u32> = (0..20)
523 .map(|i| if i == 0 { 0 } else { (i % 3) + 1 })
524 .collect();
525 let expected_edges: usize = outseq.iter().skip(1).map(|&x| x as usize).sum();
526 let g =
527 recent_degree_aging_game(20, 0, Some(&outseq), false, 1.0, -0.5, 5, 6, 1.0, true, 7)
528 .unwrap();
529 assert_eq!(g.vcount(), 20);
530 assert_eq!(g.ecount(), expected_edges);
531 }
532
533 #[test]
534 fn rdaging_no_self_loops() {
535 let g = default_call(80, 4, false, 1.0, -1.0, 5, true, 123).unwrap();
536 for (s, d) in collect_edges(&g) {
537 assert_ne!(s, d, "self-loop at edge ({s}, {d})");
538 }
539 }
540
541 #[test]
542 fn rdaging_source_is_step_index() {
543 let m: u32 = 3;
544 let n: u32 = 30;
545 let g = default_call(n, m, false, 1.0, -0.5, 5, true, 99).unwrap();
546 let edges = collect_edges(&g);
547 for (idx, (s, _d)) in edges.iter().enumerate() {
548 let expected_src = (idx as u32 / m) + 1;
549 assert_eq!(
550 *s, expected_src,
551 "edge {idx}: src {s} != expected {expected_src}"
552 );
553 }
554 }
555
556 #[test]
557 fn rdaging_target_below_source() {
558 let m: u32 = 2;
559 let n: u32 = 40;
560 let g = default_call(n, m, false, 1.0, -0.5, 5, true, 7).unwrap();
561 let edges = collect_edges(&g);
562 for (idx, (s, d)) in edges.iter().enumerate() {
563 assert!(*d < *s, "edge {idx}: target {d} should be < source {s}");
564 }
565 }
566
567 #[test]
568 fn rdaging_deterministic_same_seed() {
569 let g1 = default_call(40, 3, false, 1.0, -0.5, 5, true, 11).unwrap();
570 let g2 = default_call(40, 3, false, 1.0, -0.5, 5, true, 11).unwrap();
571 assert_eq!(collect_edges(&g1), collect_edges(&g2));
572 }
573
574 #[test]
575 fn rdaging_seed_divergence() {
576 let g1 = default_call(40, 3, false, 1.0, -0.5, 5, true, 11).unwrap();
577 let g2 = default_call(40, 3, false, 1.0, -0.5, 5, true, 12).unwrap();
578 assert_ne!(collect_edges(&g1), collect_edges(&g2));
579 }
580
581 #[test]
582 fn rdaging_directed_flag_propagates() {
583 let g_dir = default_call(20, 2, false, 1.0, 0.0, 5, true, 1).unwrap();
584 let g_undir = default_call(20, 2, false, 1.0, 0.0, 5, false, 1).unwrap();
585 assert!(g_dir.is_directed());
586 assert!(!g_undir.is_directed());
587 }
588
589 #[test]
590 fn rdaging_outpref_changes_graph() {
591 let g_off = default_call(30, 2, false, 1.0, -1.0, 5, true, 5).unwrap();
592 let g_on = default_call(30, 2, true, 1.0, -1.0, 5, true, 5).unwrap();
593 assert_ne!(collect_edges(&g_off), collect_edges(&g_on));
596 }
597
598 #[test]
599 fn rdaging_short_window_changes_graph() {
600 let g_short = default_call(50, 2, false, 1.0, -1.0, 2, true, 17).unwrap();
604 let g_long = default_call(50, 2, false, 1.0, -1.0, 40, true, 17).unwrap();
605 assert_ne!(collect_edges(&g_short), collect_edges(&g_long));
606 }
607
608 #[test]
609 fn rdaging_strong_aging_lifts_young_share() {
610 let make = |aging_exp: f64| {
611 recent_degree_aging_game(200, 2, None, false, 1.0, aging_exp, 50, 10, 1.0, true, 33)
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 rdaging_targets_unique_when_strongly_aged() {
633 let g = default_call(40, 2, false, 1.0, -3.0, 5, true, 21).unwrap();
634 let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
635 for e in collect_edges(&g) {
636 assert_ne!(e.0, e.1);
637 seen.insert(e); }
639 }
640
641 #[test]
642 fn rdaging_validation_aging_bins_zero() {
643 let err =
644 recent_degree_aging_game(10, 2, None, false, 1.0, 0.0, 0, 5, 1.0, true, 1).unwrap_err();
645 assert!(matches!(err, IgraphError::InvalidArgument(_)));
646 }
647
648 #[test]
649 fn rdaging_validation_negative_zero_appeal() {
650 let err = recent_degree_aging_game(10, 2, None, false, 1.0, 0.0, 5, 5, -1.0, true, 1)
651 .unwrap_err();
652 assert!(matches!(err, IgraphError::InvalidArgument(_)));
653 }
654
655 #[test]
656 fn rdaging_validation_pa_exp_nan() {
657 let err = recent_degree_aging_game(10, 2, None, false, f64::NAN, 0.0, 5, 5, 1.0, true, 1)
658 .unwrap_err();
659 assert!(matches!(err, IgraphError::InvalidArgument(_)));
660 }
661
662 #[test]
663 fn rdaging_validation_aging_exp_nan() {
664 let err = recent_degree_aging_game(10, 2, None, false, 1.0, f64::NAN, 5, 5, 1.0, true, 1)
665 .unwrap_err();
666 assert!(matches!(err, IgraphError::InvalidArgument(_)));
667 }
668
669 #[test]
670 fn rdaging_validation_outseq_wrong_length() {
671 let outseq = vec![0u32; 5];
672 let err =
673 recent_degree_aging_game(10, 0, Some(&outseq), false, 1.0, 0.0, 5, 5, 1.0, true, 1)
674 .unwrap_err();
675 assert!(matches!(err, IgraphError::InvalidArgument(_)));
676 }
677
678 #[test]
679 fn rdaging_time_window_zero_collapses_to_pure_aging() {
680 let g =
685 recent_degree_aging_game(30, 2, None, false, 1.0, -1.0, 5, 0, 1.0, true, 11).unwrap();
686 assert_eq!(g.vcount(), 30);
687 assert_eq!(g.ecount(), 29 * 2);
688 for (s, d) in collect_edges(&g) {
689 assert_ne!(s, d);
690 assert!(d < s);
691 }
692 }
693}
694
695#[cfg(all(test, feature = "proptest-harness"))]
696mod proptests {
697 use super::*;
698 use proptest::prelude::*;
699
700 proptest! {
701 #[test]
702 fn rdaging_ecount_exact_constant_m(
703 n in 2u32..40,
704 m in 1u32..6,
705 pa_exp in -1.0_f64..2.0,
706 aging_exp in -2.0_f64..1.0,
707 aging_bins in 1u32..20,
708 time_window in 0u32..20,
709 outpref: bool,
710 directed: bool,
711 seed in 0u64..1_000_000,
712 ) {
713 let g = recent_degree_aging_game(
714 n, m, None, outpref,
715 pa_exp, aging_exp, aging_bins, time_window,
716 1.0, directed, seed,
717 ).unwrap();
718 prop_assert_eq!(g.vcount(), n);
719 prop_assert_eq!(g.ecount() as u32, (n - 1) * m);
720 }
721
722 #[test]
723 fn rdaging_no_self_loops(
724 n in 2u32..40,
725 m in 1u32..6,
726 pa_exp in -1.0_f64..2.0,
727 aging_exp in -2.0_f64..1.0,
728 aging_bins in 1u32..20,
729 time_window in 0u32..20,
730 outpref: bool,
731 directed: bool,
732 seed in 0u64..1_000_000,
733 ) {
734 let g = recent_degree_aging_game(
735 n, m, None, outpref,
736 pa_exp, aging_exp, aging_bins, time_window,
737 1.0, directed, seed,
738 ).unwrap();
739 let m_edges = u32::try_from(g.ecount()).unwrap();
740 for eid in 0..m_edges {
741 let (s, d) = g.edge(eid).unwrap();
742 prop_assert_ne!(s, d);
743 }
744 }
745
746 #[test]
747 fn rdaging_source_is_step_index(
748 n in 2u32..40,
749 m in 1u32..6,
750 seed in 0u64..1_000_000,
751 ) {
752 let g = recent_degree_aging_game(
753 n, m, None, false,
754 1.0, -0.5, 5, 5,
755 1.0, true, seed,
756 ).unwrap();
757 let m_edges = u32::try_from(g.ecount()).unwrap();
758 for eid in 0..m_edges {
759 let (s, _d) = g.edge(eid).unwrap();
760 let expected_src = (eid / m) + 1;
761 prop_assert_eq!(s, expected_src);
762 }
763 }
764
765 #[test]
766 fn rdaging_targets_below_source(
767 n in 2u32..40,
768 m in 1u32..6,
769 aging_exp in -2.0_f64..1.0,
770 seed in 0u64..1_000_000,
771 ) {
772 let g = recent_degree_aging_game(
773 n, m, None, false,
774 1.0, aging_exp, 5, 5,
775 1.0, true, seed,
776 ).unwrap();
777 let m_edges = u32::try_from(g.ecount()).unwrap();
778 for eid in 0..m_edges {
779 let (s, d) = g.edge(eid).unwrap();
780 prop_assert!(d < s);
781 }
782 }
783
784 #[test]
785 fn rdaging_determinism(
786 n in 2u32..30,
787 m in 1u32..5,
788 pa_exp in -1.0_f64..2.0,
789 aging_exp in -2.0_f64..1.0,
790 time_window in 0u32..20,
791 outpref: bool,
792 seed in 0u64..1_000_000,
793 ) {
794 let g1 = recent_degree_aging_game(
795 n, m, None, outpref,
796 pa_exp, aging_exp, 5, time_window,
797 1.0, true, seed,
798 ).unwrap();
799 let g2 = recent_degree_aging_game(
800 n, m, None, outpref,
801 pa_exp, aging_exp, 5, time_window,
802 1.0, true, seed,
803 ).unwrap();
804 let m_edges = u32::try_from(g1.ecount()).unwrap();
805 for eid in 0..m_edges {
806 prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
807 }
808 }
809 }
810}