use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
pub fn circulant(n: u32, shifts: &[i64], directed: bool) -> IgraphResult<Graph> {
if n == 0 {
return Graph::new(0, directed);
}
let n_i64 = i64::from(n);
let mut seen: Vec<u32> = Vec::with_capacity(shifts.len());
seen.push(0);
let mut canonical_shifts: Vec<u32> = Vec::with_capacity(shifts.len());
for &raw in shifts {
let mut s = raw % n_i64;
if s < 0 {
s += n_i64;
}
let mut s = u32::try_from(s).map_err(|_| {
IgraphError::InvalidArgument(format!(
"circulant: shift {raw} (normalised {s}) cannot fit u32"
))
})?;
if !directed && s >= n.div_ceil(2) {
s = n - s;
}
if !seen.contains(&s) {
seen.push(s);
canonical_shifts.push(s);
}
}
let cap = usize::try_from(n)
.ok()
.and_then(|nu| nu.checked_mul(canonical_shifts.len()))
.ok_or_else(|| {
IgraphError::InvalidArgument(format!(
"circulant: n * |shifts| overflows usize (n = {n}, |shifts| = {})",
canonical_shifts.len()
))
})?;
let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(cap);
for &shift in &canonical_shifts {
let limit = if !directed && n % 2 == 0 && shift == n / 2 {
n / 2
} else {
n
};
for j in 0..limit {
edges.push((j, (j + shift) % n));
}
}
let mut g = Graph::new(n, directed)?;
g.add_edges(edges)?;
Ok(g)
}
#[cfg(test)]
mod tests {
use super::*;
fn dump_edges(g: &Graph) -> Vec<(u32, u32)> {
let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
(0..m)
.map(|e| g.edge(e).expect("edge id in bounds"))
.collect()
}
fn canon(u: u32, v: u32) -> (u32, u32) {
if u <= v { (u, v) } else { (v, u) }
}
fn canon_set(edges: &[(u32, u32)]) -> std::collections::BTreeSet<(u32, u32)> {
edges.iter().map(|&(u, v)| canon(u, v)).collect()
}
#[test]
fn empty_graph_for_n_zero() {
let g = circulant(0, &[1], false).expect("n=0 ok");
assert_eq!(g.vcount(), 0);
assert_eq!(g.ecount(), 0);
}
#[test]
fn n_one_with_any_shift_is_singleton_no_edges() {
let g = circulant(1, &[0, 1, 2, 5], false).expect("n=1 ok");
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
}
#[test]
fn shift_one_equals_ring_graph() {
for n in 3..=12u32 {
let g = circulant(n, &[1], false).expect("valid");
assert_eq!(g.vcount(), n);
assert_eq!(g.ecount(), n as usize);
for v in 0..n {
assert_eq!(
g.degree(v).expect("in range"),
2,
"C_{n} should be 2-regular"
);
}
}
}
#[test]
fn directed_shift_one_is_directed_cycle() {
let g = circulant(5, &[1], true).expect("valid");
assert!(g.is_directed());
assert_eq!(g.ecount(), 5);
let edges = dump_edges(&g);
for j in 0..5 {
assert!(edges.iter().any(|&e| e == (j, (j + 1) % 5)));
}
}
#[test]
fn shift_zero_yields_no_edges() {
let g = circulant(5, &[0], false).expect("ok");
assert_eq!(g.ecount(), 0);
}
#[test]
fn duplicate_shifts_are_deduplicated() {
let g = circulant(7, &[2, 2, 9, -5], false).expect("ok");
assert_eq!(g.ecount(), 7);
}
#[test]
fn undirected_complementary_shift_collapses() {
let a = circulant(7, &[2], false).expect("ok");
let b = circulant(7, &[5], false).expect("ok");
assert_eq!(canon_set(&dump_edges(&a)), canon_set(&dump_edges(&b)));
}
#[test]
fn directed_keeps_complementary_distinct() {
let a = circulant(7, &[2], true).expect("ok");
let b = circulant(7, &[5], true).expect("ok");
let ea: std::collections::BTreeSet<(u32, u32)> = dump_edges(&a).into_iter().collect();
let eb: std::collections::BTreeSet<(u32, u32)> = dump_edges(&b).into_iter().collect();
assert_ne!(ea, eb);
}
#[test]
fn even_n_antipodal_shift_halves_emission() {
let g = circulant(6, &[3], false).expect("ok");
assert_eq!(g.ecount(), 3);
let edges = canon_set(&dump_edges(&g));
for j in 0..3u32 {
assert!(edges.contains(&(j, j + 3)));
}
}
#[test]
fn odd_n_no_antipodal_shortcut() {
let g = circulant(7, &[3], false).expect("ok");
assert_eq!(g.ecount(), 7);
}
#[test]
fn complete_graph_via_all_distinct_shifts() {
for n in 3..=10u32 {
let shifts: Vec<i64> = (1..=(n / 2)).map(i64::from).collect();
let g = circulant(n, &shifts, false).expect("valid");
let expected_edges = (n as usize) * ((n as usize) - 1) / 2;
assert_eq!(g.ecount(), expected_edges, "K_{n}");
for v in 0..n {
assert_eq!(
g.degree(v).expect("in range"),
(n - 1) as usize,
"K_{n} regularity"
);
}
}
}
#[test]
fn negative_shifts_normalised_into_range() {
let g = circulant(5, &[-1], false).expect("ok");
let r = circulant(5, &[1], false).expect("ok");
assert_eq!(canon_set(&dump_edges(&g)), canon_set(&dump_edges(&r)));
}
#[test]
fn no_self_loops_in_any_simple_case() {
for n in 1..=12u32 {
let max_shift = i64::from(n / 2);
if max_shift == 0 {
continue;
}
let shifts: Vec<i64> = (1..=max_shift).collect();
let g = circulant(n, &shifts, false).expect("valid");
for (u, v) in dump_edges(&g) {
assert_ne!(u, v, "self-loop in circulant({n}, [{shifts:?}])");
}
}
}
#[test]
fn no_parallel_edges() {
for n in 3..=12u32 {
let shifts: Vec<i64> = (1..=i64::from(n / 2)).collect();
let g = circulant(n, &shifts, false).expect("valid");
let edges = dump_edges(&g);
let canon: std::collections::HashSet<(u32, u32)> = edges
.iter()
.map(|&(u, v)| super::tests::canon(u, v))
.collect();
assert_eq!(
canon.len(),
edges.len(),
"parallel edges in circulant({n}, [{shifts:?}])"
);
}
}
#[test]
fn matches_inner_layer_of_generalized_petersen() {
use crate::generalized_petersen;
for n in 3..=12u32 {
let max_k = (n - 1) / 2;
for k in 1..=max_k {
let gpg = generalized_petersen(n, k).expect("valid");
let inner_edges: std::collections::BTreeSet<(u32, u32)> = (0..gpg.ecount())
.map(|e| {
gpg.edge(u32::try_from(e).expect("ecount fits u32"))
.expect("in range")
})
.filter(|&(u, v)| u >= n && v >= n)
.map(|(u, v)| canon(u - n, v - n))
.collect();
let c = circulant(n, &[i64::from(k)], false).expect("valid");
let c_edges = canon_set(&dump_edges(&c));
assert_eq!(inner_edges, c_edges, "inner layer of G({n},{k})");
}
}
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptest_invariants {
use super::*;
use proptest::prelude::*;
fn dump_edges(g: &Graph) -> Vec<(u32, u32)> {
let m = u32::try_from(g.ecount()).expect("ecount fits u32");
(0..m)
.map(|e| g.edge(e).expect("edge id in bounds"))
.collect()
}
fn canon(u: u32, v: u32) -> (u32, u32) {
if u <= v { (u, v) } else { (v, u) }
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 64,
max_shrink_iters: 1000,
.. ProptestConfig::default()
})]
#[test]
fn simple_undirected(n in 1u32..=40, mask in 0u64..(1u64 << 20)) {
let max = (n / 2).min(20);
if max == 0 { return Ok(()); }
let shifts: Vec<i64> = (1..=max)
.filter(|s| mask & (1u64 << (s - 1)) != 0)
.map(i64::from)
.collect();
if shifts.is_empty() { return Ok(()); }
let g = circulant(n, &shifts, false).expect("valid");
let mut set: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for (u, v) in dump_edges(&g) {
prop_assert_ne!(u, v);
prop_assert!(set.insert(canon(u, v)));
}
}
#[test]
fn vertex_regularity(n in 2u32..=30, mask in 0u64..(1u64 << 15)) {
let max = (n / 2).min(15);
if max == 0 { return Ok(()); }
let shifts: Vec<i64> = (1..=max)
.filter(|s| mask & (1u64 << (s - 1)) != 0)
.map(i64::from)
.collect();
if shifts.is_empty() { return Ok(()); }
let g = circulant(n, &shifts, false).expect("valid");
let d0 = g.degree(0).expect("in range");
for v in 1..n {
prop_assert_eq!(g.degree(v).expect("in range"), d0, "vertex {} differs", v);
}
}
#[test]
fn negative_shifts_equiv(n in 3u32..=30, raw_shift in 1i64..=200) {
let g_pos = circulant(n, &[raw_shift], false).expect("valid");
let g_neg = circulant(n, &[-raw_shift], false).expect("valid");
let ep: std::collections::BTreeSet<(u32, u32)> =
dump_edges(&g_pos).into_iter().map(|(u, v)| canon(u, v)).collect();
let en: std::collections::BTreeSet<(u32, u32)> =
dump_edges(&g_neg).into_iter().map(|(u, v)| canon(u, v)).collect();
prop_assert_eq!(ep, en);
}
#[test]
fn complete_specialization(n in 2u32..=30) {
let shifts: Vec<i64> = (1..=(n / 2)).map(i64::from).collect();
let g = circulant(n, &shifts, false).expect("valid");
let exp = (n as usize) * ((n as usize) - 1) / 2;
prop_assert_eq!(g.ecount(), exp);
for v in 0..n {
prop_assert_eq!(g.degree(v).expect("in range"), (n - 1) as usize);
}
}
}
}