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}