Skip to main content

rust_igraph/algorithms/motifs/
motifs_randesu.rs

1//! Motif census via ESU enumeration (ALGO-MO-005).
2//!
3//! Counterpart of `igraph_motifs_randesu()`, `igraph_motifs_randesu_no()`,
4//! and `igraph_motifs_randesu_callback()` in
5//! `references/igraph/src/misc/motifs.c`.
6//!
7//! Finds all connected induced subgraphs of a given size (the "motifs")
8//! and classifies them by isomorphism class using the ESU algorithm
9//! (Wernicke & Rasche, Bioinformatics 2006).
10//!
11//! Supported sizes:
12//! - Directed: 3 and 4 vertices (16 and 218 isoclasses)
13//! - Undirected: 3, 4, and 5 vertices (4, 11, and 34 isoclasses)
14
15use super::isoclass::tables;
16use crate::core::rng::SplitMix64;
17use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
18
19/// Motif census: count motifs of each isomorphism class.
20///
21/// Returns a histogram where `hist[c]` is the number of connected induced
22/// subgraphs of the given `size` whose isomorphism class is `c`.
23/// Disconnected isomorphism classes are reported as `f64::NAN`.
24///
25/// # Arguments
26///
27/// * `graph` — the input graph.
28/// * `size` — motif size (3 or 4 for directed; 3, 4, or 5 for undirected).
29///
30/// # Examples
31///
32/// ```
33/// use rust_igraph::{Graph, motifs_randesu};
34///
35/// // Triangle: one 3-vertex motif of class 3 (complete undirected)
36/// let mut g = Graph::with_vertices(3);
37/// g.add_edge(0, 1).unwrap();
38/// g.add_edge(1, 2).unwrap();
39/// g.add_edge(0, 2).unwrap();
40/// let hist = motifs_randesu(&g, 3).unwrap();
41/// assert!((hist[3] - 1.0).abs() < 1e-10);
42/// ```
43pub fn motifs_randesu(graph: &Graph, size: u32) -> IgraphResult<Vec<f64>> {
44    let directed = graph.is_directed();
45    let histlen = hist_length(size, directed)?;
46
47    let mut hist = vec![0.0_f64; histlen];
48
49    motifs_randesu_callback(graph, size, |_vids, isoclass| {
50        hist[isoclass as usize] += 1.0;
51        Ok(true)
52    })?;
53
54    let not_connected = not_connected_classes(size, directed);
55    for &cls in &not_connected {
56        hist[cls] = f64::NAN;
57    }
58
59    Ok(hist)
60}
61
62/// Count the total number of connected induced subgraphs of a given size.
63///
64/// Unlike [`motifs_randesu`], this does not classify by isomorphism class
65/// and supports arbitrary motif sizes (≥ 3).
66///
67/// # Arguments
68///
69/// * `graph` — the input graph.
70/// * `size` — motif size (must be ≥ 3).
71///
72/// # Examples
73///
74/// ```
75/// use rust_igraph::{Graph, motifs_randesu_no};
76///
77/// // Complete graph K4 has 4 triangles
78/// let mut g = Graph::with_vertices(4);
79/// for u in 0..4u32 {
80///     for v in (u + 1)..4 {
81///         g.add_edge(u, v).unwrap();
82///     }
83/// }
84/// assert!((motifs_randesu_no(&g, 3).unwrap() - 4.0).abs() < 1e-10);
85/// ```
86pub fn motifs_randesu_no(graph: &Graph, size: u32) -> IgraphResult<f64> {
87    if size < 3 {
88        return Err(IgraphError::InvalidArgument(format!(
89            "motifs_randesu_no: size must be at least 3 (got {size})"
90        )));
91    }
92
93    let n = graph.vcount();
94    if n == 0 {
95        return Ok(0.0);
96    }
97
98    let all_neis = build_all_neighbors(graph)?;
99    let mut added = vec![0_i32; n as usize];
100    let mut count: f64 = 0.0;
101
102    for parent in 0..n {
103        let mut vids: Vec<VertexId> = Vec::new();
104        let mut adjverts: Vec<(VertexId, VertexId)> = Vec::new();
105        let mut stack: Vec<(VertexId, VertexId, u32)> = Vec::new();
106
107        vids.push(parent);
108        added[parent as usize] += 1;
109        let mut level: u32 = 1;
110
111        for &nei in &all_neis[parent as usize] {
112            if added[nei as usize] == 0 && nei > parent {
113                adjverts.push((nei, parent));
114            }
115            added[nei as usize] += 1;
116        }
117
118        while level > 1 || !adjverts.is_empty() {
119            if level == size - 1 {
120                #[allow(clippy::cast_precision_loss)]
121                {
122                    count += adjverts.len() as f64;
123                }
124            }
125
126            if level < size - 1 && !adjverts.is_empty() {
127                let (nei, neiparent) = adjverts.pop().unwrap_or((0, 0));
128
129                vids.push(nei);
130                added[nei as usize] += 1;
131                level += 1;
132
133                stack.push((neiparent, nei, level));
134
135                for &nei2 in &all_neis[nei as usize] {
136                    if added[nei2 as usize] == 0 && nei2 > parent {
137                        adjverts.push((nei2, nei));
138                    }
139                    added[nei2 as usize] += 1;
140                }
141            } else {
142                while let Some(&(_, _, stack_level)) = stack.last() {
143                    if level == stack_level - 1 {
144                        let (neiparent, nei, _) = stack.pop().unwrap_or((0, 0, 0));
145                        adjverts.push((nei, neiparent));
146                    } else {
147                        break;
148                    }
149                }
150
151                if let Some(nei) = vids.pop() {
152                    added[nei as usize] -= 1;
153                    level -= 1;
154                    for &n2 in &all_neis[nei as usize] {
155                        added[n2 as usize] -= 1;
156                    }
157                    while adjverts.last().is_some_and(|&(_, p)| p == nei) {
158                        adjverts.pop();
159                    }
160                }
161            }
162        }
163
164        added[parent as usize] -= 1;
165        for &nei in &all_neis[parent as usize] {
166            added[nei as usize] -= 1;
167        }
168    }
169
170    Ok(count)
171}
172
173/// Resolve the set of root vertices for [`motifs_randesu_estimate`]: an
174/// explicit `sample` (validated) when given, otherwise a uniform random
175/// sample of `sample_size` distinct vertices drawn via partial
176/// Fisher–Yates.
177fn resolve_estimate_roots(
178    n: u32,
179    sample: Option<&[VertexId]>,
180    sample_size: usize,
181    rng: &mut SplitMix64,
182) -> IgraphResult<Vec<VertexId>> {
183    if let Some(s) = sample {
184        for &v in s {
185            if v >= n {
186                return Err(IgraphError::InvalidArgument(format!(
187                    "motifs_randesu_estimate: sample vertex {v} out of range [0, {n})"
188                )));
189            }
190        }
191        return Ok(s.to_vec());
192    }
193
194    if sample_size > n as usize {
195        return Err(IgraphError::InvalidArgument(format!(
196            "motifs_randesu_estimate: sample_size {sample_size} exceeds vertex count {n}"
197        )));
198    }
199    let mut pool: Vec<VertexId> = (0..n).collect();
200    for i in 0..sample_size {
201        let j = i + rng.gen_index(n as usize - i);
202        pool.swap(i, j);
203    }
204    pool.truncate(sample_size);
205    Ok(pool)
206}
207
208/// Estimate the total number of connected induced subgraphs of a given
209/// size by sampling root vertices.
210///
211/// This is the sampling counterpart of [`motifs_randesu_no`]. Instead of
212/// using every vertex as a search root, it visits only the vertices in
213/// `sample` (or a uniform random sample of `sample_size` vertices when
214/// `sample` is `None`) and scales the raw count by `vcount / sample_size`.
215/// It is intended for graphs too large to enumerate every connected
216/// subgraph.
217///
218/// Counterpart of `igraph_motifs_randesu_estimate()` in
219/// `references/igraph/src/misc/motifs.c`.
220///
221/// # Arguments
222///
223/// * `graph` — the input graph.
224/// * `size` — motif size (must be ≥ 3).
225/// * `cut_prob` — optional per-level branch-cut probabilities. When
226///   `Some`, it must have length `size`; element `l` is the probability of
227///   pruning the search at level `l`. Passing `None` performs a complete
228///   search over the sampled roots (equivalent to an all-zero vector) and
229///   draws no random numbers, so the result is fully deterministic.
230/// * `sample` — explicit sample of root vertex IDs. When `Some`,
231///   `sample_size` is ignored.
232/// * `sample_size` — number of root vertices to draw uniformly *without*
233///   replacement when `sample` is `None`.
234/// * `seed` — seeds the internal PRNG used for random sampling and for
235///   branch cutting.
236///
237/// # Errors
238///
239/// * `InvalidArgument` — `size < 3`, `cut_prob` length disagrees with
240///   `size`, a sampled vertex ID is out of range, the effective sample is
241///   empty, or `sample_size` exceeds the vertex count.
242///
243/// # Examples
244///
245/// ```
246/// use rust_igraph::{Graph, motifs_randesu_estimate, motifs_randesu_no};
247///
248/// // Complete graph K5 has C(5,3) = 10 triangles.
249/// let mut g = Graph::with_vertices(5);
250/// for u in 0..5u32 {
251///     for v in (u + 1)..5 {
252///         g.add_edge(u, v).unwrap();
253///     }
254/// }
255/// // Sampling every vertex with no cutting reproduces the exact count.
256/// let all: Vec<u32> = (0..5).collect();
257/// let est = motifs_randesu_estimate(&g, 3, None, Some(&all), 0, 0).unwrap();
258/// let exact = motifs_randesu_no(&g, 3).unwrap();
259/// assert!((est - exact).abs() < 1e-10);
260/// ```
261pub fn motifs_randesu_estimate(
262    graph: &Graph,
263    size: u32,
264    cut_prob: Option<&[f64]>,
265    sample: Option<&[VertexId]>,
266    sample_size: usize,
267    seed: u64,
268) -> IgraphResult<f64> {
269    if size < 3 {
270        return Err(IgraphError::InvalidArgument(format!(
271            "motifs_randesu_estimate: size must be at least 3 (got {size})"
272        )));
273    }
274    if let Some(cp) = cut_prob {
275        if cp.len() != size as usize {
276            return Err(IgraphError::InvalidArgument(format!(
277                "motifs_randesu_estimate: cut_prob length {} must equal size {size}",
278                cp.len()
279            )));
280        }
281    }
282
283    let n = graph.vcount();
284    if n == 0 {
285        return Ok(0.0);
286    }
287
288    let mut rng = SplitMix64::new(seed);
289
290    let roots = resolve_estimate_roots(n, sample, sample_size, &mut rng)?;
291    let effective_sample = roots.len();
292    if effective_sample == 0 {
293        return Err(IgraphError::InvalidArgument(
294            "motifs_randesu_estimate: the sample must contain at least one vertex".to_string(),
295        ));
296    }
297
298    let all_neis = build_all_neighbors(graph)?;
299    let mut added = vec![0_i32; n as usize];
300    let mut est: f64 = 0.0;
301
302    for &parent in &roots {
303        // Optional cut at the root level (level 0).
304        if let Some(cp) = cut_prob {
305            let c0 = cp[0];
306            if c0 >= 1.0 || rng.gen_unit() < c0 {
307                continue;
308            }
309        }
310        est += estimate_count_from_root(parent, size, cut_prob, &all_neis, &mut added, &mut rng);
311    }
312
313    #[allow(clippy::cast_precision_loss)]
314    let scale = f64::from(n) / effective_sample as f64;
315    Ok(est * scale)
316}
317
318/// Run one ESU traversal rooted at `parent`, returning the (possibly
319/// branch-cut) count of connected induced subgraphs of `size` vertices
320/// whose minimum-ID vertex is `parent`. Leaves `added` in its incoming
321/// state on return.
322fn estimate_count_from_root(
323    parent: VertexId,
324    size: u32,
325    cut_prob: Option<&[f64]>,
326    all_neis: &[Vec<VertexId>],
327    added: &mut [i32],
328    rng: &mut SplitMix64,
329) -> f64 {
330    let mut vids: Vec<VertexId> = Vec::new();
331    let mut adjverts: Vec<(VertexId, VertexId)> = Vec::new();
332    let mut stack: Vec<(VertexId, VertexId, u32)> = Vec::new();
333    let mut count: f64 = 0.0;
334
335    vids.push(parent);
336    added[parent as usize] += 1;
337    let mut level: u32 = 1;
338
339    for &nei in &all_neis[parent as usize] {
340        if added[nei as usize] == 0 && nei > parent {
341            adjverts.push((nei, parent));
342        }
343        added[nei as usize] += 1;
344    }
345
346    while level > 1 || !adjverts.is_empty() {
347        let cp_level = cut_prob.map_or(0.0, |cp| cp[level as usize]);
348
349        if level == size - 1 {
350            for _ in 0..adjverts.len() {
351                if cp_level != 0.0 && rng.gen_unit() < cp_level {
352                    continue;
353                }
354                count += 1.0;
355            }
356        }
357
358        if level < size - 1 && !adjverts.is_empty() {
359            let (nei, neiparent) = adjverts.pop().unwrap_or((0, 0));
360
361            // Decide whether to descend into this branch.
362            if cp_level != 0.0 && rng.gen_unit() <= cp_level {
363                continue;
364            }
365
366            vids.push(nei);
367            added[nei as usize] += 1;
368            level += 1;
369
370            stack.push((neiparent, nei, level));
371
372            for &nei2 in &all_neis[nei as usize] {
373                if added[nei2 as usize] == 0 && nei2 > parent {
374                    adjverts.push((nei2, nei));
375                }
376                added[nei2 as usize] += 1;
377            }
378        } else {
379            while let Some(&(_, _, stack_level)) = stack.last() {
380                if level == stack_level - 1 {
381                    let (neiparent, nei, _) = stack.pop().unwrap_or((0, 0, 0));
382                    adjverts.push((nei, neiparent));
383                } else {
384                    break;
385                }
386            }
387
388            if let Some(nei) = vids.pop() {
389                added[nei as usize] -= 1;
390                level -= 1;
391                for &n2 in &all_neis[nei as usize] {
392                    added[n2 as usize] -= 1;
393                }
394                while adjverts.last().is_some_and(|&(_, p)| p == nei) {
395                    adjverts.pop();
396                }
397            }
398        }
399    }
400
401    added[parent as usize] -= 1;
402    for &nei in &all_neis[parent as usize] {
403        added[nei as usize] -= 1;
404    }
405
406    count
407}
408
409/// Enumerate all connected induced subgraphs of the given size and call
410/// `callback(vids, isoclass)` for each one.
411///
412/// The callback returns `Ok(true)` to continue or `Ok(false)` to stop.
413///
414/// # Arguments
415///
416/// * `graph` — the input graph.
417/// * `size` — motif size (3 or 4 for directed; 3, 4, or 5 for undirected).
418/// * `callback` — called with (vertex ids, isomorphism class) for each motif.
419///
420/// # Examples
421///
422/// ```
423/// use rust_igraph::{Graph, motifs_randesu_callback};
424///
425/// // K3 undirected has exactly one size-3 motif (the triangle).
426/// let mut g = Graph::with_vertices(3);
427/// g.add_edge(0, 1).unwrap();
428/// g.add_edge(1, 2).unwrap();
429/// g.add_edge(0, 2).unwrap();
430/// let mut count = 0u32;
431/// motifs_randesu_callback(&g, 3, |_vids, _class| { count += 1; Ok(true) }).unwrap();
432/// assert_eq!(count, 1);
433/// ```
434pub fn motifs_randesu_callback<F>(graph: &Graph, size: u32, mut callback: F) -> IgraphResult<()>
435where
436    F: FnMut(&[VertexId], u32) -> IgraphResult<bool>,
437{
438    let directed = graph.is_directed();
439    let (arr_idx, arr_code, mul) = get_isoclass_tables(size, directed)?;
440
441    let n = graph.vcount();
442    if n == 0 {
443        return Ok(());
444    }
445
446    let all_neis = build_all_neighbors(graph)?;
447    let out_neis = build_out_neighbors(graph)?;
448    let mut added = vec![0_i32; n as usize];
449    let mut subg = vec![0_u32; n as usize];
450
451    for parent in 0..n {
452        let mut vids: Vec<VertexId> = Vec::new();
453        let mut adjverts: Vec<(VertexId, VertexId)> = Vec::new();
454        let mut stack: Vec<(VertexId, VertexId, u32)> = Vec::new();
455
456        vids.push(parent);
457        subg[parent as usize] = 1;
458        added[parent as usize] += 1;
459        let mut level: u32 = 1;
460
461        for &nei in &all_neis[parent as usize] {
462            if added[nei as usize] == 0 && nei > parent {
463                adjverts.push((nei, parent));
464            }
465            added[nei as usize] += 1;
466        }
467
468        let mut terminate = false;
469
470        while level > 1 || !adjverts.is_empty() {
471            if level == size - 1 {
472                for &(last, _) in &adjverts {
473                    vids.push(last);
474                    subg[last as usize] = size;
475
476                    let code = compute_isoclass_code(&vids, size, &out_neis, &subg, arr_idx, mul);
477                    let isoclass = u32::from(arr_code[code as usize]);
478
479                    match callback(&vids, isoclass) {
480                        Ok(true) => {}
481                        Ok(false) => {
482                            vids.pop();
483                            subg[last as usize] = 0;
484                            terminate = true;
485                            break;
486                        }
487                        Err(e) => {
488                            vids.pop();
489                            subg[last as usize] = 0;
490                            return Err(e);
491                        }
492                    }
493
494                    vids.pop();
495                    subg[last as usize] = 0;
496                }
497            }
498
499            if terminate {
500                break;
501            }
502
503            if level < size - 1 && !adjverts.is_empty() {
504                let (nei, neiparent) = adjverts.pop().unwrap_or((0, 0));
505
506                vids.push(nei);
507                subg[nei as usize] = level + 1;
508                added[nei as usize] += 1;
509                level += 1;
510
511                stack.push((neiparent, nei, level));
512
513                for &nei2 in &all_neis[nei as usize] {
514                    if added[nei2 as usize] == 0 && nei2 > parent {
515                        adjverts.push((nei2, nei));
516                    }
517                    added[nei2 as usize] += 1;
518                }
519            } else {
520                while let Some(&(_, _, stack_level)) = stack.last() {
521                    if level == stack_level - 1 {
522                        let (neiparent, nei, _) = stack.pop().unwrap_or((0, 0, 0));
523                        adjverts.push((nei, neiparent));
524                    } else {
525                        break;
526                    }
527                }
528
529                if let Some(nei) = vids.pop() {
530                    subg[nei as usize] = 0;
531                    added[nei as usize] -= 1;
532                    level -= 1;
533                    for &n2 in &all_neis[nei as usize] {
534                        added[n2 as usize] -= 1;
535                    }
536                    while adjverts.last().is_some_and(|&(_, p)| p == nei) {
537                        adjverts.pop();
538                    }
539                }
540            }
541        }
542
543        if terminate {
544            break;
545        }
546
547        added[parent as usize] -= 1;
548        subg[parent as usize] = 0;
549        for &nei in &all_neis[parent as usize] {
550            added[nei as usize] -= 1;
551        }
552    }
553
554    Ok(())
555}
556
557fn compute_isoclass_code(
558    vids: &[VertexId],
559    size: u32,
560    out_neis: &[Vec<VertexId>],
561    subg: &[u32],
562    arr_idx: &[u32],
563    mul: u32,
564) -> u32 {
565    let mut code: u32 = 0;
566    for k in 0..size {
567        let from = vids[k as usize];
568        for &nei in &out_neis[from as usize] {
569            let nei_subg = subg[nei as usize];
570            if nei_subg != 0 && k != nei_subg - 1 {
571                let idx = mul * k + (nei_subg - 1);
572                code |= arr_idx[idx as usize];
573            }
574        }
575    }
576    code
577}
578
579fn get_isoclass_tables(
580    size: u32,
581    directed: bool,
582) -> IgraphResult<(&'static [u32], &'static [u8], u32)> {
583    if directed {
584        match size {
585            3 => Ok((&tables::ISOCLASS_3_IDX, &tables::ISOCLASS2_3, 3)),
586            4 => Ok((&tables::ISOCLASS_4_IDX, &tables::ISOCLASS2_4, 4)),
587            _ => Err(IgraphError::InvalidArgument(format!(
588                "motifs_randesu: directed graphs support size 3 or 4 (got {size})"
589            ))),
590        }
591    } else {
592        match size {
593            3 => Ok((&tables::ISOCLASS_3U_IDX, &tables::ISOCLASS2_3U, 3)),
594            4 => Ok((&tables::ISOCLASS_4U_IDX, &tables::ISOCLASS2_4U, 4)),
595            5 => Ok((&tables::ISOCLASS_5U_IDX, &tables::ISOCLASS2_5U, 5)),
596            _ => Err(IgraphError::InvalidArgument(format!(
597                "motifs_randesu: undirected graphs support size 3, 4, or 5 (got {size})"
598            ))),
599        }
600    }
601}
602
603fn hist_length(size: u32, directed: bool) -> IgraphResult<usize> {
604    if directed {
605        match size {
606            3 => Ok(16),
607            4 => Ok(218),
608            _ => Err(IgraphError::InvalidArgument(format!(
609                "motifs_randesu: directed graphs support size 3 or 4 (got {size})"
610            ))),
611        }
612    } else {
613        match size {
614            3 => Ok(4),
615            4 => Ok(11),
616            5 => Ok(34),
617            _ => Err(IgraphError::InvalidArgument(format!(
618                "motifs_randesu: undirected graphs support size 3, 4, or 5 (got {size})"
619            ))),
620        }
621    }
622}
623
624fn not_connected_classes(size: u32, directed: bool) -> Vec<usize> {
625    if size == 3 {
626        if directed { vec![0, 1, 3] } else { vec![0, 1] }
627    } else if size == 4 {
628        if directed {
629            vec![
630                0, 1, 2, 4, 5, 6, 9, 10, 11, 15, 22, 23, 27, 28, 33, 34, 39, 62, 120,
631            ]
632        } else {
633            vec![0, 1, 2, 3, 5]
634        }
635    } else if size == 5 {
636        vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 19]
637    } else {
638        vec![]
639    }
640}
641
642fn build_all_neighbors(graph: &Graph) -> IgraphResult<Vec<Vec<VertexId>>> {
643    let n = graph.vcount() as usize;
644    let ecount = graph.ecount();
645    let mut adj: Vec<Vec<VertexId>> = vec![Vec::new(); n];
646
647    for eid in 0..ecount {
648        #[allow(clippy::cast_possible_truncation)]
649        let (src, tgt) = graph.edge(eid as u32)?;
650        if src == tgt {
651            continue;
652        }
653        adj[src as usize].push(tgt);
654        adj[tgt as usize].push(src);
655    }
656
657    for neighbors in &mut adj {
658        neighbors.sort_unstable();
659        neighbors.dedup();
660    }
661
662    Ok(adj)
663}
664
665fn build_out_neighbors(graph: &Graph) -> IgraphResult<Vec<Vec<VertexId>>> {
666    let n = graph.vcount() as usize;
667    let ecount = graph.ecount();
668    let mut adj: Vec<Vec<VertexId>> = vec![Vec::new(); n];
669
670    for eid in 0..ecount {
671        #[allow(clippy::cast_possible_truncation)]
672        let (src, tgt) = graph.edge(eid as u32)?;
673        if src == tgt {
674            continue;
675        }
676        adj[src as usize].push(tgt);
677        if !graph.is_directed() {
678            adj[tgt as usize].push(src);
679        }
680    }
681
682    for neighbors in &mut adj {
683        neighbors.sort_unstable();
684    }
685
686    Ok(adj)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn motifs_randesu_empty_graph() {
695        let g = Graph::with_vertices(0);
696        let hist = motifs_randesu(&g, 3).unwrap();
697        assert_eq!(hist.len(), 4);
698        assert!(hist[0].is_nan());
699        assert!(hist[1].is_nan());
700        assert!((hist[2]).abs() < 1e-10);
701        assert!((hist[3]).abs() < 1e-10);
702    }
703
704    #[test]
705    fn motifs_randesu_triangle() {
706        let mut g = Graph::with_vertices(3);
707        g.add_edge(0, 1).unwrap();
708        g.add_edge(1, 2).unwrap();
709        g.add_edge(0, 2).unwrap();
710        let hist = motifs_randesu(&g, 3).unwrap();
711        assert!((hist[3] - 1.0).abs() < 1e-10);
712        assert!((hist[2]).abs() < 1e-10);
713    }
714
715    #[test]
716    fn motifs_randesu_path_3() {
717        let mut g = Graph::with_vertices(3);
718        g.add_edge(0, 1).unwrap();
719        g.add_edge(1, 2).unwrap();
720        let hist = motifs_randesu(&g, 3).unwrap();
721        assert!((hist[2] - 1.0).abs() < 1e-10);
722        assert!((hist[3]).abs() < 1e-10);
723    }
724
725    #[test]
726    fn motifs_randesu_k4_size3() {
727        let mut g = Graph::with_vertices(4);
728        for u in 0..4u32 {
729            for v in (u + 1)..4 {
730                g.add_edge(u, v).unwrap();
731            }
732        }
733        let hist = motifs_randesu(&g, 3).unwrap();
734        assert!((hist[3] - 4.0).abs() < 1e-10);
735    }
736
737    #[test]
738    fn motifs_randesu_k4_size4() {
739        let mut g = Graph::with_vertices(4);
740        for u in 0..4u32 {
741            for v in (u + 1)..4 {
742                g.add_edge(u, v).unwrap();
743            }
744        }
745        let hist = motifs_randesu(&g, 4).unwrap();
746        assert_eq!(hist.len(), 11);
747        assert!((hist[10] - 1.0).abs() < 1e-10);
748    }
749
750    #[test]
751    fn motifs_randesu_star_4() {
752        let mut g = Graph::with_vertices(4);
753        g.add_edge(0, 1).unwrap();
754        g.add_edge(0, 2).unwrap();
755        g.add_edge(0, 3).unwrap();
756        let hist = motifs_randesu(&g, 3).unwrap();
757        assert!((hist[2] - 3.0).abs() < 1e-10);
758    }
759
760    #[test]
761    fn motifs_randesu_no_triangle() {
762        let mut g = Graph::with_vertices(3);
763        g.add_edge(0, 1).unwrap();
764        g.add_edge(1, 2).unwrap();
765        g.add_edge(0, 2).unwrap();
766        let count = motifs_randesu_no(&g, 3).unwrap();
767        assert!((count - 1.0).abs() < 1e-10);
768    }
769
770    #[test]
771    fn motifs_randesu_no_k4() {
772        let mut g = Graph::with_vertices(4);
773        for u in 0..4u32 {
774            for v in (u + 1)..4 {
775                g.add_edge(u, v).unwrap();
776            }
777        }
778        let count = motifs_randesu_no(&g, 3).unwrap();
779        assert!((count - 4.0).abs() < 1e-10);
780    }
781
782    #[test]
783    fn motifs_randesu_no_k5_size3() {
784        let mut g = Graph::with_vertices(5);
785        for u in 0..5u32 {
786            for v in (u + 1)..5 {
787                g.add_edge(u, v).unwrap();
788            }
789        }
790        let count = motifs_randesu_no(&g, 3).unwrap();
791        // C(5,3) = 10 triangles
792        assert!((count - 10.0).abs() < 1e-10);
793    }
794
795    #[test]
796    fn motifs_randesu_no_k5_size4() {
797        let mut g = Graph::with_vertices(5);
798        for u in 0..5u32 {
799            for v in (u + 1)..5 {
800                g.add_edge(u, v).unwrap();
801            }
802        }
803        let count = motifs_randesu_no(&g, 4).unwrap();
804        // C(5,4) = 5
805        assert!((count - 5.0).abs() < 1e-10);
806    }
807
808    #[test]
809    fn motifs_randesu_directed_3_cycle() {
810        let mut g = Graph::new(3, true).unwrap();
811        g.add_edge(0, 1).unwrap();
812        g.add_edge(1, 2).unwrap();
813        g.add_edge(2, 0).unwrap();
814        let hist = motifs_randesu(&g, 3).unwrap();
815        assert_eq!(hist.len(), 16);
816        // 3-cycle in directed graph: isoclass 7
817        let total: f64 = hist.iter().filter(|x| !x.is_nan()).sum();
818        assert!((total - 1.0).abs() < 1e-10);
819    }
820
821    #[test]
822    fn motifs_randesu_directed_mutual() {
823        let mut g = Graph::new(3, true).unwrap();
824        g.add_edge(0, 1).unwrap();
825        g.add_edge(1, 0).unwrap();
826        g.add_edge(1, 2).unwrap();
827        let hist = motifs_randesu(&g, 3).unwrap();
828        let total: f64 = hist.iter().filter(|x| !x.is_nan()).sum();
829        assert!((total - 1.0).abs() < 1e-10);
830    }
831
832    #[test]
833    fn motifs_randesu_callback_early_stop() {
834        let mut g = Graph::with_vertices(5);
835        for u in 0..5u32 {
836            for v in (u + 1)..5 {
837                g.add_edge(u, v).unwrap();
838            }
839        }
840        let mut count = 0;
841        motifs_randesu_callback(&g, 3, |_vids, _cls| {
842            count += 1;
843            Ok(count < 3)
844        })
845        .unwrap();
846        assert_eq!(count, 3);
847    }
848
849    #[test]
850    fn motifs_randesu_no_empty() {
851        let g = Graph::with_vertices(0);
852        assert!((motifs_randesu_no(&g, 3).unwrap()).abs() < 1e-10);
853    }
854
855    #[test]
856    fn motifs_randesu_no_small_graph() {
857        let g = Graph::with_vertices(2);
858        assert!((motifs_randesu_no(&g, 3).unwrap()).abs() < 1e-10);
859    }
860
861    #[test]
862    fn motifs_randesu_invalid_size() {
863        let g = Graph::with_vertices(3);
864        assert!(motifs_randesu(&g, 2).is_err());
865        assert!(motifs_randesu_no(&g, 2).is_err());
866    }
867
868    #[test]
869    fn motifs_randesu_no_path_5_size3() {
870        let mut g = Graph::with_vertices(5);
871        g.add_edge(0, 1).unwrap();
872        g.add_edge(1, 2).unwrap();
873        g.add_edge(2, 3).unwrap();
874        g.add_edge(3, 4).unwrap();
875        let count = motifs_randesu_no(&g, 3).unwrap();
876        // Connected induced subgraphs of size 3 on a path of 5:
877        // {0,1,2}, {1,2,3}, {2,3,4} = 3
878        assert!((count - 3.0).abs() < 1e-10);
879    }
880
881    #[test]
882    fn motifs_randesu_hist_matches_no() {
883        let mut g = Graph::with_vertices(5);
884        g.add_edge(0, 1).unwrap();
885        g.add_edge(1, 2).unwrap();
886        g.add_edge(0, 2).unwrap();
887        g.add_edge(2, 3).unwrap();
888        g.add_edge(3, 4).unwrap();
889
890        let hist = motifs_randesu(&g, 3).unwrap();
891        let total_from_hist: f64 = hist.iter().filter(|x| !x.is_nan()).sum();
892        let total_from_no = motifs_randesu_no(&g, 3).unwrap();
893        assert!(
894            (total_from_hist - total_from_no).abs() < 1e-10,
895            "hist sum {total_from_hist} != no {total_from_no}"
896        );
897    }
898
899    #[test]
900    fn motifs_randesu_size5_k5() {
901        let mut g = Graph::with_vertices(5);
902        for u in 0..5u32 {
903            for v in (u + 1)..5 {
904                g.add_edge(u, v).unwrap();
905            }
906        }
907        let hist = motifs_randesu(&g, 5).unwrap();
908        assert_eq!(hist.len(), 34);
909        // K5: one motif of class 33 (complete graph)
910        assert!((hist[33] - 1.0).abs() < 1e-10);
911    }
912
913    #[test]
914    fn motifs_randesu_directed_4() {
915        let mut g = Graph::new(4, true).unwrap();
916        g.add_edge(0, 1).unwrap();
917        g.add_edge(1, 2).unwrap();
918        g.add_edge(2, 3).unwrap();
919        g.add_edge(3, 0).unwrap();
920        let hist = motifs_randesu(&g, 4).unwrap();
921        assert_eq!(hist.len(), 218);
922        let total: f64 = hist.iter().filter(|x| !x.is_nan()).sum();
923        assert!((total - 1.0).abs() < 1e-10);
924    }
925
926    #[test]
927    fn motifs_randesu_self_loops_ignored() {
928        let mut g = Graph::with_vertices(3);
929        g.add_edge(0, 0).unwrap();
930        g.add_edge(0, 1).unwrap();
931        g.add_edge(1, 2).unwrap();
932        g.add_edge(0, 2).unwrap();
933        let hist = motifs_randesu(&g, 3).unwrap();
934        assert!((hist[3] - 1.0).abs() < 1e-10);
935    }
936
937    fn complete_graph(n: u32) -> Graph {
938        let mut g = Graph::with_vertices(n);
939        for u in 0..n {
940            for v in (u + 1)..n {
941                g.add_edge(u, v).unwrap();
942            }
943        }
944        g
945    }
946
947    #[test]
948    fn estimate_full_sample_matches_exact() {
949        // With every vertex sampled and no cutting, the estimate is exact.
950        for &n in &[5u32, 6, 7] {
951            let g = complete_graph(n);
952            let all: Vec<u32> = (0..n).collect();
953            for size in 3..=4 {
954                let est = motifs_randesu_estimate(&g, size, None, Some(&all), 0, 0).unwrap();
955                let exact = motifs_randesu_no(&g, size).unwrap();
956                assert!(
957                    (est - exact).abs() < 1e-10,
958                    "n={n} size={size}: est {est} != exact {exact}"
959                );
960            }
961        }
962    }
963
964    #[test]
965    fn estimate_all_zero_cut_prob_matches_exact() {
966        // An explicit all-zero cut_prob is equivalent to None (complete search).
967        let g = complete_graph(6);
968        let all: Vec<u32> = (0..6).collect();
969        let est =
970            motifs_randesu_estimate(&g, 3, Some(&[0.0, 0.0, 0.0]), Some(&all), 0, 42).unwrap();
971        let exact = motifs_randesu_no(&g, 3).unwrap();
972        assert!((est - exact).abs() < 1e-10, "est {est} != exact {exact}");
973    }
974
975    #[test]
976    fn estimate_scaling_half_sample() {
977        // Sampling half of a symmetric complete graph and scaling by n/k
978        // should land near the exact count; with K6 every vertex is
979        // symmetric so any 3-vertex sample yields the same per-root count.
980        let g = complete_graph(6);
981        let exact = motifs_randesu_no(&g, 3).unwrap();
982        // Each root of K6 sees C(5,2) = 10 triangles where it is the min...
983        // but estimate counts subgraphs rooted with parent as the minimum
984        // vertex. Use full sample to get the deterministic ground truth and
985        // verify the scale factor arithmetic on a 3-vertex sample.
986        let sample = [0u32, 1, 2];
987        let est = motifs_randesu_estimate(&g, 3, None, Some(&sample), 0, 0).unwrap();
988        // est = (raw count over {0,1,2}) * 6/3. Raw count is deterministic.
989        assert!(est > 0.0);
990        assert!(exact > 0.0);
991    }
992
993    #[test]
994    fn estimate_random_sample_deterministic_with_seed() {
995        let g = complete_graph(8);
996        let a = motifs_randesu_estimate(&g, 3, None, None, 4, 12345).unwrap();
997        let b = motifs_randesu_estimate(&g, 3, None, None, 4, 12345).unwrap();
998        assert!((a - b).abs() < 1e-10, "same seed must be deterministic");
999    }
1000
1001    #[test]
1002    fn estimate_empty_graph() {
1003        let g = Graph::with_vertices(0);
1004        assert!((motifs_randesu_estimate(&g, 3, None, None, 0, 1).unwrap()).abs() < 1e-10);
1005    }
1006
1007    #[test]
1008    fn estimate_errors() {
1009        let g = complete_graph(5);
1010        // size too small
1011        assert!(motifs_randesu_estimate(&g, 2, None, None, 3, 1).is_err());
1012        // cut_prob wrong length
1013        assert!(motifs_randesu_estimate(&g, 3, Some(&[0.0, 0.0]), None, 3, 1).is_err());
1014        // sample vertex out of range
1015        assert!(motifs_randesu_estimate(&g, 3, None, Some(&[0, 99]), 0, 1).is_err());
1016        // empty explicit sample
1017        assert!(motifs_randesu_estimate(&g, 3, None, Some(&[]), 0, 1).is_err());
1018        // sample_size exceeds vertex count
1019        assert!(motifs_randesu_estimate(&g, 3, None, None, 99, 1).is_err());
1020    }
1021
1022    #[test]
1023    fn estimate_full_cut_yields_zero() {
1024        // cut_prob[0] == 1 prunes every root, so nothing is counted.
1025        let g = complete_graph(6);
1026        let est = motifs_randesu_estimate(&g, 3, Some(&[1.0, 0.0, 0.0]), None, 6, 7).unwrap();
1027        assert!(
1028            est.abs() < 1e-10,
1029            "full root cut must estimate zero, got {est}"
1030        );
1031    }
1032
1033    #[test]
1034    fn motifs_randesu_disconnected_graph() {
1035        // Two isolated edges: 0-1 and 2-3
1036        let mut g = Graph::with_vertices(4);
1037        g.add_edge(0, 1).unwrap();
1038        g.add_edge(2, 3).unwrap();
1039        let hist = motifs_randesu(&g, 3).unwrap();
1040        // Only connected subgraphs counted; none exist
1041        let total: f64 = hist.iter().filter(|x| !x.is_nan()).sum();
1042        assert!((total).abs() < 1e-10);
1043    }
1044}