use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
use super::star::{StarMode, star_graph};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WheelMode {
Out,
In,
Mutual,
Undirected,
}
impl WheelMode {
#[cfg(test)]
fn is_directed(self) -> bool {
!matches!(self, WheelMode::Undirected)
}
fn star_mode(self) -> StarMode {
match self {
WheelMode::Out => StarMode::Out,
WheelMode::In => StarMode::In,
WheelMode::Mutual => StarMode::Mutual,
WheelMode::Undirected => StarMode::Undirected,
}
}
}
pub fn wheel_graph(n: u32, mode: WheelMode, center: u32) -> IgraphResult<Graph> {
let mut graph = star_graph(n, mode.star_mode(), center)?;
if n <= 1 {
return Ok(graph);
}
let rim_count = (n as usize) - 1;
let forward_count = rim_count;
let per_edge = if matches!(mode, WheelMode::Mutual) {
2
} else {
1
};
let total_rim_edges = forward_count
.checked_mul(per_edge)
.ok_or(IgraphError::Internal("wheel_graph rim edge count overflow"))?;
let mut rim: Vec<(VertexId, VertexId)> = Vec::with_capacity(total_rim_edges);
if n >= 3 {
for i in 0..(n - 2) {
let (a, b) = if i < center {
let head = i;
let tail = if i + 1 < center { i + 1 } else { i + 2 };
(head, tail)
} else {
(i + 1, i + 2)
};
rim.push((a, b));
}
}
let wrap_head = if n - 2 < center { n - 2 } else { n - 1 };
let wrap_tail = u32::from(center == 0);
rim.push((wrap_head, wrap_tail));
if matches!(mode, WheelMode::Mutual) {
let forwards: Vec<(VertexId, VertexId)> = rim.clone();
for &(u, v) in forwards.iter().rev() {
rim.push((v, u));
}
}
graph.add_edges(rim)?;
Ok(graph)
}
#[cfg(test)]
mod tests {
use super::*;
fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
(0..m)
.map(|eid| g.edge(eid).expect("edge id in bounds"))
.collect()
}
#[test]
fn empty_graph_all_modes() {
for mode in [
WheelMode::Out,
WheelMode::In,
WheelMode::Mutual,
WheelMode::Undirected,
] {
let g = wheel_graph(0, mode, 0).expect("wheel n=0");
assert_eq!(g.vcount(), 0);
assert_eq!(g.ecount(), 0);
assert_eq!(g.is_directed(), mode.is_directed());
}
}
#[test]
fn singleton_is_star() {
for mode in [
WheelMode::Out,
WheelMode::In,
WheelMode::Mutual,
WheelMode::Undirected,
] {
let g = wheel_graph(1, mode, 0).expect("wheel n=1");
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
}
}
#[test]
fn two_vertex_wheel_has_self_loop_on_rim() {
let g = wheel_graph(2, WheelMode::Out, 0).expect("W2");
assert_eq!(g.vcount(), 2);
assert_eq!(g.ecount(), 2);
let edges = collect_edges(&g);
assert_eq!(edges, vec![(0, 1), (1, 1)]);
}
#[test]
fn three_vertex_wheel_has_parallel_rim_edge() {
let g = wheel_graph(3, WheelMode::Out, 0).expect("W3");
assert_eq!(g.vcount(), 3);
assert_eq!(g.ecount(), 4);
let edges = collect_edges(&g);
assert_eq!(edges, vec![(0, 1), (0, 2), (1, 2), (2, 1)]);
}
#[test]
fn w5_out_mode_edges_in_upstream_order() {
let g = wheel_graph(5, WheelMode::Out, 0).expect("W5 out");
assert!(g.is_directed());
assert_eq!(g.ecount(), 8);
let edges = collect_edges(&g);
assert_eq!(
edges,
vec![
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(1, 2),
(2, 3),
(3, 4),
(4, 1),
]
);
}
#[test]
fn w5_undirected_mode_canon_edges() {
let g = wheel_graph(5, WheelMode::Undirected, 0).expect("W5 undir");
assert!(!g.is_directed());
assert_eq!(g.ecount(), 8);
let edges = collect_edges(&g);
assert_eq!(
edges,
vec![
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(1, 2),
(2, 3),
(3, 4),
(1, 4),
]
);
}
#[test]
fn w5_mutual_mode_double_count() {
let g = wheel_graph(5, WheelMode::Mutual, 0).expect("W5 mutual");
assert!(g.is_directed());
assert_eq!(g.ecount(), 16);
let edges = collect_edges(&g);
assert_eq!(
edges,
vec![
(0, 1),
(1, 0),
(0, 2),
(2, 0),
(0, 3),
(3, 0),
(0, 4),
(4, 0),
(1, 2),
(2, 3),
(3, 4),
(4, 1),
(1, 4),
(4, 3),
(3, 2),
(2, 1),
]
);
}
#[test]
fn w5_center_two_skips_center_on_rim() {
let g = wheel_graph(5, WheelMode::Out, 2).expect("W5 center=2");
assert_eq!(g.ecount(), 8);
let edges = collect_edges(&g);
assert_eq!(
edges,
vec![
(2, 0),
(2, 1),
(2, 3),
(2, 4),
(0, 1),
(1, 3),
(3, 4),
(4, 0),
]
);
}
#[test]
fn center_out_of_range_errors() {
let err = wheel_graph(3, WheelMode::Out, 3).unwrap_err();
assert!(matches!(err, IgraphError::InvalidArgument(_)));
let err = wheel_graph(3, WheelMode::Out, 5).unwrap_err();
assert!(matches!(err, IgraphError::InvalidArgument(_)));
}
#[test]
fn rim_vertices_form_cycle_for_n_large() {
let n = 10u32;
let center = 4u32;
let g = wheel_graph(n, WheelMode::Undirected, center).expect("W10");
for v in 0..n {
let deg = g.neighbors(v).expect("neighbors").len();
let expected = if v == center { (n - 1) as usize } else { 3 };
assert_eq!(deg, expected, "vertex {v}");
}
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptest_tests {
use super::*;
use proptest::prelude::*;
fn arb_mode() -> impl Strategy<Value = WheelMode> {
prop_oneof![
Just(WheelMode::Out),
Just(WheelMode::In),
Just(WheelMode::Mutual),
Just(WheelMode::Undirected),
]
}
proptest! {
#[test]
fn edge_count_matches_formula(
n in 0u32..30,
mode in arb_mode(),
center_raw in 0u32..30,
) {
let center = if n == 0 { 0 } else { center_raw % n };
let g = wheel_graph(n, mode, center).unwrap();
prop_assert_eq!(g.vcount(), n);
prop_assert_eq!(g.is_directed(), mode.is_directed());
let per_layer = n.saturating_sub(1);
let star_total = per_layer * (if matches!(mode, WheelMode::Mutual) { 2 } else { 1 });
let rim_total = per_layer * (if matches!(mode, WheelMode::Mutual) { 2 } else { 1 });
let expected = star_total + rim_total;
let m = u32::try_from(g.ecount()).unwrap();
prop_assert_eq!(m, expected);
}
#[test]
fn invalid_center_returns_error(
n in 1u32..20,
mode in arb_mode(),
offset in 0u32..20,
) {
let bad_center = n + offset;
prop_assert!(wheel_graph(n, mode, bad_center).is_err());
}
#[test]
fn rim_avoids_center_for_n_at_least_three(
n in 3u32..30,
mode in arb_mode(),
center_raw in 0u32..30,
) {
let center = center_raw % n;
let g = wheel_graph(n, mode, center).unwrap();
let m = u32::try_from(g.ecount()).unwrap();
let mul = if matches!(mode, WheelMode::Mutual) { 2 } else { 1 };
let star_count = (n - 1) * mul;
for eid in star_count..m {
let (u, v) = g.edge(eid).unwrap();
prop_assert_ne!(u, center, "rim edge touches centre");
prop_assert_ne!(v, center, "rim edge touches centre");
}
}
}
}