1#![allow(
68 unknown_lints,
69 clippy::cast_possible_truncation,
70 clippy::cast_precision_loss,
71 clippy::cast_sign_loss,
72 clippy::float_cmp,
73 clippy::similar_names,
74 clippy::many_single_char_names,
75 clippy::unnecessary_cast
76)]
77
78use crate::core::rng::SplitMix64;
79use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ChungLuVariant {
87 Original,
90 Maxent,
93 Nr,
96}
97
98const MAX_NODES: usize = if usize::BITS >= 64 {
104 1usize.wrapping_shl(53)
105} else {
106 usize::MAX
107};
108
109fn check_weights(label: &str, weights: &[f64]) -> IgraphResult<()> {
110 let mut max_w = f64::NEG_INFINITY;
111 let mut min_w = f64::INFINITY;
112 for (i, &w) in weights.iter().enumerate() {
113 if w.is_nan() {
114 return Err(IgraphError::InvalidArgument(format!(
115 "{label}[{i}] is NaN; Chung–Lu weights must be finite"
116 )));
117 }
118 if w < min_w {
119 min_w = w;
120 }
121 if w > max_w {
122 max_w = w;
123 }
124 }
125 if weights.is_empty() {
126 return Ok(());
127 }
128 if min_w < 0.0 {
129 return Err(IgraphError::InvalidArgument(format!(
130 "{label} must be non-negative; got minimum {min_w}"
131 )));
132 }
133 if !max_w.is_finite() {
134 return Err(IgraphError::InvalidArgument(format!(
135 "{label} must be finite; got maximum {max_w}"
136 )));
137 }
138 Ok(())
139}
140
141fn sort_indices_desc(key: &[f64]) -> Vec<usize> {
145 let mut idx: Vec<usize> = (0..key.len()).collect();
146 idx.sort_by(|&a, &b| {
147 key[b]
148 .partial_cmp(&key[a])
149 .unwrap_or(std::cmp::Ordering::Equal)
150 .then(a.cmp(&b))
151 });
152 idx
153}
154
155#[inline]
156fn apply_variant(q: f64, variant: ChungLuVariant) -> f64 {
157 match variant {
158 ChungLuVariant::Original => {
159 if q > 1.0 {
160 1.0
161 } else {
162 q
163 }
164 }
165 ChungLuVariant::Maxent => q / (1.0 + q),
166 ChungLuVariant::Nr => 1.0 - (-q).exp(),
167 }
168}
169
170pub fn chung_lu_game(
217 out_weights: &[f64],
218 in_weights: Option<&[f64]>,
219 loops: bool,
220 variant: ChungLuVariant,
221 seed: u64,
222) -> IgraphResult<Graph> {
223 let n = out_weights.len();
224 let directed = in_weights.is_some();
225
226 #[allow(clippy::absurd_extreme_comparisons)]
227 if n > MAX_NODES {
228 return Err(IgraphError::InvalidArgument(format!(
229 "Chung–Lu vertex count {n} exceeds the largest exactly representable f64 integer (2^53)"
230 )));
231 }
232
233 if let Some(inw) = in_weights {
234 if inw.len() != n {
235 return Err(IgraphError::InvalidArgument(format!(
236 "in_weights length {} does not match out_weights length {n}",
237 inw.len()
238 )));
239 }
240 }
241
242 let n_u32 = u32::try_from(n).map_err(|_| {
243 IgraphError::InvalidArgument(format!("Chung–Lu vertex count {n} exceeds u32::MAX"))
244 })?;
245
246 if n == 0 {
247 return Graph::new(0, directed);
248 }
249
250 check_weights("out_weights", out_weights)?;
251 let wsum: f64 = out_weights.iter().sum();
252
253 let in_view: &[f64] = match in_weights {
254 Some(inw) => {
255 check_weights("in_weights", inw)?;
256 let in_sum: f64 = inw.iter().sum();
257 if in_sum != wsum {
258 return Err(IgraphError::InvalidArgument(format!(
259 "Sum of out- and in-weights must be the same; got out_sum = {wsum} and in_sum = {in_sum}"
260 )));
261 }
262 inw
263 }
264 None => out_weights,
265 };
266
267 if wsum == 0.0 {
269 return Graph::new(n_u32, directed);
270 }
271
272 let idx = sort_indices_desc(in_view);
273 let mut rng = SplitMix64::new(seed);
274 let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
275
276 for i in 0..n {
277 let vi = idx[i];
278 let wi = out_weights[vi];
279 if wi == 0.0 {
280 if directed {
281 continue;
282 }
283 break;
284 }
285
286 let mut j = if directed { 0 } else { i };
287 let mut p: f64 = 1.0;
288
289 loop {
290 let remaining = n - j;
291 let gap = rng.gen_geom(p);
292 if !gap.is_finite() || gap >= remaining as f64 {
295 break;
296 }
297 j += gap as usize;
299 if j >= n {
300 break;
301 }
302
303 let vj = idx[j];
304 let wj = in_view[vj];
305
306 let q_raw = wi * wj / wsum;
308 let q = apply_variant(q_raw, variant);
309
310 if q == 0.0 {
311 break;
312 }
313
314 let ratio = if p > 0.0 { q / p } else { 0.0 };
319 let accept = rng.gen_unit() < ratio;
320 if accept && (loops || vi != vj) {
321 let from = u32::try_from(vi).map_err(|_| {
322 IgraphError::InvalidArgument(format!(
323 "Chung–Lu source vertex index {vi} overflows u32"
324 ))
325 })?;
326 let to = u32::try_from(vj).map_err(|_| {
327 IgraphError::InvalidArgument(format!(
328 "Chung–Lu target vertex index {vj} overflows u32"
329 ))
330 })?;
331 edges.push((from, to));
332 }
333
334 p = q;
335 j += 1;
336 }
337 }
338
339 let mut g = Graph::new(n_u32, directed)?;
340 g.add_edges(edges)?;
341 Ok(g)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn ecount(g: &Graph) -> usize {
349 g.ecount() as usize
350 }
351
352 #[test]
353 fn empty_weights_undirected() {
354 let g = chung_lu_game(&[], None, false, ChungLuVariant::Original, 0).unwrap();
355 assert_eq!(g.vcount(), 0);
356 assert!(!g.is_directed());
357 assert_eq!(g.ecount(), 0);
358 }
359
360 #[test]
361 fn empty_weights_directed() {
362 let in_w: [f64; 0] = [];
363 let g = chung_lu_game(&[], Some(&in_w), true, ChungLuVariant::Maxent, 0).unwrap();
364 assert_eq!(g.vcount(), 0);
365 assert!(g.is_directed());
366 }
367
368 #[test]
369 fn zero_weights_yields_empty_graph() {
370 let w = vec![0.0; 8];
371 for variant in [
372 ChungLuVariant::Original,
373 ChungLuVariant::Maxent,
374 ChungLuVariant::Nr,
375 ] {
376 let g = chung_lu_game(&w, None, true, variant, 1).unwrap();
377 assert_eq!(g.vcount(), 8);
378 assert_eq!(g.ecount(), 0, "variant {variant:?} should give empty graph");
379 }
380 }
381
382 #[test]
383 fn single_vertex_no_loops_is_empty() {
384 let g = chung_lu_game(&[5.0], None, false, ChungLuVariant::Maxent, 7).unwrap();
385 assert_eq!(g.vcount(), 1);
386 assert_eq!(g.ecount(), 0);
387 }
388
389 #[test]
390 fn rejects_negative_weight() {
391 let w = vec![1.0, -0.5, 2.0];
392 let err = chung_lu_game(&w, None, false, ChungLuVariant::Original, 0).unwrap_err();
393 match err {
394 IgraphError::InvalidArgument(msg) => {
395 assert!(msg.contains("non-negative"), "msg = {msg}");
396 }
397 other => panic!("unexpected error: {other:?}"),
398 }
399 }
400
401 #[test]
402 fn rejects_non_finite_weight() {
403 let w = vec![1.0, f64::INFINITY];
404 let err = chung_lu_game(&w, None, false, ChungLuVariant::Original, 0).unwrap_err();
405 assert!(matches!(err, IgraphError::InvalidArgument(_)));
406
407 let w_nan = vec![1.0, f64::NAN];
408 let err = chung_lu_game(&w_nan, None, false, ChungLuVariant::Maxent, 0).unwrap_err();
409 assert!(matches!(err, IgraphError::InvalidArgument(_)));
410 }
411
412 #[test]
413 fn rejects_mismatched_in_out_lengths() {
414 let out_w = vec![1.0, 2.0, 3.0];
415 let in_w = vec![1.0, 5.0];
416 let err =
417 chung_lu_game(&out_w, Some(&in_w), false, ChungLuVariant::Original, 0).unwrap_err();
418 match err {
419 IgraphError::InvalidArgument(msg) => assert!(msg.contains("length"), "msg = {msg}"),
420 other => panic!("{other:?}"),
421 }
422 }
423
424 #[test]
425 fn rejects_mismatched_in_out_sums() {
426 let out_w = vec![1.0, 2.0, 3.0];
427 let in_w = vec![1.0, 1.0, 1.0];
428 let err = chung_lu_game(&out_w, Some(&in_w), false, ChungLuVariant::Maxent, 0).unwrap_err();
429 match err {
430 IgraphError::InvalidArgument(msg) => assert!(msg.contains("sum"), "msg = {msg}"),
431 other => panic!("{other:?}"),
432 }
433 }
434
435 #[test]
436 fn deterministic_for_fixed_seed() {
437 let w: Vec<f64> = (0..40).map(|i| 1.0 + f64::from(i) * 0.1).collect();
438 let g1 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 0xABC).unwrap();
439 let g2 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 0xABC).unwrap();
440 assert_eq!(ecount(&g1), ecount(&g2));
441 let m = ecount(&g1);
442 for eid in 0..m as u32 {
443 assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
444 }
445 }
446
447 #[test]
448 fn different_seeds_differ_or_match_size_only() {
449 let w: Vec<f64> = (0..40).map(|i| 1.0 + f64::from(i) * 0.1).collect();
450 let g1 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 1).unwrap();
451 let g2 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 2).unwrap();
452 assert_eq!(g1.vcount(), g2.vcount());
454 let m1 = ecount(&g1);
457 let m2 = ecount(&g2);
458 if m1 == m2 && m1 > 0 {
459 let mut any_diff = false;
460 for eid in 0..m1 as u32 {
461 if g1.edge(eid).unwrap() != g2.edge(eid).unwrap() {
462 any_diff = true;
463 break;
464 }
465 }
466 assert!(
467 any_diff,
468 "two seeds happened to produce identical edge sequences"
469 );
470 }
471 }
472
473 #[test]
474 fn no_loops_when_loops_false() {
475 let w = vec![5.0; 20];
476 let g = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 9).unwrap();
477 let m = ecount(&g) as u32;
478 for eid in 0..m {
479 let (u, v) = g.edge(eid).unwrap();
480 assert!(u != v, "found self-loop at edge {eid}: ({u},{v})");
481 }
482 }
483
484 #[test]
485 fn directed_graph_has_directed_edges() {
486 let w = vec![3.0; 15];
487 let g = chung_lu_game(&w, Some(&w), false, ChungLuVariant::Maxent, 11).unwrap();
488 assert!(g.is_directed());
489 assert_eq!(g.vcount(), 15);
490 }
491
492 #[test]
493 fn maxent_mean_degree_matches_weight_in_sparse_limit() {
494 let n = 200_usize;
497 let w_val = 4.0_f64;
498 let w = vec![w_val; n];
499 let g = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 0x5EE_D001).unwrap();
500 let total_deg = 2.0 * ecount(&g) as f64;
502 let mean_deg = total_deg / n as f64;
503 let expected = w_val * (n as f64 - 1.0) / (n as f64 + w_val);
507 let tol = 1.0_f64; assert!(
509 (mean_deg - expected).abs() < tol,
510 "mean_deg = {mean_deg}, expected ≈ {expected}"
511 );
512 }
513
514 #[test]
515 fn nr_and_original_all_zero_probability_yields_no_edges() {
516 let w = vec![1e-3; 50];
518 for variant in [
519 ChungLuVariant::Original,
520 ChungLuVariant::Maxent,
521 ChungLuVariant::Nr,
522 ] {
523 let g = chung_lu_game(&w, None, false, variant, 13).unwrap();
524 assert!(
527 ecount(&g) <= 3,
528 "variant {variant:?} produced too many edges"
529 );
530 }
531 }
532
533 #[test]
534 fn vertex_count_is_input_length() {
535 for n in [1, 2, 5, 20] {
536 let w = vec![2.0; n];
537 let g = chung_lu_game(&w, None, true, ChungLuVariant::Maxent, 1).unwrap();
538 assert_eq!(g.vcount() as usize, n);
539 }
540 }
541
542 #[test]
543 fn original_with_very_large_weights_is_dense() {
544 let n = 30;
548 let w = vec![100.0; n];
549 let g = chung_lu_game(&w, None, false, ChungLuVariant::Original, 19).unwrap();
550 let m = ecount(&g);
551 let cmax = n * (n - 1) / 2;
554 assert!(
555 m as f64 >= 0.95 * cmax as f64,
556 "expected near-complete graph, got {m}/{cmax}"
557 );
558 }
559}
560
561#[cfg(all(test, feature = "proptest-harness"))]
562mod proptests {
563 use super::*;
564 use proptest::prelude::*;
565
566 proptest! {
567 #![proptest_config(ProptestConfig::with_cases(48))]
568
569 #[test]
571 fn vcount_matches_weight_len(
572 n in 1usize..40,
573 w_max in 0.0_f64..5.0,
574 seed: u64,
575 variant_pick in 0u8..3,
576 ) {
577 let weights: Vec<f64> = (0..n).map(|i| w_max * (i as f64 + 1.0) / n as f64).collect();
578 let variant = match variant_pick {
579 0 => ChungLuVariant::Original,
580 1 => ChungLuVariant::Maxent,
581 _ => ChungLuVariant::Nr,
582 };
583 let g = chung_lu_game(&weights, None, false, variant, seed).unwrap();
584 prop_assert_eq!(g.vcount() as usize, n);
585 prop_assert!(!g.is_directed());
586 }
587
588 #[test]
590 fn determinism(
591 n in 1usize..40,
592 w_max in 0.0_f64..3.0,
593 seed: u64,
594 loops: bool,
595 ) {
596 let weights: Vec<f64> = (0..n).map(|i| w_max * (i as f64 + 1.0) / n as f64).collect();
597 let g1 = chung_lu_game(&weights, None, loops, ChungLuVariant::Maxent, seed).unwrap();
598 let g2 = chung_lu_game(&weights, None, loops, ChungLuVariant::Maxent, seed).unwrap();
599 prop_assert_eq!(g1.ecount(), g2.ecount());
600 let m = g1.ecount();
601 for eid in 0..m as u32 {
602 prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
603 }
604 }
605
606 #[test]
608 fn no_self_loops_when_disabled(
609 n in 2usize..30,
610 w_max in 0.5_f64..4.0,
611 seed: u64,
612 ) {
613 let weights: Vec<f64> = (0..n).map(|i| w_max * (i as f64 + 1.0) / n as f64).collect();
614 let g = chung_lu_game(&weights, None, false, ChungLuVariant::Maxent, seed).unwrap();
615 let m = g.ecount() as u32;
616 for eid in 0..m {
617 let (u, v) = g.edge(eid).unwrap();
618 prop_assert_ne!(u, v);
619 }
620 }
621 }
622}