Skip to main content

rust_igraph/algorithms/motifs/
mod.rs

1//! Motif census algorithms.
2//!
3//! - Dyad census (ALGO-MO-001): counts mutual, asymmetric, and null dyads.
4//! - Triad census (ALGO-MO-002): classifies all vertex triples into 16 types.
5//! - Graph count (ALGO-MO-003): number of non-isomorphic graphs.
6//! - Isoclass (ALGO-MO-004): isomorphism class classification for small graphs.
7
8pub mod graph_count;
9pub mod isoclass;
10pub mod motifs_randesu;
11pub mod triad_census;
12
13pub use graph_count::graph_count;
14pub use isoclass::{isoclass, isoclass_create, isoclass_subgraph};
15pub use motifs_randesu::{
16    motifs_randesu, motifs_randesu_callback, motifs_randesu_estimate, motifs_randesu_no,
17};
18pub use triad_census::{TriadCensus, TriadType, triad_census};
19
20use crate::core::{Graph, IgraphResult, VertexId};
21
22/// Result of a dyad census on a directed graph.
23#[derive(Debug, Clone, PartialEq)]
24pub struct DyadCensus {
25    /// Number of mutual (reciprocated) dyads.
26    pub mutual: f64,
27    /// Number of asymmetric (one-way) dyads.
28    pub asymmetric: f64,
29    /// Number of null (no edge) dyads.
30    pub null: f64,
31}
32
33/// Counts the mutual, asymmetric, and null dyads in a directed graph.
34///
35/// A *dyad* is an unordered pair of vertices. In a directed graph:
36/// - **Mutual**: both `(u,v)` and `(v,u)` edges exist.
37/// - **Asymmetric**: exactly one of `(u,v)` or `(v,u)` exists.
38/// - **Null**: neither edge exists.
39///
40/// Self-loops and multi-edges are ignored (only simple edges counted).
41/// For undirected graphs, all edges are treated as mutual.
42///
43/// Uses `f64` to avoid overflow for large graphs where `n*(n-1)/2`
44/// may exceed `u32::MAX`.
45///
46/// # Examples
47///
48/// ```
49/// use rust_igraph::{Graph, dyad_census};
50///
51/// let mut g = Graph::new(4, true).unwrap();
52/// // Mutual: 0↔1
53/// g.add_edge(0, 1).unwrap();
54/// g.add_edge(1, 0).unwrap();
55/// // Asymmetric: 2→3
56/// g.add_edge(2, 3).unwrap();
57///
58/// let dc = dyad_census(&g).unwrap();
59/// assert!((dc.mutual - 1.0).abs() < 1e-10);
60/// assert!((dc.asymmetric - 1.0).abs() < 1e-10);
61/// assert!((dc.null - 4.0).abs() < 1e-10);
62/// ```
63pub fn dyad_census(graph: &Graph) -> IgraphResult<DyadCensus> {
64    let n = graph.vcount();
65
66    if n < 2 {
67        return Ok(DyadCensus {
68            mutual: 0.0,
69            asymmetric: 0.0,
70            null: 0.0,
71        });
72    }
73
74    if !graph.is_directed() {
75        return dyad_census_undirected(graph);
76    }
77
78    let (in_adj, out_adj) = build_directed_adj(graph)?;
79    let mut rec: f64 = 0.0;
80    let mut nonrec: f64 = 0.0;
81
82    for v in 0..n {
83        let in_neis = &in_adj[v as usize];
84        let out_neis = &out_adj[v as usize];
85
86        let mut ip = 0;
87        let mut op = 0;
88
89        while ip < in_neis.len() && op < out_neis.len() {
90            match in_neis[ip].cmp(&out_neis[op]) {
91                std::cmp::Ordering::Less => {
92                    nonrec += 1.0;
93                    ip += 1;
94                }
95                std::cmp::Ordering::Greater => {
96                    nonrec += 1.0;
97                    op += 1;
98                }
99                std::cmp::Ordering::Equal => {
100                    rec += 1.0;
101                    ip += 1;
102                    op += 1;
103                }
104            }
105        }
106        #[allow(clippy::cast_precision_loss)]
107        {
108            nonrec += (in_neis.len() - ip) as f64 + (out_neis.len() - op) as f64;
109        }
110    }
111
112    let mutual = rec / 2.0;
113    let asymmetric = nonrec / 2.0;
114    #[allow(clippy::cast_precision_loss)]
115    let total_dyads = 0.5 * (f64::from(n)) * ((f64::from(n)) - 1.0);
116    let null = total_dyads - mutual - asymmetric;
117
118    Ok(DyadCensus {
119        mutual,
120        asymmetric,
121        null: if null == 0.0 { 0.0 } else { null },
122    })
123}
124
125fn dyad_census_undirected(graph: &Graph) -> IgraphResult<DyadCensus> {
126    let n = graph.vcount();
127    let ecount = graph.ecount();
128
129    // Count simple edges (no self-loops, no multi-edges)
130    let mut edge_set = std::collections::HashSet::new();
131    for eid in 0..ecount {
132        #[allow(clippy::cast_possible_truncation)]
133        let (src, tgt) = graph.edge(eid as u32)?;
134        if src != tgt {
135            let key = if src < tgt { (src, tgt) } else { (tgt, src) };
136            edge_set.insert(key);
137        }
138    }
139
140    #[allow(clippy::cast_precision_loss)]
141    let mutual = edge_set.len() as f64;
142    #[allow(clippy::cast_precision_loss)]
143    let total_dyads = 0.5 * (f64::from(n)) * ((f64::from(n)) - 1.0);
144    let null = total_dyads - mutual;
145
146    Ok(DyadCensus {
147        mutual,
148        asymmetric: 0.0,
149        null: if null == 0.0 { 0.0 } else { null },
150    })
151}
152
153type AdjPair = (Vec<Vec<VertexId>>, Vec<Vec<VertexId>>);
154
155/// Build sorted simple in-neighbor and out-neighbor lists for directed graph.
156fn build_directed_adj(graph: &Graph) -> IgraphResult<AdjPair> {
157    let n = graph.vcount() as usize;
158    let ecount = graph.ecount();
159    let mut in_adj: Vec<Vec<VertexId>> = vec![Vec::new(); n];
160    let mut out_adj: Vec<Vec<VertexId>> = vec![Vec::new(); n];
161
162    for eid in 0..ecount {
163        #[allow(clippy::cast_possible_truncation)]
164        let (src, tgt) = graph.edge(eid as u32)?;
165        if src != tgt {
166            out_adj[src as usize].push(tgt);
167            in_adj[tgt as usize].push(src);
168        }
169    }
170
171    for neighbors in &mut in_adj {
172        neighbors.sort_unstable();
173        neighbors.dedup();
174    }
175    for neighbors in &mut out_adj {
176        neighbors.sort_unstable();
177        neighbors.dedup();
178    }
179
180    Ok((in_adj, out_adj))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_empty_graph() {
189        let g = Graph::with_vertices(0);
190        let dc = dyad_census(&g).unwrap();
191        assert!((dc.mutual).abs() < 1e-10);
192        assert!((dc.asymmetric).abs() < 1e-10);
193        assert!((dc.null).abs() < 1e-10);
194    }
195
196    #[test]
197    fn test_single_vertex() {
198        let g = Graph::new(1, true).unwrap();
199        let dc = dyad_census(&g).unwrap();
200        assert!((dc.mutual).abs() < 1e-10);
201        assert!((dc.asymmetric).abs() < 1e-10);
202        assert!((dc.null).abs() < 1e-10);
203    }
204
205    #[test]
206    fn test_two_vertices_no_edges() {
207        let g = Graph::new(2, true).unwrap();
208        let dc = dyad_census(&g).unwrap();
209        assert!((dc.mutual).abs() < 1e-10);
210        assert!((dc.asymmetric).abs() < 1e-10);
211        assert!((dc.null - 1.0).abs() < 1e-10);
212    }
213
214    #[test]
215    fn test_two_vertices_one_edge() {
216        let mut g = Graph::new(2, true).unwrap();
217        g.add_edge(0, 1).unwrap();
218        let dc = dyad_census(&g).unwrap();
219        assert!((dc.mutual).abs() < 1e-10);
220        assert!((dc.asymmetric - 1.0).abs() < 1e-10);
221        assert!((dc.null).abs() < 1e-10);
222    }
223
224    #[test]
225    fn test_two_vertices_mutual() {
226        let mut g = Graph::new(2, true).unwrap();
227        g.add_edge(0, 1).unwrap();
228        g.add_edge(1, 0).unwrap();
229        let dc = dyad_census(&g).unwrap();
230        assert!((dc.mutual - 1.0).abs() < 1e-10);
231        assert!((dc.asymmetric).abs() < 1e-10);
232        assert!((dc.null).abs() < 1e-10);
233    }
234
235    #[test]
236    fn test_three_vertices_cycle() {
237        // 0→1, 1→2, 2→0: three asymmetric dyads
238        let mut g = Graph::new(3, true).unwrap();
239        g.add_edge(0, 1).unwrap();
240        g.add_edge(1, 2).unwrap();
241        g.add_edge(2, 0).unwrap();
242        let dc = dyad_census(&g).unwrap();
243        assert!((dc.mutual).abs() < 1e-10);
244        assert!((dc.asymmetric - 3.0).abs() < 1e-10);
245        assert!((dc.null).abs() < 1e-10);
246    }
247
248    #[test]
249    fn test_complete_directed_mutual() {
250        // K4 directed with all edges mutual: 6 mutual dyads
251        let mut g = Graph::new(4, true).unwrap();
252        for i in 0..4u32 {
253            for j in 0..4 {
254                if i != j {
255                    g.add_edge(i, j).unwrap();
256                }
257            }
258        }
259        let dc = dyad_census(&g).unwrap();
260        assert!((dc.mutual - 6.0).abs() < 1e-10);
261        assert!((dc.asymmetric).abs() < 1e-10);
262        assert!((dc.null).abs() < 1e-10);
263    }
264
265    #[test]
266    fn test_mixed() {
267        // 4 vertices:
268        // mutual: 0↔1
269        // asymmetric: 2→3
270        // null: (0,2), (0,3), (1,2), (1,3) = 4
271        let mut g = Graph::new(4, true).unwrap();
272        g.add_edge(0, 1).unwrap();
273        g.add_edge(1, 0).unwrap();
274        g.add_edge(2, 3).unwrap();
275        let dc = dyad_census(&g).unwrap();
276        assert!((dc.mutual - 1.0).abs() < 1e-10);
277        assert!((dc.asymmetric - 1.0).abs() < 1e-10);
278        assert!((dc.null - 4.0).abs() < 1e-10);
279    }
280
281    #[test]
282    fn test_self_loops_ignored() {
283        let mut g = Graph::new(3, true).unwrap();
284        g.add_edge(0, 0).unwrap();
285        g.add_edge(0, 1).unwrap();
286        g.add_edge(1, 1).unwrap();
287        let dc = dyad_census(&g).unwrap();
288        // Only 0→1 counts as asymmetric
289        assert!((dc.mutual).abs() < 1e-10);
290        assert!((dc.asymmetric - 1.0).abs() < 1e-10);
291        assert!((dc.null - 2.0).abs() < 1e-10);
292    }
293
294    #[test]
295    fn test_multi_edges_deduplicated() {
296        let mut g = Graph::new(2, true).unwrap();
297        g.add_edge(0, 1).unwrap();
298        g.add_edge(0, 1).unwrap();
299        g.add_edge(0, 1).unwrap();
300        let dc = dyad_census(&g).unwrap();
301        // Multiple edges same direction still count as one asymmetric dyad
302        assert!((dc.mutual).abs() < 1e-10);
303        assert!((dc.asymmetric - 1.0).abs() < 1e-10);
304        assert!((dc.null).abs() < 1e-10);
305    }
306
307    #[test]
308    fn test_undirected_all_mutual() {
309        // Undirected: all edges counted as mutual
310        let mut g = Graph::with_vertices(4);
311        g.add_edge(0, 1).unwrap();
312        g.add_edge(1, 2).unwrap();
313        g.add_edge(2, 3).unwrap();
314        let dc = dyad_census(&g).unwrap();
315        assert!((dc.mutual - 3.0).abs() < 1e-10);
316        assert!((dc.asymmetric).abs() < 1e-10);
317        assert!((dc.null - 3.0).abs() < 1e-10);
318    }
319
320    #[test]
321    fn test_undirected_complete() {
322        let mut g = Graph::with_vertices(5);
323        for i in 0..5u32 {
324            for j in (i + 1)..5 {
325                g.add_edge(i, j).unwrap();
326            }
327        }
328        let dc = dyad_census(&g).unwrap();
329        assert!((dc.mutual - 10.0).abs() < 1e-10);
330        assert!((dc.asymmetric).abs() < 1e-10);
331        assert!((dc.null).abs() < 1e-10);
332    }
333
334    #[test]
335    fn test_counts_sum_to_total() {
336        let mut g = Graph::new(5, true).unwrap();
337        g.add_edge(0, 1).unwrap();
338        g.add_edge(1, 0).unwrap();
339        g.add_edge(2, 3).unwrap();
340        g.add_edge(3, 4).unwrap();
341        g.add_edge(4, 2).unwrap();
342        let dc = dyad_census(&g).unwrap();
343        let total = dc.mutual + dc.asymmetric + dc.null;
344        // n*(n-1)/2 = 5*4/2 = 10
345        assert!((total - 10.0).abs() < 1e-10);
346    }
347}