Skip to main content

rust_igraph/algorithms/properties/
rich_club.rs

1//! Rich-club coefficient sequence (ALGO-PR-040).
2//!
3//! Counterpart of `igraph_rich_club_sequence()` from
4//! `references/igraph/src/properties/rich_club.c:91-166`.
5//!
6//! Definition. Given a vertex ordering `vertex_order` (a permutation of
7//! `0..vcount`), peel the vertices off the graph one at a time in that
8//! order. After `i` removals, the surviving subgraph spans the trailing
9//! `vcount - i` vertices of `vertex_order`. The rich-club sequence
10//! returns either the surviving edge count (or summed edge weight) at
11//! each peeling step (`normalized = false`) or that quantity divided by
12//! the total number of possible edges on `vcount - i` vertices
13//! (`normalized = true`, i.e. a density per step).
14//!
15//! Algorithm (linear, no actual peeling). Invert `vertex_order` into
16//! `order_of[v] = position of v in vertex_order`. For every edge
17//! `(v1, v2)` add its weight (default 1.0) to
18//! `res[min(order_of[v1], order_of[v2])]` — that is the index at which
19//! the *first* endpoint is removed and the edge disappears with it. A
20//! single reverse cumulative sweep then turns the "removed-at-step-i"
21//! tally into a "remaining-after-i-removals" sequence. With
22//! `normalized = true`, divide each entry by `total_possible_edges`
23//! evaluated at the remaining vertex count.
24//!
25//! `total_possible_edges(n, directed, loops)` is the standard density
26//! denominator (see [`crate::density`]):
27//!
28//! | loops? | directed? | denominator |
29//! |--------|-----------|-------------|
30//! | no     | no        | `n(n-1)/2`  |
31//! | no     | yes       | `n(n-1)`    |
32//! | yes    | no        | `n(n+1)/2`  |
33//! | yes    | yes       | `n²`        |
34//!
35//! When `vcount == 0`, the result is the empty vector. When `vcount == 1`,
36//! the normalized result is the single value `0 / 0 = f64::NAN` (matching
37//! upstream's `print_vector` output `( NaN )`). For any `vcount > 0`,
38//! the very last entry `res[vcount - 1]` becomes `f64::NAN` in normalized
39//! mode whenever the trailing single-vertex subgraph has zero possible
40//! edges (loopless case), because the formula evaluates `0/0` there.
41//!
42//! Edge orientation. If `graph` is undirected, the caller-supplied
43//! `directed` flag is silently coerced to `false` — matching upstream's
44//! `if (!igraph_is_directed(graph)) directed = false;` line. Otherwise
45//! `directed = true` selects the larger directed denominator without
46//! changing the edge-count tally (which is already per directed edge in
47//! the underlying [`Graph`]).
48//!
49//! Loops + normalization. When `normalized && !loops` and the graph
50//! contains a self-loop, upstream issues a `Warning` (not an error) the
51//! first time it sees one and continues with the loopless denominator;
52//! this port silently follows the same behaviour without surfacing the
53//! warning (we have no warning channel and the user explicitly opted in
54//! to "assume no loops" — the responsibility for ensuring the graph
55//! matches that assumption is on them).
56//!
57//! Complexity. `O(|V| + |E|)`.
58
59use crate::core::graph::EdgeId;
60use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
61
62/// Per-vertex rich-club coefficient sequence for `graph`.
63///
64/// Returns a `Vec<f64>` of length `graph.vcount()` whose `i`-th entry is
65/// the density (`normalized = true`) or raw edge count
66/// (`normalized = false`) of the subgraph that survives after removing
67/// the first `i` vertices of `vertex_order` from `graph`.
68///
69/// Counterpart of `igraph_rich_club_sequence` — see the module-level
70/// docs for the full mapping of arguments and semantics.
71///
72/// # Arguments
73///
74/// * `graph` — input graph.
75/// * `weights` — optional per-edge weights of length `ecount()`. When
76///   `None`, every edge contributes `1.0`.
77/// * `vertex_order` — permutation of `0..vcount()` giving the order in
78///   which vertices are peeled off.
79/// * `normalized` — when `true`, divide the surviving edge count at each
80///   step by the total possible edges on the remaining vertices; when
81///   `false`, return the raw remaining edge count (or summed weight).
82/// * `loops` — whether self-loops are *assumed possible* (affects the
83///   normalization denominator only; ignored when `!normalized`).
84/// * `directed` — whether to use the directed denominator when
85///   normalizing. Silently coerced to `false` when `graph` is undirected.
86///
87/// # Errors
88///
89/// * [`IgraphError::InvalidArgument`] — when:
90///   * `vertex_order.len() != graph.vcount() as usize`,
91///   * `weights.is_some()` and `weights.unwrap().len() != graph.ecount()`,
92///   * any entry of `vertex_order` is `>= graph.vcount()` (i.e. it is not
93///     a valid permutation of `0..vcount`).
94///
95/// # Examples
96///
97/// ```
98/// use rust_igraph::{Graph, rich_club_sequence};
99///
100/// // Triangle K_3: after 0 removals → 3 edges, after 1 → 1, after 2 → 0.
101/// let mut g = Graph::with_vertices(3);
102/// g.add_edge(0, 1).unwrap();
103/// g.add_edge(1, 2).unwrap();
104/// g.add_edge(2, 0).unwrap();
105/// let seq = rich_club_sequence(&g, None, &[0, 1, 2], false, false, false).unwrap();
106/// assert_eq!(seq, vec![3.0, 1.0, 0.0]);
107/// ```
108pub fn rich_club_sequence(
109    graph: &Graph,
110    weights: Option<&[f64]>,
111    vertex_order: &[VertexId],
112    normalized: bool,
113    loops: bool,
114    directed: bool,
115) -> IgraphResult<Vec<f64>> {
116    let vcount = graph.vcount();
117    let ecount = graph.ecount();
118    let vcount_usize = vcount as usize;
119
120    if vertex_order.len() != vcount_usize {
121        return Err(IgraphError::InvalidArgument(format!(
122            "rich_club_sequence: vertex_order length ({}) does not match vcount ({})",
123            vertex_order.len(),
124            vcount
125        )));
126    }
127    if let Some(w) = weights {
128        if w.len() != ecount {
129            return Err(IgraphError::InvalidArgument(format!(
130                "rich_club_sequence: weights length ({}) does not match ecount ({})",
131                w.len(),
132                ecount
133            )));
134        }
135    }
136
137    // Upstream: undirected graphs ignore the caller's `directed` flag.
138    let directed_eff = directed && graph.is_directed();
139
140    // Invert the permutation: order_of[v] = position of v in vertex_order.
141    // Doubles as a permutation check — any out-of-range entry, repeat, or
142    // missing value is caught here.
143    let mut order_of: Vec<u32> = vec![u32::MAX; vcount_usize];
144    for (pos, &v) in vertex_order.iter().enumerate() {
145        if v >= vcount {
146            return Err(IgraphError::InvalidArgument(format!(
147                "rich_club_sequence: vertex_order entry {v} is out of range [0, {vcount})"
148            )));
149        }
150        let slot = &mut order_of[v as usize];
151        if *slot != u32::MAX {
152            return Err(IgraphError::InvalidArgument(format!(
153                "rich_club_sequence: vertex_order is not a permutation; vertex {v} appears more than once"
154            )));
155        }
156        // `pos < vcount_usize` and `vcount: u32`, so `pos < u32::MAX`
157        // and the conversion is infallible.
158        *slot = u32::try_from(pos).map_err(|_| {
159            IgraphError::InvalidArgument(
160                "rich_club_sequence: index exceeds u32::MAX (internal invariant)".to_string(),
161            )
162        })?;
163    }
164
165    let mut res = vec![0.0f64; vcount_usize];
166
167    // Distribute each edge's weight into the bucket of its first-removed
168    // endpoint — that is the step at which the edge disappears.
169    let ecount_u32 = u32::try_from(ecount).map_err(|_| {
170        IgraphError::InvalidArgument(
171            "rich_club_sequence: ecount exceeds u32::MAX (internal invariant)".to_string(),
172        )
173    })?;
174    for eid in 0..ecount_u32 {
175        let (v1, v2) = graph.edge(eid as EdgeId)?;
176        let o1 = order_of[v1 as usize];
177        let o2 = order_of[v2 as usize];
178        let bucket = o1.min(o2) as usize;
179        let contribution = match weights {
180            Some(w) => w[eid as usize],
181            None => 1.0,
182        };
183        res[bucket] += contribution;
184    }
185
186    // Reverse cumulative sum: res[i] becomes the surviving weight after
187    // i removals (= sum of contributions of every edge whose first
188    // endpoint is removed at step ≥ i).
189    let mut total = 0.0f64;
190    for slot in res.iter_mut().rev() {
191        total += *slot;
192        *slot = total;
193    }
194
195    if normalized {
196        // `vcount_usize == vertex_order.len() == res.len()`; the
197        // enumerate counter is bounded by it, which itself fits in u32
198        // because `vcount: u32`. So the try_from is infallible.
199        for (i, slot) in res.iter_mut().enumerate() {
200            let i_u32 = u32::try_from(i).map_err(|_| {
201                IgraphError::InvalidArgument(
202                    "rich_club_sequence: index exceeds u32::MAX (internal invariant)".to_string(),
203                )
204            })?;
205            let remaining = vcount - i_u32;
206            *slot /= total_possible_edges(remaining, directed_eff, loops);
207        }
208    }
209
210    Ok(res)
211}
212
213/// Total possible edges on a `n`-vertex graph, matching upstream's
214/// per-mode denominator. Returns `f64` because the loopless undirected
215/// case `n=0,1` yields zero (and `0/0 = NaN` is the intended sentinel).
216///
217/// | loops? | directed? | denominator |
218/// |--------|-----------|-------------|
219/// | no     | no        | `n(n-1)/2`  |
220/// | no     | yes       | `n(n-1)`    |
221/// | yes    | no        | `n(n+1)/2`  |
222/// | yes    | yes       | `n²`        |
223fn total_possible_edges(n: u32, directed: bool, loops: bool) -> f64 {
224    // Widen to f64 once up front — same behaviour as the C `igraph_real_t`
225    // cast in upstream's `total_possible_edges`.
226    let nv = f64::from(n);
227    if loops {
228        if directed {
229            nv * nv
230        } else {
231            nv * (nv + 1.0) / 2.0
232        }
233    } else if directed {
234        nv * (nv - 1.0)
235    } else {
236        nv * (nv - 1.0) / 2.0
237    }
238}
239
240#[cfg(test)]
241#[allow(
242    // Comparisons in these tests are against exact integer-valued
243    // doubles (e.g. raw edge counts, denominator-table values); strict
244    // equality is correct and intended.
245    clippy::float_cmp,
246    // `g.ecount() as f64` for tiny test graphs is always representable.
247    clippy::cast_precision_loss,
248)]
249mod tests {
250    use super::*;
251
252    /// Assert two `f64` slices are equal up to absolute tolerance, with
253    /// `NaN == NaN` (the upstream `.out` files print `NaN` and we want
254    /// to match that exactly).
255    fn assert_close(actual: &[f64], expected: &[f64], tol: f64) {
256        assert_eq!(
257            actual.len(),
258            expected.len(),
259            "lengths differ: actual={actual:?} expected={expected:?}"
260        );
261        for (i, (&a, &e)) in actual.iter().zip(expected.iter()).enumerate() {
262            if e.is_nan() {
263                assert!(a.is_nan(), "index {i}: expected NaN, got {a}");
264            } else {
265                assert!(
266                    (a - e).abs() <= tol,
267                    "index {i}: |{a} - {e}| = {} > {tol}",
268                    (a - e).abs()
269                );
270            }
271        }
272    }
273
274    /// Build the 7-vertex undirected, loopless test graph from upstream
275    /// Test 3 (`undirected_no_loop_graph`).
276    fn upstream_undirected_no_loop() -> Graph {
277        let mut g = Graph::with_vertices(7);
278        // Same edge list, same order as upstream `igraph_small`.
279        let edges = [
280            (0, 3),
281            (1, 3),
282            (2, 3),
283            (4, 3),
284            (5, 3),
285            (5, 6),
286            (1, 2),
287            (2, 5),
288        ];
289        for (u, v) in edges {
290            g.add_edge(u, v).expect("add_edge");
291        }
292        g
293    }
294
295    /// Build the 7-vertex directed, loopless test graph from upstream
296    /// Test 4 (`directed_no_loop_graph`).
297    fn upstream_directed_no_loop() -> Graph {
298        let mut g = Graph::new(7, true).expect("Graph::new directed");
299        let edges = [
300            (0, 2),
301            (1, 2),
302            (2, 3),
303            (1, 3),
304            (3, 5),
305            (3, 4),
306            (5, 6),
307            (6, 5),
308        ];
309        for (u, v) in edges {
310            g.add_edge(u, v).expect("add_edge");
311        }
312        g
313    }
314
315    /// Build the 7-vertex undirected graph WITH self-loop from Test 5.
316    fn upstream_undirected_loop() -> Graph {
317        let mut g = Graph::with_vertices(7);
318        let edges = [
319            (0, 3),
320            (1, 3),
321            (2, 3),
322            (4, 4),
323            (5, 3),
324            (5, 6),
325            (1, 2),
326            (2, 5),
327            (6, 4),
328        ];
329        for (u, v) in edges {
330            g.add_edge(u, v).expect("add_edge");
331        }
332        g
333    }
334
335    /// Build the 7-vertex directed graph WITH self-loop from Test 6.
336    fn upstream_directed_loop() -> Graph {
337        let mut g = Graph::new(7, true).expect("Graph::new directed");
338        let edges = [
339            (0, 2),
340            (1, 2),
341            (2, 3),
342            (1, 3),
343            (3, 5),
344            (3, 4),
345            (5, 6),
346            (6, 5),
347            (4, 4),
348        ];
349        for (u, v) in edges {
350            g.add_edge(u, v).expect("add_edge");
351        }
352        g
353    }
354
355    #[test]
356    fn test1_null_graph_matches_upstream() {
357        let g = Graph::with_vertices(0);
358        let r = rich_club_sequence(&g, None, &[], true, false, false).expect("ok");
359        assert!(r.is_empty());
360    }
361
362    #[test]
363    fn test2_singleton_matches_upstream() {
364        let g = Graph::with_vertices(1);
365        let r = rich_club_sequence(&g, None, &[0], true, false, false).expect("ok");
366        // Upstream prints `( NaN )` — 0/0 from the loopless undirected
367        // single-vertex denominator.
368        assert_eq!(r.len(), 1);
369        assert!(r[0].is_nan());
370    }
371
372    #[test]
373    fn test3a_undirected_no_loop_in_order_matches_upstream() {
374        let g = upstream_undirected_no_loop();
375        let r =
376            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, false, false).expect("ok");
377        // From rich_club.out line 8:
378        // ( 0.380952 0.466667 0.5 0.5 0.333333 1 NaN )
379        let expected = [
380            8.0 / 21.0, // 0.380952...
381            7.0 / 15.0, // 0.466666...
382            0.5,
383            0.5,
384            1.0 / 3.0, // 0.333333...
385            1.0,
386            f64::NAN,
387        ];
388        assert_close(&r, &expected, 1e-9);
389    }
390
391    #[test]
392    fn test3b_undirected_no_loop_reverse_matches_upstream() {
393        let g = upstream_undirected_no_loop();
394        let r =
395            rich_club_sequence(&g, None, &[6, 5, 4, 3, 2, 1, 0], true, false, false).expect("ok");
396        // ( 0.380952 0.466667 0.5 0.666667 0.333333 0 NaN )
397        let expected = [
398            8.0 / 21.0,
399            7.0 / 15.0,
400            0.5,
401            2.0 / 3.0,
402            1.0 / 3.0,
403            0.0,
404            f64::NAN,
405        ];
406        assert_close(&r, &expected, 1e-9);
407    }
408
409    #[test]
410    fn test4a_directed_no_loop_in_order_matches_upstream() {
411        let g = upstream_directed_no_loop();
412        let r =
413            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, false, true).expect("ok");
414        // ( 0.190476 0.233333 0.25 0.333333 0.333333 1 NaN )
415        let expected = [
416            8.0 / 42.0, // 0.190476
417            7.0 / 30.0, // 0.233333
418            0.25,
419            1.0 / 3.0,
420            1.0 / 3.0,
421            1.0,
422            f64::NAN,
423        ];
424        assert_close(&r, &expected, 1e-9);
425    }
426
427    #[test]
428    fn test4b_directed_no_loop_reverse_matches_upstream() {
429        let g = upstream_directed_no_loop();
430        let r =
431            rich_club_sequence(&g, None, &[6, 5, 4, 3, 2, 1, 0], true, false, true).expect("ok");
432        // ( 0.190476 0.2 0.25 0.333333 0.333333 0 NaN )
433        let expected = [
434            8.0 / 42.0,
435            6.0 / 30.0, // 0.2
436            3.0 / 12.0, // 0.25
437            1.0 / 3.0,
438            1.0 / 3.0,
439            0.0,
440            f64::NAN,
441        ];
442        assert_close(&r, &expected, 1e-9);
443    }
444
445    #[test]
446    fn test5a_undirected_loop_in_order_matches_upstream() {
447        let g = upstream_undirected_loop();
448        let r =
449            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, true, false).expect("ok");
450        // ( 0.321429 0.380952 0.4 0.4 0.5 0.333333 0 )
451        // (denominator n(n+1)/2: 7→28, 6→21, 5→15, 4→10, 3→6, 2→3, 1→1)
452        let expected = [
453            9.0 / 28.0,
454            8.0 / 21.0,
455            6.0 / 15.0,
456            4.0 / 10.0,
457            3.0 / 6.0,
458            1.0 / 3.0,
459            0.0 / 1.0,
460        ];
461        assert_close(&r, &expected, 1e-9);
462    }
463
464    #[test]
465    fn test5b_undirected_loop_reverse_matches_upstream() {
466        let g = upstream_undirected_loop();
467        let r =
468            rich_club_sequence(&g, None, &[6, 5, 4, 3, 2, 1, 0], true, true, false).expect("ok");
469        // ( 0.321429 0.333333 0.333333 0.4 0.166667 0 0 )
470        let expected = [
471            9.0 / 28.0,
472            7.0 / 21.0,
473            5.0 / 15.0,
474            4.0 / 10.0,
475            1.0 / 6.0,
476            0.0,
477            0.0,
478        ];
479        assert_close(&r, &expected, 1e-9);
480    }
481
482    #[test]
483    fn test6a_directed_loop_in_order_matches_upstream() {
484        let g = upstream_directed_loop();
485        let r = rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, true, true).expect("ok");
486        // ( 0.183673 0.222222 0.24 0.3125 0.333333 0.5 0 )
487        // (denominator n²: 7→49, 6→36, 5→25, 4→16, 3→9, 2→4, 1→1)
488        let expected = [
489            9.0 / 49.0,
490            8.0 / 36.0,
491            6.0 / 25.0,
492            5.0 / 16.0,
493            3.0 / 9.0,
494            2.0 / 4.0,
495            0.0,
496        ];
497        assert_close(&r, &expected, 1e-9);
498    }
499
500    #[test]
501    fn test6b_directed_loop_reverse_matches_upstream() {
502        let g = upstream_directed_loop();
503        let r = rich_club_sequence(&g, None, &[6, 5, 4, 3, 2, 1, 0], true, true, true).expect("ok");
504        // ( 0.183673 0.194444 0.24 0.25 0.222222 0 0 )
505        let expected = [
506            9.0 / 49.0,
507            7.0 / 36.0,
508            6.0 / 25.0,
509            4.0 / 16.0,
510            2.0 / 9.0,
511            0.0,
512            0.0,
513        ];
514        assert_close(&r, &expected, 1e-9);
515    }
516
517    #[test]
518    fn test7a_weighted_doubles_test3a() {
519        let g = upstream_undirected_no_loop();
520        let weights = vec![2.0; g.ecount()];
521        let r = rich_club_sequence(
522            &g,
523            Some(&weights),
524            &[0, 1, 2, 3, 4, 5, 6],
525            true,
526            false,
527            false,
528        )
529        .expect("ok");
530        // ( 0.761905 0.933333 1 1 0.666667 2 NaN ) — exactly double Test 3a.
531        let expected = [16.0 / 21.0, 14.0 / 15.0, 1.0, 1.0, 2.0 / 3.0, 2.0, f64::NAN];
532        assert_close(&r, &expected, 1e-9);
533    }
534
535    #[test]
536    fn test7b_weighted_non_integer_matches_test3a() {
537        let g = upstream_undirected_no_loop();
538        // Edge order matches upstream: (0,3), (1,3), (2,3), (4,3), (5,3),
539        // (5,6), (1,2), (2,5). Weights chosen so the unnormalized counts
540        // recover the all-ones case → result equals Test 3a.
541        let weights = [1.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.5, 1.5];
542        let r = rich_club_sequence(
543            &g,
544            Some(&weights),
545            &[0, 1, 2, 3, 4, 5, 6],
546            true,
547            false,
548            false,
549        )
550        .expect("ok");
551        let expected = [8.0 / 21.0, 7.0 / 15.0, 0.5, 0.5, 1.0 / 3.0, 1.0, f64::NAN];
552        assert_close(&r, &expected, 1e-9);
553    }
554
555    #[test]
556    fn raw_unnormalized_returns_edge_counts() {
557        let g = upstream_undirected_no_loop();
558        let r =
559            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], false, false, false).expect("ok");
560        // 8 edges total. Per-bucket counts (= "edge removed when vertex i
561        // gets peeled"):
562        //   (0,3)→0  (1,3)→1  (2,3)→2  (4,3)→3  (5,3)→3
563        //   (5,6)→5  (1,2)→1  (2,5)→2  =>  [1, 2, 2, 2, 0, 1, 0]
564        // Reverse cumsum (= "edges remaining after i removals"):
565        //   [8, 7, 5, 3, 1, 1, 0].
566        assert_eq!(r, vec![8.0, 7.0, 5.0, 3.0, 1.0, 1.0, 0.0]);
567    }
568
569    #[test]
570    fn raw_unnormalized_undirected_no_loop_reverse() {
571        let g = upstream_undirected_no_loop();
572        let r =
573            rich_club_sequence(&g, None, &[6, 5, 4, 3, 2, 1, 0], false, false, false).expect("ok");
574        // First bucket = total edges = 8 always; last must be 0 because
575        // removing every vertex empties the graph.
576        assert_eq!(r[0], 8.0);
577        assert_eq!(r[6], 0.0);
578        // Non-increasing.
579        for w in r.windows(2) {
580            assert!(w[0] >= w[1], "non-monotone: {r:?}");
581        }
582    }
583
584    #[test]
585    fn error_vertex_order_wrong_length() {
586        let g = upstream_undirected_no_loop();
587        let r = rich_club_sequence(&g, None, &[0], true, false, false);
588        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
589    }
590
591    #[test]
592    fn error_weights_wrong_length() {
593        let g = upstream_undirected_no_loop();
594        let w = vec![1.0];
595        let r = rich_club_sequence(&g, Some(&w), &[0, 1, 2, 3, 4, 5, 6], true, false, false);
596        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
597    }
598
599    #[test]
600    fn error_vertex_order_out_of_range() {
601        let g = upstream_undirected_no_loop();
602        let r = rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 99], true, false, false);
603        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
604    }
605
606    #[test]
607    fn error_vertex_order_duplicate() {
608        let g = upstream_undirected_no_loop();
609        // 0 appears twice; 6 missing.
610        let r = rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 0], true, false, false);
611        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
612    }
613
614    #[test]
615    fn directed_flag_silently_false_on_undirected_graph() {
616        let g = upstream_undirected_no_loop();
617        // Passing directed=true on an undirected graph must produce the
618        // same result as directed=false.
619        let r_true =
620            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, false, true).expect("ok");
621        let r_false =
622            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, false, false).expect("ok");
623        assert_close(&r_true, &r_false, 0.0);
624    }
625
626    #[test]
627    fn first_entry_equals_total_when_unnormalized() {
628        // Tested across all four (directed, loops) combinations.
629        let g = upstream_undirected_no_loop();
630        let r =
631            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], false, false, false).expect("ok");
632        assert_eq!(r[0], g.ecount() as f64);
633
634        let g = upstream_directed_no_loop();
635        let r =
636            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], false, false, true).expect("ok");
637        assert_eq!(r[0], g.ecount() as f64);
638    }
639
640    #[test]
641    fn loops_normalize_avoids_nan_when_single_vertex_remains() {
642        // With loops allowed, the n=1 denominator is n(n+1)/2 = 1 ≠ 0,
643        // so the final entry should be 0.0 rather than NaN.
644        let g = upstream_undirected_no_loop();
645        let r =
646            rich_club_sequence(&g, None, &[0, 1, 2, 3, 4, 5, 6], true, true, false).expect("ok");
647        assert_eq!(r[6], 0.0);
648        assert!(r.iter().all(|x| x.is_finite()));
649    }
650
651    #[test]
652    fn total_possible_edges_table() {
653        // Spot-check the four modes at n=4.
654        assert_eq!(super::total_possible_edges(4, false, false), 6.0); // 4*3/2
655        assert_eq!(super::total_possible_edges(4, true, false), 12.0); // 4*3
656        assert_eq!(super::total_possible_edges(4, false, true), 10.0); // 4*5/2
657        assert_eq!(super::total_possible_edges(4, true, true), 16.0); // 4*4
658        // Edge: n=0 → all four cases are 0.
659        for &(d, l) in &[(false, false), (true, false), (false, true), (true, true)] {
660            assert_eq!(super::total_possible_edges(0, d, l), 0.0);
661        }
662    }
663}
664
665#[cfg(all(test, feature = "proptest-harness"))]
666mod proptests {
667    use super::*;
668    use proptest::prelude::*;
669
670    /// Build a random `(n, edges)` pair with `0 ≤ n ≤ 20` and edges
671    /// drawn from `0..n`. Allows duplicates and self-loops.
672    fn random_graph_strategy(directed: bool) -> impl Strategy<Value = Graph> {
673        (1usize..=20).prop_flat_map(move |n| {
674            let edge_count = 0usize..=(2 * n);
675            edge_count
676                .prop_flat_map(move |m| {
677                    let n_u32 = n as u32;
678                    (Just(n_u32), prop::collection::vec((0..n_u32, 0..n_u32), m))
679                })
680                .prop_map(move |(n_u32, edges)| {
681                    let mut g = Graph::new(n_u32, directed).expect("Graph::new");
682                    for (u, v) in edges {
683                        g.add_edge(u, v).expect("add_edge");
684                    }
685                    g
686                })
687        })
688    }
689
690    /// A vertex_order is a random permutation of `0..vcount`. We
691    /// generate one by shuffling `0..n`.
692    fn permutation_strategy(n: u32) -> impl Strategy<Value = Vec<u32>> {
693        Just((0..n).collect::<Vec<_>>()).prop_shuffle()
694    }
695
696    proptest! {
697        #[test]
698        fn unnormalized_first_entry_equals_ecount(g in random_graph_strategy(false)) {
699            let n = g.vcount();
700            if n == 0 {
701                let r = rich_club_sequence(&g, None, &[], false, false, false).expect("ok");
702                prop_assert!(r.is_empty());
703                return Ok(());
704            }
705            let order: Vec<u32> = (0..n).collect();
706            let r = rich_club_sequence(&g, None, &order, false, false, false).expect("ok");
707            prop_assert_eq!(r[0], g.ecount() as f64);
708        }
709
710        #[test]
711        fn unnormalized_last_entry_equals_self_loops_on_last_vertex(
712            g in random_graph_strategy(false),
713        ) {
714            // The reverse-cumsum at index n-1 collects exactly the edges
715            // whose *both* endpoints are removed at step ≥ n-1, i.e.
716            // self-loops on the last vertex of the ordering. For
717            // loopless graphs this collapses to 0.
718            let n = g.vcount();
719            if n == 0 {
720                return Ok(());
721            }
722            let order: Vec<u32> = (0..n).collect();
723            let r = rich_club_sequence(&g, None, &order, false, false, false).expect("ok");
724            let last_vertex = order[(n - 1) as usize];
725            let mut self_loops_on_last = 0u32;
726            for eid in 0..g.ecount() {
727                let (u, v) = g.edge(eid as u32).expect("edge");
728                if u == last_vertex && v == last_vertex {
729                    self_loops_on_last += 1;
730                }
731            }
732            prop_assert_eq!(r[(n - 1) as usize], f64::from(self_loops_on_last));
733        }
734
735        #[test]
736        fn unnormalized_monotone_non_increasing(g in random_graph_strategy(true)) {
737            let n = g.vcount();
738            if n == 0 {
739                return Ok(());
740            }
741            let order: Vec<u32> = (0..n).collect();
742            let r = rich_club_sequence(&g, None, &order, false, false, true).expect("ok");
743            for w in r.windows(2) {
744                prop_assert!(w[0] >= w[1], "non-monotone at window {w:?}");
745            }
746        }
747
748        #[test]
749        fn permutation_invariant_first_entry(
750            g in random_graph_strategy(false),
751        ) {
752            let n = g.vcount();
753            if n == 0 {
754                return Ok(());
755            }
756            // For any permutation, the first entry (no removals yet) is
757            // always the total edge count / weight — the order picks
758            // *when* each edge disappears, not the starting subgraph.
759            let perm_strat = permutation_strategy(n);
760            proptest!(|(order in perm_strat)| {
761                let r = rich_club_sequence(&g, None, &order, false, false, false).expect("ok");
762                prop_assert_eq!(r[0], g.ecount() as f64);
763            });
764        }
765
766        #[test]
767        fn weighted_scale_invariance(g in random_graph_strategy(false), c in 0.1f64..10.0) {
768            let n = g.vcount();
769            if n == 0 {
770                return Ok(());
771            }
772            let order: Vec<u32> = (0..n).collect();
773            let r_unit = rich_club_sequence(&g, None, &order, false, false, false).expect("ok");
774            let w: Vec<f64> = vec![c; g.ecount()];
775            let r_scaled =
776                rich_club_sequence(&g, Some(&w), &order, false, false, false).expect("ok");
777            for (a, b) in r_unit.iter().zip(r_scaled.iter()) {
778                prop_assert!((a * c - b).abs() <= 1e-9 * (1.0 + b.abs()));
779            }
780        }
781
782        #[test]
783        fn normalize_in_unit_interval_for_loopless_undirected(
784            g in random_graph_strategy(false),
785        ) {
786            let n = g.vcount();
787            if n == 0 {
788                return Ok(());
789            }
790            // Pre-condition the property: density ∈ [0, 1] only holds
791            // for *simple* graphs (no self-loops, no multi-edges).
792            // Loopless: with loops=false the denominator drops the
793            // diagonal, but the numerator still counts loops, so a graph
794            // with many self-loops can exceed 1. Simple (no multi-edges):
795            // parallel copies of the same edge contribute multiple unit
796            // weights to the same bucket, which can also push the count
797            // above the n(n-1)/2 ceiling. Skip those configurations.
798            let mut bad = false;
799            let mut seen: std::collections::HashSet<(u32, u32)> =
800                std::collections::HashSet::new();
801            for eid in 0..g.ecount() {
802                let (u, v) = g.edge(eid as u32).expect("edge");
803                if u == v {
804                    bad = true;
805                    break;
806                }
807                let key = if u <= v { (u, v) } else { (v, u) };
808                if !seen.insert(key) {
809                    bad = true;
810                    break;
811                }
812            }
813            if bad {
814                return Ok(());
815            }
816            let order: Vec<u32> = (0..n).collect();
817            let r = rich_club_sequence(&g, None, &order, true, false, false).expect("ok");
818            for (i, &x) in r.iter().enumerate() {
819                if i + 1 == n as usize {
820                    // The trailing single-vertex subgraph is 0/0 = NaN.
821                    prop_assert!(x.is_nan());
822                } else {
823                    prop_assert!((0.0..=1.0).contains(&x), "out of [0,1]: r[{i}] = {x}");
824                }
825            }
826        }
827
828        #[test]
829        fn error_on_wrong_vertex_order_length(g in random_graph_strategy(false)) {
830            let n = g.vcount();
831            // Force a wrong length: 0 if n>0, 1 if n==0.
832            let bad = if n == 0 { vec![0u32] } else { vec![] };
833            let r = rich_club_sequence(&g, None, &bad, false, false, false);
834            prop_assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
835        }
836    }
837}