Skip to main content

rust_igraph/algorithms/constructors/
extended_chordal_ring.rs

1//! Extended chordal ring constructor (ALGO-CN-028).
2//!
3//! Counterpart of `igraph_extended_chordal_ring()` in
4//! `references/igraph/src/constructors/regular.c:868-963`.
5//!
6//! An *extended chordal ring* is the cycle `C_n` together with one
7//! additional chord per row of a chord matrix `W`. The matrix is
8//! interpreted in upstream igraph as follows:
9//!
10//! * `period = ncol(W)`. The period must divide `nodes`.
11//! * For every vertex `i ∈ [0, nodes)` and every row `j ∈ [0, nrow(W))`
12//!   the constructor emits the directed edge `(i, (i + W[j][i mod
13//!   period]) mod nodes)`. Matrix entries are allowed to be negative —
14//!   the result is reduced into `[0, nodes)` with Euclidean modulus.
15//!
16//! The total edge count is therefore `nodes + nodes * nrow(W)` (plus an
17//! extra factor of `2` only inside the candidate buffer — `igraph_create`
18//! still treats each `(i, v)` as a single directed/undirected edge). The
19//! result is **not** simplified: matrix patterns that produce the same
20//! chord twice (e.g. the article example with `W = [[4, 2], [8, 10]]` on
21//! 12 nodes) intentionally retain the parallel edges so the caller can
22//! preserve the published multigraph or simplify it themselves.
23//!
24//! Rust matrix encoding: `w` is passed as `&[&[i64]]` — a slice of rows
25//! where every row has the same length (the period). An empty `w` (no
26//! rows) skips the chord pass entirely and returns the bare cycle
27//! `C_n`; the upstream divide-by-zero in that case is replaced with a
28//! defensive no-op.
29//!
30//! Degenerate / error cases (matching upstream wherever it is defined):
31//!
32//! * `nodes < 3` → [`IgraphError::InvalidArgument`].
33//! * `nrow(W) > 0` and any row has a different length than the first →
34//!   [`IgraphError::InvalidArgument`] (the C version stores the matrix
35//!   as a true rectangle and cannot represent ragged input — we reject
36//!   it explicitly).
37//! * `nrow(W) > 0` and `period == 0` → [`IgraphError::InvalidArgument`]
38//!   (a zero-column matrix is not a valid chord spec).
39//! * `nrow(W) > 0` and `nodes % period != 0` →
40//!   [`IgraphError::InvalidArgument`].
41//!
42//! Time complexity: `O(|V| + |E|) = O(nodes · (1 + nrow))`.
43
44use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
45
46/// Build the extended chordal ring `R(nodes, W)`.
47///
48/// `nodes` is the cycle length (must be ≥ 3); `w` is a matrix of chord
49/// offsets stored row-major as a slice of equal-length rows; `directed`
50/// controls whether the resulting graph is a digraph. The cycle edges
51/// always run forward `(i, i+1)` and close with `(n-1, 0)`; chord edges
52/// run `(i, (i + W[j][i mod period]) mod nodes)`.
53///
54/// The returned graph is **not** simplified; if `W` is designed in a way
55/// that produces parallel chords or self-loops, they are preserved.
56///
57/// # Errors
58///
59/// See the module-level docs for the full list. Briefly:
60///
61/// * [`IgraphError::InvalidArgument`] — `nodes < 3`, or `w` is non-empty
62///   and ragged, or its row width is zero, or it does not divide
63///   `nodes`.
64///
65/// # Examples
66///
67/// ```
68/// use rust_igraph::extended_chordal_ring;
69///
70/// // Pentagram on 5 vertices: 5-cycle plus chords two steps ahead.
71/// // W is a 1×1 matrix containing the offset 2.
72/// let pent = extended_chordal_ring(5, &[&[2]], true).unwrap();
73/// assert_eq!(pent.vcount(), 5);
74/// assert_eq!(pent.ecount(), 10); // 5 cycle + 5 chord
75/// assert!(pent.is_directed());
76///
77/// // Negative offsets are allowed — W = [[-3]] gives the same edge set
78/// // as W = [[2]] in the directed pentagram, since (i - 3) ≡ (i + 2) mod 5.
79/// let alt = extended_chordal_ring(5, &[&[-3]], true).unwrap();
80/// assert_eq!(alt.ecount(), 10);
81/// ```
82pub 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    // Compute and validate the period eagerly so the chord pass can use
93    // the already-narrowed u32 form without an `as`-cast.
94    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        // Reject ragged input — upstream's `igraph_matrix_int_t` is a
101        // rectangle and we want the same contract.
102        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    // Edge-count budget: cycle (`nodes`) + chord pass (`nodes * nrow`).
123    // Use checked arithmetic so a pathological `nrow` cannot wrap.
124    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    // 1) Backbone cycle C_n — emission order matches upstream:
141    //    (0,1) (1,2) ... (n-2, n-1) (n-1, 0).
142    for i in 0..(nodes - 1) {
143        edges.push((i, i + 1));
144    }
145    edges.push((nodes - 1, 0));
146
147    // 2) Chord pass. Per the C source, mpos advances every outer-i
148    //    iteration and wraps at `period`; since `period | nodes`, the
149    //    natural alternative is `(i % period)`, which we use directly.
150    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        // 7 vertices with period 3 — 7 % 3 = 1.
226        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        // No chord rows → bare C_5. Use the directed variant so the
237        // (n-1, 0) closing arc is preserved verbatim (undirected edges
238        // canonicalize endpoint order via `(lo, hi)`).
239        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        // Upstream test 1: igraph_extended_chordal_ring(5, [[2]], directed).
269        // Cycle: (0,1)(1,2)(2,3)(3,4)(4,0); chords: (0,2)(1,3)(2,4)(3,0)(4,1).
270        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        // Upstream test 1b: W = [[-3]] should be edge-identical to [[2]]
292        // because (i - 3) ≡ (i + 2) (mod 5).
293        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        // Upstream test 2: W = [[4, 2], [8, 10]] on 12 nodes, undirected.
301        // The article matrix intentionally produces double-edges in
302        // igraph's interpretation: chord row 0 column 0 emits (i, i+4)
303        // for even i, while chord row 1 column 0 emits (i, i+8) ≡ (i, i-4)
304        // for even i — same canonical edge. Same pattern on odd i with
305        // offsets 2 and 10 ≡ -2.
306        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        // Edge count: 12 cycle + 12 nodes × 2 rows = 36.
310        assert_eq!(g.ecount(), 36);
311
312        let mset = canonical_undirected_multiset(&g);
313        // Backbone cycle: 12 distinct undirected edges, each appearing
314        // exactly once.
315        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        // Even-side chord set: 6 unique edges, each with multiplicity 2.
323        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        // Odd-side chord set: 6 unique edges, each with multiplicity 2.
332        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        // For W = [[2]] on n=5, undirected, every chord (i, i+2) has
345        // distinct endpoints.
346        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        // W = [[0]] explicitly asks for chord (i, i) — should keep them
355        // (no auto-simplification).
356        let g = extended_chordal_ring(4, &[&[0]], false).expect("ok");
357        // 4 cycle + 4 self-loop chords.
358        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        // W = [[5]] on n=5 wraps back to i: every chord is a self-loop.
366        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        // n=12, W = [[1, 3, 5], [2, 4, 6]] → 12 + 12*2 = 36 edges.
375        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        // n=7, W = [[-1]]: chords (i, i-1 mod 7).
384        let g = extended_chordal_ring(7, &[&[-1]], true).expect("ok");
385        // Cycle: (0,1) (1,2) ... (5,6) (6,0).
386        // Chords: (0,6) (1,0) (2,1) (3,2) (4,3) (5,4) (6,5).
387        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        // The same W must produce the same canonical (lo, hi) endpoint
409        // multiset regardless of `directed`; only the per-edge ordering
410        // and the digraph flag differ.
411        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        // W = [[1]] on n=5, directed: chord (i, i+1) duplicates each
431        // backbone arc — total 10 edges with every (i, i+1) arc appearing
432        // twice.
433        let g = extended_chordal_ring(5, &[&[1]], true).expect("ok");
434        assert_eq!(g.ecount(), 10);
435        // Count multiplicity per (i, i+1) directed arc.
436        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            // Truncate `n` so it is a multiple of the matrix period.
469            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 any single-row W with offset k, the result is
536            // edge-identical to W with offset (k - n).
537            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}