Skip to main content

hopr_network_graph/petgraph/
traverse.rs

1use std::{collections::HashSet, hash::RandomState, sync::Arc};
2
3use hopr_api::{
4    OffchainPublicKey,
5    graph::{
6        costs::EdgeCostFn,
7        traits::{CostFn, EdgeNetworkObservableRead, EdgeObservableRead},
8    },
9    types::internal::routing::PathId,
10};
11use petgraph::graph::NodeIndex;
12
13use crate::{
14    ChannelGraph, DEFAULT_EDGE_PENALTY, DEFAULT_MIN_ACK_RATE, algorithm::all_simple_paths_multi, graph::InnerGraph,
15};
16
17/// A shared cost function that computes a cumulative cost from edge observations.
18pub(crate) type SharedCostFn<C> = Arc<dyn Fn(C, &crate::Observations, usize) -> C + Send + Sync>;
19
20/// Core path-finding routine that runs `all_simple_paths_multi` on the
21/// inner petgraph.
22#[allow(clippy::too_many_arguments)]
23pub(crate) fn find_paths<C>(
24    inner: &InnerGraph,
25    source: NodeIndex,
26    destinations: &HashSet<NodeIndex>,
27    length: usize,
28    take_count: Option<usize>,
29    initial_cost: C,
30    min_cost: Option<C>,
31    cost_fn: SharedCostFn<C>,
32) -> Vec<(Vec<OffchainPublicKey>, PathId, C)>
33where
34    C: Clone + PartialOrd,
35{
36    if length == 0 {
37        return Default::default();
38    }
39
40    let intermediates = length - 1;
41
42    let paths = all_simple_paths_multi::<Vec<_>, _, RandomState, _, _>(
43        &inner.graph,
44        source,
45        destinations,
46        intermediates,
47        Some(intermediates),
48        initial_cost,
49        min_cost,
50        move |c, w, i| cost_fn(c, w, i),
51    )
52    .filter_map(|(node_indices, final_cost)| {
53        // Build PathId from node indices along the path
54        let mut path_id: PathId = [0u64; 5];
55        for (i, &node_idx) in node_indices.iter().enumerate() {
56            if i >= path_id.len() {
57                return None;
58            }
59            path_id[i] = node_idx.index() as u64;
60        }
61
62        // Convert node indices to public keys
63        let nodes = node_indices
64            .into_iter()
65            .filter_map(|v| inner.indices.get_by_right(&v).copied())
66            .collect::<Vec<_>>();
67        // Path includes source + intermediates + destination = length + 1 nodes
68        (nodes.len() == length + 1).then_some((nodes, path_id, final_cost))
69    });
70
71    if let Some(take_count) = take_count {
72        paths.take(take_count).collect::<Vec<_>>()
73    } else {
74        paths.collect::<Vec<_>>()
75    }
76}
77
78impl hopr_api::graph::NetworkGraphTraverse for ChannelGraph {
79    type NodeId = OffchainPublicKey;
80    type Observed = crate::Observations;
81
82    fn simple_paths<C: CostFn<Weight = Self::Observed>>(
83        &self,
84        source: &Self::NodeId,
85        destination: &Self::NodeId,
86        length: usize,
87        take_count: Option<usize>,
88        cost_fn: C,
89    ) -> Vec<(Vec<Self::NodeId>, PathId, C::Cost)> {
90        if length == 0 {
91            return Default::default();
92        }
93
94        let inner = self.inner.read();
95        let Some(start) = inner.indices.get_by_left(source) else {
96            return Default::default();
97        };
98        let Some(end) = inner.indices.get_by_left(destination) else {
99            return Default::default();
100        };
101        let end = HashSet::from_iter([*end]);
102
103        find_paths(
104            &inner,
105            *start,
106            &end,
107            length,
108            take_count,
109            cost_fn.initial_cost(),
110            cost_fn.min_cost(),
111            cost_fn.into_cost_fn(),
112        )
113    }
114
115    fn simple_paths_from<C: CostFn<Weight = Self::Observed>>(
116        &self,
117        source: &Self::NodeId,
118        length: usize,
119        take_count: Option<usize>,
120        cost_fn: C,
121    ) -> Vec<(Vec<Self::NodeId>, PathId, C::Cost)> {
122        if length == 0 {
123            return Default::default();
124        }
125
126        let inner = self.inner.read();
127        let Some(start) = inner.indices.get_by_left(source) else {
128            return Default::default();
129        };
130
131        let destinations: HashSet<NodeIndex> = inner.graph.node_indices().filter(|idx| idx != start).collect();
132
133        find_paths(
134            &inner,
135            *start,
136            &destinations,
137            length,
138            take_count,
139            cost_fn.initial_cost(),
140            cost_fn.min_cost(),
141            cost_fn.into_cost_fn(),
142        )
143    }
144
145    fn simple_loopback_to_self(&self, length: usize, take_count: Option<usize>) -> Vec<(Vec<Self::NodeId>, PathId)> {
146        if length > 1 {
147            let inner = self.inner.read();
148
149            if let Some(me_idx) = inner.indices.get_by_left(&self.me) {
150                let connected_neighbors = inner
151                    .graph
152                    .neighbors(*me_idx)
153                    .filter(|neighbor| {
154                        inner
155                            .graph
156                            .edges_connecting(*me_idx, *neighbor)
157                            .next()
158                            .and_then(|e| e.weight().immediate_qos().map(|e| e.is_connected()))
159                            .unwrap_or(false)
160                    })
161                    .collect::<HashSet<_>>();
162
163                let cost_fn = EdgeCostFn::forward_without_self_loopback(DEFAULT_EDGE_PENALTY, DEFAULT_MIN_ACK_RATE);
164
165                return find_paths(
166                    &inner,
167                    *me_idx,
168                    &connected_neighbors,
169                    length,
170                    take_count,
171                    cost_fn.initial_cost(),
172                    cost_fn.min_cost(),
173                    cost_fn.into_cost_fn(),
174                )
175                .into_iter()
176                .map(|(mut a, mut b, _c)| {
177                    // Append me's node index to close the loopback in the PathId
178                    let path_node_count = a.len();
179                    if path_node_count < b.len() {
180                        b[path_node_count] = me_idx.index() as u64;
181                    }
182                    a.push(self.me);
183                    (a, b)
184                })
185                .collect();
186            };
187        }
188
189        vec![]
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use anyhow::Context;
196    use hex_literal::hex;
197    use hopr_api::{
198        graph::{
199            NetworkGraphTraverse, NetworkGraphWrite,
200            costs::EdgeCostFn,
201            traits::{EdgeObservableWrite, EdgeWeightType},
202        },
203        types::{
204            crypto::prelude::{Keypair, OffchainKeypair},
205            internal::routing::PathId,
206        },
207    };
208
209    use super::*;
210
211    /// Deliberately different from the production default (0.5) so tests
212    /// verify that the configured penalty is actually propagated.
213    const TEST_EDGE_PENALTY: f64 = 0.73;
214    /// Disabled in tests — no protocol conformance data is recorded.
215    const TEST_MIN_ACK_RATE: f64 = 0.0;
216
217    /// Fixed test secret keys (reused from the broader codebase).
218    const SECRET_0: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
219    const SECRET_1: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
220    const SECRET_2: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
221    const SECRET_3: [u8; 32] = hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
222    const SECRET_4: [u8; 32] = hex!("cfc66f718ec66fb822391775d749d7a0d66b690927673634816b63339bc12a3c");
223    const SECRET_5: [u8; 32] = hex!("203ca4d3c5f98dd2066bb204b5930c10b15c095585c224c826b4e11f08bfa85d");
224    const SECRET_7: [u8; 32] = hex!("4ab03f6f75f845ca1bf8b7104804ea5bda18bda29d1ec5fc5d4267feca5fb8e1");
225
226    /// Creates an OffchainPublicKey from a fixed secret.
227    fn pubkey_from(secret: &[u8; 32]) -> OffchainPublicKey {
228        *OffchainKeypair::from_secret(secret).expect("valid secret key").public()
229    }
230
231    /// Marks an edge as connected with an immediate probe measurement, satisfying the
232    /// cost function's requirement for the last edge in a path.
233    fn mark_edge_connected(graph: &ChannelGraph, src: &OffchainPublicKey, dest: &OffchainPublicKey) {
234        graph.upsert_edge(src, dest, |obs| {
235            obs.record(EdgeWeightType::Connected(true));
236            obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
237        });
238    }
239
240    #[test]
241    fn one_edge_path_should_return_direct_route() -> anyhow::Result<()> {
242        let me = pubkey_from(&SECRET_0);
243        let dest = pubkey_from(&SECRET_1);
244
245        let graph = ChannelGraph::new(me);
246        graph.add_node(dest);
247        graph.add_edge(&me, &dest)?;
248        mark_edge_loopback_ready(&graph, &me, &dest);
249
250        let routes = graph.simple_paths(
251            &me,
252            &dest,
253            1,
254            None,
255            EdgeCostFn::forward(
256                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
257                TEST_EDGE_PENALTY,
258                TEST_MIN_ACK_RATE,
259            ),
260        );
261
262        assert_eq!(routes.len(), 1, "should find exactly one 1-edge route");
263
264        Ok(())
265    }
266
267    #[test]
268    fn two_edge_path_should_route_through_intermediate() -> anyhow::Result<()> {
269        let me = pubkey_from(&SECRET_0);
270        let hop = pubkey_from(&SECRET_1);
271        let dest = pubkey_from(&SECRET_2);
272
273        let graph = ChannelGraph::new(me);
274        graph.add_node(hop);
275        graph.add_node(dest);
276        graph.add_edge(&me, &hop)?;
277        graph.add_edge(&hop, &dest)?;
278        mark_edge_loopback_ready(&graph, &me, &hop);
279        mark_edge_connected(&graph, &hop, &dest);
280
281        let length = std::num::NonZeroUsize::new(2).context("should be non-zero")?;
282        let routes = graph.simple_paths(
283            &me,
284            &dest,
285            2,
286            None,
287            EdgeCostFn::forward(length, TEST_EDGE_PENALTY, TEST_MIN_ACK_RATE),
288        );
289
290        assert!(!routes.is_empty(), "should find at least one 2-edge route");
291
292        Ok(())
293    }
294
295    #[test]
296    fn penalty_should_affect_cost_of_unprobed_edges() -> anyhow::Result<()> {
297        let me = pubkey_from(&SECRET_0);
298        let hop = pubkey_from(&SECRET_1);
299        let dest = pubkey_from(&SECRET_2);
300
301        let graph = ChannelGraph::new(me);
302        graph.add_node(hop);
303        graph.add_node(dest);
304        graph.add_edge(&me, &hop)?;
305        graph.add_edge(&hop, &dest)?;
306
307        // First edge: fully probed — produces score ~1.0.
308        mark_edge_loopback_ready(&graph, &me, &hop);
309        // Last edge: no observations at all — penalty must kick in.
310
311        let length = std::num::NonZeroUsize::new(2).context("should be non-zero")?;
312
313        let routes_test = graph.simple_paths(
314            &me,
315            &dest,
316            2,
317            None,
318            EdgeCostFn::forward(length, TEST_EDGE_PENALTY, TEST_MIN_ACK_RATE),
319        );
320        let routes_other = graph.simple_paths(
321            &me,
322            &dest,
323            2,
324            None,
325            EdgeCostFn::forward(length, 0.99, TEST_MIN_ACK_RATE),
326        );
327
328        assert_eq!(routes_test.len(), 1);
329        assert_eq!(routes_other.len(), 1);
330
331        let (_, _, cost_test) = &routes_test[0];
332        let (_, _, cost_other) = &routes_other[0];
333        assert!(
334            (cost_test - cost_other).abs() > f64::EPSILON,
335            "different penalties ({TEST_EDGE_PENALTY} vs 0.99) should produce different costs for unprobed edges, got \
336             {cost_test} vs {cost_other}"
337        );
338
339        Ok(())
340    }
341
342    #[test]
343    fn unreachable_destination_should_return_empty() -> anyhow::Result<()> {
344        let me = pubkey_from(&SECRET_0);
345        let dest = pubkey_from(&SECRET_1);
346
347        let graph = ChannelGraph::new(me);
348        graph.add_node(dest);
349        // No edge between me and dest
350
351        let routes = graph.simple_paths(
352            &me,
353            &dest,
354            1,
355            None,
356            EdgeCostFn::forward(
357                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
358                TEST_EDGE_PENALTY,
359                TEST_MIN_ACK_RATE,
360            ),
361        );
362
363        assert!(routes.is_empty(), "should return no routes when unreachable");
364
365        Ok(())
366    }
367
368    #[test]
369    fn unknown_destination_should_return_empty() -> anyhow::Result<()> {
370        let me = pubkey_from(&SECRET_0);
371        let unknown = pubkey_from(&SECRET_1);
372
373        let graph = ChannelGraph::new(me);
374        let routes = graph.simple_paths(
375            &me,
376            &unknown,
377            1,
378            None,
379            EdgeCostFn::forward(
380                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
381                TEST_EDGE_PENALTY,
382                TEST_MIN_ACK_RATE,
383            ),
384        );
385
386        assert!(routes.is_empty());
387
388        Ok(())
389    }
390
391    #[test]
392    fn diamond_topology_should_yield_multiple_paths() -> anyhow::Result<()> {
393        //   me -> a -> dest
394        //   me -> b -> dest
395        let me = pubkey_from(&SECRET_0);
396        let a = pubkey_from(&SECRET_1);
397        let b = pubkey_from(&SECRET_2);
398        let dest = pubkey_from(&SECRET_3);
399
400        let graph = ChannelGraph::new(me);
401        graph.add_node(a);
402        graph.add_node(b);
403        graph.add_node(dest);
404        graph.add_edge(&me, &a)?;
405        graph.add_edge(&me, &b)?;
406        graph.add_edge(&a, &dest)?;
407        graph.add_edge(&b, &dest)?;
408        mark_edge_loopback_ready(&graph, &me, &a);
409        mark_edge_loopback_ready(&graph, &me, &b);
410        mark_edge_connected(&graph, &a, &dest);
411        mark_edge_connected(&graph, &b, &dest);
412
413        let routes = graph.simple_paths(
414            &me,
415            &dest,
416            2,
417            None,
418            EdgeCostFn::forward(
419                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
420                TEST_EDGE_PENALTY,
421                TEST_MIN_ACK_RATE,
422            ),
423        );
424        assert_eq!(routes.len(), 2, "diamond topology should yield two 2-edge routes");
425        Ok(())
426    }
427
428    #[test]
429    fn three_edge_chain_should_find_single_path() -> anyhow::Result<()> {
430        // me -> a -> b -> dest
431        let me = pubkey_from(&SECRET_0);
432        let a = pubkey_from(&SECRET_1);
433        let b = pubkey_from(&SECRET_2);
434        let dest = pubkey_from(&SECRET_3);
435
436        let graph = ChannelGraph::new(me);
437        graph.add_node(a);
438        graph.add_node(b);
439        graph.add_node(dest);
440        graph.add_edge(&me, &a)?;
441        graph.add_edge(&a, &b)?;
442        graph.add_edge(&b, &dest)?;
443        mark_edge_loopback_ready(&graph, &me, &a);
444        mark_edge_with_capacity(&graph, &a, &b);
445
446        let routes = graph.simple_paths(
447            &me,
448            &dest,
449            3,
450            None,
451            EdgeCostFn::forward(
452                std::num::NonZeroUsize::new(3).context("should be non-zero")?,
453                TEST_EDGE_PENALTY,
454                TEST_MIN_ACK_RATE,
455            ),
456        );
457        assert_eq!(routes.len(), 1, "should find exactly one 3-edge route");
458        Ok(())
459    }
460
461    #[test]
462    fn back_edge_should_not_produce_cyclic_paths() -> anyhow::Result<()> {
463        // me -> a -> b -> dest, plus a -> me (back-edge creating cycle)
464        let me = pubkey_from(&SECRET_0);
465        let a = pubkey_from(&SECRET_1);
466        let b = pubkey_from(&SECRET_2);
467        let dest = pubkey_from(&SECRET_3);
468
469        let graph = ChannelGraph::new(me);
470        graph.add_node(a);
471        graph.add_node(b);
472        graph.add_node(dest);
473        graph.add_edge(&me, &a)?;
474        graph.add_edge(&a, &b)?;
475        graph.add_edge(&b, &dest)?;
476        graph.add_edge(&a, &me)?; // back-edge
477        mark_edge_loopback_ready(&graph, &me, &a);
478        mark_edge_with_capacity(&graph, &a, &b);
479
480        let routes = graph.simple_paths(
481            &me,
482            &dest,
483            3,
484            None,
485            EdgeCostFn::forward(
486                std::num::NonZeroUsize::new(3).context("should be non-zero")?,
487                TEST_EDGE_PENALTY,
488                TEST_MIN_ACK_RATE,
489            ),
490        );
491        assert_eq!(routes.len(), 1, "cycle should not produce extra paths");
492        Ok(())
493    }
494
495    #[test]
496    fn mismatched_edge_count_should_return_empty() -> anyhow::Result<()> {
497        // me -> dest (1 edge), but ask for 2 edges
498        let me = pubkey_from(&SECRET_0);
499        let dest = pubkey_from(&SECRET_1);
500        let graph = ChannelGraph::new(me);
501        graph.add_node(dest);
502        graph.add_edge(&me, &dest)?;
503
504        let routes = graph.simple_paths(
505            &me,
506            &dest,
507            2,
508            None,
509            EdgeCostFn::forward(
510                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
511                TEST_EDGE_PENALTY,
512                TEST_MIN_ACK_RATE,
513            ),
514        );
515        assert!(routes.is_empty(), "no 2-edge route should exist for a single edge");
516        Ok(())
517    }
518
519    #[test]
520    fn zero_edge_should_always_return_empty() -> anyhow::Result<()> {
521        let me = pubkey_from(&SECRET_0);
522        let other = pubkey_from(&SECRET_1);
523        let graph = ChannelGraph::new(me);
524
525        // length=0 returns empty before the cost fn is used; any NonZeroUsize value is fine
526        let routes = graph.simple_paths(
527            &me,
528            &other,
529            0,
530            None,
531            EdgeCostFn::forward(
532                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
533                TEST_EDGE_PENALTY,
534                TEST_MIN_ACK_RATE,
535            ),
536        );
537        assert!(routes.is_empty(), "zero-edge path should find no routes");
538        Ok(())
539    }
540
541    #[test]
542    fn reverse_edge_should_not_be_traversable() -> anyhow::Result<()> {
543        // me -> a, but no a -> dest, only dest -> a
544        let me = pubkey_from(&SECRET_0);
545        let a = pubkey_from(&SECRET_1);
546        let dest = pubkey_from(&SECRET_2);
547
548        let graph = ChannelGraph::new(me);
549        graph.add_node(a);
550        graph.add_node(dest);
551        graph.add_edge(&me, &a)?;
552        graph.add_edge(&dest, &a)?; // wrong direction
553
554        let routes = graph.simple_paths(
555            &me,
556            &dest,
557            2,
558            None,
559            EdgeCostFn::forward(
560                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
561                TEST_EDGE_PENALTY,
562                TEST_MIN_ACK_RATE,
563            ),
564        );
565        assert!(routes.is_empty(), "should not traverse edge in wrong direction");
566        Ok(())
567    }
568
569    #[test]
570    fn non_trivial_graph_should_find_all_simple_paths() -> anyhow::Result<()> {
571        // Topology (7 nodes):
572        //
573        //   me(0) ──→ a(1)
574        //   me(0) ──→ b(2)
575        //   a(1)  ──→ c(3)   [capacity]
576        //   a(1)  ──→ d(4)   [capacity]
577        //   b(2)  ──→ c(3)   [capacity]
578        //   b(2)  ──→ d(4)   [capacity]
579        //   b(2)  ──→ e(5)   [capacity]
580        //   c(3)  ──→ f(7)
581        //   d(4)  ──→ f(7)
582        //   e(5)  ──→ f(7)
583        //
584        // Valid 3-edge paths (me → ? → ? → f):
585        //   1. me → a → c → f
586        //   2. me → a → d → f
587        //   3. me → b → c → f
588        //   4. me → b → d → f
589        //   5. me → b → e → f
590        //
591        // Blocked paths:
592        //   - me → a → e → f : edge a→e missing
593        //   - me → e → … → f : edge me→e missing
594
595        let me = pubkey_from(&SECRET_0);
596        let a = pubkey_from(&SECRET_1);
597        let b = pubkey_from(&SECRET_2);
598        let c = pubkey_from(&SECRET_3);
599        let d = pubkey_from(&SECRET_4);
600        let e = pubkey_from(&SECRET_5);
601        let f = pubkey_from(&SECRET_7);
602
603        let graph = ChannelGraph::new(me);
604        for node in [a, b, c, d, e, f] {
605            graph.add_node(node);
606        }
607
608        // Edges from me
609        graph.add_edge(&me, &a)?;
610        graph.add_edge(&me, &b)?;
611
612        // Edges from a
613        graph.add_edge(&a, &c)?;
614        graph.add_edge(&a, &d)?;
615
616        // Edges from b
617        graph.add_edge(&b, &c)?;
618        graph.add_edge(&b, &d)?;
619        graph.add_edge(&b, &e)?;
620
621        // Edges to f (last hop)
622        graph.add_edge(&c, &f)?;
623        graph.add_edge(&d, &f)?;
624        graph.add_edge(&e, &f)?;
625
626        // Mark first edges with full QoS (connected + intermediate capacity)
627        mark_edge_loopback_ready(&graph, &me, &a);
628        mark_edge_loopback_ready(&graph, &me, &b);
629
630        // Mark middle edges with capacity (required by EdgeCostFn::forward)
631        mark_edge_with_capacity(&graph, &a, &c);
632        mark_edge_with_capacity(&graph, &a, &d);
633        mark_edge_with_capacity(&graph, &b, &c);
634        mark_edge_with_capacity(&graph, &b, &d);
635        mark_edge_with_capacity(&graph, &b, &e);
636
637        // Last edges (c→f, d→f, e→f) are lenient with EdgeCostFn::forward
638
639        // --- 3-edge paths: should find exactly 5 ---
640        let routes_3 = graph.simple_paths(
641            &me,
642            &f,
643            3,
644            None,
645            EdgeCostFn::forward(
646                std::num::NonZeroUsize::new(3).context("should be non-zero")?,
647                TEST_EDGE_PENALTY,
648                TEST_MIN_ACK_RATE,
649            ),
650        );
651        assert_eq!(routes_3.len(), 5, "should find exactly 5 three-edge paths");
652
653        // Verify all returned paths have positive cost
654        for (path, _path_id, cost) in &routes_3 {
655            assert!(*cost > 0.0, "path {path:?} should have positive cost, got {cost}");
656            assert_eq!(path.len(), 4, "3-edge path should contain 4 nodes");
657            assert_eq!(path.first(), Some(&me), "path should start at me");
658            assert_eq!(path.last(), Some(&f), "path should end at f");
659        }
660
661        // --- 1-edge path: no direct me→f edge ---
662        let routes_1 = graph.simple_paths(
663            &me,
664            &f,
665            1,
666            None,
667            EdgeCostFn::forward(
668                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
669                TEST_EDGE_PENALTY,
670                TEST_MIN_ACK_RATE,
671            ),
672        );
673        assert!(routes_1.is_empty(), "no direct edge from me to f");
674
675        Ok(())
676    }
677
678    #[test]
679    fn three_edge_loop_should_return_empty_because_source_is_visited() -> anyhow::Result<()> {
680        // Ring topology: me → a → b → me (3 edges forming a cycle)
681        //
682        // The underlying all_simple_paths_multi algorithm marks the source node
683        // as visited before traversal begins. Because the destination equals the
684        // source, the algorithm can never "reach" it — the visited-set check
685        // (`visited.contains(&child)`) rejects the back-edge to source, and the
686        // expansion guard (`to.iter().any(|n| !visited.contains(n))`) is always
687        // false since the only target (source) is always visited.
688        let me = pubkey_from(&SECRET_0);
689        let a = pubkey_from(&SECRET_1);
690        let b = pubkey_from(&SECRET_2);
691
692        let graph = ChannelGraph::new(me);
693        graph.add_node(a);
694        graph.add_node(b);
695        graph.add_edge(&me, &a)?;
696        graph.add_edge(&a, &b)?;
697        graph.add_edge(&b, &me)?;
698        mark_edge_connected(&graph, &b, &me);
699
700        let routes = graph.simple_paths(
701            &me,
702            &me,
703            3,
704            None,
705            EdgeCostFn::forward(
706                std::num::NonZeroUsize::new(3).context("should be non-zero")?,
707                TEST_EDGE_PENALTY,
708                TEST_MIN_ACK_RATE,
709            ),
710        );
711        assert!(
712            routes.is_empty(),
713            "simple_paths cannot discover cycles (source == destination) due to visited-set semantics"
714        );
715
716        Ok(())
717    }
718
719    #[test]
720    fn path_id_should_contain_node_indices_for_one_edge() -> anyhow::Result<()> {
721        // me = node 0, dest = node 1
722        let me = pubkey_from(&SECRET_0);
723        let dest = pubkey_from(&SECRET_1);
724
725        let graph = ChannelGraph::new(me);
726        graph.add_node(dest);
727        graph.add_edge(&me, &dest)?;
728        mark_edge_loopback_ready(&graph, &me, &dest);
729
730        let routes = graph.simple_paths(
731            &me,
732            &dest,
733            1,
734            None,
735            EdgeCostFn::forward(
736                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
737                TEST_EDGE_PENALTY,
738                TEST_MIN_ACK_RATE,
739            ),
740        );
741        assert_eq!(routes.len(), 1);
742
743        let (_path, path_id, _cost) = &routes[0];
744        assert_eq!(path_id[0], 0, "first node should be me (node index 0)");
745        assert_eq!(path_id[1], 1, "second node should be dest (node index 1)");
746        assert_eq!(path_id[2..], [0, 0, 0], "unused positions should be 0");
747
748        Ok(())
749    }
750
751    #[test]
752    fn path_id_should_contain_node_indices_for_three_edges() -> anyhow::Result<()> {
753        // me = node 0, a = node 1, b = node 2, dest = node 3
754        let me = pubkey_from(&SECRET_0);
755        let a = pubkey_from(&SECRET_1);
756        let b = pubkey_from(&SECRET_2);
757        let dest = pubkey_from(&SECRET_3);
758
759        let graph = ChannelGraph::new(me);
760        graph.add_node(a);
761        graph.add_node(b);
762        graph.add_node(dest);
763        graph.add_edge(&me, &a)?;
764        graph.add_edge(&a, &b)?;
765        graph.add_edge(&b, &dest)?;
766        mark_edge_loopback_ready(&graph, &me, &a);
767        mark_edge_with_capacity(&graph, &a, &b);
768
769        let routes = graph.simple_paths(
770            &me,
771            &dest,
772            3,
773            None,
774            EdgeCostFn::forward(
775                std::num::NonZeroUsize::new(3).context("should be non-zero")?,
776                TEST_EDGE_PENALTY,
777                TEST_MIN_ACK_RATE,
778            ),
779        );
780        assert_eq!(routes.len(), 1);
781
782        let (_path, path_id, _cost) = &routes[0];
783        assert_eq!(path_id[0], 0, "me should be node index 0");
784        assert_eq!(path_id[1], 1, "a should be node index 1");
785        assert_eq!(path_id[2], 2, "b should be node index 2");
786        assert_eq!(path_id[3], 3, "dest should be node index 3");
787        assert_eq!(path_id[4], 0, "unused position should be 0");
788
789        Ok(())
790    }
791
792    #[test]
793    fn path_id_should_differ_for_distinct_paths_in_diamond() -> anyhow::Result<()> {
794        //   me → a → dest
795        //   me → b → dest
796        // me = node 0, a = node 1, b = node 2, dest = node 3
797        let me = pubkey_from(&SECRET_0);
798        let a = pubkey_from(&SECRET_1);
799        let b = pubkey_from(&SECRET_2);
800        let dest = pubkey_from(&SECRET_3);
801
802        let graph = ChannelGraph::new(me);
803        graph.add_node(a);
804        graph.add_node(b);
805        graph.add_node(dest);
806        graph.add_edge(&me, &a)?;
807        graph.add_edge(&me, &b)?;
808        graph.add_edge(&a, &dest)?;
809        graph.add_edge(&b, &dest)?;
810        mark_edge_loopback_ready(&graph, &me, &a);
811        mark_edge_loopback_ready(&graph, &me, &b);
812        mark_edge_connected(&graph, &a, &dest);
813        mark_edge_connected(&graph, &b, &dest);
814
815        let routes = graph.simple_paths(
816            &me,
817            &dest,
818            2,
819            None,
820            EdgeCostFn::forward(
821                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
822                TEST_EDGE_PENALTY,
823                TEST_MIN_ACK_RATE,
824            ),
825        );
826        assert_eq!(routes.len(), 2, "diamond should yield two 2-edge routes");
827
828        let path_ids: Vec<PathId> = routes.iter().map(|(_, pid, _)| *pid).collect();
829        assert_ne!(path_ids[0], path_ids[1], "distinct paths should have different PathIds");
830
831        // Each path: [me(0), intermediate(1 or 2), dest(3), 0, 0]
832        for pid in &path_ids {
833            assert_eq!(pid[0], 0, "first node should be me (node index 0)");
834            assert!(pid[1] == 1 || pid[1] == 2, "second node should be a (1) or b (2)");
835            assert_eq!(pid[2], 3, "third node should be dest (node index 3)");
836            assert_eq!(pid[3..], [0, 0], "unused positions should be 0");
837        }
838
839        Ok(())
840    }
841
842    // ── return-path tests (EdgeCostFn::returning) ──────────────────────────
843
844    #[test]
845    fn return_path_one_edge_should_find_route() -> anyhow::Result<()> {
846        // Return path: dest -> me (1 edge)
847        // For length=1, path_index=0 matches the first-edge arm which requires capacity.
848        let me = pubkey_from(&SECRET_0);
849        let dest = pubkey_from(&SECRET_1);
850
851        let graph = ChannelGraph::new(me);
852        graph.add_node(dest);
853        graph.add_edge(&dest, &me)?;
854        // dest→me: for length=1 this is the last edge, requiring connectivity
855        mark_edge_connected(&graph, &dest, &me);
856
857        let routes = graph.simple_paths(
858            &dest,
859            &me,
860            1,
861            None,
862            EdgeCostFn::returning(
863                std::num::NonZeroUsize::new(1).context("should be non-zero")?,
864                TEST_EDGE_PENALTY,
865                TEST_MIN_ACK_RATE,
866            ),
867        );
868
869        assert_eq!(routes.len(), 1, "should find exactly one 1-edge return route");
870        Ok(())
871    }
872
873    #[test]
874    fn return_path_two_edge_should_route_through_intermediate() -> anyhow::Result<()> {
875        // Return path: dest -> relay -> me (2 edges)
876        // Edge 0 (dest→relay): needs capacity only
877        // Edge 1 (relay→me): needs connectivity (last edge)
878        let me = pubkey_from(&SECRET_0);
879        let relay = pubkey_from(&SECRET_1);
880        let dest = pubkey_from(&SECRET_2);
881
882        let graph = ChannelGraph::new(me);
883        graph.add_node(relay);
884        graph.add_node(dest);
885        graph.add_edge(&dest, &relay)?;
886        graph.add_edge(&relay, &me)?;
887        // dest→relay: first edge needs capacity
888        mark_edge_with_capacity(&graph, &dest, &relay);
889        // relay→me: last edge needs connectivity
890        mark_edge_connected(&graph, &relay, &me);
891
892        let routes = graph.simple_paths(
893            &dest,
894            &me,
895            2,
896            None,
897            EdgeCostFn::returning(
898                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
899                TEST_EDGE_PENALTY,
900                TEST_MIN_ACK_RATE,
901            ),
902        );
903
904        assert!(!routes.is_empty(), "should find at least one 2-edge return route");
905        Ok(())
906    }
907
908    #[test]
909    fn return_path_last_edge_without_connectivity_should_be_pruned() -> anyhow::Result<()> {
910        // Return path: dest -> relay -> me (2 edges)
911        // relay→me lacks connectivity → last-edge cost goes negative
912        let me = pubkey_from(&SECRET_0);
913        let relay = pubkey_from(&SECRET_1);
914        let dest = pubkey_from(&SECRET_2);
915
916        let graph = ChannelGraph::new(me);
917        graph.add_node(relay);
918        graph.add_node(dest);
919        graph.add_edge(&dest, &relay)?;
920        graph.add_edge(&relay, &me)?;
921        // dest→relay: has capacity (passes edge-0)
922        mark_edge_with_capacity(&graph, &dest, &relay);
923        // relay→me: only capacity, NO connectivity → last edge fails
924
925        let routes = graph.simple_paths(
926            &dest,
927            &me,
928            2,
929            None,
930            EdgeCostFn::returning(
931                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
932                TEST_EDGE_PENALTY,
933                TEST_MIN_ACK_RATE,
934            ),
935        );
936
937        assert!(
938            routes.is_empty(),
939            "return path should be pruned when last edge lacks connectivity"
940        );
941        Ok(())
942    }
943
944    #[test]
945    fn return_path_first_edge_without_capacity_should_be_pruned() -> anyhow::Result<()> {
946        // Return path: dest -> relay -> me (2 edges)
947        // dest→relay has no capacity → first-edge cost goes negative
948        let me = pubkey_from(&SECRET_0);
949        let relay = pubkey_from(&SECRET_1);
950        let dest = pubkey_from(&SECRET_2);
951
952        let graph = ChannelGraph::new(me);
953        graph.add_node(relay);
954        graph.add_node(dest);
955        graph.add_edge(&dest, &relay)?;
956        graph.add_edge(&relay, &me)?;
957        // dest→relay: no capacity (default edge)
958        // relay→me: connected
959        mark_edge_connected(&graph, &relay, &me);
960
961        let routes = graph.simple_paths(
962            &dest,
963            &me,
964            2,
965            None,
966            EdgeCostFn::returning(
967                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
968                TEST_EDGE_PENALTY,
969                TEST_MIN_ACK_RATE,
970            ),
971        );
972
973        assert!(
974            routes.is_empty(),
975            "return path should be pruned when first edge lacks capacity"
976        );
977        Ok(())
978    }
979
980    #[test]
981    fn return_path_diamond_should_yield_multiple_paths() -> anyhow::Result<()> {
982        // Return paths: dest -> a -> me, dest -> b -> me
983        let me = pubkey_from(&SECRET_0);
984        let a = pubkey_from(&SECRET_1);
985        let b = pubkey_from(&SECRET_2);
986        let dest = pubkey_from(&SECRET_3);
987
988        let graph = ChannelGraph::new(me);
989        graph.add_node(a);
990        graph.add_node(b);
991        graph.add_node(dest);
992        graph.add_edge(&dest, &a)?;
993        graph.add_edge(&dest, &b)?;
994        graph.add_edge(&a, &me)?;
995        graph.add_edge(&b, &me)?;
996        // First edges (dest→a, dest→b): need capacity
997        mark_edge_with_capacity(&graph, &dest, &a);
998        mark_edge_with_capacity(&graph, &dest, &b);
999        // Last edges (a→me, b→me): need connectivity
1000        mark_edge_connected(&graph, &a, &me);
1001        mark_edge_connected(&graph, &b, &me);
1002
1003        let routes = graph.simple_paths(
1004            &dest,
1005            &me,
1006            2,
1007            None,
1008            EdgeCostFn::returning(
1009                std::num::NonZeroUsize::new(2).context("should be non-zero")?,
1010                TEST_EDGE_PENALTY,
1011                TEST_MIN_ACK_RATE,
1012            ),
1013        );
1014        assert_eq!(
1015            routes.len(),
1016            2,
1017            "diamond topology should yield two 2-edge return routes"
1018        );
1019        Ok(())
1020    }
1021
1022    // ── simple_loopback_to_self tests ──────────────────────────────────
1023
1024    /// Marks an edge as connected AND with intermediate capacity so that it
1025    /// satisfies the `EdgeCostFn::forward_without_self_loopback` at edge index 0 (connected + capacity)
1026    /// and at any other index (capacity).
1027    fn mark_edge_loopback_ready(graph: &ChannelGraph, src: &OffchainPublicKey, dest: &OffchainPublicKey) {
1028        graph.upsert_edge(src, dest, |obs| {
1029            obs.record(EdgeWeightType::Connected(true));
1030            obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
1031            obs.record(EdgeWeightType::Intermediate(Ok(std::time::Duration::from_millis(50))));
1032            obs.record(EdgeWeightType::Capacity(Some(1000)));
1033        });
1034    }
1035
1036    /// Marks an edge with intermediate capacity and probe data (no connected flag).
1037    /// Satisfies `EdgeCostFn::forward_without_self_loopback` at index > 0 but NOT at index 0.
1038    fn mark_edge_with_capacity(graph: &ChannelGraph, src: &OffchainPublicKey, dest: &OffchainPublicKey) {
1039        graph.upsert_edge(src, dest, |obs| {
1040            obs.record(EdgeWeightType::Intermediate(Ok(std::time::Duration::from_millis(50))));
1041            obs.record(EdgeWeightType::Capacity(Some(1000)));
1042        });
1043    }
1044
1045    #[test]
1046    fn loopback_returns_empty_for_length_zero() {
1047        let me = pubkey_from(&SECRET_0);
1048        let graph = ChannelGraph::new(me);
1049        assert!(graph.simple_loopback_to_self(0, None).is_empty());
1050    }
1051
1052    #[test]
1053    fn loopback_returns_empty_for_length_one() {
1054        let me = pubkey_from(&SECRET_0);
1055        let a = pubkey_from(&SECRET_1);
1056        let graph = ChannelGraph::new(me);
1057        graph.add_node(a);
1058        graph.add_edge(&me, &a).unwrap();
1059        mark_edge_loopback_ready(&graph, &me, &a);
1060
1061        assert!(
1062            graph.simple_loopback_to_self(1, None).is_empty(),
1063            "length=1 is below the minimum threshold"
1064        );
1065    }
1066
1067    #[test]
1068    fn loopback_returns_empty_without_any_peers() {
1069        let me = pubkey_from(&SECRET_0);
1070        let graph = ChannelGraph::new(me);
1071        assert!(
1072            graph.simple_loopback_to_self(2, None).is_empty(),
1073            "no peers means no connected neighbors"
1074        );
1075    }
1076
1077    #[test]
1078    fn loopback_returns_empty_without_connected_neighbors() -> anyhow::Result<()> {
1079        // me → a → b, me → b exists but is NOT connected
1080        let me = pubkey_from(&SECRET_0);
1081        let a = pubkey_from(&SECRET_1);
1082        let b = pubkey_from(&SECRET_2);
1083
1084        let graph = ChannelGraph::new(me);
1085        graph.add_node(a);
1086        graph.add_node(b);
1087        graph.add_edge(&me, &a)?;
1088        graph.add_edge(&a, &b)?;
1089        graph.add_edge(&me, &b)?;
1090        // me→b is NOT marked connected, so b is not in connected_neighbors
1091        mark_edge_loopback_ready(&graph, &me, &a);
1092        mark_edge_with_capacity(&graph, &a, &b);
1093
1094        assert!(
1095            graph.simple_loopback_to_self(2, None).is_empty(),
1096            "b is not a connected neighbor, so no loopback destinations exist"
1097        );
1098
1099        Ok(())
1100    }
1101
1102    #[test]
1103    fn loopback_returns_empty_when_first_hop_lacks_capacity() -> anyhow::Result<()> {
1104        // me → a → b, me → b (connected)
1105        // me→a is connected but has NO intermediate capacity → edge-0 cost goes negative
1106        let me = pubkey_from(&SECRET_0);
1107        let a = pubkey_from(&SECRET_1);
1108        let b = pubkey_from(&SECRET_2);
1109
1110        let graph = ChannelGraph::new(me);
1111        graph.add_node(a);
1112        graph.add_node(b);
1113        graph.add_edge(&me, &a)?;
1114        graph.add_edge(&a, &b)?;
1115        graph.add_edge(&me, &b)?;
1116        // me→a: connected but no capacity (only Connected + Immediate)
1117        mark_edge_connected(&graph, &me, &a);
1118        // a→b: has capacity
1119        mark_edge_with_capacity(&graph, &a, &b);
1120        // me→b: connected (makes b a connected neighbor)
1121        mark_edge_connected(&graph, &me, &b);
1122
1123        assert!(
1124            graph.simple_loopback_to_self(2, None).is_empty(),
1125            "edge me→a lacks intermediate capacity, so EdgeCostFn::forward_without_self_loopback prunes it"
1126        );
1127
1128        Ok(())
1129    }
1130
1131    #[test]
1132    fn loopback_returns_empty_when_intermediate_edge_lacks_capacity() -> anyhow::Result<()> {
1133        // me → a → b, me → b (connected)
1134        // me→a passes cost-0, but a→b has NO capacity → cost goes negative at edge-1
1135        let me = pubkey_from(&SECRET_0);
1136        let a = pubkey_from(&SECRET_1);
1137        let b = pubkey_from(&SECRET_2);
1138
1139        let graph = ChannelGraph::new(me);
1140        graph.add_node(a);
1141        graph.add_node(b);
1142        graph.add_edge(&me, &a)?;
1143        graph.add_edge(&a, &b)?;
1144        graph.add_edge(&me, &b)?;
1145        // me→a: connected + capacity (passes edge-0)
1146        mark_edge_loopback_ready(&graph, &me, &a);
1147        // a→b: NO capacity — default edge weight
1148        // me→b: connected
1149        mark_edge_connected(&graph, &me, &b);
1150
1151        assert!(
1152            graph.simple_loopback_to_self(2, None).is_empty(),
1153            "edge a→b lacks capacity, so EdgeCostFn::forward_without_self_loopback prunes the path"
1154        );
1155
1156        Ok(())
1157    }
1158
1159    #[test]
1160    fn loopback_two_edge_triangle() -> anyhow::Result<()> {
1161        // Topology: me → a → b, me → b (connected)
1162        // Loopback path: me → a → b → me
1163        let me = pubkey_from(&SECRET_0);
1164        let a = pubkey_from(&SECRET_1);
1165        let b = pubkey_from(&SECRET_2);
1166
1167        let graph = ChannelGraph::new(me);
1168        graph.add_node(a);
1169        graph.add_node(b);
1170        graph.add_edge(&me, &a)?;
1171        graph.add_edge(&a, &b)?;
1172        graph.add_edge(&me, &b)?;
1173        // me→a: connected + capacity (edge-0 cost passes)
1174        mark_edge_loopback_ready(&graph, &me, &a);
1175        // a→b: capacity (edge-1 cost passes)
1176        mark_edge_with_capacity(&graph, &a, &b);
1177        // me→b: connected (makes b a connected neighbor destination)
1178        mark_edge_connected(&graph, &me, &b);
1179
1180        let routes = graph.simple_loopback_to_self(2, None);
1181        assert_eq!(routes.len(), 1, "should find exactly one 2-edge loopback");
1182
1183        let (path, _path_id) = &routes[0];
1184        assert_eq!(
1185            path.len(),
1186            4,
1187            "loopback path should have 4 nodes (2 internal edges + appended me)"
1188        );
1189        assert_eq!(path.first(), Some(&me), "path should start with me");
1190        assert_eq!(path.last(), Some(&me), "path should end with me (appended)");
1191        assert_eq!(path[1], a, "first intermediate should be a");
1192        assert_eq!(path[2], b, "destination (connected neighbor) should be b");
1193
1194        Ok(())
1195    }
1196
1197    #[test]
1198    fn loopback_three_edge_chain() -> anyhow::Result<()> {
1199        // Topology: me → a → b → c, me → c (connected)
1200        // Loopback path: me → a → b → c → me
1201        let me = pubkey_from(&SECRET_0);
1202        let a = pubkey_from(&SECRET_1);
1203        let b = pubkey_from(&SECRET_2);
1204        let c = pubkey_from(&SECRET_3);
1205
1206        let graph = ChannelGraph::new(me);
1207        graph.add_node(a);
1208        graph.add_node(b);
1209        graph.add_node(c);
1210        graph.add_edge(&me, &a)?;
1211        graph.add_edge(&a, &b)?;
1212        graph.add_edge(&b, &c)?;
1213        graph.add_edge(&me, &c)?;
1214        mark_edge_loopback_ready(&graph, &me, &a);
1215        mark_edge_with_capacity(&graph, &a, &b);
1216        mark_edge_with_capacity(&graph, &b, &c);
1217        mark_edge_connected(&graph, &me, &c);
1218
1219        let routes = graph.simple_loopback_to_self(3, None);
1220        assert_eq!(routes.len(), 1, "should find exactly one 3-edge loopback");
1221
1222        let (path, _path_id) = &routes[0];
1223        assert_eq!(path.len(), 5, "3-edge internal path + appended me = 5 nodes");
1224        assert_eq!(path.first(), Some(&me), "starts with me");
1225        assert_eq!(path.last(), Some(&me), "ends with me");
1226        assert_eq!(&path[1..4], &[a, b, c], "interior nodes");
1227
1228        Ok(())
1229    }
1230
1231    #[test]
1232    fn loopback_multiple_paths_through_diamond() -> anyhow::Result<()> {
1233        // Topology:
1234        //   me → a → c, me → b → c, me → c (connected)
1235        // Two 2-edge loopback paths: me → a → c → me, me → b → c → me
1236        let me = pubkey_from(&SECRET_0);
1237        let a = pubkey_from(&SECRET_1);
1238        let b = pubkey_from(&SECRET_2);
1239        let c = pubkey_from(&SECRET_3);
1240
1241        let graph = ChannelGraph::new(me);
1242        graph.add_node(a);
1243        graph.add_node(b);
1244        graph.add_node(c);
1245        graph.add_edge(&me, &a)?;
1246        graph.add_edge(&me, &b)?;
1247        graph.add_edge(&a, &c)?;
1248        graph.add_edge(&b, &c)?;
1249        graph.add_edge(&me, &c)?;
1250        mark_edge_loopback_ready(&graph, &me, &a);
1251        mark_edge_loopback_ready(&graph, &me, &b);
1252        mark_edge_with_capacity(&graph, &a, &c);
1253        mark_edge_with_capacity(&graph, &b, &c);
1254        mark_edge_connected(&graph, &me, &c);
1255
1256        let routes = graph.simple_loopback_to_self(2, None);
1257        assert_eq!(routes.len(), 2, "diamond should yield two 2-edge loopback paths");
1258
1259        for (path, _path_id) in &routes {
1260            assert_eq!(path.first(), Some(&me), "every path starts with me");
1261            assert_eq!(path.last(), Some(&me), "every path ends with me");
1262            assert_eq!(path[path.len() - 2], c, "penultimate node is c (connected neighbor)");
1263        }
1264
1265        // Verify distinct intermediates (a and b)
1266        let intermediates: HashSet<_> = routes.iter().map(|(p, _)| p[1]).collect();
1267        assert!(intermediates.contains(&a), "should include path through a");
1268        assert!(intermediates.contains(&b), "should include path through b");
1269
1270        Ok(())
1271    }
1272
1273    #[test]
1274    fn loopback_to_multiple_connected_neighbors() -> anyhow::Result<()> {
1275        // Topology: me → a, me → b (both connected)
1276        // a and b are both connected neighbors of me.
1277        // With length=2: me → a → b → me and me → b → a → me
1278        let me = pubkey_from(&SECRET_0);
1279        let a = pubkey_from(&SECRET_1);
1280        let b = pubkey_from(&SECRET_2);
1281
1282        let graph = ChannelGraph::new(me);
1283        graph.add_node(a);
1284        graph.add_node(b);
1285        graph.add_edge(&me, &a)?;
1286        graph.add_edge(&me, &b)?;
1287        graph.add_edge(&a, &b)?;
1288        graph.add_edge(&b, &a)?;
1289        mark_edge_loopback_ready(&graph, &me, &a);
1290        mark_edge_loopback_ready(&graph, &me, &b);
1291        mark_edge_with_capacity(&graph, &a, &b);
1292        mark_edge_with_capacity(&graph, &b, &a);
1293
1294        let routes = graph.simple_loopback_to_self(2, None);
1295        assert_eq!(
1296            routes.len(),
1297            2,
1298            "should find loopback paths to both connected neighbors"
1299        );
1300
1301        // Both paths start and end with me
1302        for (path, _) in &routes {
1303            assert_eq!(path.first(), Some(&me));
1304            assert_eq!(path.last(), Some(&me));
1305        }
1306
1307        // Collect the connected-neighbor destinations (penultimate node)
1308        let destinations: HashSet<_> = routes.iter().map(|(p, _)| p[p.len() - 2]).collect();
1309        assert_eq!(destinations.len(), 2, "should reach both connected neighbors");
1310        assert!(destinations.contains(&a));
1311        assert!(destinations.contains(&b));
1312
1313        Ok(())
1314    }
1315
1316    #[test]
1317    fn loopback_disconnected_neighbor_is_excluded() -> anyhow::Result<()> {
1318        // me → a → b, me → a → c
1319        // me → b (connected), me → c (NOT connected)
1320        // length=2: only me → a → b → me should be found, not me → a → c → me
1321        let me = pubkey_from(&SECRET_0);
1322        let a = pubkey_from(&SECRET_1);
1323        let b = pubkey_from(&SECRET_2);
1324        let c = pubkey_from(&SECRET_3);
1325
1326        let graph = ChannelGraph::new(me);
1327        graph.add_node(a);
1328        graph.add_node(b);
1329        graph.add_node(c);
1330        graph.add_edge(&me, &a)?;
1331        graph.add_edge(&a, &b)?;
1332        graph.add_edge(&a, &c)?;
1333        graph.add_edge(&me, &b)?;
1334        graph.add_edge(&me, &c)?;
1335        mark_edge_loopback_ready(&graph, &me, &a);
1336        mark_edge_with_capacity(&graph, &a, &b);
1337        mark_edge_with_capacity(&graph, &a, &c);
1338        // me→b: connected (b IS a connected neighbor)
1339        mark_edge_connected(&graph, &me, &b);
1340        // me→c: NOT connected (c is NOT a connected neighbor)
1341
1342        let routes = graph.simple_loopback_to_self(2, None);
1343        assert_eq!(routes.len(), 1, "only the path to connected neighbor b should be found");
1344
1345        let (path, _) = &routes[0];
1346        assert_eq!(path[path.len() - 2], b, "destination should be b, not c");
1347
1348        Ok(())
1349    }
1350
1351    #[test]
1352    fn loopback_take_count_limits_results() -> anyhow::Result<()> {
1353        // Create 3 possible loopback paths, but take_count=1
1354        //   me → a → d, me → b → d, me → c → d, me → d (connected)
1355        let me = pubkey_from(&SECRET_0);
1356        let a = pubkey_from(&SECRET_1);
1357        let b = pubkey_from(&SECRET_2);
1358        let c = pubkey_from(&SECRET_3);
1359        let d = pubkey_from(&SECRET_4);
1360
1361        let graph = ChannelGraph::new(me);
1362        for node in [a, b, c, d] {
1363            graph.add_node(node);
1364        }
1365        graph.add_edge(&me, &a)?;
1366        graph.add_edge(&me, &b)?;
1367        graph.add_edge(&me, &c)?;
1368        graph.add_edge(&me, &d)?;
1369        graph.add_edge(&a, &d)?;
1370        graph.add_edge(&b, &d)?;
1371        graph.add_edge(&c, &d)?;
1372        mark_edge_loopback_ready(&graph, &me, &a);
1373        mark_edge_loopback_ready(&graph, &me, &b);
1374        mark_edge_loopback_ready(&graph, &me, &c);
1375        mark_edge_with_capacity(&graph, &a, &d);
1376        mark_edge_with_capacity(&graph, &b, &d);
1377        mark_edge_with_capacity(&graph, &c, &d);
1378        mark_edge_connected(&graph, &me, &d);
1379
1380        // Without limit: should find 3 paths
1381        let all_routes = graph.simple_loopback_to_self(2, None);
1382        assert_eq!(all_routes.len(), 3, "should find 3 loopback paths without limit");
1383
1384        // With take_count=1: should return exactly 1
1385        let limited = graph.simple_loopback_to_self(2, Some(1));
1386        assert_eq!(limited.len(), 1, "take_count=1 should limit to 1 result");
1387
1388        Ok(())
1389    }
1390
1391    #[test]
1392    fn loopback_path_ids_differ_for_distinct_routes() -> anyhow::Result<()> {
1393        // me → a → c, me → b → c, me → c (connected)
1394        let me = pubkey_from(&SECRET_0);
1395        let a = pubkey_from(&SECRET_1);
1396        let b = pubkey_from(&SECRET_2);
1397        let c = pubkey_from(&SECRET_3);
1398
1399        let graph = ChannelGraph::new(me);
1400        graph.add_node(a);
1401        graph.add_node(b);
1402        graph.add_node(c);
1403        graph.add_edge(&me, &a)?;
1404        graph.add_edge(&me, &b)?;
1405        graph.add_edge(&a, &c)?;
1406        graph.add_edge(&b, &c)?;
1407        graph.add_edge(&me, &c)?;
1408        mark_edge_loopback_ready(&graph, &me, &a);
1409        mark_edge_loopback_ready(&graph, &me, &b);
1410        mark_edge_with_capacity(&graph, &a, &c);
1411        mark_edge_with_capacity(&graph, &b, &c);
1412        mark_edge_connected(&graph, &me, &c);
1413
1414        let routes = graph.simple_loopback_to_self(2, None);
1415        assert_eq!(routes.len(), 2);
1416
1417        let path_ids: Vec<PathId> = routes.iter().map(|(_, pid)| *pid).collect();
1418        assert_ne!(
1419            path_ids[0], path_ids[1],
1420            "distinct loopback paths should have different PathIds"
1421        );
1422
1423        Ok(())
1424    }
1425
1426    #[test]
1427    fn loopback_mismatched_length_returns_empty() -> anyhow::Result<()> {
1428        // Topology only supports 2-edge internal path, but we request 3
1429        // me → a → b, me → b (connected)
1430        let me = pubkey_from(&SECRET_0);
1431        let a = pubkey_from(&SECRET_1);
1432        let b = pubkey_from(&SECRET_2);
1433
1434        let graph = ChannelGraph::new(me);
1435        graph.add_node(a);
1436        graph.add_node(b);
1437        graph.add_edge(&me, &a)?;
1438        graph.add_edge(&a, &b)?;
1439        graph.add_edge(&me, &b)?;
1440        mark_edge_loopback_ready(&graph, &me, &a);
1441        mark_edge_with_capacity(&graph, &a, &b);
1442        mark_edge_connected(&graph, &me, &b);
1443
1444        // length=2 works
1445        assert_eq!(graph.simple_loopback_to_self(2, None).len(), 1);
1446        // length=3 has no 3-edge path to any connected neighbor
1447        assert!(
1448            graph.simple_loopback_to_self(3, None).is_empty(),
1449            "no 3-edge internal path exists"
1450        );
1451
1452        Ok(())
1453    }
1454}