Skip to main content

rust_igraph/algorithms/
chordality.rs

1//! Chordality algorithms.
2//!
3//! ALGO-CL-002: `maximum_cardinality_search` — Tarjan-Yannakakis 1984
4//! maximum cardinality search ordering, and `is_chordal` — chordal
5//! graph test with optional fill-in computation.
6//!
7//! Reference: `references/igraph/src/misc/chordality.c` (477 lines).
8
9#![allow(
10    clippy::cast_possible_truncation,
11    clippy::cast_sign_loss,
12    clippy::cast_possible_wrap,
13    clippy::many_single_char_names,
14    clippy::too_many_lines,
15    clippy::similar_names,
16    clippy::module_name_repetitions
17)]
18
19use crate::core::error::{IgraphError, IgraphResult};
20use crate::core::graph::{Graph, VertexId};
21
22/// Result of [`maximum_cardinality_search`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct McsResult {
25    /// `alpha[v]` is the rank of vertex `v` in the MCS ordering, in `0..n`.
26    /// Higher rank means the vertex was visited earlier.
27    pub alpha: Vec<u32>,
28    /// `alpham1[i]` is the vertex with rank `i`. This is the inverse of
29    /// `alpha`: visiting vertices in order `alpham1[n-1], alpham1[n-2], ..., alpham1[0]`
30    /// gives the MCS visitation order.
31    pub alpham1: Vec<u32>,
32}
33
34/// Result of [`is_chordal`].
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ChordalResult {
37    /// Whether the graph is chordal.
38    pub chordal: bool,
39    /// Fill-in edges: pairs `(u, w)` that would need to be added to make
40    /// the graph chordal. Empty if the graph is already chordal.
41    /// Note: the fill-in may not be minimal.
42    pub fill_in: Vec<(u32, u32)>,
43}
44
45/// Computes a maximum cardinality search ordering.
46///
47/// Assigns a rank to each vertex such that visiting vertices in
48/// decreasing rank order corresponds to always choosing the vertex
49/// with the most already-visited neighbors next. Edge directions are
50/// ignored. Self-loops and multi-edges are stripped internally.
51///
52/// # Returns
53///
54/// An [`McsResult`] with `alpha` (vertex-to-rank) and `alpham1`
55/// (rank-to-vertex, the inverse).
56///
57/// # Complexity
58///
59/// O(|V| + |E|) using bucket-list data structures.
60///
61/// # References
62///
63/// R. E. Tarjan, M. Yannakakis: "Simple linear-time algorithms to
64/// test chordality of graphs, test acyclicity of hypergraphs, and
65/// selectively reduce acyclic hypergraphs." SIAM J. Comput. 13,
66/// 566--579, 1984.
67///
68/// # Examples
69///
70/// ```
71/// use rust_igraph::{maximum_cardinality_search, create};
72///
73/// let g = create(&[(0, 1), (1, 2), (2, 0)], 3, false).unwrap();
74/// let mcs = maximum_cardinality_search(&g).unwrap();
75/// assert_eq!(mcs.alpha.len(), 3);
76/// assert_eq!(mcs.alpham1.len(), 3);
77/// // alpham1 is the inverse of alpha
78/// for (v, &rank) in mcs.alpha.iter().enumerate() {
79///     assert_eq!(mcs.alpham1[rank as usize], v as u32);
80/// }
81/// ```
82pub fn maximum_cardinality_search(graph: &Graph) -> IgraphResult<McsResult> {
83    let n = graph.vcount() as usize;
84
85    if n == 0 {
86        return Ok(McsResult {
87            alpha: vec![],
88            alpham1: vec![],
89        });
90    }
91
92    // Build simple adjacency (no loops, no multi-edges, undirected).
93    let adj = build_simple_adj(graph)?;
94
95    let mut alpha = vec![0u32; n];
96    let mut alpham1 = vec![0u32; n];
97
98    // Doubly-linked bucket lists indexed by "number of already-visited
99    // neighbours". head[s] = 1-based first vertex in bucket s (0 = empty).
100    // next[v] / prev[v] are 1-based pointers (0 = end / no predecessor).
101    let mut size = vec![0i32; n];
102    let mut head = vec![0u32; n];
103    let mut next = vec![0u32; n];
104    let mut prev = vec![0u32; n];
105
106    // Initially all vertices in bucket 0.
107    head[0] = 1; // 1-based: vertex 0
108    for v in 0..n {
109        next[v] = u32::try_from(v + 2).unwrap_or(0);
110        prev[v] = v as u32;
111    }
112    next[n - 1] = 0;
113
114    let mut i = n; // ranks assigned from n-1 down to 0
115    let mut j: i32 = 0; // current max bucket
116
117    while i >= 1 {
118        // Pick any vertex from bucket j (the highest non-empty bucket).
119        let v = (head[j as usize])
120            .checked_sub(1)
121            .ok_or_else(|| IgraphError::InvalidArgument("MCS: empty bucket unexpectedly".into()))?
122            as usize;
123
124        let x = next[v];
125        head[j as usize] = x;
126        if x != 0 {
127            prev[(x - 1) as usize] = 0;
128        }
129
130        i -= 1;
131        alpha[v] = i as u32;
132        alpham1[i] = v as u32;
133        size[v] = -1; // mark as visited
134
135        // Update neighbours: move each unvisited neighbour from
136        // bucket size[w] to bucket size[w]+1.
137        for &w in &adj[v] {
138            let wu = w as usize;
139            let ws = size[wu];
140            if ws < 0 {
141                continue;
142            }
143
144            // Delete w from its current bucket.
145            let nw = next[wu];
146            let pw = prev[wu];
147            if nw != 0 {
148                prev[(nw - 1) as usize] = pw;
149            }
150            if pw != 0 {
151                next[(pw - 1) as usize] = nw;
152            } else {
153                head[ws as usize] = nw;
154            }
155
156            size[wu] += 1;
157            let ws_new = size[wu] as usize;
158
159            // Insert w at the head of the new bucket.
160            let nw_new = head[ws_new];
161            next[wu] = nw_new;
162            prev[wu] = 0;
163            if nw_new != 0 {
164                prev[(nw_new - 1) as usize] = w + 1;
165            }
166            head[ws_new] = w + 1;
167        }
168
169        j += 1;
170
171        // Find the next non-empty bucket at or below j.
172        if (j as usize) < n {
173            while j >= 0 && head[j as usize] == 0 {
174                j -= 1;
175            }
176        }
177    }
178
179    Ok(McsResult { alpha, alpham1 })
180}
181
182/// Tests whether a graph is chordal.
183///
184/// A graph is chordal if every cycle of four or more nodes has a
185/// chord (an edge joining two non-adjacent nodes in the cycle).
186/// Equivalently, all chordless cycles have at most three nodes.
187///
188/// Uses the MCS ordering from [`maximum_cardinality_search`] and the
189/// zero-fill-in test of Tarjan-Yannakakis 1984. Optionally computes
190/// the fill-in edges needed to make the graph chordal (note: the
191/// fill-in is not guaranteed to be minimal).
192///
193/// Edge directions are ignored. Self-loops and multi-edges are
194/// stripped internally.
195///
196/// # Arguments
197///
198/// * `graph` — the input graph.
199/// * `alpha` — optional pre-computed MCS ordering (`alpha[v]` = rank).
200///   If `None`, MCS is computed internally.
201///
202/// # Returns
203///
204/// A [`ChordalResult`] with `chordal` flag and `fill_in` edges.
205///
206/// # Complexity
207///
208/// O(|V| + |E|).
209///
210/// # Examples
211///
212/// ```
213/// use rust_igraph::{is_chordal, create};
214///
215/// // Triangle is chordal
216/// let g = create(&[(0, 1), (1, 2), (2, 0)], 3, false).unwrap();
217/// let r = is_chordal(&g, None).unwrap();
218/// assert!(r.chordal);
219/// assert!(r.fill_in.is_empty());
220///
221/// // 4-cycle is not chordal (needs one diagonal)
222/// let g4 = create(&[(0, 1), (1, 2), (2, 3), (3, 0)], 4, false).unwrap();
223/// let r4 = is_chordal(&g4, None).unwrap();
224/// assert!(!r4.chordal);
225/// assert!(!r4.fill_in.is_empty());
226/// ```
227pub fn is_chordal(graph: &Graph, alpha: Option<&[u32]>) -> IgraphResult<ChordalResult> {
228    let n = graph.vcount() as usize;
229
230    if n == 0 {
231        return Ok(ChordalResult {
232            chordal: true,
233            fill_in: vec![],
234        });
235    }
236
237    if let Some(a) = alpha {
238        if a.len() != n {
239            return Err(IgraphError::InvalidArgument(format!(
240                "is_chordal: alpha length {} != vcount {}",
241                a.len(),
242                n
243            )));
244        }
245        let mut inv = vec![0u32; n];
246        for (v, &rank) in a.iter().enumerate() {
247            let ri = rank as usize;
248            if ri >= n {
249                return Err(IgraphError::InvalidArgument(format!(
250                    "is_chordal: alpha[{v}] = {rank} out of range [0, {n})"
251                )));
252            }
253            inv[ri] = v as u32;
254        }
255        is_chordal_impl(graph, n, a, &inv)
256    } else {
257        let mcs = maximum_cardinality_search(graph)?;
258        is_chordal_impl(graph, n, &mcs.alpha, &mcs.alpham1)
259    }
260}
261
262fn is_chordal_impl(
263    graph: &Graph,
264    n: usize,
265    alpha: &[u32],
266    alpham1: &[u32],
267) -> IgraphResult<ChordalResult> {
268    let adj = build_simple_adj(graph)?;
269
270    let mut f = vec![0u32; n]; // path-compression forest
271    let mut index = vec![0u32; n];
272    let mut mark = vec![0u32; n]; // mark[v] = w+1 if v is a neighbour of w
273    let mut fill_in: Vec<(u32, u32)> = Vec::new();
274    let mut chordal = true;
275
276    for (i, &alpham1_i) in alpham1.iter().enumerate() {
277        let w = alpham1_i as usize;
278        f[w] = w as u32;
279        index[w] = i as u32;
280
281        // Mark all neighbours of w.
282        for &nei in &adj[w] {
283            mark[nei as usize] = (w + 1) as u32;
284        }
285
286        // Walk neighbours with alpha < i.
287        for &nei in &adj[w] {
288            let v = nei as usize;
289            if alpha[v] as usize >= i {
290                continue;
291            }
292
293            let mut x = v;
294            while (index[x] as usize) < i {
295                index[x] = i as u32;
296
297                if mark[x] != (w + 1) as u32 {
298                    chordal = false;
299                    fill_in.push((x as u32, w as u32));
300                }
301
302                x = f[x] as usize;
303            }
304
305            if f[x] == x as u32 {
306                f[x] = w as u32;
307            }
308        }
309    }
310
311    Ok(ChordalResult { chordal, fill_in })
312}
313
314/// Build simple undirected adjacency lists (no self-loops, no multi-edges).
315fn build_simple_adj(graph: &Graph) -> IgraphResult<Vec<Vec<VertexId>>> {
316    let n = graph.vcount() as usize;
317    let mut adj: Vec<Vec<VertexId>> = vec![Vec::new(); n];
318
319    let ec = graph.ecount();
320    for e in 0..ec {
321        let eid = u32::try_from(e)
322            .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32".into()))?;
323        let (u, v) = graph.edge(eid)?;
324        if u == v {
325            continue;
326        }
327        adj[u as usize].push(v);
328        adj[v as usize].push(u);
329    }
330
331    // Dedup each list.
332    for list in &mut adj {
333        list.sort_unstable();
334        list.dedup();
335    }
336
337    Ok(adj)
338}
339
340// =================================================================
341// Tests
342// =================================================================
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::create;
348
349    fn make(n: u32, edges: &[(u32, u32)]) -> Graph {
350        create(edges, n, false).expect("make")
351    }
352
353    // ---- maximum_cardinality_search ----
354
355    #[test]
356    fn test_mcs_empty() {
357        let g = make(0, &[]);
358        let r = maximum_cardinality_search(&g).expect("ok");
359        assert!(r.alpha.is_empty());
360        assert!(r.alpham1.is_empty());
361    }
362
363    #[test]
364    fn test_mcs_singleton() {
365        let g = make(1, &[]);
366        let r = maximum_cardinality_search(&g).expect("ok");
367        assert_eq!(r.alpha, vec![0]);
368        assert_eq!(r.alpham1, vec![0]);
369    }
370
371    #[test]
372    fn test_mcs_triangle() {
373        let g = make(3, &[(0, 1), (1, 2), (2, 0)]);
374        let r = maximum_cardinality_search(&g).expect("ok");
375        assert_eq!(r.alpha.len(), 3);
376        assert_eq!(r.alpham1.len(), 3);
377        // Inverse property
378        for (v, &rank) in r.alpha.iter().enumerate() {
379            assert_eq!(r.alpham1[rank as usize], v as u32);
380        }
381    }
382
383    #[test]
384    fn test_mcs_path() {
385        let g = make(4, &[(0, 1), (1, 2), (2, 3)]);
386        let r = maximum_cardinality_search(&g).expect("ok");
387        for (v, &rank) in r.alpha.iter().enumerate() {
388            assert_eq!(r.alpham1[rank as usize], v as u32);
389        }
390    }
391
392    #[test]
393    fn test_mcs_disconnected() {
394        let g = make(4, &[(0, 1)]);
395        let r = maximum_cardinality_search(&g).expect("ok");
396        assert_eq!(r.alpha.len(), 4);
397        for (v, &rank) in r.alpha.iter().enumerate() {
398            assert_eq!(r.alpham1[rank as usize], v as u32);
399        }
400    }
401
402    #[test]
403    fn test_mcs_self_loops() {
404        let g = make(3, &[(0, 0), (0, 1), (1, 2), (2, 2)]);
405        let r = maximum_cardinality_search(&g).expect("ok");
406        assert_eq!(r.alpha.len(), 3);
407        for (v, &rank) in r.alpha.iter().enumerate() {
408            assert_eq!(r.alpham1[rank as usize], v as u32);
409        }
410    }
411
412    // ---- is_chordal ----
413
414    #[test]
415    fn test_chordal_empty() {
416        let g = make(0, &[]);
417        let r = is_chordal(&g, None).expect("ok");
418        assert!(r.chordal);
419        assert!(r.fill_in.is_empty());
420    }
421
422    #[test]
423    fn test_chordal_singleton() {
424        let g = make(1, &[]);
425        let r = is_chordal(&g, None).expect("ok");
426        assert!(r.chordal);
427        assert!(r.fill_in.is_empty());
428    }
429
430    #[test]
431    fn test_chordal_triangle() {
432        let g = make(3, &[(0, 1), (1, 2), (2, 0)]);
433        let r = is_chordal(&g, None).expect("ok");
434        assert!(r.chordal);
435        assert!(r.fill_in.is_empty());
436    }
437
438    #[test]
439    fn test_chordal_complete_k4() {
440        let g = make(4, &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
441        let r = is_chordal(&g, None).expect("ok");
442        assert!(r.chordal);
443        assert!(r.fill_in.is_empty());
444    }
445
446    #[test]
447    fn test_not_chordal_4cycle() {
448        let g = make(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
449        let r = is_chordal(&g, None).expect("ok");
450        assert!(!r.chordal);
451        assert!(!r.fill_in.is_empty());
452    }
453
454    #[test]
455    fn test_not_chordal_5cycle() {
456        let g = make(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]);
457        let r = is_chordal(&g, None).expect("ok");
458        assert!(!r.chordal);
459    }
460
461    #[test]
462    fn test_chordal_tree() {
463        let g = make(5, &[(0, 1), (0, 2), (1, 3), (1, 4)]);
464        let r = is_chordal(&g, None).expect("ok");
465        assert!(r.chordal);
466        assert!(r.fill_in.is_empty());
467    }
468
469    #[test]
470    fn test_chordal_isolated_vertices() {
471        let g = make(5, &[]);
472        let r = is_chordal(&g, None).expect("ok");
473        assert!(r.chordal);
474    }
475
476    #[test]
477    fn test_chordal_with_alpha() {
478        let g = make(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
479        let mcs = maximum_cardinality_search(&g).expect("mcs");
480        let r = is_chordal(&g, Some(&mcs.alpha)).expect("ok");
481        assert!(!r.chordal);
482    }
483
484    #[test]
485    fn test_chordal_wrong_alpha_size() {
486        let g = make(3, &[(0, 1)]);
487        assert!(is_chordal(&g, Some(&[0, 1])).is_err());
488    }
489
490    #[test]
491    fn test_chordal_igraph_lmu_graph() {
492        // From igraph test: 6 vertices, edges 0-1, 0-2, 1-1, 1-3, 2-0, 2-0, 2-3, 3-4, 3-4
493        let g = make(
494            6,
495            &[
496                (0, 1),
497                (0, 2),
498                (1, 1),
499                (1, 3),
500                (2, 0),
501                (2, 0),
502                (2, 3),
503                (3, 4),
504                (3, 4),
505            ],
506        );
507        let r = is_chordal(&g, None).expect("ok");
508        assert!(!r.chordal);
509        // Fill-in should be (3, 0) — the missing chord
510        assert!(!r.fill_in.is_empty());
511    }
512
513    #[test]
514    fn test_chordal_directed_ignored() {
515        let g = create(&[(0, 1), (1, 2), (2, 0)], 3, true).expect("directed");
516        let r = is_chordal(&g, None).expect("ok");
517        assert!(r.chordal);
518    }
519
520    #[test]
521    fn test_chordal_fan_graph() {
522        // Fan: 0 connected to all, plus path 1-2-3-4
523        let g = make(5, &[(0, 1), (0, 2), (0, 3), (0, 4), (1, 2), (2, 3), (3, 4)]);
524        let r = is_chordal(&g, None).expect("ok");
525        assert!(r.chordal);
526    }
527
528    #[test]
529    fn test_fill_in_makes_chordal() {
530        // 4-cycle: add the fill-in edge to make it chordal
531        let g = make(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
532        let r = is_chordal(&g, None).expect("ok");
533        assert!(!r.chordal);
534
535        // Add fill-in edges to the graph
536        let mut all_edges: Vec<(u32, u32)> = vec![(0, 1), (1, 2), (2, 3), (3, 0)];
537        all_edges.extend(r.fill_in.iter());
538        let g2 = make(4, &all_edges);
539        let r2 = is_chordal(&g2, None).expect("ok");
540        assert!(r2.chordal);
541    }
542}
543
544// =================================================================
545// Proptest
546// =================================================================
547
548#[cfg(test)]
549#[cfg(all(test, feature = "proptest-harness"))]
550mod proptests {
551    use super::*;
552    use crate::create;
553    use proptest::prelude::*;
554
555    proptest! {
556        #[test]
557        fn prop_mcs_inverse_property(n in 1u32..30, edge_count in 0usize..60) {
558            let mut edges = Vec::new();
559            let mut rng_state = 0x1234_5678u64;
560            for _ in 0..edge_count {
561                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
562                let u = (rng_state >> 32) as u32 % n;
563                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
564                let v = (rng_state >> 32) as u32 % n;
565                if u != v {
566                    edges.push((u, v));
567                }
568            }
569            let g = create(&edges, n, false).expect("graph");
570            let r = maximum_cardinality_search(&g).expect("mcs");
571            prop_assert_eq!(r.alpha.len(), n as usize);
572            prop_assert_eq!(r.alpham1.len(), n as usize);
573            for (v, &rank) in r.alpha.iter().enumerate() {
574                prop_assert!((rank as usize) < n as usize, "rank out of range");
575                prop_assert_eq!(r.alpham1[rank as usize], v as u32, "inverse mismatch");
576            }
577        }
578
579        #[test]
580        fn prop_mcs_ranks_are_permutation(n in 1u32..30, edge_count in 0usize..60) {
581            let mut edges = Vec::new();
582            let mut rng_state = 0xABCD_EF01u64;
583            for _ in 0..edge_count {
584                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
585                let u = (rng_state >> 32) as u32 % n;
586                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
587                let v = (rng_state >> 32) as u32 % n;
588                if u != v {
589                    edges.push((u, v));
590                }
591            }
592            let g = create(&edges, n, false).expect("graph");
593            let r = maximum_cardinality_search(&g).expect("mcs");
594            let mut sorted_ranks: Vec<u32> = r.alpha.clone();
595            sorted_ranks.sort_unstable();
596            let expected: Vec<u32> = (0..n).collect();
597            prop_assert_eq!(sorted_ranks, expected, "ranks should be a permutation of 0..n");
598        }
599
600        #[test]
601        fn prop_fill_in_makes_chordal(n in 3u32..20, edge_count in 3usize..40) {
602            let mut edges = Vec::new();
603            let mut rng_state = 0xDEAD_BEEFu64;
604            for _ in 0..edge_count {
605                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
606                let u = (rng_state >> 32) as u32 % n;
607                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
608                let v = (rng_state >> 32) as u32 % n;
609                if u != v {
610                    edges.push((u, v));
611                }
612            }
613            let g = create(&edges, n, false).expect("graph");
614            let r = is_chordal(&g, None).expect("chordal");
615            if !r.chordal {
616                let mut all_edges = edges.clone();
617                all_edges.extend(r.fill_in.iter());
618                let g2 = create(&all_edges, n, false).expect("graph2");
619                let r2 = is_chordal(&g2, None).expect("chordal2");
620                prop_assert!(r2.chordal, "adding fill-in should make graph chordal");
621            }
622        }
623
624        #[test]
625        fn prop_tree_is_chordal(n in 2u32..30) {
626            // Build a random tree (parent = random vertex < v)
627            let mut edges = Vec::new();
628            let mut rng_state = 0xCAFE_BABEu64;
629            for v in 1..n {
630                rng_state = rng_state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
631                let parent = (rng_state >> 32) as u32 % v;
632                edges.push((parent, v));
633            }
634            let g = create(&edges, n, false).expect("tree");
635            let r = is_chordal(&g, None).expect("chordal");
636            prop_assert!(r.chordal, "every tree is chordal");
637            prop_assert!(r.fill_in.is_empty(), "tree needs no fill-in");
638        }
639
640        #[test]
641        fn prop_complete_graph_is_chordal(n in 2u32..15) {
642            let mut edges = Vec::new();
643            for u in 0..n {
644                for v in (u + 1)..n {
645                    edges.push((u, v));
646                }
647            }
648            let g = create(&edges, n, false).expect("complete");
649            let r = is_chordal(&g, None).expect("chordal");
650            prop_assert!(r.chordal, "complete graph is chordal");
651        }
652    }
653}