use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
use std::collections::BTreeSet;
pub fn lcf(n: u32, shifts: &[i64], repeats: u32) -> IgraphResult<Graph> {
let len = u32::try_from(shifts.len()).map_err(|_| {
IgraphError::InvalidArgument(format!(
"lcf: shifts.len() = {} exceeds u32::MAX",
shifts.len()
))
})?;
let chord_count = repeats.checked_mul(len).ok_or_else(|| {
IgraphError::InvalidArgument(format!(
"lcf: repeats * shifts.len() = {repeats} * {len} overflows u32",
))
})?;
let _total_candidates = n.checked_add(chord_count).ok_or_else(|| {
IgraphError::InvalidArgument(format!(
"lcf: n + repeats * shifts.len() = {n} + {chord_count} overflows u32",
))
})?;
if n == 0 {
return Graph::new(0, false);
}
let mut edge_set: BTreeSet<(VertexId, VertexId)> = BTreeSet::new();
for i in 0..n {
let from = i;
let to = (i + 1) % n;
if from != to {
let (lo, hi) = if from < to { (from, to) } else { (to, from) };
edge_set.insert((lo, hi));
}
}
if chord_count > 0 && !shifts.is_empty() {
let n_i64 = i64::from(n);
for sptr in 0..chord_count {
let shift = shifts[(sptr % len) as usize];
let from = sptr % n;
let to_i64 = (i64::from(sptr) + shift).rem_euclid(n_i64);
let to = u32::try_from(to_i64).map_err(|_| {
IgraphError::InvalidArgument(format!("lcf: chord target {to_i64} out of u32 range"))
})?;
if from != to {
let (lo, hi) = if from < to { (from, to) } else { (to, from) };
edge_set.insert((lo, hi));
}
}
}
let mut graph = Graph::new(n, false)?;
graph.add_edges(edge_set)?;
Ok(graph)
}
#[cfg(test)]
mod tests {
use super::*;
fn canonical_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
let mut out: Vec<(VertexId, VertexId)> = (0..m)
.map(|e| g.edge(e).expect("edge in range"))
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
.collect();
out.sort_unstable();
out
}
fn degree(g: &Graph, v: VertexId) -> usize {
g.neighbors(v).expect("neighbors").len()
}
#[test]
fn null_graph_regression_bug_996() {
let g = lcf(0, &[], 0).unwrap();
assert_eq!(g.vcount(), 0);
assert_eq!(g.ecount(), 0);
assert!(!g.is_directed());
}
#[test]
fn franklin_5_minus5_repeats_6() {
let g = lcf(12, &[5, -5], 6).unwrap();
assert_eq!(g.vcount(), 12);
assert_eq!(g.ecount(), 18);
for v in 0..12 {
assert_eq!(degree(&g, v), 3, "Franklin must be 3-regular at v={v}");
}
}
#[test]
fn three_minus2_repeats_4_n8_yields_16_edges() {
let g = lcf(8, &[3, -2], 4).unwrap();
assert_eq!(g.vcount(), 8);
assert_eq!(g.ecount(), 16);
}
#[test]
fn n2_collapses_to_single_edge_two_chord_pattern() {
let g = lcf(2, &[2, -2], 2).unwrap();
assert_eq!(g.vcount(), 2);
assert_eq!(g.ecount(), 1);
assert_eq!(canonical_edges(&g), vec![(0, 1)]);
}
#[test]
fn n2_collapses_to_single_edge_one_chord() {
let g = lcf(2, &[2], 2).unwrap();
assert_eq!(g.vcount(), 2);
assert_eq!(g.ecount(), 1);
}
#[test]
fn empty_shifts_yields_pure_cycle() {
let g = lcf(6, &[], 0).unwrap();
assert_eq!(g.vcount(), 6);
assert_eq!(g.ecount(), 6);
for v in 0..6 {
assert_eq!(degree(&g, v), 2);
}
}
#[test]
fn repeats_zero_yields_pure_cycle() {
let g = lcf(5, &[1, 2, 3], 0).unwrap();
assert_eq!(g.vcount(), 5);
assert_eq!(g.ecount(), 5);
}
#[test]
fn heawood_graph_5_minus5_repeats_7() {
let g = lcf(14, &[5, -5], 7).unwrap();
assert_eq!(g.vcount(), 14);
assert_eq!(g.ecount(), 21);
for v in 0..14 {
assert_eq!(degree(&g, v), 3, "Heawood must be 3-regular at v={v}");
}
}
#[test]
fn truncated_tetrahedron_2_6_minus2_minus6_repeats_3() {
let g = lcf(12, &[2, 6, -2, -6], 3).unwrap();
assert_eq!(g.vcount(), 12);
assert_eq!(g.ecount(), 18);
for v in 0..12 {
assert_eq!(degree(&g, v), 3);
}
}
#[test]
fn truncated_octahedron_3_minus7_7_minus3_repeats_6() {
let g = lcf(24, &[3, -7, 7, -3], 6).unwrap();
assert_eq!(g.vcount(), 24);
assert_eq!(g.ecount(), 36);
for v in 0..24 {
assert_eq!(degree(&g, v), 3);
}
}
#[test]
fn n1_yields_singleton_no_edges() {
let g = lcf(1, &[3, -5], 4).unwrap();
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
}
#[test]
fn always_undirected_no_self_loops() {
let g = lcf(10, &[3, -3], 5).unwrap();
assert!(!g.is_directed());
let m = u32::try_from(g.ecount()).expect("ecount fits u32");
for e in 0..m {
let (a, b) = g.edge(e).expect("edge in range");
assert_ne!(a, b, "no self-loops");
}
}
#[test]
fn always_simple_no_parallel_edges() {
let g = lcf(8, &[3, -2], 4).unwrap();
let edges = canonical_edges(&g);
let mut unique = edges.clone();
unique.dedup();
assert_eq!(edges.len(), unique.len(), "no parallel edges");
}
#[test]
fn frucht_graph_full_12_shift_pattern() {
let g = lcf(12, &[-5, -2, -4, 2, 5, -2, 2, 5, -2, -5, 4, 2], 1).unwrap();
assert_eq!(g.vcount(), 12);
assert_eq!(g.ecount(), 18);
for v in 0..12 {
assert_eq!(degree(&g, v), 3, "Frucht must be 3-regular at v={v}");
}
}
#[test]
fn negative_shift_larger_than_n_wraps_correctly() {
let g = lcf(10, &[-100, 100], 5).unwrap();
assert_eq!(g.vcount(), 10);
let edges = canonical_edges(&g);
let mut unique = edges.clone();
unique.dedup();
assert_eq!(edges.len(), unique.len(), "no parallels");
for (a, b) in &edges {
assert_ne!(a, b);
}
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptest_tests {
use super::*;
use proptest::prelude::*;
use std::collections::BTreeSet;
fn arb_lcf_params() -> impl Strategy<Value = (u32, Vec<i64>, u32)> {
(
0u32..=30,
prop::collection::vec(-30i64..=30, 0..=6),
0u32..=4,
)
}
proptest! {
#[test]
fn always_undirected_no_self_loops_no_parallels(
(n, shifts, repeats) in arb_lcf_params()
) {
let g = lcf(n, &shifts, repeats).unwrap();
prop_assert!(!g.is_directed());
prop_assert_eq!(g.vcount(), n);
let m = u32::try_from(g.ecount()).unwrap();
let mut seen: BTreeSet<(u32, u32)> = BTreeSet::new();
for e in 0..m {
let (a, b) = g.edge(e).unwrap();
prop_assert_ne!(a, b, "self-loop survived simplify");
let canon = if a <= b { (a, b) } else { (b, a) };
prop_assert!(seen.insert(canon), "duplicate edge survived simplify");
}
}
#[test]
fn ecount_bounded_by_candidate_count(
(n, shifts, repeats) in arb_lcf_params()
) {
let g = lcf(n, &shifts, repeats).unwrap();
let backbone_edges = if n >= 2 { n as usize } else { 0 };
let chord_edges = (repeats as usize)
.checked_mul(shifts.len())
.unwrap_or(0);
prop_assert!(g.ecount() <= backbone_edges + chord_edges);
}
#[test]
fn pure_cycle_when_no_chords(n in 3u32..=30, shifts_empty in any::<bool>()) {
let shifts: Vec<i64> = if shifts_empty { vec![] } else { vec![5, -5] };
let repeats: u32 = if shifts_empty { 7 } else { 0 };
let g = lcf(n, &shifts, repeats).unwrap();
prop_assert_eq!(g.vcount(), n);
prop_assert_eq!(g.ecount(), n as usize);
for v in 0..n {
prop_assert_eq!(g.neighbors(v).unwrap().len(), 2);
}
}
#[test]
fn n_zero_yields_empty_regardless_of_pattern(
shifts in prop::collection::vec(-30i64..=30, 0..=6),
repeats in 0u32..=4,
) {
let g = lcf(0, &shifts, repeats).unwrap();
prop_assert_eq!(g.vcount(), 0);
prop_assert_eq!(g.ecount(), 0);
prop_assert!(!g.is_directed());
}
#[test]
fn n_one_yields_singleton_regardless_of_pattern(
shifts in prop::collection::vec(-30i64..=30, 0..=6),
repeats in 0u32..=4,
) {
let g = lcf(1, &shifts, repeats).unwrap();
prop_assert_eq!(g.vcount(), 1);
prop_assert_eq!(g.ecount(), 0);
}
#[test]
fn matches_unrolled_shifts(
n in 3u32..=20,
shifts in prop::collection::vec(-15i64..=15, 1..=4),
repeats in 1u32..=3,
) {
let g_repeated = lcf(n, &shifts, repeats).unwrap();
let unrolled: Vec<i64> = (0..repeats)
.flat_map(|_| shifts.iter().copied())
.collect();
let g_unrolled = lcf(n, &unrolled, 1).unwrap();
prop_assert_eq!(g_repeated.ecount(), g_unrolled.ecount());
let collect_canon = |g: &Graph| -> BTreeSet<(u32, u32)> {
let m = u32::try_from(g.ecount()).unwrap();
(0..m)
.map(|e| g.edge(e).unwrap())
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
.collect()
};
prop_assert_eq!(collect_canon(&g_repeated), collect_canon(&g_unrolled));
}
#[test]
fn maximum_degree_bounded(
(n, shifts, repeats) in arb_lcf_params()
) {
let g = lcf(n, &shifts, repeats).unwrap();
if n == 0 {
return Ok(());
}
let chord_count = (repeats as usize) * shifts.len();
let max_deg = 2usize.saturating_add(2 * chord_count);
for v in 0..n {
prop_assert!(g.neighbors(v).unwrap().len() <= max_deg);
}
}
}
}