1use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
45
46pub fn extended_chordal_ring(nodes: u32, w: &[&[i64]], directed: bool) -> IgraphResult<Graph> {
83 if nodes < 3 {
84 return Err(IgraphError::InvalidArgument(
85 "extended_chordal_ring: an extended chordal ring has at least 3 nodes".into(),
86 ));
87 }
88
89 let nrow = w.len();
90 let period = if nrow > 0 { w[0].len() } else { 0 };
91
92 let period_u32 = if nrow > 0 {
95 if period == 0 {
96 return Err(IgraphError::InvalidArgument(
97 "extended_chordal_ring: matrix W has zero columns".into(),
98 ));
99 }
100 if w.iter().any(|row| row.len() != period) {
103 return Err(IgraphError::InvalidArgument(
104 "extended_chordal_ring: all rows of W must have the same length (period)".into(),
105 ));
106 }
107 let p = u32::try_from(period).map_err(|_| {
108 IgraphError::InvalidArgument(
109 "extended_chordal_ring: matrix period exceeds u32::MAX".into(),
110 )
111 })?;
112 if nodes % p != 0 {
113 return Err(IgraphError::InvalidArgument(format!(
114 "extended_chordal_ring: period (W ncol = {p}) must divide nodes ({nodes})"
115 )));
116 }
117 p
118 } else {
119 0
120 };
121
122 let nrow_u64 = nrow as u64;
125 let nodes_u64 = u64::from(nodes);
126 let chord_total = nodes_u64.checked_mul(nrow_u64).ok_or_else(|| {
127 IgraphError::InvalidArgument("extended_chordal_ring: nodes * nrow overflows u64".into())
128 })?;
129 let edge_total_u64 = chord_total.checked_add(nodes_u64).ok_or_else(|| {
130 IgraphError::InvalidArgument("extended_chordal_ring: total edge count overflows u64".into())
131 })?;
132 let edge_total = usize::try_from(edge_total_u64).map_err(|_| {
133 IgraphError::InvalidArgument(
134 "extended_chordal_ring: total edge count exceeds usize::MAX".into(),
135 )
136 })?;
137
138 let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(edge_total);
139
140 for i in 0..(nodes - 1) {
143 edges.push((i, i + 1));
144 }
145 edges.push((nodes - 1, 0));
146
147 if nrow > 0 {
151 let n_i64 = i64::from(nodes);
152 for i in 0..nodes {
153 let mpos = (i % period_u32) as usize;
154 for row in w {
155 let offset = row[mpos];
156 let v_i64 = (i64::from(i) + offset).rem_euclid(n_i64);
157 let v = u32::try_from(v_i64).map_err(|_| {
158 IgraphError::Internal("extended_chordal_ring: chord target out of u32 range")
159 })?;
160 edges.push((i, v));
161 }
162 }
163 }
164
165 let mut graph = Graph::new(nodes, directed)?;
166 graph.add_edges(edges)?;
167 Ok(graph)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use std::collections::BTreeMap;
174
175 fn edges_in_order(g: &Graph) -> Vec<(VertexId, VertexId)> {
176 let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
177 (0..m)
178 .map(|e| g.edge(e).expect("edge id in bounds"))
179 .collect()
180 }
181
182 fn canonical_undirected_multiset(g: &Graph) -> BTreeMap<(VertexId, VertexId), usize> {
183 let mut out = BTreeMap::new();
184 for (u, v) in edges_in_order(g) {
185 let key = if u <= v { (u, v) } else { (v, u) };
186 *out.entry(key).or_insert(0) += 1;
187 }
188 out
189 }
190
191 #[test]
192 fn rejects_nodes_less_than_three() {
193 for n in [0u32, 1, 2] {
194 let err = extended_chordal_ring(n, &[&[1]], false).expect_err("must reject n < 3");
195 match err {
196 IgraphError::InvalidArgument(_) => {}
197 other => panic!("expected InvalidArgument, got {other:?}"),
198 }
199 }
200 }
201
202 #[test]
203 fn rejects_zero_width_matrix() {
204 let empty_row: &[i64] = &[];
205 let err = extended_chordal_ring(6, &[empty_row], false)
206 .expect_err("zero-column W must be rejected");
207 match err {
208 IgraphError::InvalidArgument(_) => {}
209 other => panic!("expected InvalidArgument, got {other:?}"),
210 }
211 }
212
213 #[test]
214 fn rejects_ragged_matrix() {
215 let err = extended_chordal_ring(6, &[&[1, 2], &[3]], false)
216 .expect_err("ragged W must be rejected");
217 match err {
218 IgraphError::InvalidArgument(_) => {}
219 other => panic!("expected InvalidArgument, got {other:?}"),
220 }
221 }
222
223 #[test]
224 fn rejects_period_not_dividing_nodes() {
225 let err = extended_chordal_ring(7, &[&[1, 1, 1]], false)
227 .expect_err("non-dividing period must be rejected");
228 match err {
229 IgraphError::InvalidArgument(_) => {}
230 other => panic!("expected InvalidArgument, got {other:?}"),
231 }
232 }
233
234 #[test]
235 fn empty_w_returns_pure_cycle() {
236 let g = extended_chordal_ring(5, &[], true).expect("ok");
240 assert_eq!(g.vcount(), 5);
241 assert_eq!(g.ecount(), 5);
242 assert!(g.is_directed());
243 let expected: Vec<(u32, u32)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)];
244 assert_eq!(edges_in_order(&g), expected);
245 }
246
247 #[test]
248 fn empty_w_undirected_yields_pure_cycle_canonical() {
249 let g = extended_chordal_ring(5, &[], false).expect("ok");
250 assert_eq!(g.vcount(), 5);
251 assert_eq!(g.ecount(), 5);
252 assert!(!g.is_directed());
253 let mset = canonical_undirected_multiset(&g);
254 let expected: std::collections::BTreeMap<(u32, u32), usize> = [
255 ((0, 1), 1),
256 ((1, 2), 1),
257 ((2, 3), 1),
258 ((3, 4), 1),
259 ((0, 4), 1),
260 ]
261 .into_iter()
262 .collect();
263 assert_eq!(mset, expected);
264 }
265
266 #[test]
267 fn pentagram_directed_w_positive_matches_upstream() {
268 let g = extended_chordal_ring(5, &[&[2]], true).expect("ok");
271 assert_eq!(g.vcount(), 5);
272 assert_eq!(g.ecount(), 10);
273 assert!(g.is_directed());
274 let expected: Vec<(u32, u32)> = vec![
275 (0, 1),
276 (1, 2),
277 (2, 3),
278 (3, 4),
279 (4, 0),
280 (0, 2),
281 (1, 3),
282 (2, 4),
283 (3, 0),
284 (4, 1),
285 ];
286 assert_eq!(edges_in_order(&g), expected);
287 }
288
289 #[test]
290 fn pentagram_directed_w_negative_equivalent() {
291 let g_pos = extended_chordal_ring(5, &[&[2]], true).expect("ok");
294 let g_neg = extended_chordal_ring(5, &[&[-3]], true).expect("ok");
295 assert_eq!(edges_in_order(&g_pos), edges_in_order(&g_neg));
296 }
297
298 #[test]
299 fn article_example_12_undirected_produces_parallel_chords() {
300 let g = extended_chordal_ring(12, &[&[4, 2], &[8, 10]], false).expect("ok");
307 assert_eq!(g.vcount(), 12);
308 assert!(!g.is_directed());
309 assert_eq!(g.ecount(), 36);
311
312 let mset = canonical_undirected_multiset(&g);
313 for i in 0..12u32 {
316 let e = if i < 11 { (i, i + 1) } else { (0, 11) };
317 assert!(
318 mset.contains_key(&e),
319 "expected backbone edge {e:?} to be present"
320 );
321 }
322 let even_chords: [(u32, u32); 6] = [(0, 4), (2, 6), (4, 8), (6, 10), (0, 8), (2, 10)];
324 for &e in &even_chords {
325 assert_eq!(
326 mset.get(&e).copied().unwrap_or(0),
327 2,
328 "expected even chord {e:?} ×2"
329 );
330 }
331 let odd_chords: [(u32, u32); 6] = [(1, 3), (3, 5), (5, 7), (7, 9), (9, 11), (1, 11)];
333 for &e in &odd_chords {
334 assert_eq!(
335 mset.get(&e).copied().unwrap_or(0),
336 2,
337 "expected odd chord {e:?} ×2"
338 );
339 }
340 }
341
342 #[test]
343 fn undirected_simple_w_yields_no_self_loops() {
344 let g = extended_chordal_ring(5, &[&[2]], false).expect("ok");
347 for (u, v) in edges_in_order(&g) {
348 assert_ne!(u, v, "extended_chordal_ring should not self-loop here");
349 }
350 }
351
352 #[test]
353 fn chord_zero_offset_emits_self_loops() {
354 let g = extended_chordal_ring(4, &[&[0]], false).expect("ok");
357 assert_eq!(g.ecount(), 8);
359 let self_loops: usize = edges_in_order(&g).iter().filter(|(u, v)| u == v).count();
360 assert_eq!(self_loops, 4);
361 }
362
363 #[test]
364 fn chord_offset_equal_to_nodes_emits_self_loops() {
365 let g = extended_chordal_ring(5, &[&[5]], false).expect("ok");
367 assert_eq!(g.ecount(), 10);
368 let self_loops: usize = edges_in_order(&g).iter().filter(|(u, v)| u == v).count();
369 assert_eq!(self_loops, 5);
370 }
371
372 #[test]
373 fn large_period_two_rows_directed_edge_count() {
374 let g = extended_chordal_ring(12, &[&[1, 3, 5], &[2, 4, 6]], true).expect("ok");
376 assert_eq!(g.vcount(), 12);
377 assert_eq!(g.ecount(), 36);
378 assert!(g.is_directed());
379 }
380
381 #[test]
382 fn chord_targets_use_euclidean_modulus_for_negatives() {
383 let g = extended_chordal_ring(7, &[&[-1]], true).expect("ok");
385 let expected: Vec<(u32, u32)> = vec![
388 (0, 1),
389 (1, 2),
390 (2, 3),
391 (3, 4),
392 (4, 5),
393 (5, 6),
394 (6, 0),
395 (0, 6),
396 (1, 0),
397 (2, 1),
398 (3, 2),
399 (4, 3),
400 (5, 4),
401 (6, 5),
402 ];
403 assert_eq!(edges_in_order(&g), expected);
404 }
405
406 #[test]
407 fn directed_and_undirected_share_canonical_edge_multiset() {
408 let g_d = extended_chordal_ring(8, &[&[3, 5]], true).expect("ok");
412 let g_u = extended_chordal_ring(8, &[&[3, 5]], false).expect("ok");
413 assert_eq!(g_d.ecount(), g_u.ecount());
414 assert_eq!(
415 canonical_undirected_multiset(&g_d),
416 canonical_undirected_multiset(&g_u),
417 );
418 }
419
420 #[test]
421 fn cycle_is_always_present_in_emission_order() {
422 let g = extended_chordal_ring(6, &[&[2, 4]], true).expect("ok");
423 let first_six: Vec<_> = edges_in_order(&g).into_iter().take(6).collect();
424 let expected: Vec<(u32, u32)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)];
425 assert_eq!(first_six, expected);
426 }
427
428 #[test]
429 fn isolated_period_one_chord_offset_one_is_doubled_cycle() {
430 let g = extended_chordal_ring(5, &[&[1]], true).expect("ok");
434 assert_eq!(g.ecount(), 10);
435 let mut counts = BTreeMap::<(u32, u32), usize>::new();
437 for (u, v) in edges_in_order(&g) {
438 *counts.entry((u, v)).or_insert(0) += 1;
439 }
440 for i in 0..5u32 {
441 let arc = (i, (i + 1) % 5);
442 assert_eq!(
443 counts.get(&arc).copied().unwrap_or(0),
444 2,
445 "arc {arc:?} should appear twice"
446 );
447 }
448 }
449}
450
451#[cfg(all(test, feature = "proptest-harness"))]
452mod proptest_invariants {
453 use super::*;
454 use proptest::prelude::*;
455
456 fn rect_matrix() -> impl Strategy<Value = Vec<Vec<i64>>> {
457 (1usize..4, 1usize..4).prop_flat_map(|(rows, cols)| {
458 let cell = -50i64..50;
459 proptest::collection::vec(proptest::collection::vec(cell, cols), rows)
460 })
461 }
462
463 proptest! {
464 #![proptest_config(ProptestConfig::with_cases(64))]
465
466 #[test]
467 fn vcount_always_nodes(n in 3u32..30, w in rect_matrix(), directed in any::<bool>()) {
468 let period = w[0].len() as u32;
470 let n_adj = ((n / period).max(1)) * period;
471 if n_adj < 3 { return Ok(()); }
472 let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
473 let g = extended_chordal_ring(n_adj, &w_refs, directed).expect("ok");
474 prop_assert_eq!(g.vcount(), n_adj);
475 }
476
477 #[test]
478 fn ecount_equals_nodes_times_one_plus_nrow(
479 n in 3u32..30,
480 w in rect_matrix(),
481 directed in any::<bool>(),
482 ) {
483 let period = w[0].len() as u32;
484 let n_adj = ((n / period).max(1)) * period;
485 if n_adj < 3 { return Ok(()); }
486 let nrow = w.len();
487 let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
488 let g = extended_chordal_ring(n_adj, &w_refs, directed).expect("ok");
489 let expected = (n_adj as usize) * (1 + nrow);
490 prop_assert_eq!(g.ecount(), expected);
491 }
492
493 #[test]
494 fn directed_flag_propagates(
495 n in 3u32..20,
496 w in rect_matrix(),
497 directed in any::<bool>(),
498 ) {
499 let period = w[0].len() as u32;
500 let n_adj = ((n / period).max(1)) * period;
501 if n_adj < 3 { return Ok(()); }
502 let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
503 let g = extended_chordal_ring(n_adj, &w_refs, directed).expect("ok");
504 prop_assert_eq!(g.is_directed(), directed);
505 }
506
507 #[test]
508 fn empty_w_is_pure_cycle(n in 3u32..30, directed in any::<bool>()) {
509 let g = extended_chordal_ring(n, &[], directed).expect("ok");
510 prop_assert_eq!(g.ecount(), n as usize);
511 }
512
513 #[test]
514 fn chord_targets_are_in_range(
515 n in 3u32..30,
516 w in rect_matrix(),
517 ) {
518 let period = w[0].len() as u32;
519 let n_adj = ((n / period).max(1)) * period;
520 if n_adj < 3 { return Ok(()); }
521 let w_refs: Vec<&[i64]> = w.iter().map(|r| r.as_slice()).collect();
522 let g = extended_chordal_ring(n_adj, &w_refs, true).expect("ok");
523 let m = u32::try_from(g.ecount()).unwrap();
524 for e in 0..m {
525 let (u, v) = g.edge(e).unwrap();
526 prop_assert!(u < n_adj);
527 prop_assert!(v < n_adj);
528 }
529 }
530
531 #[test]
532 fn negative_and_positive_offsets_agree_mod_nodes(
533 n in 3u32..15,
534 ) {
535 for k in 1..(n as i64) {
538 let w_pos = vec![vec![k]];
539 let w_neg = vec![vec![k - n as i64]];
540 let pos_refs: Vec<&[i64]> = w_pos.iter().map(|r| r.as_slice()).collect();
541 let neg_refs: Vec<&[i64]> = w_neg.iter().map(|r| r.as_slice()).collect();
542 let g_pos = extended_chordal_ring(n, &pos_refs, true).expect("ok");
543 let g_neg = extended_chordal_ring(n, &neg_refs, true).expect("ok");
544 let m = u32::try_from(g_pos.ecount()).unwrap();
545 for e in 0..m {
546 prop_assert_eq!(g_pos.edge(e).unwrap(), g_neg.edge(e).unwrap());
547 }
548 }
549 }
550 }
551}