use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
pub fn extended_chordal_ring(nodes: u32, w: &[&[i64]], directed: bool) -> IgraphResult<Graph> {
if nodes < 3 {
return Err(IgraphError::InvalidArgument(
"extended_chordal_ring: an extended chordal ring has at least 3 nodes".into(),
));
}
let nrow = w.len();
let period = if nrow > 0 { w[0].len() } else { 0 };
let period_u32 = if nrow > 0 {
if period == 0 {
return Err(IgraphError::InvalidArgument(
"extended_chordal_ring: matrix W has zero columns".into(),
));
}
if w.iter().any(|row| row.len() != period) {
return Err(IgraphError::InvalidArgument(
"extended_chordal_ring: all rows of W must have the same length (period)".into(),
));
}
let p = u32::try_from(period).map_err(|_| {
IgraphError::InvalidArgument(
"extended_chordal_ring: matrix period exceeds u32::MAX".into(),
)
})?;
if nodes % p != 0 {
return Err(IgraphError::InvalidArgument(format!(
"extended_chordal_ring: period (W ncol = {p}) must divide nodes ({nodes})"
)));
}
p
} else {
0
};
let nrow_u64 = nrow as u64;
let nodes_u64 = u64::from(nodes);
let chord_total = nodes_u64.checked_mul(nrow_u64).ok_or_else(|| {
IgraphError::InvalidArgument("extended_chordal_ring: nodes * nrow overflows u64".into())
})?;
let edge_total_u64 = chord_total.checked_add(nodes_u64).ok_or_else(|| {
IgraphError::InvalidArgument("extended_chordal_ring: total edge count overflows u64".into())
})?;
let edge_total = usize::try_from(edge_total_u64).map_err(|_| {
IgraphError::InvalidArgument(
"extended_chordal_ring: total edge count exceeds usize::MAX".into(),
)
})?;
let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(edge_total);
for i in 0..(nodes - 1) {
edges.push((i, i + 1));
}
edges.push((nodes - 1, 0));
if nrow > 0 {
let n_i64 = i64::from(nodes);
for i in 0..nodes {
let mpos = (i % period_u32) as usize;
for row in w {
let offset = row[mpos];
let v_i64 = (i64::from(i) + offset).rem_euclid(n_i64);
let v = u32::try_from(v_i64).map_err(|_| {
IgraphError::Internal("extended_chordal_ring: chord target out of u32 range")
})?;
edges.push((i, v));
}
}
}
let mut graph = Graph::new(nodes, directed)?;
graph.add_edges(edges)?;
Ok(graph)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn edges_in_order(g: &Graph) -> Vec<(VertexId, VertexId)> {
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 canonical_undirected_multiset(g: &Graph) -> BTreeMap<(VertexId, VertexId), usize> {
let mut out = BTreeMap::new();
for (u, v) in edges_in_order(g) {
let key = if u <= v { (u, v) } else { (v, u) };
*out.entry(key).or_insert(0) += 1;
}
out
}
#[test]
fn rejects_nodes_less_than_three() {
for n in [0u32, 1, 2] {
let err = extended_chordal_ring(n, &[&[1]], false).expect_err("must reject n < 3");
match err {
IgraphError::InvalidArgument(_) => {}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
}
#[test]
fn rejects_zero_width_matrix() {
let empty_row: &[i64] = &[];
let err = extended_chordal_ring(6, &[empty_row], false)
.expect_err("zero-column W must be rejected");
match err {
IgraphError::InvalidArgument(_) => {}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn rejects_ragged_matrix() {
let err = extended_chordal_ring(6, &[&[1, 2], &[3]], false)
.expect_err("ragged W must be rejected");
match err {
IgraphError::InvalidArgument(_) => {}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn rejects_period_not_dividing_nodes() {
let err = extended_chordal_ring(7, &[&[1, 1, 1]], false)
.expect_err("non-dividing period must be rejected");
match err {
IgraphError::InvalidArgument(_) => {}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn empty_w_returns_pure_cycle() {
let g = extended_chordal_ring(5, &[], true).expect("ok");
assert_eq!(g.vcount(), 5);
assert_eq!(g.ecount(), 5);
assert!(g.is_directed());
let expected: Vec<(u32, u32)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)];
assert_eq!(edges_in_order(&g), expected);
}
#[test]
fn empty_w_undirected_yields_pure_cycle_canonical() {
let g = extended_chordal_ring(5, &[], false).expect("ok");
assert_eq!(g.vcount(), 5);
assert_eq!(g.ecount(), 5);
assert!(!g.is_directed());
let mset = canonical_undirected_multiset(&g);
let expected: std::collections::BTreeMap<(u32, u32), usize> = [
((0, 1), 1),
((1, 2), 1),
((2, 3), 1),
((3, 4), 1),
((0, 4), 1),
]
.into_iter()
.collect();
assert_eq!(mset, expected);
}
#[test]
fn pentagram_directed_w_positive_matches_upstream() {
let g = extended_chordal_ring(5, &[&[2]], true).expect("ok");
assert_eq!(g.vcount(), 5);
assert_eq!(g.ecount(), 10);
assert!(g.is_directed());
let expected: Vec<(u32, u32)> = vec![
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(4, 0),
(0, 2),
(1, 3),
(2, 4),
(3, 0),
(4, 1),
];
assert_eq!(edges_in_order(&g), expected);
}
#[test]
fn pentagram_directed_w_negative_equivalent() {
let g_pos = extended_chordal_ring(5, &[&[2]], true).expect("ok");
let g_neg = extended_chordal_ring(5, &[&[-3]], true).expect("ok");
assert_eq!(edges_in_order(&g_pos), edges_in_order(&g_neg));
}
#[test]
fn article_example_12_undirected_produces_parallel_chords() {
let g = extended_chordal_ring(12, &[&[4, 2], &[8, 10]], false).expect("ok");
assert_eq!(g.vcount(), 12);
assert!(!g.is_directed());
assert_eq!(g.ecount(), 36);
let mset = canonical_undirected_multiset(&g);
for i in 0..12u32 {
let e = if i < 11 { (i, i + 1) } else { (0, 11) };
assert!(
mset.contains_key(&e),
"expected backbone edge {e:?} to be present"
);
}
let even_chords: [(u32, u32); 6] = [(0, 4), (2, 6), (4, 8), (6, 10), (0, 8), (2, 10)];
for &e in &even_chords {
assert_eq!(
mset.get(&e).copied().unwrap_or(0),
2,
"expected even chord {e:?} ×2"
);
}
let odd_chords: [(u32, u32); 6] = [(1, 3), (3, 5), (5, 7), (7, 9), (9, 11), (1, 11)];
for &e in &odd_chords {
assert_eq!(
mset.get(&e).copied().unwrap_or(0),
2,
"expected odd chord {e:?} ×2"
);
}
}
#[test]
fn undirected_simple_w_yields_no_self_loops() {
let g = extended_chordal_ring(5, &[&[2]], false).expect("ok");
for (u, v) in edges_in_order(&g) {
assert_ne!(u, v, "extended_chordal_ring should not self-loop here");
}
}
#[test]
fn chord_zero_offset_emits_self_loops() {
let g = extended_chordal_ring(4, &[&[0]], false).expect("ok");
assert_eq!(g.ecount(), 8);
let self_loops: usize = edges_in_order(&g).iter().filter(|(u, v)| u == v).count();
assert_eq!(self_loops, 4);
}
#[test]
fn chord_offset_equal_to_nodes_emits_self_loops() {
let g = extended_chordal_ring(5, &[&[5]], false).expect("ok");
assert_eq!(g.ecount(), 10);
let self_loops: usize = edges_in_order(&g).iter().filter(|(u, v)| u == v).count();
assert_eq!(self_loops, 5);
}
#[test]
fn large_period_two_rows_directed_edge_count() {
let g = extended_chordal_ring(12, &[&[1, 3, 5], &[2, 4, 6]], true).expect("ok");
assert_eq!(g.vcount(), 12);
assert_eq!(g.ecount(), 36);
assert!(g.is_directed());
}
#[test]
fn chord_targets_use_euclidean_modulus_for_negatives() {
let g = extended_chordal_ring(7, &[&[-1]], true).expect("ok");
let expected: Vec<(u32, u32)> = vec![
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(4, 5),
(5, 6),
(6, 0),
(0, 6),
(1, 0),
(2, 1),
(3, 2),
(4, 3),
(5, 4),
(6, 5),
];
assert_eq!(edges_in_order(&g), expected);
}
#[test]
fn directed_and_undirected_share_canonical_edge_multiset() {
let g_d = extended_chordal_ring(8, &[&[3, 5]], true).expect("ok");
let g_u = extended_chordal_ring(8, &[&[3, 5]], false).expect("ok");
assert_eq!(g_d.ecount(), g_u.ecount());
assert_eq!(
canonical_undirected_multiset(&g_d),
canonical_undirected_multiset(&g_u),
);
}
#[test]
fn cycle_is_always_present_in_emission_order() {
let g = extended_chordal_ring(6, &[&[2, 4]], true).expect("ok");
let first_six: Vec<_> = edges_in_order(&g).into_iter().take(6).collect();
let expected: Vec<(u32, u32)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)];
assert_eq!(first_six, expected);
}
#[test]
fn isolated_period_one_chord_offset_one_is_doubled_cycle() {
let g = extended_chordal_ring(5, &[&[1]], true).expect("ok");
assert_eq!(g.ecount(), 10);
let mut counts = BTreeMap::<(u32, u32), usize>::new();
for (u, v) in edges_in_order(&g) {
*counts.entry((u, v)).or_insert(0) += 1;
}
for i in 0..5u32 {
let arc = (i, (i + 1) % 5);
assert_eq!(
counts.get(&arc).copied().unwrap_or(0),
2,
"arc {arc:?} should appear twice"
);
}
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptest_invariants {
use super::*;
use proptest::prelude::*;
fn rect_matrix() -> impl Strategy<Value = Vec<Vec<i64>>> {
(1usize..4, 1usize..4).prop_flat_map(|(rows, cols)| {
let cell = -50i64..50;
proptest::collection::vec(proptest::collection::vec(cell, cols), rows)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn vcount_always_nodes(n in 3u32..30, w in rect_matrix(), directed in any::<bool>()) {
let period = w[0].len() as u32;
let n_adj = ((n / period).max(1)) * period;
if n_adj < 3 { return Ok(()); }
let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
let g = extended_chordal_ring(n_adj, &w_refs, directed).expect("ok");
prop_assert_eq!(g.vcount(), n_adj);
}
#[test]
fn ecount_equals_nodes_times_one_plus_nrow(
n in 3u32..30,
w in rect_matrix(),
directed in any::<bool>(),
) {
let period = w[0].len() as u32;
let n_adj = ((n / period).max(1)) * period;
if n_adj < 3 { return Ok(()); }
let nrow = w.len();
let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
let g = extended_chordal_ring(n_adj, &w_refs, directed).expect("ok");
let expected = (n_adj as usize) * (1 + nrow);
prop_assert_eq!(g.ecount(), expected);
}
#[test]
fn directed_flag_propagates(
n in 3u32..20,
w in rect_matrix(),
directed in any::<bool>(),
) {
let period = w[0].len() as u32;
let n_adj = ((n / period).max(1)) * period;
if n_adj < 3 { return Ok(()); }
let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
let g = extended_chordal_ring(n_adj, &w_refs, directed).expect("ok");
prop_assert_eq!(g.is_directed(), directed);
}
#[test]
fn empty_w_is_pure_cycle(n in 3u32..30, directed in any::<bool>()) {
let g = extended_chordal_ring(n, &[], directed).expect("ok");
prop_assert_eq!(g.ecount(), n as usize);
}
#[test]
fn chord_targets_are_in_range(
n in 3u32..30,
w in rect_matrix(),
) {
let period = w[0].len() as u32;
let n_adj = ((n / period).max(1)) * period;
if n_adj < 3 { return Ok(()); }
let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
let g = extended_chordal_ring(n_adj, &w_refs, true).expect("ok");
let m = u32::try_from(g.ecount()).unwrap();
for e in 0..m {
let (u, v) = g.edge(e).unwrap();
prop_assert!(u < n_adj);
prop_assert!(v < n_adj);
}
}
#[test]
fn negative_and_positive_offsets_agree_mod_nodes(
n in 3u32..15,
) {
for k in 1..(n as i64) {
let w_pos = vec![vec![k]];
let w_neg = vec![vec![k - n as i64]];
let pos_refs: Vec<&[i64]> = w_pos.iter().map(|r| r.as_slice()).collect();
let neg_refs: Vec<&[i64]> = w_neg.iter().map(|r| r.as_slice()).collect();
let g_pos = extended_chordal_ring(n, &pos_refs, true).expect("ok");
let g_neg = extended_chordal_ring(n, &neg_refs, true).expect("ok");
let m = u32::try_from(g_pos.ecount()).unwrap();
for e in 0..m {
prop_assert_eq!(g_pos.edge(e).unwrap(), g_neg.edge(e).unwrap());
}
}
}
}
}