Skip to main content

rust_igraph/algorithms/constructors/
wheel.rs

1//! Wheel graph constructor (ALGO-CN-003).
2//!
3//! Counterpart of `igraph_wheel()` in
4//! `references/igraph/src/constructors/regular.c:145-287`.
5//!
6//! A *wheel graph* on `n` vertices is the union of a star and a cycle:
7//! one vertex (the **centre**) is connected to every other vertex via
8//! star-spokes, and those `n - 1` rim vertices are linked by a cycle.
9//! Wheel layout is controlled by [`WheelMode`], whose four cases line
10//! up with [`crate::StarMode`]:
11//!
12//! * [`WheelMode::Out`] — directed, every spoke flows **from** the
13//!   centre to a leaf, every rim edge is `prev → next` (visited in the
14//!   `[0, center) ∪ (center, n)` raw vertex order).
15//! * [`WheelMode::In`] — directed, every spoke flows **from each leaf**
16//!   to the centre, rim direction `prev → next` matches `Out`.
17//! * [`WheelMode::Mutual`] — directed, every spoke and every rim edge
18//!   is emitted in **both** directions. Star-spokes are added first
19//!   (forward arc before back-arc, per leaf); the rim is then appended
20//!   as `e_0, e_1, …, e_{n-2}, rev(e_{n-2}), rev(e_{n-3}), …, rev(e_0)`
21//!   — i.e. forward rim sweep followed by the reverse of each forward
22//!   arc in reverse-discovery order, matching the upstream C loop
23//!   verbatim.
24//! * [`WheelMode::Undirected`] — undirected; each spoke and each rim
25//!   edge is added once, `Graph::add_edges` canonicalises endpoints to
26//!   `(min, max)`.
27//!
28//! Edge counts:
29//!
30//! * `n = 0` or `n = 1` — wheel ≡ star; no edges.
31//! * `n ≥ 2`, mode ∈ {Out, In, Undirected} — `2(n − 1)` edges
32//!   (`n − 1` spokes plus `n − 1` rim edges).
33//! * `n ≥ 2`, mode = Mutual — `4(n − 1)` edges.
34//!
35//! Degenerate two- and three-vertex wheels are intentionally non-simple
36//! (upstream `igraph_wheel` documents this): the two-vertex wheel
37//! contains a self-loop on the only rim vertex (the rim collapses to a
38//! 1-cycle), and the three-vertex wheel produces parallel edges between
39//! the two rim vertices (rim collapses to a 2-cycle).
40//!
41//! Time complexity: O(|V|).
42
43use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
44
45use super::star::{StarMode, star_graph};
46
47/// Direction policy for [`wheel_graph`].
48///
49/// Mirrors `igraph_wheel_mode_t` in the C reference. The four variants
50/// map one-to-one onto [`StarMode`] for the spoke layer.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum WheelMode {
53    /// Directed wheel, all spokes and rim arcs flow forward.
54    Out,
55    /// Directed wheel, all spokes flow from rim to centre; rim arcs
56    /// still flow `prev → next`.
57    In,
58    /// Directed wheel with both arcs on every spoke and every rim edge.
59    Mutual,
60    /// Undirected wheel.
61    Undirected,
62}
63
64impl WheelMode {
65    #[cfg(test)]
66    fn is_directed(self) -> bool {
67        !matches!(self, WheelMode::Undirected)
68    }
69
70    fn star_mode(self) -> StarMode {
71        match self {
72            WheelMode::Out => StarMode::Out,
73            WheelMode::In => StarMode::In,
74            WheelMode::Mutual => StarMode::Mutual,
75            WheelMode::Undirected => StarMode::Undirected,
76        }
77    }
78}
79
80/// Build a wheel graph on `n` vertices with the given `center` and arc
81/// policy `mode`.
82///
83/// See the module-level docs for the precise role of each mode and the
84/// degenerate two/three-vertex shapes.
85///
86/// # Errors
87///
88/// * [`IgraphError::InvalidArgument`] — `center >= n` for `n > 0` (the
89///   spoke layer rejects it via [`star_graph`]).
90/// * [`IgraphError::Internal`] — implied rim edge count would overflow
91///   `usize` (only reachable on 32-bit targets with absurdly large `n`).
92///
93/// # Examples
94///
95/// ```
96/// use rust_igraph::{wheel_graph, WheelMode};
97///
98/// // Undirected wheel W6 (5 rim vertices around centre 0).
99/// let w = wheel_graph(6, WheelMode::Undirected, 0).unwrap();
100/// assert_eq!(w.vcount(), 6);
101/// assert_eq!(w.ecount(), 2 * (6 - 1)); // 5 spokes + 5 rim edges
102/// assert!(!w.is_directed());
103/// ```
104pub fn wheel_graph(n: u32, mode: WheelMode, center: u32) -> IgraphResult<Graph> {
105    // Spoke layer + parameter validation (center bounds + n=0 short-circuit)
106    // share `star_graph`, mirroring the upstream "create star, then add rim"
107    // composition.
108    let mut graph = star_graph(n, mode.star_mode(), center)?;
109
110    // n <= 1 → wheel ≡ star (no rim possible).
111    if n <= 1 {
112        return Ok(graph);
113    }
114
115    let rim_count = (n as usize) - 1;
116    let forward_count = rim_count;
117    let per_edge = if matches!(mode, WheelMode::Mutual) {
118        2
119    } else {
120        1
121    };
122    let total_rim_edges = forward_count
123        .checked_mul(per_edge)
124        .ok_or(IgraphError::Internal("wheel_graph rim edge count overflow"))?;
125    let mut rim: Vec<(VertexId, VertexId)> = Vec::with_capacity(total_rim_edges);
126
127    // First (n - 2) rim arcs, then the wrap-around — matching the C
128    // index arithmetic in `regular.c` lines 244-269. The branch skips
129    // `center` when stepping `i → i+1` so that `center` never appears
130    // on the rim.
131    if n >= 3 {
132        for i in 0..(n - 2) {
133            let (a, b) = if i < center {
134                let head = i;
135                let tail = if i + 1 < center { i + 1 } else { i + 2 };
136                (head, tail)
137            } else {
138                (i + 1, i + 2)
139            };
140            rim.push((a, b));
141        }
142    }
143
144    // Wrap-around: last rim arc connects the largest rim vertex back to
145    // the smallest rim vertex.
146    let wrap_head = if n - 2 < center { n - 2 } else { n - 1 };
147    let wrap_tail = u32::from(center == 0);
148    rim.push((wrap_head, wrap_tail));
149
150    // Mutual mode: append the reverse of every forward arc in
151    // reverse-discovery order (forward arc i appended at "end - i"
152    // upstream → reversal flips both endpoint order and arc order).
153    if matches!(mode, WheelMode::Mutual) {
154        let forwards: Vec<(VertexId, VertexId)> = rim.clone();
155        for &(u, v) in forwards.iter().rev() {
156            rim.push((v, u));
157        }
158    }
159
160    graph.add_edges(rim)?;
161    Ok(graph)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
169        let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
170        (0..m)
171            .map(|eid| g.edge(eid).expect("edge id in bounds"))
172            .collect()
173    }
174
175    #[test]
176    fn empty_graph_all_modes() {
177        for mode in [
178            WheelMode::Out,
179            WheelMode::In,
180            WheelMode::Mutual,
181            WheelMode::Undirected,
182        ] {
183            let g = wheel_graph(0, mode, 0).expect("wheel n=0");
184            assert_eq!(g.vcount(), 0);
185            assert_eq!(g.ecount(), 0);
186            assert_eq!(g.is_directed(), mode.is_directed());
187        }
188    }
189
190    #[test]
191    fn singleton_is_star() {
192        for mode in [
193            WheelMode::Out,
194            WheelMode::In,
195            WheelMode::Mutual,
196            WheelMode::Undirected,
197        ] {
198            let g = wheel_graph(1, mode, 0).expect("wheel n=1");
199            assert_eq!(g.vcount(), 1);
200            assert_eq!(g.ecount(), 0);
201        }
202    }
203
204    #[test]
205    fn two_vertex_wheel_has_self_loop_on_rim() {
206        // For n=2 the rim collapses to a 1-cycle (wrap-around endpoints
207        // coincide on vertex 1 when center=0). Star contributes 1 edge,
208        // rim contributes 1 self-loop edge.
209        let g = wheel_graph(2, WheelMode::Out, 0).expect("W2");
210        assert_eq!(g.vcount(), 2);
211        assert_eq!(g.ecount(), 2);
212        let edges = collect_edges(&g);
213        assert_eq!(edges, vec![(0, 1), (1, 1)]);
214    }
215
216    #[test]
217    fn three_vertex_wheel_has_parallel_rim_edge() {
218        // For n=3 the rim collapses to a 2-cycle: forward (1,2) plus
219        // wrap (2,1). Star contributes (0,1), (0,2).
220        let g = wheel_graph(3, WheelMode::Out, 0).expect("W3");
221        assert_eq!(g.vcount(), 3);
222        assert_eq!(g.ecount(), 4);
223        let edges = collect_edges(&g);
224        assert_eq!(edges, vec![(0, 1), (0, 2), (1, 2), (2, 1)]);
225    }
226
227    #[test]
228    fn w5_out_mode_edges_in_upstream_order() {
229        // n=5, center=0:
230        //   star spokes: (0,1) (0,2) (0,3) (0,4)
231        //   rim forward: i=0: i<center? false → (1,2)
232        //                i=1: false → (2,3)
233        //                i=2: false → (3,4)
234        //   rim wrap: n-2=3, center=0 → head=n-1=4; center>0? false → tail=1
235        //            → (4, 1)
236        let g = wheel_graph(5, WheelMode::Out, 0).expect("W5 out");
237        assert!(g.is_directed());
238        assert_eq!(g.ecount(), 8);
239        let edges = collect_edges(&g);
240        assert_eq!(
241            edges,
242            vec![
243                (0, 1),
244                (0, 2),
245                (0, 3),
246                (0, 4),
247                (1, 2),
248                (2, 3),
249                (3, 4),
250                (4, 1),
251            ]
252        );
253    }
254
255    #[test]
256    fn w5_undirected_mode_canon_edges() {
257        // Same n=5, center=0 but undirected — endpoints get
258        // canonicalised to (min, max). Star-spokes from In/Undirected
259        // are emitted as (leaf, center) which canonicalises to
260        // (center, leaf).
261        let g = wheel_graph(5, WheelMode::Undirected, 0).expect("W5 undir");
262        assert!(!g.is_directed());
263        assert_eq!(g.ecount(), 8);
264        let edges = collect_edges(&g);
265        assert_eq!(
266            edges,
267            vec![
268                (0, 1),
269                (0, 2),
270                (0, 3),
271                (0, 4),
272                (1, 2),
273                (2, 3),
274                (3, 4),
275                (1, 4),
276            ]
277        );
278    }
279
280    #[test]
281    fn w5_mutual_mode_double_count() {
282        // n=5 mutual: star contributes 2*(n-1)=8 arcs, rim contributes
283        // 2*(n-1)=8 arcs (forward sweep + reverse-discovery sweep).
284        let g = wheel_graph(5, WheelMode::Mutual, 0).expect("W5 mutual");
285        assert!(g.is_directed());
286        assert_eq!(g.ecount(), 16);
287        let edges = collect_edges(&g);
288        // Star: forward (0,leaf) then back (leaf,0) per leaf.
289        // Rim forward: (1,2) (2,3) (3,4) (4,1)
290        // Rim reverse-discovery: (1,4) (4,3) (3,2) (2,1)
291        assert_eq!(
292            edges,
293            vec![
294                // star
295                (0, 1),
296                (1, 0),
297                (0, 2),
298                (2, 0),
299                (0, 3),
300                (3, 0),
301                (0, 4),
302                (4, 0),
303                // rim forward
304                (1, 2),
305                (2, 3),
306                (3, 4),
307                (4, 1),
308                // rim reverse-discovery
309                (1, 4),
310                (4, 3),
311                (3, 2),
312                (2, 1),
313            ]
314        );
315    }
316
317    #[test]
318    fn w5_center_two_skips_center_on_rim() {
319        // n=5, center=2 — rim must visit 0,1,3,4 in that order.
320        // Loop iterations (i in 0..n-2 = 0..3):
321        //   i=0: i<center (0<2) → head=0; i+1<center (1<2) → tail=1 ⇒ (0,1)
322        //   i=1: i<center (1<2) → head=1; i+1<center (2<2)? false → tail=i+2=3 ⇒ (1,3)
323        //   i=2: i<center? (2<2 false) → (i+1, i+2) = (3, 4)
324        // Wrap: n-2=3, center=2 → head=n-1=4 (3<2 false); center>0 → tail=0 ⇒ (4, 0)
325        let g = wheel_graph(5, WheelMode::Out, 2).expect("W5 center=2");
326        assert_eq!(g.ecount(), 8);
327        let edges = collect_edges(&g);
328        assert_eq!(
329            edges,
330            vec![
331                // star (out from centre 2)
332                (2, 0),
333                (2, 1),
334                (2, 3),
335                (2, 4),
336                // rim
337                (0, 1),
338                (1, 3),
339                (3, 4),
340                (4, 0),
341            ]
342        );
343    }
344
345    #[test]
346    fn center_out_of_range_errors() {
347        let err = wheel_graph(3, WheelMode::Out, 3).unwrap_err();
348        assert!(matches!(err, IgraphError::InvalidArgument(_)));
349        let err = wheel_graph(3, WheelMode::Out, 5).unwrap_err();
350        assert!(matches!(err, IgraphError::InvalidArgument(_)));
351    }
352
353    #[test]
354    fn rim_vertices_form_cycle_for_n_large() {
355        // For n ≥ 4 the rim should be a simple cycle through all
356        // (n - 1) leaves, each leaf with rim-degree exactly 2.
357        let n = 10u32;
358        let center = 4u32;
359        let g = wheel_graph(n, WheelMode::Undirected, center).expect("W10");
360        // Leaf degrees in the full wheel = 1 (spoke) + 2 (rim) = 3.
361        // Centre degree = n - 1 = 9.
362        for v in 0..n {
363            let deg = g.neighbors(v).expect("neighbors").len();
364            let expected = if v == center { (n - 1) as usize } else { 3 };
365            assert_eq!(deg, expected, "vertex {v}");
366        }
367    }
368}
369
370#[cfg(all(test, feature = "proptest-harness"))]
371mod proptest_tests {
372    use super::*;
373    use proptest::prelude::*;
374
375    fn arb_mode() -> impl Strategy<Value = WheelMode> {
376        prop_oneof![
377            Just(WheelMode::Out),
378            Just(WheelMode::In),
379            Just(WheelMode::Mutual),
380            Just(WheelMode::Undirected),
381        ]
382    }
383
384    proptest! {
385        #[test]
386        fn edge_count_matches_formula(
387            n in 0u32..30,
388            mode in arb_mode(),
389            center_raw in 0u32..30,
390        ) {
391            let center = if n == 0 { 0 } else { center_raw % n };
392            let g = wheel_graph(n, mode, center).unwrap();
393            prop_assert_eq!(g.vcount(), n);
394            prop_assert_eq!(g.is_directed(), mode.is_directed());
395
396            let per_layer = n.saturating_sub(1);
397            let star_total = per_layer * (if matches!(mode, WheelMode::Mutual) { 2 } else { 1 });
398            let rim_total = per_layer * (if matches!(mode, WheelMode::Mutual) { 2 } else { 1 });
399            let expected = star_total + rim_total;
400            let m = u32::try_from(g.ecount()).unwrap();
401            prop_assert_eq!(m, expected);
402        }
403
404        #[test]
405        fn invalid_center_returns_error(
406            n in 1u32..20,
407            mode in arb_mode(),
408            offset in 0u32..20,
409        ) {
410            let bad_center = n + offset;
411            prop_assert!(wheel_graph(n, mode, bad_center).is_err());
412        }
413
414        #[test]
415        fn rim_avoids_center_for_n_at_least_three(
416            n in 3u32..30,
417            mode in arb_mode(),
418            center_raw in 0u32..30,
419        ) {
420            let center = center_raw % n;
421            let g = wheel_graph(n, mode, center).unwrap();
422            let m = u32::try_from(g.ecount()).unwrap();
423            // Star occupies the first (n-1) or 2(n-1) edge slots; the
424            // remaining slots are the rim. Mutual doubles both counts.
425            let mul = if matches!(mode, WheelMode::Mutual) { 2 } else { 1 };
426            let star_count = (n - 1) * mul;
427            for eid in star_count..m {
428                let (u, v) = g.edge(eid).unwrap();
429                prop_assert_ne!(u, center, "rim edge touches centre");
430                prop_assert_ne!(v, center, "rim edge touches centre");
431            }
432        }
433    }
434}