Skip to main content

rust_igraph/algorithms/constructors/
circulant.rs

1//! Circulant graph constructor (ALGO-CN-011).
2//!
3//! Counterpart of `igraph_circulant()` in
4//! `references/igraph/src/constructors/circulant.c:51-112`.
5//!
6//! A circulant graph `G(n, shifts)` has `n` vertices `v_0, …, v_{n-1}`
7//! and, for every shift `s` in `shifts`, the edges
8//! `(v_j, v_{(j + s) mod n})` for every `j ∈ [0, n)`.
9//!
10//! The constructor handles a few canonical-form rules so the same graph
11//! is built regardless of how shifts are spelled:
12//!
13//! * Each shift is reduced modulo `n` into `[0, n)`.
14//! * In the **undirected** case, a shift `s` and its complement `n − s`
15//!   generate the same set of edges, so a shift `≥ (n + 1) / 2` is
16//!   replaced with `n − s` before dedup.
17//! * `shift == 0` is always dropped (it would emit `n` self-loops).
18//! * Repeated shifts (after the above normalisation) are deduplicated
19//!   so the result has no parallel edges.
20//! * Special case: when `n` is even, the graph is undirected, and a
21//!   shift equals `n / 2`, only the first `n / 2` edges are emitted —
22//!   the wrap-around would otherwise produce a duplicate of every
23//!   antipodal edge.
24//!
25//! Specializations:
26//!
27//! * `circulant(n, &[1], false)` is `ring_graph(n)` — the cycle `C_n`.
28//! * `circulant(n, &[k], false)` (with `0 < k < n/2`) is precisely the
29//!   inner layer of [`generalized_petersen(n, k)`].
30//! * `circulant(n, &[1, 2], false)` is the squared cycle / Möbius
31//!   ladder family.
32//! * `circulant(n, &(1..n/2).collect::<Vec<_>>(), false)` is the
33//!   complete graph `K_n` (every distinct undirected shift contributes).
34//!
35//! Time complexity: `O(|V| · |shifts|)`.
36//!
37//! [`generalized_petersen(n, k)`]: super::generalized_petersen::generalized_petersen
38
39use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
40
41/// Build the circulant graph `G(n, shifts)`.
42///
43/// The result is directed iff `directed` is `true`. The graph never
44/// contains self-loops or parallel edges; redundant or zero shifts are
45/// silently dropped per the normalisation rules described in the
46/// module-level docs.
47///
48/// # Errors
49///
50/// * [`IgraphError::InvalidArgument`] — any individual shift cannot be
51///   represented as `i64` (this only fires on negative inputs that
52///   would not round-trip, since the public type is unsigned).
53/// * [`IgraphError::InvalidArgument`] — `n * |shifts|` overflows `usize`
54///   so the edge buffer cannot be sized safely.
55///
56/// # Examples
57///
58/// ```
59/// use rust_igraph::circulant;
60///
61/// // C_6 — cycle on 6 vertices.
62/// let g = circulant(6, &[1], false).unwrap();
63/// assert_eq!(g.vcount(), 6);
64/// assert_eq!(g.ecount(), 6);
65///
66/// // K_4 — complete graph via every distinct undirected shift.
67/// let k4 = circulant(4, &[1, 2], false).unwrap();
68/// assert_eq!(k4.vcount(), 4);
69/// assert_eq!(k4.ecount(), 6); // 4*3/2
70/// ```
71pub fn circulant(n: u32, shifts: &[i64], directed: bool) -> IgraphResult<Graph> {
72    if n == 0 {
73        return Graph::new(0, directed);
74    }
75
76    // Normalise shifts: bring into [0, n); fold s and n-s in undirected.
77    // We track normalised shifts in a small Vec (deduplicated) rather
78    // than a bool[n] table — the canonical igraph pass uses bool[n],
79    // but |shifts| is generally tiny.
80    let n_i64 = i64::from(n);
81    let mut seen: Vec<u32> = Vec::with_capacity(shifts.len());
82    seen.push(0); // zero shift always suppressed (self-loops)
83
84    let mut canonical_shifts: Vec<u32> = Vec::with_capacity(shifts.len());
85    for &raw in shifts {
86        // Modulo into [0, n), Rust style: rem then add n if negative.
87        let mut s = raw % n_i64;
88        if s < 0 {
89            s += n_i64;
90        }
91        let mut s = u32::try_from(s).map_err(|_| {
92            IgraphError::InvalidArgument(format!(
93                "circulant: shift {raw} (normalised {s}) cannot fit u32"
94            ))
95        })?;
96
97        if !directed && s >= n.div_ceil(2) {
98            s = n - s;
99        }
100
101        if !seen.contains(&s) {
102            seen.push(s);
103            canonical_shifts.push(s);
104        }
105    }
106
107    // Pre-size the edge buffer using the worst-case `n * |canonical|`.
108    // Even-n undirected at shift n/2 shaves half off, but the saving is
109    // small and not worth a second pass.
110    let cap = usize::try_from(n)
111        .ok()
112        .and_then(|nu| nu.checked_mul(canonical_shifts.len()))
113        .ok_or_else(|| {
114            IgraphError::InvalidArgument(format!(
115                "circulant: n * |shifts| overflows usize (n = {n}, |shifts| = {})",
116                canonical_shifts.len()
117            ))
118        })?;
119
120    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(cap);
121    for &shift in &canonical_shifts {
122        // Even-n undirected antipodal shift halves the loop range.
123        let limit = if !directed && n % 2 == 0 && shift == n / 2 {
124            n / 2
125        } else {
126            n
127        };
128        for j in 0..limit {
129            edges.push((j, (j + shift) % n));
130        }
131    }
132
133    let mut g = Graph::new(n, directed)?;
134    g.add_edges(edges)?;
135    Ok(g)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn dump_edges(g: &Graph) -> Vec<(u32, u32)> {
143        let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
144        (0..m)
145            .map(|e| g.edge(e).expect("edge id in bounds"))
146            .collect()
147    }
148
149    fn canon(u: u32, v: u32) -> (u32, u32) {
150        if u <= v { (u, v) } else { (v, u) }
151    }
152
153    fn canon_set(edges: &[(u32, u32)]) -> std::collections::BTreeSet<(u32, u32)> {
154        edges.iter().map(|&(u, v)| canon(u, v)).collect()
155    }
156
157    #[test]
158    fn empty_graph_for_n_zero() {
159        let g = circulant(0, &[1], false).expect("n=0 ok");
160        assert_eq!(g.vcount(), 0);
161        assert_eq!(g.ecount(), 0);
162    }
163
164    #[test]
165    fn n_one_with_any_shift_is_singleton_no_edges() {
166        // Every shift normalises to 0 mod 1 and is dropped.
167        let g = circulant(1, &[0, 1, 2, 5], false).expect("n=1 ok");
168        assert_eq!(g.vcount(), 1);
169        assert_eq!(g.ecount(), 0);
170    }
171
172    #[test]
173    fn shift_one_equals_ring_graph() {
174        // circulant(n, &[1], false) is exactly C_n.
175        for n in 3..=12u32 {
176            let g = circulant(n, &[1], false).expect("valid");
177            assert_eq!(g.vcount(), n);
178            assert_eq!(g.ecount(), n as usize);
179            for v in 0..n {
180                assert_eq!(
181                    g.degree(v).expect("in range"),
182                    2,
183                    "C_{n} should be 2-regular"
184                );
185            }
186        }
187    }
188
189    #[test]
190    fn directed_shift_one_is_directed_cycle() {
191        let g = circulant(5, &[1], true).expect("valid");
192        assert!(g.is_directed());
193        assert_eq!(g.ecount(), 5);
194        // Each vertex has out-degree 1 (to (j+1) mod 5).
195        let edges = dump_edges(&g);
196        for j in 0..5 {
197            assert!(edges.iter().any(|&e| e == (j, (j + 1) % 5)));
198        }
199    }
200
201    #[test]
202    fn shift_zero_yields_no_edges() {
203        let g = circulant(5, &[0], false).expect("ok");
204        assert_eq!(g.ecount(), 0);
205    }
206
207    #[test]
208    fn duplicate_shifts_are_deduplicated() {
209        let g = circulant(7, &[2, 2, 9, -5], false).expect("ok");
210        // After mod 7: 2, 2, 2, 2 → 2 only. n - 2 = 5 also collapses to 2.
211        // Edge count = n = 7.
212        assert_eq!(g.ecount(), 7);
213    }
214
215    #[test]
216    fn undirected_complementary_shift_collapses() {
217        // For undirected n=7: shift 5 ≡ shift 2.
218        let a = circulant(7, &[2], false).expect("ok");
219        let b = circulant(7, &[5], false).expect("ok");
220        assert_eq!(canon_set(&dump_edges(&a)), canon_set(&dump_edges(&b)));
221    }
222
223    #[test]
224    fn directed_keeps_complementary_distinct() {
225        let a = circulant(7, &[2], true).expect("ok");
226        let b = circulant(7, &[5], true).expect("ok");
227        // In directed mode, shifts 2 and 5 give different edge sets.
228        let ea: std::collections::BTreeSet<(u32, u32)> = dump_edges(&a).into_iter().collect();
229        let eb: std::collections::BTreeSet<(u32, u32)> = dump_edges(&b).into_iter().collect();
230        assert_ne!(ea, eb);
231    }
232
233    #[test]
234    fn even_n_antipodal_shift_halves_emission() {
235        // n=6 undirected, shift=3: only 3 edges (perfect matching).
236        let g = circulant(6, &[3], false).expect("ok");
237        assert_eq!(g.ecount(), 3);
238        let edges = canon_set(&dump_edges(&g));
239        for j in 0..3u32 {
240            assert!(edges.contains(&(j, j + 3)));
241        }
242    }
243
244    #[test]
245    fn odd_n_no_antipodal_shortcut() {
246        // n=7 undirected, shift=3: 7 edges (no halving — there is no n/2).
247        let g = circulant(7, &[3], false).expect("ok");
248        assert_eq!(g.ecount(), 7);
249    }
250
251    #[test]
252    fn complete_graph_via_all_distinct_shifts() {
253        // K_n = circulant(n, &[1, 2, …, n/2], false) — every undirected
254        // shift class is present.
255        for n in 3..=10u32 {
256            let shifts: Vec<i64> = (1..=(n / 2)).map(i64::from).collect();
257            let g = circulant(n, &shifts, false).expect("valid");
258            let expected_edges = (n as usize) * ((n as usize) - 1) / 2;
259            assert_eq!(g.ecount(), expected_edges, "K_{n}");
260            for v in 0..n {
261                assert_eq!(
262                    g.degree(v).expect("in range"),
263                    (n - 1) as usize,
264                    "K_{n} regularity"
265                );
266            }
267        }
268    }
269
270    #[test]
271    fn negative_shifts_normalised_into_range() {
272        // shift -1 ≡ n-1 in mod, then folded to 1 in undirected mode.
273        let g = circulant(5, &[-1], false).expect("ok");
274        let r = circulant(5, &[1], false).expect("ok");
275        assert_eq!(canon_set(&dump_edges(&g)), canon_set(&dump_edges(&r)));
276    }
277
278    #[test]
279    fn no_self_loops_in_any_simple_case() {
280        for n in 1..=12u32 {
281            let max_shift = i64::from(n / 2);
282            if max_shift == 0 {
283                continue;
284            }
285            let shifts: Vec<i64> = (1..=max_shift).collect();
286            let g = circulant(n, &shifts, false).expect("valid");
287            for (u, v) in dump_edges(&g) {
288                assert_ne!(u, v, "self-loop in circulant({n}, [{shifts:?}])");
289            }
290        }
291    }
292
293    #[test]
294    fn no_parallel_edges() {
295        // The shift dedup must prevent any duplicate canonical edge.
296        for n in 3..=12u32 {
297            let shifts: Vec<i64> = (1..=i64::from(n / 2)).collect();
298            let g = circulant(n, &shifts, false).expect("valid");
299            let edges = dump_edges(&g);
300            let canon: std::collections::HashSet<(u32, u32)> = edges
301                .iter()
302                .map(|&(u, v)| super::tests::canon(u, v))
303                .collect();
304            assert_eq!(
305                canon.len(),
306                edges.len(),
307                "parallel edges in circulant({n}, [{shifts:?}])"
308            );
309        }
310    }
311
312    #[test]
313    fn matches_inner_layer_of_generalized_petersen() {
314        // The inner layer of generalized_petersen(n, k) is the circulant
315        // with shift k, just on vertex ids [n, 2n).
316        use crate::generalized_petersen;
317        for n in 3..=12u32 {
318            let max_k = (n - 1) / 2;
319            for k in 1..=max_k {
320                let gpg = generalized_petersen(n, k).expect("valid");
321                let inner_edges: std::collections::BTreeSet<(u32, u32)> = (0..gpg.ecount())
322                    .map(|e| {
323                        gpg.edge(u32::try_from(e).expect("ecount fits u32"))
324                            .expect("in range")
325                    })
326                    .filter(|&(u, v)| u >= n && v >= n)
327                    .map(|(u, v)| canon(u - n, v - n))
328                    .collect();
329                let c = circulant(n, &[i64::from(k)], false).expect("valid");
330                let c_edges = canon_set(&dump_edges(&c));
331                assert_eq!(inner_edges, c_edges, "inner layer of G({n},{k})");
332            }
333        }
334    }
335}
336
337#[cfg(all(test, feature = "proptest-harness"))]
338mod proptest_invariants {
339    use super::*;
340    use proptest::prelude::*;
341
342    fn dump_edges(g: &Graph) -> Vec<(u32, u32)> {
343        let m = u32::try_from(g.ecount()).expect("ecount fits u32");
344        (0..m)
345            .map(|e| g.edge(e).expect("edge id in bounds"))
346            .collect()
347    }
348
349    fn canon(u: u32, v: u32) -> (u32, u32) {
350        if u <= v { (u, v) } else { (v, u) }
351    }
352
353    proptest! {
354        #![proptest_config(ProptestConfig {
355            cases: 64,
356            max_shrink_iters: 1000,
357            .. ProptestConfig::default()
358        })]
359
360        /// No self-loops or parallel edges in any undirected circulant
361        /// whose shifts come from `1..=n/2`.
362        #[test]
363        fn simple_undirected(n in 1u32..=40, mask in 0u64..(1u64 << 20)) {
364            let max = (n / 2).min(20);
365            if max == 0 { return Ok(()); }
366            let shifts: Vec<i64> = (1..=max)
367                .filter(|s| mask & (1u64 << (s - 1)) != 0)
368                .map(i64::from)
369                .collect();
370            if shifts.is_empty() { return Ok(()); }
371            let g = circulant(n, &shifts, false).expect("valid");
372            let mut set: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
373            for (u, v) in dump_edges(&g) {
374                prop_assert_ne!(u, v);
375                prop_assert!(set.insert(canon(u, v)));
376            }
377        }
378
379        /// Vertex regularity: every vertex has the same degree, which is
380        /// `2 * |canonical shifts|` minus 1 per antipodal shift on even n
381        /// (because the antipodal edge counts once per endpoint, not
382        /// twice).
383        #[test]
384        fn vertex_regularity(n in 2u32..=30, mask in 0u64..(1u64 << 15)) {
385            let max = (n / 2).min(15);
386            if max == 0 { return Ok(()); }
387            let shifts: Vec<i64> = (1..=max)
388                .filter(|s| mask & (1u64 << (s - 1)) != 0)
389                .map(i64::from)
390                .collect();
391            if shifts.is_empty() { return Ok(()); }
392            let g = circulant(n, &shifts, false).expect("valid");
393            let d0 = g.degree(0).expect("in range");
394            for v in 1..n {
395                prop_assert_eq!(g.degree(v).expect("in range"), d0, "vertex {} differs", v);
396            }
397        }
398
399        /// Negative shifts always produce the same graph as their
400        /// positive normalisations in undirected mode.
401        #[test]
402        fn negative_shifts_equiv(n in 3u32..=30, raw_shift in 1i64..=200) {
403            let g_pos = circulant(n, &[raw_shift], false).expect("valid");
404            let g_neg = circulant(n, &[-raw_shift], false).expect("valid");
405            let ep: std::collections::BTreeSet<(u32, u32)> =
406                dump_edges(&g_pos).into_iter().map(|(u, v)| canon(u, v)).collect();
407            let en: std::collections::BTreeSet<(u32, u32)> =
408                dump_edges(&g_neg).into_iter().map(|(u, v)| canon(u, v)).collect();
409            prop_assert_eq!(ep, en);
410        }
411
412        /// Complete-graph specialization: `circulant(n, &[1..=n/2], false)`
413        /// has exactly `n*(n-1)/2` edges and every vertex has degree
414        /// `n-1`.
415        #[test]
416        fn complete_specialization(n in 2u32..=30) {
417            let shifts: Vec<i64> = (1..=(n / 2)).map(i64::from).collect();
418            let g = circulant(n, &shifts, false).expect("valid");
419            let exp = (n as usize) * ((n as usize) - 1) / 2;
420            prop_assert_eq!(g.ecount(), exp);
421            for v in 0..n {
422                prop_assert_eq!(g.degree(v).expect("in range"), (n - 1) as usize);
423            }
424        }
425    }
426}