Skip to main content

rust_igraph/algorithms/
graphlets.rs

1//! Graphlet decomposition (ALGO-CL-020).
2//!
3//! Counterpart of `igraph_graphlets()`, `igraph_graphlets_candidate_basis()`,
4//! and `igraph_graphlets_project()` from `references/igraph/src/cliques/glet.c`.
5//!
6//! Graphlet decomposition models a weighted undirected graph via the union of
7//! potentially overlapping dense social groups. Step one finds a candidate
8//! basis of cliques at weight thresholds; step two projects the graph onto
9//! that basis via an iterative NNLS-like algorithm.
10//!
11//! Reference: Hossein Azari Soufiani and Edoardo M. Airoldi,
12//! "Graphlet decomposition of a weighted network" (2012).
13
14use crate::algorithms::cliques::maximal_cliques;
15use crate::algorithms::properties::is_simple::is_simple;
16use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
17
18/// Result of [`graphlets_candidate_basis`]: a list of basis cliques with
19/// their associated weight thresholds.
20#[derive(Debug, Clone, PartialEq)]
21pub struct GraphletBasis {
22    /// Each element is a sorted list of vertex IDs forming a basis clique.
23    pub cliques: Vec<Vec<VertexId>>,
24    /// `thresholds[i]` is the weight threshold at which `cliques[i]` was
25    /// found (the minimum edge weight within that clique).
26    pub thresholds: Vec<f64>,
27}
28
29/// Result of [`graphlets`]: basis cliques with their projected weights,
30/// sorted by decreasing weight.
31#[derive(Debug, Clone, PartialEq)]
32pub struct GraphletDecomposition {
33    /// Basis cliques, sorted by decreasing projected weight.
34    pub cliques: Vec<Vec<VertexId>>,
35    /// `mu[i]` is the projected weight coefficient of `cliques[i]`.
36    pub mu: Vec<f64>,
37}
38
39/// Find a candidate graphlets basis for a weighted undirected graph.
40///
41/// The input graph must be simple (no self-loops, no parallel edges). Edge
42/// directions are ignored. The algorithm recursively finds maximal cliques
43/// at successively higher weight thresholds, then filters out non-maximal
44/// cliques that are subsets of larger cliques at the same threshold.
45///
46/// # Errors
47///
48/// - `InvalidArgument` if `weights` length doesn't match edge count, or
49///   the graph is not simple.
50///
51/// # Examples
52///
53/// ```
54/// use rust_igraph::{Graph, graphlets_candidate_basis};
55///
56/// let mut g = Graph::with_vertices(4);
57/// g.add_edge(0, 1).unwrap(); // weight 1
58/// g.add_edge(1, 2).unwrap(); // weight 2
59/// g.add_edge(0, 2).unwrap(); // weight 1
60/// g.add_edge(2, 3).unwrap(); // weight 3
61///
62/// let basis = graphlets_candidate_basis(&g, &[1.0, 2.0, 1.0, 3.0]).unwrap();
63/// assert!(!basis.cliques.is_empty());
64/// ```
65pub fn graphlets_candidate_basis(graph: &Graph, weights: &[f64]) -> IgraphResult<GraphletBasis> {
66    validate_graphlets_input(graph, weights)?;
67
68    let n = graph.vcount();
69    let ecount = graph.ecount();
70
71    if ecount == 0 {
72        return Ok(GraphletBasis {
73            cliques: Vec::new(),
74            thresholds: Vec::new(),
75        });
76    }
77
78    let min_thr = weights.iter().copied().fold(f64::INFINITY, f64::min);
79
80    let ids: Vec<VertexId> = (0..n).collect();
81
82    let mut cliques: Vec<Vec<VertexId>> = Vec::new();
83    let mut thresholds: Vec<f64> = Vec::new();
84
85    graphlets_recursive(graph, weights, &ids, min_thr, &mut cliques, &mut thresholds)?;
86
87    graphlets_filter(&mut cliques, &mut thresholds);
88
89    Ok(GraphletBasis {
90        cliques,
91        thresholds,
92    })
93}
94
95/// Project a graph onto a graphlets basis.
96///
97/// Performs `niter` iterations of the multiplicative update (NNLS-like).
98/// If `start_mu` is `Some(vec)`, uses it as the initial weight vector;
99/// otherwise starts from all-ones.
100///
101/// # Errors
102///
103/// - `InvalidArgument` if `weights` length doesn't match edge count,
104///   `start_mu` length doesn't match clique count, `niter < 0`, or
105///   the graph is not simple.
106///
107/// # Examples
108///
109/// ```
110/// use rust_igraph::{Graph, graphlets_candidate_basis, graphlets_project};
111///
112/// let mut g = Graph::with_vertices(4);
113/// g.add_edge(0, 1).unwrap();
114/// g.add_edge(1, 2).unwrap();
115/// g.add_edge(0, 2).unwrap();
116/// g.add_edge(2, 3).unwrap();
117///
118/// let weights = [1.0, 2.0, 1.0, 3.0];
119/// let basis = graphlets_candidate_basis(&g, &weights).unwrap();
120/// let mu = graphlets_project(&g, &weights, &basis.cliques, None, 1000).unwrap();
121/// assert_eq!(mu.len(), basis.cliques.len());
122/// ```
123pub fn graphlets_project(
124    graph: &Graph,
125    weights: &[f64],
126    cliques: &[Vec<VertexId>],
127    start_mu: Option<&[f64]>,
128    niter: u32,
129) -> IgraphResult<Vec<f64>> {
130    validate_graphlets_input(graph, weights)?;
131
132    let no_cliques = cliques.len();
133    if no_cliques == 0 {
134        return Ok(Vec::new());
135    }
136
137    if let Some(sm) = start_mu {
138        if sm.len() != no_cliques {
139            return Err(IgraphError::InvalidArgument(format!(
140                "start_mu length {} does not match clique count {no_cliques}",
141                sm.len()
142            )));
143        }
144    }
145
146    let mut mu = match start_mu {
147        Some(sm) => sm.to_vec(),
148        None => vec![1.0; no_cliques],
149    };
150
151    project_inner(graph, weights, cliques, &mut mu, niter)?;
152
153    Ok(mu)
154}
155
156/// Full graphlet decomposition: find basis + project + sort by weight.
157///
158/// Convenience function that calls [`graphlets_candidate_basis`] and
159/// [`graphlets_project`], then sorts the result by decreasing weight.
160///
161/// # Errors
162///
163/// - `InvalidArgument` if `weights` length doesn't match edge count, or
164///   the graph is not simple.
165///
166/// # Examples
167///
168/// ```
169/// use rust_igraph::{Graph, graphlets};
170///
171/// let mut g = Graph::with_vertices(4);
172/// g.add_edge(0, 1).unwrap();
173/// g.add_edge(1, 2).unwrap();
174/// g.add_edge(0, 2).unwrap();
175/// g.add_edge(2, 3).unwrap();
176///
177/// let result = graphlets(&g, &[1.0, 2.0, 1.0, 3.0], 1000).unwrap();
178/// // Weights are in decreasing order
179/// for w in result.mu.windows(2) {
180///     assert!(w[0] >= w[1]);
181/// }
182/// ```
183pub fn graphlets(
184    graph: &Graph,
185    weights: &[f64],
186    niter: u32,
187) -> IgraphResult<GraphletDecomposition> {
188    let basis = graphlets_candidate_basis(graph, weights)?;
189    let mu = graphlets_project(graph, weights, &basis.cliques, None, niter)?;
190
191    let mut order: Vec<usize> = (0..mu.len()).collect();
192    order.sort_by(|&a, &b| {
193        mu[b]
194            .partial_cmp(&mu[a])
195            .unwrap_or(std::cmp::Ordering::Equal)
196    });
197
198    let cliques: Vec<Vec<VertexId>> = order.iter().map(|&i| basis.cliques[i].clone()).collect();
199    let sorted_mu: Vec<f64> = order.iter().map(|&i| mu[i]).collect();
200
201    Ok(GraphletDecomposition {
202        cliques,
203        mu: sorted_mu,
204    })
205}
206
207fn validate_graphlets_input(graph: &Graph, weights: &[f64]) -> IgraphResult<()> {
208    if weights.len() != graph.ecount() {
209        return Err(IgraphError::InvalidArgument(format!(
210            "weights length {} does not match edge count {}",
211            weights.len(),
212            graph.ecount()
213        )));
214    }
215    if !is_simple(graph)? {
216        return Err(IgraphError::InvalidArgument(
217            "graphlets require a simple graph".to_string(),
218        ));
219    }
220    Ok(())
221}
222
223type SubCliqueResult = (Graph, Vec<f64>, Vec<VertexId>);
224
225// ---------- internal: recursive basis construction ----------
226
227fn graphlets_recursive(
228    graph: &Graph,
229    weights: &[f64],
230    ids: &[VertexId],
231    start_thr: f64,
232    cliques_out: &mut Vec<Vec<VertexId>>,
233    thresholds_out: &mut Vec<f64>,
234) -> IgraphResult<()> {
235    let n = graph.vcount();
236
237    // Build subgraph from edges >= start_thr by zeroing out adjacency
238    // for edges below threshold. We build an undirected adjacency for
239    // maximal_cliques which ignores directions.
240    let mut sub = Graph::with_vertices(n);
241    for (eid, &w) in weights.iter().enumerate() {
242        if w >= start_thr {
243            #[allow(clippy::cast_possible_truncation)]
244            let (src, tgt) = graph.edge(eid as u32)?;
245            sub.add_edge(src, tgt)?;
246        }
247    }
248
249    let my_cliques = maximal_cliques(&sub)?;
250
251    if my_cliques.is_empty() {
252        return Ok(());
253    }
254
255    // For each clique, find the minimum edge weight (clique threshold)
256    // and the next threshold (smallest weight strictly above minimum).
257    // Then build a sub-graph of edges >= next_thr for recursion.
258    let no_cliques = my_cliques.len();
259    let mut clique_thrs = Vec::with_capacity(no_cliques);
260    let mut next_thrs = Vec::with_capacity(no_cliques);
261    let mut sub_graphs: Vec<Option<SubCliqueResult>> = Vec::with_capacity(no_cliques);
262
263    for clique in &my_cliques {
264        let (min_w, next_w) = find_clique_thresholds(graph, weights, clique);
265        clique_thrs.push(min_w);
266        next_thrs.push(next_w);
267
268        // Build sub-graph for recursion: edges within clique that are >= next_w
269        if next_w.is_finite() {
270            let sub_result = build_subclique_graph(graph, weights, ids, clique, next_w)?;
271            sub_graphs.push(sub_result);
272        } else {
273            sub_graphs.push(None);
274        }
275    }
276
277    // Store cliques at the current level, mapping vertex IDs through ids[]
278    for (i, clique) in my_cliques.iter().enumerate() {
279        let mut mapped: Vec<VertexId> = clique.iter().map(|&v| ids[v as usize]).collect();
280        mapped.sort_unstable();
281        cliques_out.push(mapped);
282        thresholds_out.push(clique_thrs[i]);
283    }
284
285    // Recurse into sub-graphs
286    for (i, sub_opt) in sub_graphs.into_iter().enumerate() {
287        if let Some((sub_g, sub_w, sub_ids)) = sub_opt {
288            if sub_g.vcount() > 1 {
289                graphlets_recursive(
290                    &sub_g,
291                    &sub_w,
292                    &sub_ids,
293                    next_thrs[i],
294                    cliques_out,
295                    thresholds_out,
296                )?;
297            }
298        }
299    }
300
301    Ok(())
302}
303
304/// Find the minimum edge weight within a clique and the next-smallest
305/// weight strictly above the minimum.
306fn find_clique_thresholds(graph: &Graph, weights: &[f64], clique: &[VertexId]) -> (f64, f64) {
307    let mut min_weight = f64::INFINITY;
308    let mut next_weight = f64::INFINITY;
309
310    let n = clique.len();
311    // Check all pairs within the clique
312    for i in 0..n {
313        for j in (i + 1)..n {
314            let vi = clique[i];
315            let vj = clique[j];
316            if let Some(w) = edge_weight_between(graph, weights, vi, vj) {
317                if w < min_weight {
318                    next_weight = min_weight;
319                    min_weight = w;
320                } else if w > min_weight && w < next_weight {
321                    next_weight = w;
322                }
323            }
324        }
325    }
326
327    (min_weight, next_weight)
328}
329
330/// Find the weight of the edge between two vertices (undirected).
331fn edge_weight_between(graph: &Graph, weights: &[f64], v1: VertexId, v2: VertexId) -> Option<f64> {
332    if let Ok(edges) = graph.incident(v1) {
333        for eid in edges {
334            if let Ok(other) = graph.edge_other(eid, v1) {
335                if other == v2 {
336                    return Some(weights[eid as usize]);
337                }
338            }
339        }
340    }
341    None
342}
343
344/// Build a sub-graph from edges within a clique that are >= `next_thr`.
345/// Returns `(sub_graph, sub_weights, sub_ids)` where `sub_ids` maps
346/// sub-graph vertex IDs back to original vertex IDs via the parent `ids`.
347#[allow(clippy::cast_possible_truncation)]
348fn build_subclique_graph(
349    graph: &Graph,
350    weights: &[f64],
351    ids: &[VertexId],
352    clique: &[VertexId],
353    next_thr: f64,
354) -> IgraphResult<Option<SubCliqueResult>> {
355    let n = graph.vcount() as usize;
356    let mut in_clique = vec![false; n];
357    for &v in clique {
358        in_clique[v as usize] = true;
359    }
360
361    let mut edges_within: Vec<(VertexId, VertexId, f64)> = Vec::new();
362    for &v in clique {
363        if let Ok(inc) = graph.incident(v) {
364            for eid in inc {
365                if let Ok(other) = graph.edge_other(eid, v) {
366                    if other > v && in_clique[other as usize] {
367                        edges_within.push((v, other, weights[eid as usize]));
368                    }
369                }
370            }
371        }
372    }
373
374    // Filter edges >= next_thr and collect incident vertices
375    let mut vertex_map: Vec<Option<u32>> = vec![None; n];
376    let mut sub_ids: Vec<VertexId> = Vec::new();
377    let mut sub_edges: Vec<(u32, u32, f64)> = Vec::new();
378    let mut nov: u32 = 0;
379
380    for &(efrom, eto, ew) in &edges_within {
381        if ew >= next_thr {
382            let from_mapped = *vertex_map[efrom as usize].get_or_insert_with(|| {
383                let m = nov;
384                sub_ids.push(ids[efrom as usize]);
385                nov += 1;
386                m
387            });
388            let to_mapped = *vertex_map[eto as usize].get_or_insert_with(|| {
389                let m = nov;
390                sub_ids.push(ids[eto as usize]);
391                nov += 1;
392                m
393            });
394            sub_edges.push((from_mapped, to_mapped, ew));
395        }
396    }
397
398    if nov <= 1 || sub_edges.is_empty() {
399        return Ok(None);
400    }
401
402    let mut sub_g = Graph::with_vertices(nov);
403    let mut sub_w = Vec::with_capacity(sub_edges.len());
404    for (f, t, w) in sub_edges {
405        sub_g.add_edge(f, t)?;
406        sub_w.push(w);
407    }
408
409    Ok(Some((sub_g, sub_w, sub_ids)))
410}
411
412// ---------- internal: filter non-maximal cliques ----------
413
414fn graphlets_filter(cliques: &mut Vec<Vec<VertexId>>, thresholds: &mut Vec<f64>) {
415    let n = cliques.len();
416    if n <= 1 {
417        return;
418    }
419
420    // Sort indices by (threshold, clique_size)
421    let mut order: Vec<usize> = (0..n).collect();
422    order.sort_by(|&a, &b| {
423        thresholds[a]
424            .partial_cmp(&thresholds[b])
425            .unwrap_or(std::cmp::Ordering::Equal)
426            .then_with(|| cliques[a].len().cmp(&cliques[b].len()))
427    });
428
429    let mut to_remove = vec![false; n];
430
431    for ii in 0..n.saturating_sub(1) {
432        let ri = order[ii];
433        if to_remove[ri] {
434            continue;
435        }
436        let thr_i = thresholds[ri];
437
438        for &rj in &order[(ii + 1)..n] {
439            if to_remove[rj] {
440                continue;
441            }
442            let thr_j = thresholds[rj];
443
444            if (thr_j - thr_i).abs() > f64::EPSILON * thr_i.abs().max(1.0) {
445                break;
446            }
447
448            if cliques[ri].len() > cliques[rj].len() {
449                continue;
450            }
451
452            // Check if cliques[ri] is a subset of cliques[rj]
453            if is_sorted_subset(&cliques[ri], &cliques[rj]) {
454                to_remove[ri] = true;
455                break;
456            }
457        }
458    }
459
460    // Compact
461    let mut write = 0;
462    for (read, &remove) in to_remove.iter().enumerate() {
463        if !remove {
464            if write != read {
465                cliques.swap(write, read);
466                thresholds.swap(write, read);
467            }
468            write += 1;
469        }
470    }
471    cliques.truncate(write);
472    thresholds.truncate(write);
473}
474
475/// Check if sorted slice `needle` is a subset of sorted slice `hay`.
476fn is_sorted_subset(needle: &[VertexId], hay: &[VertexId]) -> bool {
477    if needle.len() > hay.len() {
478        return false;
479    }
480    let mut pi = 0;
481    let mut pj = 0;
482    let n_i = needle.len();
483    let n_j = hay.len();
484
485    while pi < n_i && pj < n_j && (n_i - pi) <= (n_j - pj) {
486        match needle[pi].cmp(&hay[pj]) {
487            std::cmp::Ordering::Less => return false,
488            std::cmp::Ordering::Greater => pj += 1,
489            std::cmp::Ordering::Equal => {
490                pi += 1;
491                pj += 1;
492            }
493        }
494    }
495    pi == n_i
496}
497
498// ---------- internal: projection (NNLS-like) ----------
499
500#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
501fn project_inner(
502    graph: &Graph,
503    weights: &[f64],
504    cliques: &[Vec<VertexId>],
505    mu: &mut [f64],
506    niter: u32,
507) -> IgraphResult<()> {
508    let no_of_edges = graph.ecount();
509    let no_of_nodes = graph.vcount() as usize;
510    let no_cliques = cliques.len();
511
512    // Build vertex→clique index: for each vertex, which cliques contain it
513    let mut vcl: Vec<Vec<usize>> = vec![Vec::new(); no_of_nodes];
514    for (ci, clique) in cliques.iter().enumerate() {
515        for &v in clique {
516            vcl[v as usize].push(ci);
517        }
518    }
519    // Sort for merge-intersection
520    for v in &mut vcl {
521        v.sort_unstable();
522    }
523
524    // Build edge→clique list: for each edge, which cliques contain both endpoints
525    let mut ecl: Vec<Vec<usize>> = Vec::with_capacity(no_of_edges);
526    let mut edge_from_to: Vec<(VertexId, VertexId)> = Vec::with_capacity(no_of_edges);
527    for eid in 0..no_of_edges {
528        let (from, to) = graph.edge(eid as u32)?;
529        edge_from_to.push((from, to));
530        let from_cl = &vcl[from as usize];
531        let to_cl = &vcl[to as usize];
532        ecl.push(sorted_intersection(from_cl, to_cl));
533    }
534
535    // Build clique→edge list (inverse of edge→clique)
536    let mut cel: Vec<Vec<usize>> = vec![Vec::new(); no_cliques];
537    for (eid, cls) in ecl.iter().enumerate() {
538        for &ci in cls {
539            cel[ci].push(eid);
540        }
541    }
542
543    // Normalizing factors: n*(n+1)/2 where n is clique size
544    let normfact: Vec<f64> = cliques
545        .iter()
546        .map(|c| {
547            let n = c.len() as f64;
548            n * (n + 1.0) / 2.0
549        })
550        .collect();
551
552    // Iterative projection
553    let mut new_weights = vec![0.0_f64; no_of_edges];
554
555    for _iter in 0..niter {
556        // Compute predicted weights for each edge
557        for (eid, cls) in ecl.iter().enumerate() {
558            new_weights[eid] = 0.0001;
559            for &ci in cls {
560                new_weights[eid] += mu[ci];
561            }
562        }
563
564        // Update mu
565        for (ci, edges) in cel.iter().enumerate() {
566            let mut sum_ratio = 0.0_f64;
567            for &eid in edges {
568                sum_ratio += weights[eid] / new_weights[eid];
569            }
570            mu[ci] *= sum_ratio / normfact[ci];
571        }
572    }
573
574    Ok(())
575}
576
577/// Merge-intersect two sorted slices.
578fn sorted_intersection(a: &[usize], b: &[usize]) -> Vec<usize> {
579    let mut result = Vec::new();
580    let (mut i, mut j) = (0, 0);
581    while i < a.len() && j < b.len() {
582        match a[i].cmp(&b[j]) {
583            std::cmp::Ordering::Equal => {
584                result.push(a[i]);
585                i += 1;
586                j += 1;
587            }
588            std::cmp::Ordering::Less => i += 1,
589            std::cmp::Ordering::Greater => j += 1,
590        }
591    }
592    result
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    fn triangle_with_pendant() -> (Graph, Vec<f64>) {
600        // Triangle {0,1,2} + pendant edge {2,3}
601        let mut g = Graph::with_vertices(4);
602        g.add_edge(0, 1).unwrap(); // e0: weight 1
603        g.add_edge(1, 2).unwrap(); // e1: weight 2
604        g.add_edge(0, 2).unwrap(); // e2: weight 1
605        g.add_edge(2, 3).unwrap(); // e3: weight 3
606        (g, vec![1.0, 2.0, 1.0, 3.0])
607    }
608
609    #[test]
610    fn candidate_basis_nonempty() {
611        let (g, w) = triangle_with_pendant();
612        let basis = graphlets_candidate_basis(&g, &w).unwrap();
613        assert!(!basis.cliques.is_empty());
614        assert_eq!(basis.cliques.len(), basis.thresholds.len());
615    }
616
617    #[test]
618    fn candidate_basis_cliques_sorted() {
619        let (g, w) = triangle_with_pendant();
620        let basis = graphlets_candidate_basis(&g, &w).unwrap();
621        for clique in &basis.cliques {
622            let mut sorted = clique.clone();
623            sorted.sort_unstable();
624            assert_eq!(clique, &sorted);
625        }
626    }
627
628    #[test]
629    fn candidate_basis_empty_graph() {
630        let g = Graph::with_vertices(5);
631        let basis = graphlets_candidate_basis(&g, &[]).unwrap();
632        assert!(basis.cliques.is_empty());
633        assert!(basis.thresholds.is_empty());
634    }
635
636    #[test]
637    fn candidate_basis_single_edge() {
638        let mut g = Graph::with_vertices(2);
639        g.add_edge(0, 1).unwrap();
640        let basis = graphlets_candidate_basis(&g, &[5.0]).unwrap();
641        assert_eq!(basis.cliques.len(), 1);
642        assert_eq!(basis.cliques[0], vec![0, 1]);
643    }
644
645    #[test]
646    fn candidate_basis_wrong_weight_length() {
647        let mut g = Graph::with_vertices(3);
648        g.add_edge(0, 1).unwrap();
649        assert!(graphlets_candidate_basis(&g, &[1.0, 2.0]).is_err());
650    }
651
652    #[test]
653    fn candidate_basis_non_simple_graph_errors() {
654        let mut g = Graph::with_vertices(3);
655        g.add_edge(0, 1).unwrap();
656        g.add_edge(0, 1).unwrap(); // multi-edge
657        assert!(graphlets_candidate_basis(&g, &[1.0, 2.0]).is_err());
658    }
659
660    #[test]
661    fn project_produces_correct_length() {
662        let (g, w) = triangle_with_pendant();
663        let basis = graphlets_candidate_basis(&g, &w).unwrap();
664        let mu = graphlets_project(&g, &w, &basis.cliques, None, 100).unwrap();
665        assert_eq!(mu.len(), basis.cliques.len());
666    }
667
668    #[test]
669    fn project_nonnegative_weights() {
670        let (g, w) = triangle_with_pendant();
671        let basis = graphlets_candidate_basis(&g, &w).unwrap();
672        let mu = graphlets_project(&g, &w, &basis.cliques, None, 100).unwrap();
673        for &m in &mu {
674            assert!(m >= 0.0, "mu should be non-negative, got {m}");
675        }
676    }
677
678    #[test]
679    fn project_with_start_mu() {
680        let (g, w) = triangle_with_pendant();
681        let basis = graphlets_candidate_basis(&g, &w).unwrap();
682        let start: Vec<f64> = vec![2.0; basis.cliques.len()];
683        let mu = graphlets_project(&g, &w, &basis.cliques, Some(&start), 100).unwrap();
684        assert_eq!(mu.len(), basis.cliques.len());
685    }
686
687    #[test]
688    fn project_wrong_start_mu_length() {
689        let (g, w) = triangle_with_pendant();
690        let basis = graphlets_candidate_basis(&g, &w).unwrap();
691        assert!(graphlets_project(&g, &w, &basis.cliques, Some(&[1.0]), 100).is_err());
692    }
693
694    #[test]
695    fn graphlets_full_sorted_decreasing() {
696        let (g, w) = triangle_with_pendant();
697        let result = graphlets(&g, &w, 1000).unwrap();
698        assert_eq!(result.cliques.len(), result.mu.len());
699        for pair in result.mu.windows(2) {
700            assert!(
701                pair[0] >= pair[1],
702                "mu should be sorted decreasing: {} < {}",
703                pair[0],
704                pair[1]
705            );
706        }
707    }
708
709    #[test]
710    fn graphlets_full_empty() {
711        let g = Graph::with_vertices(3);
712        let result = graphlets(&g, &[], 100).unwrap();
713        assert!(result.cliques.is_empty());
714        assert!(result.mu.is_empty());
715    }
716
717    #[test]
718    fn graphlets_complete_graph_uniform_weights() {
719        // K4 with uniform weights: should produce a single 4-clique
720        let mut g = Graph::with_vertices(4);
721        let mut w = Vec::new();
722        for i in 0..4u32 {
723            for j in (i + 1)..4 {
724                g.add_edge(i, j).unwrap();
725                w.push(1.0);
726            }
727        }
728        let basis = graphlets_candidate_basis(&g, &w).unwrap();
729        // All vertices form one maximal clique at threshold 1
730        assert!(basis.cliques.iter().any(|c| c.len() == 4));
731    }
732
733    #[test]
734    fn is_sorted_subset_basic() {
735        assert!(is_sorted_subset(&[1, 3], &[1, 2, 3, 4]));
736        assert!(!is_sorted_subset(&[1, 5], &[1, 2, 3, 4]));
737        assert!(is_sorted_subset(&[], &[1, 2, 3]));
738        assert!(!is_sorted_subset(&[1, 2, 3], &[1, 2]));
739        assert!(is_sorted_subset(&[1, 2], &[1, 2]));
740    }
741
742    #[test]
743    fn filter_removes_subsets() {
744        let mut cliques = vec![vec![0, 1], vec![0, 1, 2], vec![3, 4]];
745        let mut thresholds = vec![1.0, 1.0, 2.0];
746        graphlets_filter(&mut cliques, &mut thresholds);
747        // {0,1} is a subset of {0,1,2} at the same threshold
748        assert!(!cliques.contains(&vec![0, 1]));
749        assert!(cliques.contains(&vec![0, 1, 2]));
750        assert!(cliques.contains(&vec![3, 4]));
751    }
752
753    #[test]
754    fn filter_keeps_different_thresholds() {
755        let mut cliques = vec![vec![0, 1], vec![0, 1, 2]];
756        let mut thresholds = vec![1.0, 2.0];
757        graphlets_filter(&mut cliques, &mut thresholds);
758        // Different thresholds: both kept
759        assert_eq!(cliques.len(), 2);
760    }
761
762    #[test]
763    fn project_zero_iterations() {
764        let (g, w) = triangle_with_pendant();
765        let basis = graphlets_candidate_basis(&g, &w).unwrap();
766        let mu = graphlets_project(&g, &w, &basis.cliques, None, 0).unwrap();
767        // Zero iterations: mu stays at initial all-ones
768        for &m in &mu {
769            assert!((m - 1.0).abs() < 1e-9);
770        }
771    }
772
773    #[test]
774    fn graphlets_path_graph() {
775        // Path 0-1-2-3 with increasing weights
776        let mut g = Graph::with_vertices(4);
777        g.add_edge(0, 1).unwrap();
778        g.add_edge(1, 2).unwrap();
779        g.add_edge(2, 3).unwrap();
780        let w = [1.0, 2.0, 3.0];
781        let result = graphlets(&g, &w, 100).unwrap();
782        // Path graph: each edge is a maximal clique
783        assert!(!result.cliques.is_empty());
784        for c in &result.cliques {
785            assert_eq!(c.len(), 2);
786        }
787    }
788}