Skip to main content

rust_igraph/algorithms/constructors/
full_citation.rs

1//! Full citation graph constructor (ALGO-CN-025).
2//!
3//! Counterpart of `igraph_full_citation()` in
4//! `references/igraph/src/constructors/full.c:348-376`.
5//!
6//! A *full citation graph* is the complete DAG on `n` vertices in which
7//! every vertex `i` cites every earlier vertex `j < i`. Equivalently:
8//!
9//! * `directed = true`  → directed acyclic graph with `n·(n−1)/2` arcs
10//!   `(i, j)` for every `0 ≤ j < i < n`. The induced topological order
11//!   is `0, 1, 2, …, n−1` (in-degree of vertex `k` equals `k`,
12//!   out-degree equals `n − 1 − k`).
13//! * `directed = false` → undirected `K_n` with `n·(n−1)/2` edges. The
14//!   edge *multiset* matches [`full_graph(n, false, false)`][super::full::full_graph],
15//!   but the *emission order* is row-major over the destination's index
16//!   `j < i` (upstream emits `(1,0), (2,0), (2,1), (3,0), (3,1), …`),
17//!   so the edge ids assigned by [`Graph::add_edges`] differ from
18//!   `full_graph`'s undirected branch.
19//!
20//! Time complexity: `O(|V|² ) = O(|E|)`.
21//!
22//! Degenerate inputs:
23//!
24//! * `n == 0` → empty graph (vcount = 0, ecount = 0).
25//! * `n == 1` → singleton, no edges.
26
27use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
28
29/// Build the full citation graph on `n` vertices.
30///
31/// See the module documentation for the precise semantics of the
32/// `directed` flag.
33///
34/// # Errors
35///
36/// * [`IgraphError::InvalidArgument`] — the `n·(n−1)/2` edge count
37///   cannot be sized in `usize` (computed via [`usize::checked_mul`] so
38///   the failure mode is deterministic).
39///
40/// # Examples
41///
42/// ```
43/// use rust_igraph::full_citation;
44///
45/// // Directed complete DAG on 4 vertices: 6 arcs forming a transitive
46/// // closure where vertex i cites every j < i.
47/// let dag = full_citation(4, true).unwrap();
48/// assert_eq!(dag.vcount(), 4);
49/// assert_eq!(dag.ecount(), 6);
50/// assert!(dag.is_directed());
51///
52/// // Undirected → K_n (same edge multiset as full_graph(4, false, false)).
53/// let k4 = full_citation(4, false).unwrap();
54/// assert_eq!(k4.vcount(), 4);
55/// assert_eq!(k4.ecount(), 6);
56/// assert!(!k4.is_directed());
57/// ```
58pub fn full_citation(n: u32, directed: bool) -> IgraphResult<Graph> {
59    if n == 0 {
60        return Graph::new(0, directed);
61    }
62    if n == 1 {
63        return Graph::new(1, directed);
64    }
65
66    let n_us = usize::try_from(n).map_err(|_| {
67        IgraphError::InvalidArgument(format!("full_citation: n {n} cannot fit usize"))
68    })?;
69    // Total edges: n·(n−1)/2 — and n·(n−1) for the overflow-safe product
70    // path mirrors the upstream `IGRAPH_SAFE_MULT(n, n-1, …)` check.
71    let prod = n_us.checked_mul(n_us - 1).ok_or_else(|| {
72        IgraphError::InvalidArgument(format!("full_citation: n·(n−1) overflows usize (n = {n})"))
73    })?;
74    let ecount = prod / 2;
75
76    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(ecount);
77    // Upstream `for i in 1..n { for j in 0..i { push (i, j) } }`, byte
78    // for byte — same emission order whether `directed` is true or false.
79    for i in 1..n {
80        for j in 0..i {
81            edges.push((i, j));
82        }
83    }
84    debug_assert_eq!(edges.len(), ecount);
85
86    let mut g = Graph::new(n, directed)?;
87    g.add_edges(edges)?;
88    Ok(g)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::collections::BTreeSet;
95
96    fn dump_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
97        let ec = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
98        (0..ec)
99            .map(|e| g.edge(e).expect("edge id in range"))
100            .collect()
101    }
102
103    fn canonical_undirected(g: &Graph) -> BTreeSet<(VertexId, VertexId)> {
104        dump_edges(g)
105            .into_iter()
106            .map(|(u, v)| if u <= v { (u, v) } else { (v, u) })
107            .collect()
108    }
109
110    #[test]
111    fn null_graph_zero_vertices() {
112        let g = full_citation(0, true).expect("ok");
113        assert_eq!(g.vcount(), 0);
114        assert_eq!(g.ecount(), 0);
115        assert!(g.is_directed());
116    }
117
118    #[test]
119    fn singleton_no_edges() {
120        let g = full_citation(1, true).expect("ok");
121        assert_eq!(g.vcount(), 1);
122        assert_eq!(g.ecount(), 0);
123    }
124
125    #[test]
126    fn directed_4_emission_order_matches_upstream() {
127        // Upstream emission walks (i = 1..4, j = 0..i):
128        // (1, 0), (2, 0), (2, 1), (3, 0), (3, 1), (3, 2).
129        let g = full_citation(4, true).expect("ok");
130        assert_eq!(g.vcount(), 4);
131        assert_eq!(g.ecount(), 6);
132        assert!(g.is_directed());
133        let want = vec![(1, 0), (2, 0), (2, 1), (3, 0), (3, 1), (3, 2)];
134        assert_eq!(dump_edges(&g), want);
135    }
136
137    fn in_out_counts(g: &Graph) -> (Vec<usize>, Vec<usize>) {
138        let n = g.vcount() as usize;
139        let mut in_count = vec![0usize; n];
140        let mut out_count = vec![0usize; n];
141        for (u, v) in dump_edges(g) {
142            out_count[u as usize] += 1;
143            in_count[v as usize] += 1;
144        }
145        (in_count, out_count)
146    }
147
148    #[test]
149    fn directed_5_in_out_degree_profile() {
150        // In a complete DAG with citation order 0..n-1: vertex i has
151        // out-arcs to every j < i, so the arcs *into* k come from i > k.
152        // That means in-degree(k) = n - 1 - k and out-degree(k) = k.
153        let n = 5u32;
154        let g = full_citation(n, true).expect("ok");
155        let (in_d, out_d) = in_out_counts(&g);
156        for k in 0..n {
157            let want_in = (n - 1 - k) as usize;
158            let want_out = k as usize;
159            assert_eq!(out_d[k as usize], want_out, "vertex {k} out-degree");
160            assert_eq!(in_d[k as usize], want_in, "vertex {k} in-degree");
161        }
162    }
163
164    #[test]
165    fn undirected_5_multiset_matches_k5() {
166        let g = full_citation(5, false).expect("ok");
167        assert_eq!(g.vcount(), 5);
168        assert_eq!(g.ecount(), 10);
169        let want: BTreeSet<(u32, u32)> = (0..5u32)
170            .flat_map(|i| ((i + 1)..5u32).map(move |j| (i, j)))
171            .collect();
172        assert_eq!(canonical_undirected(&g), want);
173    }
174
175    #[test]
176    fn undirected_10_matches_k10_count() {
177        let g = full_citation(10, false).expect("ok");
178        assert_eq!(g.ecount(), 45);
179    }
180
181    #[test]
182    fn no_self_loops_in_directed() {
183        let g = full_citation(8, true).expect("ok");
184        for (u, v) in dump_edges(&g) {
185            assert_ne!(u, v, "directed full_citation must be loop-free");
186        }
187    }
188
189    #[test]
190    fn no_self_loops_in_undirected() {
191        let g = full_citation(8, false).expect("ok");
192        for (u, v) in dump_edges(&g) {
193            assert_ne!(u, v, "undirected full_citation must be loop-free");
194        }
195    }
196
197    #[test]
198    fn directed_arcs_are_canonically_descending() {
199        // Every arc has src > dst (the citation invariant).
200        let g = full_citation(7, true).expect("ok");
201        for (u, v) in dump_edges(&g) {
202            assert!(u > v, "arc ({u}, {v}) violates citation invariant");
203        }
204    }
205
206    #[test]
207    fn directed_ecount_closed_form() {
208        for n in 0..=12u32 {
209            let g = full_citation(n, true).expect("ok");
210            let want = if n == 0 {
211                0
212            } else {
213                (n as usize) * (n as usize - 1) / 2
214            };
215            assert_eq!(g.ecount(), want, "n = {n}");
216        }
217    }
218
219    #[test]
220    fn undirected_ecount_matches_directed() {
221        for n in 0..=12u32 {
222            let du = full_citation(n, false).expect("ok");
223            let dd = full_citation(n, true).expect("ok");
224            assert_eq!(du.ecount(), dd.ecount(), "n = {n}");
225        }
226    }
227
228    #[test]
229    fn directed_flag_round_trips() {
230        assert!(full_citation(5, true).expect("ok").is_directed());
231        assert!(!full_citation(5, false).expect("ok").is_directed());
232    }
233
234    #[cfg(all(test, feature = "proptest-harness"))]
235    mod prop {
236        use super::*;
237        use proptest::prelude::*;
238
239        proptest! {
240            #![proptest_config(ProptestConfig::with_cases(64))]
241
242            #[test]
243            fn ecount_is_triangular_for_directed(n in 0u32..=40) {
244                let g = full_citation(n, true).expect("ok");
245                let want = (n as usize) * (n as usize).saturating_sub(1) / 2;
246                prop_assert_eq!(g.ecount(), want);
247            }
248
249            #[test]
250            fn ecount_is_triangular_for_undirected(n in 0u32..=40) {
251                let g = full_citation(n, false).expect("ok");
252                let want = (n as usize) * (n as usize).saturating_sub(1) / 2;
253                prop_assert_eq!(g.ecount(), want);
254            }
255
256            #[test]
257            fn vcount_round_trips(n in 0u32..=80) {
258                let g = full_citation(n, true).expect("ok");
259                prop_assert_eq!(g.vcount(), n);
260            }
261
262            #[test]
263            fn directed_arcs_descending(n in 0u32..=40) {
264                let g = full_citation(n, true).expect("ok");
265                let ec = u32::try_from(g.ecount()).expect("ec");
266                for e in 0..ec {
267                    let (u, v) = g.edge(e).expect("edge");
268                    prop_assert!(u > v, "non-descending arc ({}, {})", u, v);
269                }
270            }
271
272            #[test]
273            fn in_degree_profile_directed(n in 1u32..=40) {
274                let g = full_citation(n, true).expect("ok");
275                let (in_d, out_d) = in_out_counts(&g);
276                for k in 0..n {
277                    let want_in = (n - 1 - k) as usize;
278                    let want_out = k as usize;
279                    prop_assert_eq!(in_d[k as usize], want_in);
280                    prop_assert_eq!(out_d[k as usize], want_out);
281                }
282            }
283
284            #[test]
285            fn undirected_is_simple_no_loops(n in 0u32..=40) {
286                let g = full_citation(n, false).expect("ok");
287                let ec = u32::try_from(g.ecount()).expect("ec");
288                let mut seen = std::collections::HashSet::new();
289                for e in 0..ec {
290                    let (u, v) = g.edge(e).expect("edge");
291                    prop_assert_ne!(u, v, "self loop ({}, {}) emitted", u, v);
292                    let key = if u <= v { (u, v) } else { (v, u) };
293                    prop_assert!(seen.insert(key), "parallel edge {:?}", key);
294                }
295            }
296        }
297    }
298}