1#![allow(
53 unknown_lints,
54 clippy::cast_possible_truncation,
55 clippy::cast_precision_loss,
56 clippy::cast_sign_loss,
57 clippy::float_cmp,
58 clippy::too_many_arguments,
59 clippy::similar_names,
60 clippy::many_single_char_names
61)]
62
63use crate::core::rng::SplitMix64;
64use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
65
66struct PsumTree {
73 n: usize,
74 bit: Vec<f64>,
75 values: Vec<f64>,
76 total: f64,
77}
78
79impl PsumTree {
80 fn new(n: usize) -> Self {
81 Self {
82 n,
83 bit: vec![0.0; n + 1],
84 values: vec![0.0; n],
85 total: 0.0,
86 }
87 }
88
89 fn set(&mut self, i: usize, v: f64) {
92 let delta = v - self.values[i];
93 self.values[i] = v;
94 self.total += delta;
95 let mut k = i + 1;
96 while k <= self.n {
97 self.bit[k] += delta;
98 k += k & k.wrapping_neg();
99 }
100 }
101
102 fn total(&self) -> f64 {
103 self.total
104 }
105
106 fn search(&self, target: f64) -> usize {
111 if self.n == 0 {
112 return 0;
113 }
114 let mut idx: usize = 0;
115 let mut remaining = target;
116 let mut step = (self.n + 1).next_power_of_two() >> 1;
117 while step > 0 {
118 let next = idx + step;
119 if next <= self.n && self.bit[next] <= remaining {
120 idx = next;
121 remaining -= self.bit[next];
122 }
123 step >>= 1;
124 }
125 idx.min(self.n - 1)
129 }
130}
131
132fn validate(nodes: u32, edges_per_node: u32, agebins: u32, preference: &[f64]) -> IgraphResult<()> {
133 if agebins < 1 {
134 return Err(IgraphError::InvalidArgument(format!(
135 "agebins must be at least 1 (got {agebins})"
136 )));
137 }
138 let required = (agebins as usize)
139 .checked_add(1)
140 .ok_or_else(|| IgraphError::InvalidArgument("agebins overflow".into()))?;
141 if preference.len() != required {
142 return Err(IgraphError::InvalidArgument(format!(
143 "preference vector length ({}) must equal agebins + 1 = {required}",
144 preference.len()
145 )));
146 }
147 for (i, &p) in preference.iter().enumerate() {
148 if p.is_nan() {
149 return Err(IgraphError::InvalidArgument(format!(
150 "preference[{i}] is NaN"
151 )));
152 }
153 if !p.is_finite() {
154 return Err(IgraphError::InvalidArgument(format!(
155 "preference[{i}] is not finite (got {p})"
156 )));
157 }
158 if p < 0.0 {
159 return Err(IgraphError::InvalidArgument(format!(
160 "preference[{i}] is negative ({p})"
161 )));
162 }
163 }
164 if preference[agebins as usize] <= 0.0 {
165 return Err(IgraphError::InvalidArgument(format!(
166 "preference[agebins] (never-cited weight) must be strictly positive, got {}",
167 preference[agebins as usize]
168 )));
169 }
170 let _ = (nodes, edges_per_node);
171 Ok(())
172}
173
174pub fn lastcit_game(
188 nodes: u32,
189 edges_per_node: u32,
190 agebins: u32,
191 preference: &[f64],
192 directed: bool,
193 seed: u64,
194) -> IgraphResult<Graph> {
195 validate(nodes, edges_per_node, agebins, preference)?;
196
197 let mut graph = Graph::new(nodes, directed)?;
198 if nodes == 0 || edges_per_node == 0 || nodes < 2 {
199 return Ok(graph);
200 }
201
202 let n = nodes as usize;
203 let eps = edges_per_node as usize;
204 let agebins_u = agebins as usize;
205 let binwidth = n / agebins_u + 1;
206
207 let mut rng = SplitMix64::new(seed);
208 let mut psum = PsumTree::new(n);
209 let mut lastcit: Vec<u32> = vec![0; n];
211
212 psum.set(0, preference[agebins_u]);
214
215 let edges_capacity = (n - 1).saturating_mul(eps);
216 let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(edges_capacity);
217
218 for i in 1..n {
219 for _ in 0..eps {
221 let sum = psum.total();
222 let to = if sum > 0.0 {
223 let target = rng.gen_unit() * sum;
224 psum.search(target)
225 } else {
226 let span = i as u64;
228 (rng.next_u64() % span) as usize
229 };
230 let src = u32::try_from(i)
231 .map_err(|_| IgraphError::InvalidArgument("vertex index overflow".into()))?;
232 let dst = u32::try_from(to)
233 .map_err(|_| IgraphError::InvalidArgument("vertex index overflow".into()))?;
234 edges.push((src, dst));
235 let i_plus_one = u32::try_from(i + 1)
237 .map_err(|_| IgraphError::InvalidArgument("step counter overflow".into()))?;
238 lastcit[to] = i_plus_one;
239 psum.set(to, preference[0]);
240 }
241
242 psum.set(i, preference[agebins_u]);
244
245 let mut k: usize = 1;
252 while k * binwidth <= i.saturating_sub(1) + binwidth {
253 let lhs = i.saturating_sub(k * binwidth);
254 if lhs < 1 {
255 break;
256 }
257 let shnode = lhs;
258 let start = (shnode - 1) * eps;
261 let end = shnode * eps;
262 let shnode_marker = u32::try_from(shnode + 1)
263 .map_err(|_| IgraphError::InvalidArgument("step counter overflow".into()))?;
264 let bucket = k.min(agebins_u - 1);
269 let new_weight = preference[bucket];
270 for edge in &edges[start..end] {
271 let cnode = edge.1 as usize;
272 if lastcit[cnode] == shnode_marker {
273 psum.set(cnode, new_weight);
274 }
275 }
276 k += 1;
277 }
278 }
279
280 graph.add_edges(edges)?;
281 Ok(graph)
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn nodes_zero_returns_empty_graph() {
290 let g = lastcit_game(0, 3, 2, &[1.0, 0.5, 0.1], false, 1).unwrap();
291 assert_eq!(g.vcount(), 0);
292 assert_eq!(g.ecount(), 0);
293 }
294
295 #[test]
296 fn nodes_one_returns_single_vertex_no_edges() {
297 let g = lastcit_game(1, 5, 2, &[1.0, 0.5, 0.1], false, 2).unwrap();
298 assert_eq!(g.vcount(), 1);
299 assert_eq!(g.ecount(), 0);
300 }
301
302 #[test]
303 fn edges_per_node_zero_yields_edgeless() {
304 let g = lastcit_game(50, 0, 2, &[1.0, 0.5, 0.1], false, 3).unwrap();
305 assert_eq!(g.vcount(), 50);
306 assert_eq!(g.ecount(), 0);
307 }
308
309 #[test]
310 fn exact_ecount() {
311 let n = 40u32;
312 let eps = 3u32;
313 let g = lastcit_game(n, eps, 3, &[1.0, 0.5, 0.2, 0.1], false, 4).unwrap();
314 assert_eq!(g.vcount(), n);
315 assert_eq!(g.ecount(), ((n - 1) * eps) as usize);
316 }
317
318 #[test]
319 fn determinism() {
320 let pref = vec![1.0, 0.7, 0.3, 0.1];
321 let g1 = lastcit_game(50, 3, 3, &pref, true, 12345).unwrap();
322 let g2 = lastcit_game(50, 3, 3, &pref, true, 12345).unwrap();
323 assert_eq!(g1.ecount(), g2.ecount());
324 let n_e = u32::try_from(g1.ecount()).unwrap();
325 for eid in 0..n_e {
326 assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
327 }
328 }
329
330 #[test]
331 fn different_seeds_diverge() {
332 let pref = vec![1.0, 0.5, 0.1];
333 let g1 = lastcit_game(40, 2, 2, &pref, false, 1).unwrap();
334 let g2 = lastcit_game(40, 2, 2, &pref, false, 2).unwrap();
335 let n_e = u32::try_from(g1.ecount()).unwrap();
336 let mut any_diff = false;
337 for eid in 0..n_e {
338 if g1.edge(eid).unwrap() != g2.edge(eid).unwrap() {
339 any_diff = true;
340 break;
341 }
342 }
343 assert!(any_diff);
344 }
345
346 #[test]
347 fn no_self_loops() {
348 let pref = vec![1.0, 0.5, 0.1];
349 let g = lastcit_game(80, 3, 2, &pref, true, 999).unwrap();
350 let n_e = u32::try_from(g.ecount()).unwrap();
351 for eid in 0..n_e {
352 let (a, b) = g.edge(eid).unwrap();
353 assert_ne!(a, b, "lastcit game must never self-loop");
354 }
355 }
356
357 #[test]
358 fn src_in_step_order() {
359 let pref = vec![1.0, 0.5];
360 let n = 20u32;
361 let eps = 3u32;
362 let g = lastcit_game(n, eps, 1, &pref, true, 7).unwrap();
363 let n_e = u32::try_from(g.ecount()).unwrap();
364 for eid in 0..n_e {
365 let (src, _dst) = g.edge(eid).unwrap();
366 let expected_src = 1 + eid / eps;
367 assert_eq!(src, expected_src);
368 }
369 }
370
371 #[test]
372 fn dst_strictly_less_than_src() {
373 let pref = vec![1.0, 0.5, 0.1];
374 let n = 50u32;
375 let g = lastcit_game(n, 2, 2, &pref, true, 11).unwrap();
376 let n_e = u32::try_from(g.ecount()).unwrap();
377 for eid in 0..n_e {
378 let (src, dst) = g.edge(eid).unwrap();
379 assert!(dst < src);
380 }
381 }
382
383 #[test]
384 fn directed_flag_propagates() {
385 let pref = vec![1.0, 0.5];
386 let g = lastcit_game(20, 2, 1, &pref, true, 11).unwrap();
387 assert!(g.is_directed());
388 }
389
390 #[test]
391 fn undirected_flag_propagates() {
392 let pref = vec![1.0, 0.5];
393 let g = lastcit_game(20, 2, 1, &pref, false, 12).unwrap();
394 assert!(!g.is_directed());
395 }
396
397 #[test]
398 fn err_agebins_zero() {
399 let err = lastcit_game(10, 1, 0, &[1.0], false, 1);
400 assert!(err.is_err());
401 }
402
403 #[test]
404 fn err_preference_length_mismatch() {
405 let err = lastcit_game(10, 1, 3, &[1.0, 0.5], false, 1);
406 assert!(err.is_err());
407 }
408
409 #[test]
410 fn err_never_cited_weight_zero() {
411 let err = lastcit_game(10, 1, 2, &[1.0, 0.5, 0.0], false, 1);
412 assert!(err.is_err());
413 }
414
415 #[test]
416 fn err_preference_negative() {
417 let err = lastcit_game(10, 1, 2, &[1.0, -0.5, 1.0], false, 1);
418 assert!(err.is_err());
419 }
420
421 #[test]
422 fn err_preference_nan() {
423 let err = lastcit_game(10, 1, 2, &[1.0, f64::NAN, 1.0], false, 1);
424 assert!(err.is_err());
425 }
426
427 #[test]
428 fn err_preference_inf() {
429 let err = lastcit_game(10, 1, 2, &[1.0, 0.5, f64::INFINITY], false, 1);
430 assert!(err.is_err());
431 }
432
433 #[test]
434 fn only_never_cited_weight_positive_concentrates_on_uncited() {
435 let pref = vec![0.0, 0.0, 0.0, 0.0, 1.0];
446 let n = 50u32;
447 let g = lastcit_game(n, 1, 4, &pref, true, 17).unwrap();
448 let n_e = u32::try_from(g.ecount()).unwrap();
449 let mut seen = vec![false; n as usize];
453 for eid in 0..n_e {
454 let (_s, d) = g.edge(eid).unwrap();
455 assert!(
456 !seen[d as usize],
457 "vertex {d} cited twice under never-cited-only preference"
458 );
459 seen[d as usize] = true;
460 }
461 }
462
463 #[test]
464 fn high_recent_weight_concentrates_in_degree_on_recent_vertices() {
465 let pref = vec![1000.0, 0.001, 0.001];
469 let n = 200u32;
470 let g = lastcit_game(n, 3, 2, &pref, true, 31).unwrap();
471 let n_e = u32::try_from(g.ecount()).unwrap();
472 let mut in_deg = vec![0u32; n as usize];
473 for eid in 0..n_e {
474 let (_s, d) = g.edge(eid).unwrap();
475 in_deg[d as usize] += 1;
476 }
477 let max_in = *in_deg.iter().max().unwrap();
478 assert!(
481 max_in > 10,
482 "expected concentration, got max in-degree {max_in}"
483 );
484 }
485}
486
487#[cfg(all(test, feature = "proptest-harness"))]
488mod proptests {
489 use super::*;
490 use proptest::prelude::*;
491
492 proptest! {
493 #[test]
494 fn ecount_exact(
495 n in 2u32..40,
496 eps in 1u32..5,
497 seed in any::<u64>(),
498 ) {
499 let pref = vec![1.0, 0.5, 0.1];
500 let g = lastcit_game(n, eps, 2, &pref, false, seed).unwrap();
501 prop_assert_eq!(g.vcount(), n);
502 prop_assert_eq!(g.ecount(), ((n - 1) * eps) as usize);
503 }
504
505 #[test]
506 fn no_self_loops(
507 n in 2u32..30,
508 eps in 1u32..4,
509 seed in any::<u64>(),
510 ) {
511 let pref = vec![1.0, 0.5];
512 let g = lastcit_game(n, eps, 1, &pref, true, seed).unwrap();
513 let n_e = u32::try_from(g.ecount()).unwrap();
514 for eid in 0..n_e {
515 let (a, b) = g.edge(eid).unwrap();
516 prop_assert_ne!(a, b);
517 }
518 }
519
520 #[test]
521 fn src_step_order(
522 n in 2u32..25,
523 eps in 1u32..4,
524 seed in any::<u64>(),
525 ) {
526 let pref = vec![1.0, 0.5];
527 let g = lastcit_game(n, eps, 1, &pref, true, seed).unwrap();
528 let n_e = u32::try_from(g.ecount()).unwrap();
529 for eid in 0..n_e {
530 let (src, _dst) = g.edge(eid).unwrap();
531 let expected_src = 1 + eid / eps;
532 prop_assert_eq!(src, expected_src);
533 }
534 }
535
536 #[test]
537 fn dst_lt_src(
538 n in 2u32..25,
539 eps in 1u32..4,
540 seed in any::<u64>(),
541 ) {
542 let pref = vec![1.0, 0.5, 0.1];
543 let g = lastcit_game(n, eps, 2, &pref, true, seed).unwrap();
544 let n_e = u32::try_from(g.ecount()).unwrap();
545 for eid in 0..n_e {
546 let (src, dst) = g.edge(eid).unwrap();
547 prop_assert!(dst < src);
548 }
549 }
550
551 #[test]
552 fn determinism_under_proptest(
553 n in 2u32..30,
554 eps in 1u32..4,
555 agebins in 1u32..4,
556 seed in any::<u64>(),
557 ) {
558 let pref: Vec<f64> = (0..=agebins as usize)
559 .map(|i| 1.0 / (i as f64 + 1.0))
560 .collect();
561 let g1 = lastcit_game(n, eps, agebins, &pref, false, seed).unwrap();
562 let g2 = lastcit_game(n, eps, agebins, &pref, false, seed).unwrap();
563 prop_assert_eq!(g1.ecount(), g2.ecount());
564 let n_e = u32::try_from(g1.ecount()).unwrap();
565 for eid in 0..n_e {
566 prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
567 }
568 }
569 }
570}