Skip to main content

hopr_network_graph/petgraph/
algorithm.rs

1//! Adaptation of the algorithm for `petgraph::algo::simple_path::all_simple_paths_multi` to accept
2//! a cost function interacting with the edge weight.
3
4use std::{
5    collections::HashSet,
6    hash::{BuildHasher, Hash},
7    iter::from_fn,
8};
9
10use indexmap::IndexSet;
11use petgraph::{
12    Direction::Outgoing,
13    visit::{EdgeRef, IntoEdgeReferences, IntoEdgesDirected, NodeCount},
14};
15use rand::seq::SliceRandom;
16use smallvec::SmallVec;
17
18/// Calculate all simple paths from a source node to any of several target nodes.
19///
20/// This function is a variant of `all_simple_paths` that accepts a `HashSet` of
21/// target nodes instead of a single one. A path is yielded as soon as it reaches any
22/// node in the `to` set.
23///
24/// # Performance Considerations
25///
26/// The efficiency of this function hinges on the graph's structure. It provides significant
27/// performance gains on graphs where paths share long initial segments (e.g., trees and DAGs),
28/// as the benefit of a single traversal outweighs the `HashSet` lookup overhead.
29///
30/// Conversely, in dense graphs where paths diverge quickly or for targets very close
31/// to the source, the lookup overhead could make repeated calls to `all_simple_paths`
32/// a faster alternative.
33///
34/// **Note**: If security is not a concern, a faster hasher (e.g., `FxBuildHasher`)
35/// can be specified to minimize the `HashSet` lookup overhead.
36///
37/// # Arguments
38/// * `graph`: an input graph.
39/// * `from`: an initial node of desired paths.
40/// * `to`: a `HashSet` of target nodes. A path is yielded as soon as it reaches any node in this set.
41/// * `excluded_nodes`: an optional set of nodes to exclude from all returned paths. Excluded nodes are pre-seeded into
42///   the visited set so the DFS never enters a branch containing them. Passing `Some(&empty)` or `None` is equivalent
43///   to the default behavior (no exclusions). If `from` is in the excluded set, it is ignored — the source is always
44///   reachable.
45/// * `min_intermediate_nodes`: the minimum number of nodes in the desired paths.
46/// * `max_intermediate_nodes`: the maximum number of nodes in the desired paths (optional).
47/// * `initial_cost`: the starting cost value before any edges are traversed.
48/// * `min_cost`: an optional threshold. If the accumulated cost drops below this value (via `PartialOrd`), the branch
49///   is pruned — it is neither yielded nor explored further.
50/// * `cost_fn`: an accumulator function `(accumulated_cost, &edge_weight, edge_count) -> new_cost` applied at each
51///   edge. The `edge_count` is the 0-based hop number from the source (i.e., 0 for the first edge, 1 for the second,
52///   etc.).
53///
54/// # Returns
55/// Returns an iterator that produces `(path, cost)` tuples for all simple paths from `from` node to any node in the
56/// `to` set, which contains at least `min_intermediate_nodes` and at most `max_intermediate_nodes` intermediate nodes,
57/// if given, or limited by the graph's order otherwise. The cost is the result of folding `cost_fn` over the edge
58/// weights along the path. Paths whose accumulated cost falls below `min_cost` at any point are excluded.
59///
60/// # Complexity
61/// * Time complexity: for computing the first **k** paths, the running time will be **O(k|V| + k|E|)**.
62/// * Auxiliary space: **O(|V|)**.
63///
64/// where **|V|** is the number of nodes and **|E|** is the number of edges.
65///
66/// # Example
67/// ```rust,ignore
68/// use petgraph::prelude::*;
69/// use std::collections::HashSet;
70/// use std::collections::hash_map::RandomState;
71///
72/// let mut graph = DiGraph::<&str, i32>::new();
73///
74/// let a = graph.add_node("a");
75/// let b = graph.add_node("b");
76/// let c = graph.add_node("c");
77/// let d = graph.add_node("d");
78/// graph.extend_with_edges(&[(a, b, 1), (b, c, 1), (b, d, 1)]);
79///
80/// // Find paths from "a" to either "c" or "d", accumulating edge costs.
81/// let targets = HashSet::from_iter([c, d]);
82/// let mut paths = all_simple_paths_multi::<Vec<_>, _, RandomState, _, _>(
83///     &graph, a, &targets, None, 0, None, 0i32, None, |cost, weight, _| cost + weight,
84/// )
85///     .collect::<Vec<_>>();
86///
87/// paths.sort_by_key(|(p, _)| p.clone());
88/// let expected_paths = vec![
89///     (vec![a, b, c], 2),
90///     (vec![a, b, d], 2),
91/// ];
92///
93/// assert_eq!(paths, expected_paths);
94/// ```
95#[allow(clippy::too_many_arguments)]
96pub fn all_simple_paths_multi<'a, TargetColl, G, S, F, C>(
97    graph: G,
98    from: G::NodeId,
99    to: &'a HashSet<G::NodeId, S>,
100    excluded_nodes: Option<&'a HashSet<G::NodeId, S>>,
101    min_intermediate_nodes: usize,
102    max_intermediate_nodes: Option<usize>,
103    initial_cost: C,
104    min_cost: Option<C>,
105    cost_fn: F,
106) -> impl Iterator<Item = (TargetColl, C)> + 'a
107where
108    G: NodeCount + IntoEdgesDirected + 'a,
109    <G as IntoEdgesDirected>::EdgesDirected: 'a,
110    G::NodeId: Eq + Hash,
111    TargetColl: FromIterator<G::NodeId>,
112    S: BuildHasher + Default,
113    C: Clone + PartialOrd + 'a,
114    F: Fn(C, &<<G as IntoEdgeReferences>::EdgeRef as EdgeRef>::Weight, usize) -> C + 'a,
115{
116    let max_nodes = if let Some(l) = max_intermediate_nodes {
117        l + 2
118    } else {
119        graph.node_count()
120    };
121
122    let min_nodes = min_intermediate_nodes + 2;
123
124    // list of visited nodes
125    let mut visited: IndexSet<G::NodeId, S> = IndexSet::from_iter(Some(from));
126    // list of edges from currently exploring path nodes,
127    // last elem is a shuffled vec of edges of last visited node
128    let mut rng = rand::rng();
129    let mut initial: SmallVec<[_; 16]> = graph.edges_directed(from, Outgoing).collect();
130    initial.shuffle(&mut rng);
131    let mut stack = Vec::with_capacity(max_nodes);
132    stack.push(initial.into_iter());
133    // accumulated cost at each depth level, parallel to visited
134    let mut costs: Vec<C> = Vec::with_capacity(max_nodes);
135    costs.push(initial_cost);
136
137    from_fn(move || {
138        while let Some(edges) = stack.last_mut() {
139            if let Some(edge) = edges.next() {
140                let child = edge.target();
141
142                // Excluded nodes checked separately — not inserted into `visited` — so they
143                // never appear in the yielded path. Excluding `from` is a no-op since it's
144                // already in `visited` at position 0.
145                if visited.contains(&child) || excluded_nodes.is_some_and(|excl| excl.contains(&child)) {
146                    continue;
147                }
148
149                // initialized by `from` so is always at least len 1
150                let current_nodes = visited.len();
151                let new_cost = cost_fn(costs.last().unwrap().clone(), edge.weight(), current_nodes - 1);
152
153                // Prune branch if cost drops below threshold
154                if let Some(ref min) = min_cost
155                    && new_cost < *min
156                {
157                    continue;
158                }
159
160                let mut valid_path: Option<(TargetColl, C)> = None;
161
162                // Check if we've reached a target node
163                if to.contains(&child) && (current_nodes + 1) >= min_nodes {
164                    valid_path = Some((
165                        visited.iter().cloned().chain(Some(child)).collect::<TargetColl>(),
166                        new_cost.clone(),
167                    ));
168                }
169
170                // Expand the search only if within max length and unexplored target nodes remain
171                if (current_nodes < (max_nodes - 1)) && to.iter().any(|n| *n != child && !visited.contains(n)) {
172                    visited.insert(child);
173                    let mut child_edges: SmallVec<[_; 16]> = graph.edges_directed(child, Outgoing).collect();
174                    child_edges.shuffle(&mut rng);
175                    stack.push(child_edges.into_iter());
176                    costs.push(new_cost);
177                }
178
179                // yield the valid path if found
180                if valid_path.is_some() {
181                    return valid_path;
182                }
183            } else {
184                // All edges of the current node have been explored
185                stack.pop();
186                visited.pop();
187                costs.pop();
188            }
189        }
190        None
191    })
192}
193
194#[cfg(test)]
195mod test {
196    use std::collections::{HashSet, hash_map::RandomState};
197
198    use petgraph::prelude::{DiGraph, UnGraph};
199
200    use super::all_simple_paths_multi;
201
202    /// Collect paths as sorted Vec<Vec<usize>> for deterministic snapshots.
203    fn sorted_paths<T, I: Iterator<Item = (Vec<petgraph::graph::NodeIndex>, T)>>(iter: I) -> Vec<Vec<usize>> {
204        let mut paths: Vec<Vec<usize>> = iter.map(|(v, _)| v.into_iter().map(|i| i.index()).collect()).collect();
205        paths.sort();
206        paths
207    }
208
209    #[test]
210    fn undirected_graph_should_find_all_paths_to_multiple_targets() {
211        let graph = UnGraph::<i32, i32>::from_edges([(0, 1), (1, 2), (2, 3), (2, 4)]);
212        let targets = HashSet::from_iter([3.into(), 4.into()]);
213        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
214            &graph,
215            0.into(),
216            &targets,
217            None,
218            0,
219            None,
220            0,
221            None,
222            |c, _, _| c,
223        ));
224        insta::assert_yaml_snapshot!(paths);
225    }
226
227    #[test]
228    fn directed_graph_should_find_all_paths_to_multiple_targets() {
229        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 3), (2, 4)]);
230        let targets = HashSet::from_iter([3.into(), 4.into()]);
231        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
232            &graph,
233            0.into(),
234            &targets,
235            None,
236            0,
237            None,
238            0,
239            None,
240            |c, _, _| c,
241        ));
242        insta::assert_yaml_snapshot!(paths);
243    }
244
245    #[test]
246    fn undirected_graph_should_respect_max_intermediate_nodes() {
247        let graph = UnGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 3), (2, 4)]);
248        let targets = HashSet::from_iter([3.into(), 4.into()]);
249        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
250            &graph,
251            0.into(),
252            &targets,
253            None,
254            0,
255            Some(2),
256            0,
257            None,
258            |c, _, _| c,
259        ));
260        insta::assert_yaml_snapshot!(paths);
261    }
262
263    #[test]
264    fn max_intermediate_nodes_should_not_be_exceeded_when_target_connects_to_target() {
265        // Chain: 0->1->2->3, targets={2,3}, max_intermediate_nodes=1
266        // Only [0,1,2] is valid (1 intermediate node).
267        // Bug: the algorithm also yields [0,1,2,3] (2 intermediate nodes) because
268        // it expands through target 2 to reach target 3, pushing visited to max_nodes,
269        // then yields the grandchild path without checking the max length.
270        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 3)]);
271        let targets = HashSet::from_iter([2.into(), 3.into()]);
272        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
273            &graph,
274            0.into(),
275            &targets,
276            None,
277            0,
278            Some(1),
279            0,
280            None,
281            |c, _, _| c,
282        ));
283        insta::assert_yaml_snapshot!(paths);
284    }
285
286    #[test]
287    fn directed_graph_should_respect_max_intermediate_nodes() {
288        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 3), (2, 4)]);
289        let targets = HashSet::from_iter([3.into(), 4.into()]);
290        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
291            &graph,
292            0.into(),
293            &targets,
294            None,
295            0,
296            Some(2),
297            0,
298            None,
299            |c, _, _| c,
300        ));
301        insta::assert_yaml_snapshot!(paths);
302    }
303
304    #[test]
305    fn inline_targets_should_yield_both_short_and_long_paths() {
306        let graph = UnGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 3)]);
307        let targets = HashSet::from_iter([2.into(), 3.into()]);
308        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
309            &graph,
310            0.into(),
311            &targets,
312            None,
313            0,
314            None,
315            0,
316            None,
317            |c, _, _| c,
318        ));
319        insta::assert_yaml_snapshot!(paths);
320    }
321
322    #[test]
323    fn cyclic_graph_should_yield_only_simple_paths() {
324        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 0), (1, 3)]);
325        let targets = HashSet::from_iter([2.into(), 3.into()]);
326        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
327            &graph,
328            0.into(),
329            &targets,
330            None,
331            0,
332            None,
333            0,
334            None,
335            |c, _, _| c,
336        ));
337        insta::assert_yaml_snapshot!(paths);
338    }
339
340    #[test]
341    fn source_in_target_set_should_not_yield_zero_length_path() {
342        let graph = UnGraph::<i32, ()>::from_edges([(0, 1), (1, 2)]);
343        let targets = HashSet::from_iter([0.into(), 1.into(), 2.into()]);
344        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
345            &graph,
346            0.into(),
347            &targets,
348            None,
349            0,
350            None,
351            0,
352            None,
353            |c, _, _| c,
354        ));
355        insta::assert_yaml_snapshot!(paths);
356    }
357
358    #[test]
359    fn non_trivial_graph_should_find_all_simple_paths() {
360        let graph = DiGraph::<i32, ()>::from_edges([
361            (0, 1),
362            (1, 2),
363            (2, 3),
364            (3, 4),
365            (0, 5),
366            (1, 5),
367            (1, 3),
368            (5, 4),
369            (4, 2),
370            (4, 3),
371        ]);
372        let targets = HashSet::from_iter([2.into(), 3.into()]);
373        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
374            &graph,
375            1.into(),
376            &targets,
377            None,
378            0,
379            None,
380            0,
381            None,
382            |c, _, _| c,
383        ));
384        insta::assert_yaml_snapshot!(paths);
385    }
386
387    #[test]
388    fn min_intermediate_nodes_should_exclude_shorter_paths() {
389        let graph = UnGraph::<i32, ()>::from_edges([(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
390        let targets = HashSet::from_iter([1.into(), 3.into()]);
391        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
392            &graph,
393            0.into(),
394            &targets,
395            None,
396            2,
397            None,
398            0,
399            None,
400            |c, _, _| c,
401        ));
402        insta::assert_yaml_snapshot!(paths);
403    }
404
405    #[test]
406    fn multiplicative_cost_should_accumulate_along_path() {
407        // 0 --0.9--> 1 --0.8--> 2 --0.7--> 3
408        //                  \--0.6--> 4
409        let mut graph = DiGraph::<(), f64>::new();
410        let n: Vec<_> = (0..5).map(|_| graph.add_node(())).collect();
411        graph.extend_with_edges([
412            (n[0], n[1], 0.9),
413            (n[1], n[2], 0.8),
414            (n[2], n[3], 0.7),
415            (n[1], n[4], 0.6),
416        ]);
417
418        let targets = HashSet::from_iter([n[3], n[4]]);
419        let results: Vec<(Vec<_>, f64)> = all_simple_paths_multi::<_, _, RandomState, _, _>(
420            &graph,
421            n[0],
422            &targets,
423            None,
424            0,
425            None,
426            1.0,
427            None,
428            |c, w, _| c * w,
429        )
430        .map(|(v, cost): (Vec<_>, f64)| (v.into_iter().map(|i| i.index()).collect(), cost))
431        .collect();
432
433        // Path 0->1->2->3: cost = 1.0 * 0.9 * 0.8 * 0.7 = 0.504
434        // Path 0->1->4:     cost = 1.0 * 0.9 * 0.6 = 0.54
435        assert_eq!(results.len(), 2);
436        for (path, cost) in &results {
437            match path.as_slice() {
438                [0, 1, 2, 3] => assert!((cost - 0.504).abs() < 1e-9),
439                [0, 1, 4] => assert!((cost - 0.54).abs() < 1e-9),
440                other => panic!("unexpected path: {other:?}"),
441            }
442        }
443    }
444
445    #[test]
446    fn min_cost_should_prune_path_falling_below_threshold() {
447        // 0 --0.9--> 1 --0.8--> 2 --0.7--> 3
448        //                  \--0.6--> 4
449        // With min_cost = 0.51, path 0->1->2->3 (cost 0.504) is pruned at the 2->3 edge,
450        // but 0->1->4 (cost 0.54) survives.
451        let mut graph = DiGraph::<(), f64>::new();
452        let n: Vec<_> = (0..5).map(|_| graph.add_node(())).collect();
453        graph.extend_with_edges([
454            (n[0], n[1], 0.9),
455            (n[1], n[2], 0.8),
456            (n[2], n[3], 0.7),
457            (n[1], n[4], 0.6),
458        ]);
459
460        let targets = HashSet::from_iter([n[3], n[4]]);
461        let results: Vec<(Vec<_>, f64)> = all_simple_paths_multi::<_, _, RandomState, _, _>(
462            &graph,
463            n[0],
464            &targets,
465            None,
466            0,
467            None,
468            1.0,
469            Some(0.51),
470            |c, w, _| c * w,
471        )
472        .map(|(v, cost): (Vec<_>, f64)| (v.into_iter().map(|i| i.index()).collect(), cost))
473        .collect();
474
475        assert_eq!(results.len(), 1);
476        assert_eq!(results[0].0, vec![0, 1, 4]);
477        assert!((results[0].1 - 0.54).abs() < 1e-9);
478    }
479
480    #[test]
481    fn min_cost_should_prune_entire_branch_on_low_first_edge() {
482        // 0 --0.1--> 1 --0.9--> 2
483        //      \--0.9--> 3 --0.9--> 2
484        // With min_cost = 0.5, the 0->1 edge (cost 0.1) is pruned immediately,
485        // so only 0->3->2 (cost 0.81) is found.
486        let mut graph = DiGraph::<(), f64>::new();
487        let n: Vec<_> = (0..4).map(|_| graph.add_node(())).collect();
488        graph.extend_with_edges([
489            (n[0], n[1], 0.1),
490            (n[1], n[2], 0.9),
491            (n[0], n[3], 0.9),
492            (n[3], n[2], 0.9),
493        ]);
494
495        let targets = HashSet::from_iter([n[2]]);
496        let results: Vec<(Vec<_>, f64)> = all_simple_paths_multi::<_, _, RandomState, _, _>(
497            &graph,
498            n[0],
499            &targets,
500            None,
501            0,
502            None,
503            1.0,
504            Some(0.5),
505            |c, w, _| c * w,
506        )
507        .map(|(v, cost): (Vec<_>, f64)| (v.into_iter().map(|i| i.index()).collect(), cost))
508        .collect();
509
510        assert_eq!(results.len(), 1);
511        assert_eq!(results[0].0, vec![0, 3, 2]);
512        assert!((results[0].1 - 0.81).abs() < 1e-9);
513    }
514
515    #[test]
516    fn min_cost_should_yield_empty_when_all_paths_below_threshold() {
517        // 0 --0.3--> 1 --0.3--> 2
518        // With min_cost = 0.5, the 0->1 edge (cost 0.3) is pruned immediately,
519        // so no paths are found.
520        let mut graph = DiGraph::<(), f64>::new();
521        let n: Vec<_> = (0..3).map(|_| graph.add_node(())).collect();
522        graph.extend_with_edges([(n[0], n[1], 0.3), (n[1], n[2], 0.3)]);
523
524        let targets = HashSet::from_iter([n[2]]);
525        let results: Vec<(Vec<_>, f64)> = all_simple_paths_multi::<_, _, RandomState, _, _>(
526            &graph,
527            n[0],
528            &targets,
529            None,
530            0,
531            None,
532            1.0,
533            Some(0.5),
534            |c, w, _| c * w,
535        )
536        .map(|(v, cost): (Vec<_>, f64)| (v.into_iter().map(|i| i.index()).collect(), cost))
537        .collect();
538
539        assert!(results.is_empty());
540    }
541
542    #[test]
543    fn excluded_nodes_should_prune_branches_containing_them() {
544        // 0 → 1 → 2 → 3
545        //      ↘ 4 → 3
546        // Excluding node 2 forces the DFS to take only the 0→1→4→3 branch.
547        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2), (2, 3), (1, 4), (4, 3)]);
548        let targets = HashSet::from_iter([3.into()]);
549        let excluded: HashSet<petgraph::graph::NodeIndex, RandomState> = HashSet::from_iter([2.into()]);
550        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
551            &graph,
552            0.into(),
553            &targets,
554            Some(&excluded),
555            0,
556            None,
557            0,
558            None,
559            |c, _, _| c,
560        ));
561        assert_eq!(paths, vec![vec![0, 1, 4, 3]]);
562    }
563
564    #[test]
565    fn excluded_target_yields_no_paths() {
566        // If the only target is excluded, no paths should be returned.
567        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2)]);
568        let targets = HashSet::from_iter([2.into()]);
569        let excluded: HashSet<petgraph::graph::NodeIndex, RandomState> = HashSet::from_iter([2.into()]);
570        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
571            &graph,
572            0.into(),
573            &targets,
574            Some(&excluded),
575            0,
576            None,
577            0,
578            None,
579            |c, _, _| c,
580        ));
581        assert!(paths.is_empty());
582    }
583
584    #[test]
585    fn complete_graph_should_yield_all_and_only_simple_paths() {
586        use petgraph::graph::NodeIndex;
587
588        // Build K5: complete directed graph on 5 nodes (every ordered pair gets an edge).
589        let edges: Vec<(u32, u32)> = (0u32..5)
590            .flat_map(|a| (0u32..5).filter(move |&b| b != a).map(move |b| (a, b)))
591            .collect();
592        let graph = DiGraph::<i32, ()>::from_edges(edges);
593
594        let src: NodeIndex = 0.into();
595        let dst: NodeIndex = 4.into();
596        let targets = HashSet::from_iter([dst]);
597
598        // ── Without exclusions ─────────────────────────────────────────────
599        let all_paths: Vec<(Vec<NodeIndex>, i32)> = all_simple_paths_multi::<_, _, RandomState, _, _>(
600            &graph,
601            src,
602            &targets,
603            None,
604            0,
605            None,
606            0,
607            None,
608            |c, _, _| c,
609        )
610        .collect();
611
612        // Intermediate pool = {1, 2, 3} (3 nodes).
613        // Simple paths = P(3,0) + P(3,1) + P(3,2) + P(3,3) = 1 + 3 + 6 + 6 = 16.
614        assert_eq!(all_paths.len(), 16, "expected 16 simple paths in K5 from 0 to 4");
615
616        for (path, _) in &all_paths {
617            assert_eq!(path.first(), Some(&src), "path must start at src: {path:?}");
618            assert_eq!(path.last(), Some(&dst), "path must end at dst: {path:?}");
619            // src and dst must not appear anywhere in the interior of the path.
620            let interior = &path[1..path.len() - 1];
621            assert!(!interior.contains(&src), "src repeated inside path: {path:?}");
622            assert!(!interior.contains(&dst), "dst repeated inside path: {path:?}");
623            // No node anywhere in the path (including src and dst) appears more than once.
624            let unique: HashSet<&NodeIndex, RandomState> = path.iter().collect();
625            assert_eq!(unique.len(), path.len(), "duplicate node in path: {path:?}");
626        }
627
628        // ── With excluded_nodes = {2} ──────────────────────────────────────
629        let excluded: HashSet<NodeIndex, RandomState> = HashSet::from_iter([NodeIndex::from(2u32)]);
630        let restricted: Vec<(Vec<NodeIndex>, i32)> = all_simple_paths_multi::<_, _, RandomState, _, _>(
631            &graph,
632            src,
633            &targets,
634            Some(&excluded),
635            0,
636            None,
637            0,
638            None,
639            |c, _, _| c,
640        )
641        .collect();
642
643        // Intermediate pool shrinks to {1, 3} (2 nodes).
644        // P(2,0) + P(2,1) + P(2,2) = 1 + 2 + 2 = 5.
645        assert_eq!(restricted.len(), 5, "expected 5 paths when node 2 is excluded");
646
647        for (path, _) in &restricted {
648            assert!(
649                !path.contains(&NodeIndex::from(2u32)),
650                "excluded node 2 in path: {path:?}"
651            );
652            let unique: HashSet<&NodeIndex, RandomState> = path.iter().collect();
653            assert_eq!(unique.len(), path.len(), "duplicate node in path: {path:?}");
654        }
655
656        // Restricted paths are a strict subset: every restricted path also appears in all_paths.
657        let all_set: Vec<Vec<usize>> = all_paths
658            .iter()
659            .map(|(p, _)| p.iter().map(|n| n.index()).collect())
660            .collect();
661        for (path, _) in &restricted {
662            let as_usize: Vec<usize> = path.iter().map(|n| n.index()).collect();
663            assert!(
664                all_set.contains(&as_usize),
665                "restricted path not in all-paths: {path:?}"
666            );
667        }
668    }
669
670    #[test]
671    fn excluded_from_should_be_ignored() {
672        // Excluding the source itself must not prevent paths from starting.
673        let graph = DiGraph::<i32, ()>::from_edges([(0, 1), (1, 2)]);
674        let targets = HashSet::from_iter([2.into()]);
675        let excluded: HashSet<petgraph::graph::NodeIndex, RandomState> = HashSet::from_iter([0.into()]);
676        let paths = sorted_paths(all_simple_paths_multi::<_, _, RandomState, _, _>(
677            &graph,
678            0.into(),
679            &targets,
680            Some(&excluded),
681            0,
682            None,
683            0,
684            None,
685            |c, _, _| c,
686        ));
687        assert_eq!(paths, vec![vec![0, 1, 2]]);
688    }
689}