Skip to main content

hopr_transport/path/
selector.rs

1use std::{cmp::Ordering, sync::Arc};
2
3use hopr_api::{
4    OffchainPublicKey,
5    graph::{
6        NetworkGraphTraverse, NetworkGraphView,
7        function::{BasicValueFn, EdgeValueFn},
8        traits::{
9            EdgeImmediateProtocolObservable, EdgeLinkObservable, EdgeObservableRead, EdgeProtocolObservable, ValueFn,
10        },
11    },
12    types::internal::errors::PathError,
13};
14
15use super::{
16    errors::{PathPlannerError, Result},
17    traits::{PathSelector, PathWithMetrics},
18};
19
20/// Accumulated path cost and quality aggregates, folded edge-by-edge during DFS.
21///
22/// `PartialOrd` / `PartialEq` compare only the `cost` field so the DFS
23/// pruning threshold (`min_cost`) operates on the same scalar as before.
24#[derive(Clone, Debug)]
25struct PathCostWithMetrics {
26    cost: f64,
27    total_latency_ms: Option<u32>,
28    min_probe_success_rate: Option<f64>,
29    min_ack_rate: Option<f64>,
30    capacity_floor: Option<u128>,
31}
32
33impl PartialOrd for PathCostWithMetrics {
34    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
35        self.cost.partial_cmp(&other.cost)
36    }
37}
38
39impl PartialEq for PathCostWithMetrics {
40    fn eq(&self, other: &Self) -> bool {
41        self.cost == other.cost
42    }
43}
44
45impl From<(PathCostWithMetrics, Vec<OffchainPublicKey>)> for PathWithMetrics {
46    fn from((metrics, path): (PathCostWithMetrics, Vec<OffchainPublicKey>)) -> Self {
47        PathWithMetrics {
48            path,
49            cost: metrics.cost,
50            total_latency_ms: metrics.total_latency_ms,
51            min_probe_success_rate: metrics.min_probe_success_rate,
52            min_ack_rate: metrics.min_ack_rate,
53            capacity_floor: metrics.capacity_floor,
54        }
55    }
56}
57
58/// Returns the minimum of two `Option<T>` values, preferring `Some` over `None`.
59fn opt_min<T: PartialOrd>(a: Option<T>, b: Option<T>) -> Option<T> {
60    match (a, b) {
61        (Some(x), Some(y)) => Some(if x <= y { x } else { y }),
62        (x, y) => x.or(y),
63    }
64}
65
66/// Wraps an `EdgeValueFn<f64, W>` as a `ValueFn` whose `Value` type carries
67/// both the cost and per-path quality aggregates.
68///
69/// All cost semantics are delegated to the inner `EdgeValueFn`; the wrapper
70/// only adds the aggregate fold on the same `&Weight` reference already
71/// available during DFS, so no extra graph lookups are needed.
72struct MetricsValueFn<W: EdgeObservableRead> {
73    inner: EdgeValueFn<f64, W>,
74}
75
76impl<W> ValueFn for MetricsValueFn<W>
77where
78    W: EdgeObservableRead + Send + 'static,
79{
80    type Value = PathCostWithMetrics;
81    type Weight = W;
82
83    fn initial_value(&self) -> Self::Value {
84        PathCostWithMetrics {
85            cost: self.inner.initial_value(),
86            total_latency_ms: Some(0),
87            min_probe_success_rate: None,
88            min_ack_rate: None,
89            capacity_floor: None,
90        }
91    }
92
93    fn min_value(&self) -> Option<Self::Value> {
94        self.inner.min_value().map(|c| PathCostWithMetrics {
95            cost: c,
96            total_latency_ms: None,
97            min_probe_success_rate: None,
98            min_ack_rate: None,
99            capacity_floor: None,
100        })
101    }
102
103    fn into_value_fn(self) -> BasicValueFn<Self::Value, Self::Weight> {
104        let inner = self.inner.into_value_fn();
105        Arc::new(move |prev: PathCostWithMetrics, observed: &W, idx: usize| {
106            let cost = inner(prev.cost, observed, idx);
107
108            let edge_lat = observed
109                .immediate_qos()
110                .and_then(|m| m.average_latency())
111                .or_else(|| observed.intermediate_qos().and_then(|m| m.average_latency()));
112            let total_latency_ms = match (prev.total_latency_ms, edge_lat) {
113                (Some(acc), Some(lat)) => Some(((acc as u128 + lat.as_millis()).min(u32::MAX as u128)) as u32),
114                _ => None,
115            };
116
117            // Probe rate: taking the min of immediate and intermediate guards against nodes that
118            // look good on direct probes but degrade under multi-hop load.
119            let edge_probe = observed
120                .immediate_qos()
121                .map(|m| m.average_probe_rate())
122                .into_iter()
123                .chain(observed.intermediate_qos().map(|m| m.average_probe_rate()))
124                .reduce(f64::min);
125            let min_probe_success_rate = opt_min(prev.min_probe_success_rate, edge_probe);
126
127            let edge_ack = observed.immediate_qos().and_then(|m| m.ack_rate());
128            let min_ack_rate = opt_min(prev.min_ack_rate, edge_ack);
129
130            let edge_cap = observed.intermediate_qos().and_then(|m| m.capacity());
131            let capacity_floor = opt_min(prev.capacity_floor, edge_cap);
132
133            PathCostWithMetrics {
134                cost,
135                total_latency_ms,
136                min_probe_success_rate,
137                min_ack_rate,
138                capacity_floor,
139            }
140        })
141    }
142}
143
144/// Trim the candidate set to lower median latency and minimise variance while
145/// preserving an anonymity floor.
146///
147/// Behaviour:
148/// - If `candidates.len() <= floor`, returns all candidates unchanged (`min(found_count, floor)` semantics — the floor
149///   is never a minimum to fabricate).
150/// - Sorts candidates with a known `total_latency_ms` ascending.
151/// - Drops from the high-latency tail until the total count equals `floor`, or until no populated candidates remain.
152/// - If still over the floor with no populated candidates left, drops unpopulated candidates from the input-order tail.
153///
154/// A path is "fully measured" — and therefore preferred over unmeasured alternatives —
155/// when `total_latency_ms` is known AND either `hops == 0` (direct path, no channel
156/// expected) OR `capacity_floor` is also known.  This prevents 0-hop direct paths from
157/// being demoted simply because they carry no channel-capacity data.
158pub fn prune_for_consistency(candidates: Vec<PathWithMetrics>, floor: usize, hops: usize) -> Vec<PathWithMetrics> {
159    // floor == 0 means "no pruning" — caller opts out entirely.
160    if floor == 0 || candidates.len() <= floor {
161        return candidates;
162    }
163
164    let fully_measured =
165        |p: &PathWithMetrics| p.total_latency_ms.is_some() && (hops == 0 || p.capacity_floor.is_some());
166
167    let (mut populated, unpopulated): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|p| fully_measured(p));
168
169    // Sort populated ascending by latency (lowest first → drop from the end).
170    populated.sort_by_key(|p| p.total_latency_ms.unwrap_or(u32::MAX));
171
172    // Prefer measured paths: keep as many populated paths as fit within the floor,
173    // then fill the remaining slots with unpopulated paths.  This ensures that
174    // latency-measured candidates are never discarded when unprobed paths alone
175    // would satisfy the floor.
176    let target_populated = populated.len().min(floor);
177    populated.truncate(target_populated);
178    let remaining = floor - target_populated;
179
180    let mut result = populated;
181    result.extend(unpopulated.into_iter().take(remaining));
182
183    result
184}
185
186/// Compute candidate paths from `src` to `dest` through `graph`.
187///
188/// `length` is the number of edges to traverse (= intermediate hops + 1).
189/// `take` caps the number of candidate paths returned.
190/// The graph crate returns only the intermediate nodes (both `src` and `dest` stripped);
191/// this function re-appends `dest` so callers receive `([intermediates..., dest], cost)`.
192fn compute_paths<G, W>(
193    graph: &G,
194    src: &OffchainPublicKey,
195    dest: &OffchainPublicKey,
196    length: std::num::NonZeroUsize,
197    take: usize,
198    value_fn: MetricsValueFn<W>,
199) -> Vec<PathWithMetrics>
200where
201    G: NetworkGraphTraverse<NodeId = OffchainPublicKey, Observed = W>,
202    W: EdgeObservableRead + Send + 'static,
203{
204    let raw = graph.simple_paths(src, dest, length.get(), Some(take), value_fn);
205
206    raw.into_iter()
207        .filter_map(|(path, _, metrics)| {
208            tracing::trace!(?path, cost = metrics.cost, "evaluating candidate path");
209            if metrics.cost > 0.0 {
210                let mut path = path;
211                path.push(*dest);
212                Some(PathWithMetrics::from((metrics, path)))
213            } else {
214                None
215            }
216        })
217        .collect()
218}
219
220/// A lightweight graph-backed path selector.
221///
222/// Returns all candidate paths for a `(src, dest, hops)` query directly from
223/// the network graph — no caching is performed here.  The caller (typically
224/// [`crate::path::planner::PathPlanner`]) is responsible for caching, TTL management,
225/// background refresh, and final path selection.
226///
227/// Stores the planner's own identity (`me`) so that it can choose the
228/// appropriate cost function:
229/// - forward path (`src == me`): [`EdgeValueFn::forward`]
230/// - return path (`dest == me`): [`EdgeValueFn::returning`]
231#[derive(Clone)]
232pub struct HoprGraphPathSelector<G> {
233    me: OffchainPublicKey,
234    graph: G,
235    max_paths: usize,
236    edge_penalty: f64,
237    min_ack_rate: f64,
238    anonymity_floor: usize,
239}
240
241impl<G> HoprGraphPathSelector<G>
242where
243    G: NetworkGraphTraverse<NodeId = OffchainPublicKey>
244        + NetworkGraphView<NodeId = OffchainPublicKey>
245        + Clone
246        + Send
247        + Sync
248        + 'static,
249    <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
250{
251    /// Create a new selector.
252    ///
253    /// * `me` – the planner's own offchain public key, used to determine path direction.
254    /// * `graph` – the network graph to query.
255    /// * `max_paths` – maximum number of candidate paths to return per query.
256    /// * `edge_penalty` – penalty multiplier for edges lacking probe-based quality observations.
257    /// * `min_ack_rate` – minimum acceptable message acknowledgment rate for path selection.
258    /// * `anonymity_floor` – minimum candidate count below which no latency-based pruning occurs.
259    pub fn new(
260        me: OffchainPublicKey,
261        graph: G,
262        max_paths: usize,
263        edge_penalty: f64,
264        min_ack_rate: f64,
265        anonymity_floor: usize,
266    ) -> Self {
267        Self {
268            me,
269            graph,
270            max_paths,
271            edge_penalty,
272            min_ack_rate,
273            anonymity_floor,
274        }
275    }
276
277    /// Extended forward path search: find shorter paths using
278    /// [`EdgeValueFn::forward_without_self_loopback`] and append `dest` to each one.
279    ///
280    /// This handles the case where the last edge (relay -> dest) has no graph edge
281    /// (e.g. no payment channel) but the path planner can still assume the last hop
282    /// is reachable. Paths already found by Phase 1 are excluded via `existing`.
283    ///
284    /// The cost from the shorter traversal is preserved as-is — the missing last
285    /// edge contributes a neutral `1.0` multiplier (no quality data available).
286    fn compute_extended_forward_paths(
287        &self,
288        src: &OffchainPublicKey,
289        dest: &OffchainPublicKey,
290        shorter_length: std::num::NonZeroUsize,
291        take: usize,
292        existing: &[PathWithMetrics],
293    ) -> Vec<PathWithMetrics> {
294        let value_fn = MetricsValueFn {
295            inner: EdgeValueFn::forward_without_self_loopback(self.edge_penalty, self.min_ack_rate),
296        };
297        let raw = self
298            .graph
299            .simple_paths_from(src, shorter_length.get(), Some(take), value_fn);
300
301        raw.into_iter()
302            .filter_map(|(path, _, metrics)| {
303                if metrics.cost <= 0.0 {
304                    return None;
305                }
306
307                // The graph crate already strips `src`; `path` is [intermediates…, terminator].
308                // Guard: if dest already appears as an intermediate or terminator, appending it
309                // would produce a non-adjacent duplicate that ValidatedPath::new rejects — skip early.
310                if path.contains(dest) {
311                    return None;
312                }
313                let mut candidate = path;
314                candidate.push(*dest);
315
316                if existing.iter().any(|pwm| pwm.path == candidate) {
317                    return None;
318                }
319
320                tracing::trace!(?candidate, cost = metrics.cost, "extended forward path candidate");
321                Some(PathWithMetrics::from((metrics, candidate)))
322            })
323            .take(take)
324            .collect()
325    }
326}
327
328impl<G> PathSelector for HoprGraphPathSelector<G>
329where
330    G: NetworkGraphTraverse<NodeId = OffchainPublicKey>
331        + NetworkGraphView<NodeId = OffchainPublicKey>
332        + Clone
333        + Send
334        + Sync
335        + 'static,
336    <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
337{
338    /// Return all candidate paths from `src` to `dest` via `hops` relays.
339    ///
340    /// Each returned tuple contains a path `Vec<OffchainPublicKey>` of length
341    /// `hops + 1` (`[intermediates..., dest]`; `src` excluded) paired with its
342    /// accumulated traversal cost and per-path quality aggregates.
343    ///
344    /// Returns `Err(PathNotFound)` when the graph yields no positive-cost paths.
345    ///
346    /// The function has a potential to run expensive operations, it should be benchmarked
347    /// in a production environment and possibly guarded (e.g. by offloading the long execution
348    /// in an async executor to avoid blocking the caller).
349    #[tracing::instrument(level = "trace", skip(self), fields(src = %src, dest = %dest, hops), ret, err)]
350    fn select_path(
351        &self,
352        src: OffchainPublicKey,
353        dest: OffchainPublicKey,
354        hops: usize,
355    ) -> Result<Vec<PathWithMetrics>> {
356        let direction = if src == self.me { "forward" } else { "return" };
357        tracing::debug!(%src, %dest, hops, direction, "computing paths from graph");
358
359        let length = std::num::NonZeroUsize::new(hops + 1)
360            .expect("can never fail, it is physically at least 1 after the addition");
361
362        let paths = if src == self.me {
363            // Phase 1: search for full-length forward paths to dest.
364            let mut found = compute_paths(
365                &self.graph,
366                &src,
367                &dest,
368                length,
369                self.max_paths,
370                MetricsValueFn {
371                    inner: EdgeValueFn::forward(length, self.edge_penalty, self.min_ack_rate),
372                },
373            );
374            tracing::debug!(
375                direction,
376                phase = 1,
377                count = found.len(),
378                "[forward] phase 1 candidates"
379            );
380
381            // Phase 2: if not enough paths, do an extended search with EdgeValueFn::forward_without_self_loopback
382            // for (length - 1) edges and assume the last hop can be done by anybody.
383            if found.len() < self.max_paths
384                && let Some(shorter) = std::num::NonZeroUsize::new(length.get() - 1)
385            {
386                let remaining = self.max_paths - found.len();
387                let extended = self.compute_extended_forward_paths(&src, &dest, shorter, remaining, &found);
388                tracing::debug!(
389                    direction,
390                    phase = 2,
391                    count = extended.len(),
392                    "[forward] phase 2 extended candidates"
393                );
394                found.extend(extended);
395            }
396
397            found
398        } else {
399            let found = compute_paths(
400                &self.graph,
401                &src,
402                &dest,
403                length,
404                self.max_paths,
405                MetricsValueFn {
406                    inner: EdgeValueFn::returning(length, self.edge_penalty, self.min_ack_rate),
407                },
408            );
409            tracing::debug!(direction, count = found.len(), "[return] candidates");
410            found
411        };
412
413        for (i, pwm) in paths.iter().enumerate() {
414            tracing::debug!(
415                direction,
416                index = i,
417                path = ?pwm.path,
418                cost = pwm.cost,
419                total_latency_ms = ?pwm.total_latency_ms,
420                "[{direction}] candidate path"
421            );
422        }
423
424        if paths.is_empty() {
425            Err(PathPlannerError::Path(PathError::PathNotFound(
426                hops,
427                src.to_string(),
428                dest.to_string(),
429            )))
430        } else {
431            Ok(prune_for_consistency(paths, self.anonymity_floor, hops))
432        }
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use std::time::Duration;
439
440    use anyhow::Context;
441    use hex_literal::hex;
442    use hopr_api::{
443        graph::{
444            NetworkGraphWrite,
445            traits::{EdgeObservableWrite, EdgeWeightType},
446        },
447        types::{
448            crypto::prelude::{Keypair, OffchainKeypair},
449            internal::routing::RoutingOptions,
450        },
451    };
452    use hopr_network_graph::ChannelGraph;
453
454    use super::*;
455    use crate::path::{PathPlannerConfig, traits::PathSelector};
456
457    fn test_selector(
458        me: OffchainPublicKey,
459        graph: ChannelGraph,
460        max_paths: usize,
461    ) -> HoprGraphPathSelector<ChannelGraph> {
462        let cfg = PathPlannerConfig::default();
463        HoprGraphPathSelector::new(
464            me,
465            graph,
466            max_paths,
467            cfg.edge_penalty,
468            cfg.min_ack_rate,
469            cfg.min_paths_anonymity_floor,
470        )
471    }
472
473    const SECRET_0: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
474    const SECRET_1: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
475    const SECRET_2: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
476    const SECRET_3: [u8; 32] = hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
477    const SECRET_4: [u8; 32] = hex!("cfc66f718ec66fb822391775d749d7a0d66b690927673634816b63339bc12a3c");
478
479    const MAX_PATHS: usize = 4;
480
481    fn pubkey(secret: &[u8; 32]) -> OffchainPublicKey {
482        *OffchainKeypair::from_secret(secret).expect("valid secret").public()
483    }
484
485    /// Mark an edge as fully ready for intermediate routing.
486    fn mark_edge_full(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
487        graph.upsert_edge(src, dst, |obs| {
488            obs.record(EdgeWeightType::Connected(true));
489            obs.record(EdgeWeightType::Immediate(Ok(Duration::from_millis(50))));
490            obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
491            obs.record(EdgeWeightType::Capacity(Some(1000)));
492        });
493    }
494
495    // Helper: build a bidirectional 2-hop graph: me ↔ hop ↔ dest.
496    fn two_hop_graph() -> (OffchainPublicKey, OffchainPublicKey, OffchainPublicKey, ChannelGraph) {
497        let me = pubkey(&SECRET_0);
498        let hop = pubkey(&SECRET_1);
499        let dest = pubkey(&SECRET_2);
500        let graph = ChannelGraph::new(me);
501        graph.add_node(hop);
502        graph.add_node(dest);
503        // Forward: me → hop → dest
504        graph.add_edge(&me, &hop).unwrap();
505        graph.add_edge(&hop, &dest).unwrap();
506        mark_edge_full(&graph, &me, &hop);
507        mark_edge_full(&graph, &hop, &dest);
508        // Reverse: dest → hop → me
509        graph.add_edge(&dest, &hop).unwrap();
510        graph.add_edge(&hop, &me).unwrap();
511        mark_edge_full(&graph, &dest, &hop);
512        mark_edge_full(&graph, &hop, &me);
513        (me, hop, dest, graph)
514    }
515
516    #[tokio::test]
517    async fn unreachable_dest_should_return_error() -> anyhow::Result<()> {
518        let me = pubkey(&SECRET_0);
519        let unreachable = pubkey(&SECRET_1);
520        let graph = ChannelGraph::new(me);
521        // No edges at all — neither direction has a path.
522        let selector = test_selector(me, graph, MAX_PATHS);
523
524        let fwd = selector.select_path(me, unreachable, 1);
525        assert!(fwd.is_err(), "forward: should error when destination is unreachable");
526        assert!(matches!(
527            fwd.unwrap_err(),
528            PathPlannerError::Path(PathError::PathNotFound(..))
529        ));
530
531        let rev = selector.select_path(unreachable, me, 1);
532        assert!(rev.is_err(), "reverse: should error when destination is unreachable");
533        assert!(matches!(
534            rev.unwrap_err(),
535            PathPlannerError::Path(PathError::PathNotFound(..))
536        ));
537
538        Ok(())
539    }
540
541    #[tokio::test]
542    async fn path_should_exclude_source() -> anyhow::Result<()> {
543        let (me, _hop, dest, graph) = two_hop_graph();
544        let selector = test_selector(me, graph, MAX_PATHS);
545
546        let fwd = selector.select_path(me, dest, 1).context("forward path")?;
547        assert!(!fwd.is_empty());
548        for pwm in &fwd {
549            assert!(!pwm.path.contains(&me), "forward path must not contain the source");
550            assert!(pwm.cost > 0.0, "cost must be positive");
551        }
552
553        let rev = selector.select_path(dest, me, 1).context("reverse path")?;
554        assert!(!rev.is_empty());
555        for pwm in &rev {
556            assert!(!pwm.path.contains(&dest), "reverse path must not contain the source");
557            assert!(pwm.cost > 0.0, "cost must be positive");
558        }
559
560        Ok(())
561    }
562
563    #[tokio::test]
564    async fn multi_hop_path_should_have_correct_length() -> anyhow::Result<()> {
565        // Bidirectional: me ↔ A ↔ B ↔ dest  (2 intermediate hops each way)
566        let me = pubkey(&SECRET_0);
567        let a = pubkey(&SECRET_1);
568        let b = pubkey(&SECRET_2);
569        let dest = pubkey(&SECRET_3);
570        let graph = ChannelGraph::new(me);
571        for n in [a, b, dest] {
572            graph.add_node(n);
573        }
574        // Forward: me → A → B → dest
575        graph.add_edge(&me, &a).unwrap();
576        graph.add_edge(&a, &b).unwrap();
577        graph.add_edge(&b, &dest).unwrap();
578        mark_edge_full(&graph, &me, &a);
579        mark_edge_full(&graph, &a, &b);
580        mark_edge_full(&graph, &b, &dest);
581        // Reverse: dest → B → A → me
582        graph.add_edge(&dest, &b).unwrap();
583        graph.add_edge(&b, &a).unwrap();
584        graph.add_edge(&a, &me).unwrap();
585        mark_edge_full(&graph, &dest, &b);
586        mark_edge_full(&graph, &b, &a);
587        mark_edge_full(&graph, &a, &me);
588
589        let selector = test_selector(me, graph, MAX_PATHS);
590
591        let fwd = selector.select_path(me, dest, 2).context("forward 2-hop path")?;
592        assert!(!fwd.is_empty());
593        for pwm in &fwd {
594            assert_eq!(pwm.path.len(), 3, "forward 2-hop path: [A, B, dest]");
595            assert_eq!(pwm.path.last(), Some(&dest));
596        }
597
598        let rev = selector.select_path(dest, me, 2).context("reverse 2-hop path")?;
599        assert!(!rev.is_empty());
600        for pwm in &rev {
601            assert_eq!(pwm.path.len(), 3, "reverse 2-hop path: [B, A, me]");
602            assert_eq!(pwm.path.last(), Some(&me));
603        }
604
605        Ok(())
606    }
607
608    #[tokio::test]
609    async fn one_hop_path_should_include_relay_and_destination() -> anyhow::Result<()> {
610        // Bidirectional: me ↔ relay ↔ dest
611        let (me, relay, dest, graph) = two_hop_graph();
612        let selector = test_selector(me, graph, MAX_PATHS);
613
614        let fwd = selector.select_path(me, dest, 1).context("forward 1-hop path")?;
615        assert!(!fwd.is_empty());
616        for pwm in &fwd {
617            assert_eq!(pwm.path.len(), 2, "forward: [relay, dest]");
618            assert_eq!(pwm.path.last(), Some(&dest));
619            assert!(!pwm.path.contains(&me));
620        }
621
622        let rev = selector.select_path(dest, me, 1).context("reverse 1-hop path")?;
623        assert!(!rev.is_empty());
624        for pwm in &rev {
625            assert_eq!(pwm.path.len(), 2, "reverse: [relay, me]");
626            assert_eq!(pwm.path.last(), Some(&me));
627            assert!(!pwm.path.contains(&dest));
628        }
629
630        let _ = relay;
631        Ok(())
632    }
633
634    #[tokio::test]
635    async fn diamond_topology_should_return_multiple_paths() -> anyhow::Result<()> {
636        // Bidirectional diamond: me ↔ a ↔ dest and me ↔ b ↔ dest
637        let me = pubkey(&SECRET_0);
638        let a = pubkey(&SECRET_1);
639        let b = pubkey(&SECRET_2);
640        let dest = pubkey(&SECRET_3);
641        let graph = ChannelGraph::new(me);
642        for n in [a, b, dest] {
643            graph.add_node(n);
644        }
645        // Forward: me → a → dest,  me → b → dest
646        graph.add_edge(&me, &a).unwrap();
647        graph.add_edge(&me, &b).unwrap();
648        graph.add_edge(&a, &dest).unwrap();
649        graph.add_edge(&b, &dest).unwrap();
650        mark_edge_full(&graph, &me, &a);
651        mark_edge_full(&graph, &me, &b);
652        mark_edge_full(&graph, &a, &dest);
653        mark_edge_full(&graph, &b, &dest);
654        // Reverse: dest → a → me,  dest → b → me
655        graph.add_edge(&dest, &a).unwrap();
656        graph.add_edge(&dest, &b).unwrap();
657        graph.add_edge(&a, &me).unwrap();
658        graph.add_edge(&b, &me).unwrap();
659        mark_edge_full(&graph, &dest, &a);
660        mark_edge_full(&graph, &dest, &b);
661        mark_edge_full(&graph, &a, &me);
662        mark_edge_full(&graph, &b, &me);
663
664        let selector = test_selector(me, graph, MAX_PATHS);
665
666        let fwd = selector.select_path(me, dest, 1).context("forward path")?;
667        assert_eq!(fwd.len(), 2, "forward: both paths via a and b should be returned");
668        for pwm in &fwd {
669            assert_eq!(pwm.path.last(), Some(&dest));
670        }
671
672        let rev = selector.select_path(dest, me, 1).context("reverse path")?;
673        assert_eq!(rev.len(), 2, "reverse: both paths via a and b should be returned");
674        for pwm in &rev {
675            assert_eq!(pwm.path.last(), Some(&me));
676        }
677
678        Ok(())
679    }
680
681    #[tokio::test]
682    async fn zero_cost_paths_should_return_error() -> anyhow::Result<()> {
683        // Graph has edges in both directions but no observations → all costs zero → pruned.
684        let me = pubkey(&SECRET_0);
685        let dest = pubkey(&SECRET_1);
686        let graph = ChannelGraph::new(me);
687        graph.add_node(dest);
688        graph.add_edge(&me, &dest).unwrap();
689        graph.add_edge(&dest, &me).unwrap();
690        // No observations → cost function will return non-positive cost → pruned.
691
692        let selector = test_selector(me, graph, MAX_PATHS);
693        assert!(
694            selector.select_path(me, dest, 1).is_err(),
695            "forward: edge with no observations should produce no valid path"
696        );
697        assert!(
698            selector.select_path(dest, me, 1).is_err(),
699            "reverse: edge with no observations should produce no valid path"
700        );
701        Ok(())
702    }
703
704    #[tokio::test]
705    async fn no_path_at_requested_hop_count_should_return_error() -> anyhow::Result<()> {
706        // Graph has direct edges both ways; requesting 2 hops should fail in both directions.
707        let me = pubkey(&SECRET_0);
708        let dest = pubkey(&SECRET_1);
709        let graph = ChannelGraph::new(me);
710        graph.add_node(dest);
711        graph.add_edge(&me, &dest).unwrap();
712        graph.add_edge(&dest, &me).unwrap();
713        mark_edge_full(&graph, &me, &dest);
714        mark_edge_full(&graph, &dest, &me);
715
716        let selector = test_selector(me, graph, MAX_PATHS);
717        assert!(
718            selector.select_path(me, dest, 2).is_err(),
719            "forward: no 2-hop path should exist for a direct edge"
720        );
721        assert!(
722            selector.select_path(dest, me, 2).is_err(),
723            "reverse: no 2-hop path should exist for a direct edge"
724        );
725        Ok(())
726    }
727
728    #[tokio::test]
729    async fn forward_path_should_work_without_last_edge() -> anyhow::Result<()> {
730        // In the real network, the last edge (relay → dest) may not have a
731        // payment channel. The graph has me → relay but NO relay → dest edge.
732        // The virtual last hop fallback should still find the forward path.
733        let me = pubkey(&SECRET_0);
734        let relay = pubkey(&SECRET_1);
735        let dest = pubkey(&SECRET_2);
736        let graph = ChannelGraph::new(me);
737        graph.add_node(relay);
738        graph.add_node(dest);
739        // Forward: me → relay (fully observed). NO relay → dest edge.
740        graph.add_edge(&me, &relay).unwrap();
741        mark_edge_full(&graph, &me, &relay);
742        // Reverse: dest → relay → me (both edges exist for the return path).
743        graph.add_edge(&dest, &relay).unwrap();
744        graph.add_edge(&relay, &me).unwrap();
745        mark_edge_full(&graph, &dest, &relay);
746        mark_edge_full(&graph, &relay, &me);
747
748        let selector = test_selector(me, graph, MAX_PATHS);
749
750        // Forward path should work via virtual last hop
751        let fwd = selector
752            .select_path(me, dest, 1)
753            .context("forward path with virtual last hop")?;
754        assert!(!fwd.is_empty(), "forward path should find at least one route");
755        for pwm in &fwd {
756            assert_eq!(pwm.path.len(), 2, "forward: [relay, dest]");
757            assert_eq!(pwm.path[0], relay);
758            assert_eq!(pwm.path[1], dest);
759        }
760
761        // Return path should work normally (both edges exist)
762        let rev = selector.select_path(dest, me, 1).context("return path")?;
763        assert!(!rev.is_empty(), "return path should find at least one route");
764        for pwm in &rev {
765            assert_eq!(pwm.path.len(), 2, "return: [relay, me]");
766            assert_eq!(pwm.path.last(), Some(&me));
767        }
768
769        Ok(())
770    }
771
772    #[tokio::test]
773    async fn five_node_chain_should_support_max_hops() -> anyhow::Result<()> {
774        // Bidirectional: me ↔ a ↔ b ↔ c ↔ dest  (3 intermediate hops each way)
775        let me = pubkey(&SECRET_0);
776        let a = pubkey(&SECRET_1);
777        let b = pubkey(&SECRET_2);
778        let c = pubkey(&SECRET_3);
779        let dest = pubkey(&SECRET_4);
780        let graph = ChannelGraph::new(me);
781        for n in [a, b, c, dest] {
782            graph.add_node(n);
783        }
784        // Forward: me → a → b → c → dest
785        graph.add_edge(&me, &a).unwrap();
786        graph.add_edge(&a, &b).unwrap();
787        graph.add_edge(&b, &c).unwrap();
788        graph.add_edge(&c, &dest).unwrap();
789        mark_edge_full(&graph, &me, &a);
790        mark_edge_full(&graph, &a, &b);
791        mark_edge_full(&graph, &b, &c);
792        mark_edge_full(&graph, &c, &dest);
793        // Reverse: dest → c → b → a → me
794        graph.add_edge(&dest, &c).unwrap();
795        graph.add_edge(&c, &b).unwrap();
796        graph.add_edge(&b, &a).unwrap();
797        graph.add_edge(&a, &me).unwrap();
798        mark_edge_full(&graph, &dest, &c);
799        mark_edge_full(&graph, &c, &b);
800        mark_edge_full(&graph, &b, &a);
801        mark_edge_full(&graph, &a, &me);
802
803        let selector = test_selector(me, graph, MAX_PATHS);
804
805        let fwd = selector
806            .select_path(me, dest, RoutingOptions::MAX_INTERMEDIATE_HOPS)
807            .context("forward 3-hop path")?;
808        assert!(!fwd.is_empty());
809        for pwm in &fwd {
810            assert_eq!(pwm.path.len(), 4, "forward: [a, b, c, dest]");
811            assert_eq!(pwm.path.last(), Some(&dest));
812            assert!(!pwm.path.contains(&me));
813        }
814
815        let rev = selector
816            .select_path(dest, me, RoutingOptions::MAX_INTERMEDIATE_HOPS)
817            .context("reverse 3-hop path")?;
818        assert!(!rev.is_empty());
819        for pwm in &rev {
820            assert_eq!(pwm.path.len(), 4, "reverse: [c, b, a, me]");
821            assert_eq!(pwm.path.last(), Some(&me));
822            assert!(!pwm.path.contains(&dest));
823        }
824
825        Ok(())
826    }
827
828    #[tokio::test]
829    async fn selector_should_reject_extended_path_containing_destination() -> anyhow::Result<()> {
830        // If me has a direct edge to dest (e.g. an edge node with a channel to its
831        // exit server), Phase 2 must not emit [dest, dest] — appending dest to a
832        // candidate that already ends in dest forms a loop that ValidatedPath::new
833        // would catch anyway, but we want the guard to skip it cleanly.
834        let me = pubkey(&SECRET_0);
835        let relay = pubkey(&SECRET_1);
836        let dest = pubkey(&SECRET_2);
837        let graph = ChannelGraph::new(me);
838        graph.add_node(relay);
839        graph.add_node(dest);
840        // me → dest: direct channel (this is what the own_chain_addr fix exposes)
841        graph.add_edge(&me, &dest).unwrap();
842        mark_edge_full(&graph, &me, &dest);
843        // me → relay: for 1-hop via relay
844        graph.add_edge(&me, &relay).unwrap();
845        mark_edge_full(&graph, &me, &relay);
846        // Return path
847        graph.add_edge(&dest, &relay).unwrap();
848        graph.add_edge(&relay, &me).unwrap();
849        mark_edge_full(&graph, &dest, &relay);
850        mark_edge_full(&graph, &relay, &me);
851
852        let selector = test_selector(me, graph, MAX_PATHS);
853
854        let fwd = selector
855            .select_path(me, dest, 1)
856            .context("forward path with dest as direct neighbor")?;
857        assert!(!fwd.is_empty(), "should find at least one path via relay");
858        for pwm in &fwd {
859            assert_eq!(pwm.path.len(), 2, "path must be [relay, dest]");
860            assert_eq!(pwm.path[0], relay, "first node must be relay, not dest");
861            assert_eq!(pwm.path[1], dest);
862        }
863        Ok(())
864    }
865
866    #[tokio::test]
867    async fn selector_should_reject_one_hop_path_where_relay_equals_destination() -> anyhow::Result<()> {
868        // Same as above but without relay→dest edge — only Phase 2 (virtual last hop)
869        // is in play. The guard must skip the me→dest edge as a relay candidate.
870        let me = pubkey(&SECRET_0);
871        let relay = pubkey(&SECRET_1);
872        let dest = pubkey(&SECRET_2);
873        let graph = ChannelGraph::new(me);
874        graph.add_node(relay);
875        graph.add_node(dest);
876        // me → dest: direct channel (no relay→dest edge)
877        graph.add_edge(&me, &dest).unwrap();
878        mark_edge_full(&graph, &me, &dest);
879        // me → relay (relay is a valid intermediate; no relay→dest edge needed for Phase 2)
880        graph.add_edge(&me, &relay).unwrap();
881        mark_edge_full(&graph, &me, &relay);
882        // Return path
883        graph.add_edge(&dest, &relay).unwrap();
884        graph.add_edge(&relay, &me).unwrap();
885        mark_edge_full(&graph, &dest, &relay);
886        mark_edge_full(&graph, &relay, &me);
887
888        let selector = test_selector(me, graph, MAX_PATHS);
889
890        let fwd = selector
891            .select_path(me, dest, 1)
892            .context("forward path — dest is direct neighbor, relay is intermediate")?;
893        assert!(!fwd.is_empty(), "should find path via relay (virtual last hop)");
894        for pwm in &fwd {
895            assert_eq!(pwm.path[0], relay, "intermediate must be relay, not dest");
896            assert_ne!(pwm.path[0], dest, "dest must not appear as intermediate");
897        }
898        Ok(())
899    }
900
901    #[tokio::test]
902    async fn selector_should_skip_zero_cost_paths() -> anyhow::Result<()> {
903        // Build graph with edges but NO observations → cost function returns 0.
904        let me = pubkey(&SECRET_0);
905        let hop = pubkey(&SECRET_1);
906        let dest = pubkey(&SECRET_2);
907        let graph = ChannelGraph::new(me);
908        graph.add_node(hop);
909        graph.add_node(dest);
910        graph.add_edge(&me, &hop).context("adding edge me -> hop")?;
911        graph.add_edge(&hop, &dest).context("adding edge hop -> dest")?;
912        // No mark_edge_full/mark_edge_last → observations are empty → cost = 0
913
914        let selector = test_selector(me, graph, MAX_PATHS);
915
916        let err = selector
917            .select_path(me, dest, 1)
918            .expect_err("zero-cost paths should be filtered out");
919        anyhow::ensure!(
920            matches!(err, PathPlannerError::Path(PathError::PathNotFound(..))),
921            "expected PathNotFound, got: {err}"
922        );
923        Ok(())
924    }
925
926    // ── pruning tests ─────────────────────────────────────────────────────────
927
928    fn make_path_with_latency(latency_ms: Option<u32>) -> PathWithMetrics {
929        PathWithMetrics {
930            path: vec![],
931            cost: 1.0,
932            total_latency_ms: latency_ms,
933            min_probe_success_rate: None,
934            min_ack_rate: None,
935            capacity_floor: None,
936        }
937    }
938
939    fn make_path_with_capacity(latency_ms: Option<u32>, capacity_floor: Option<u128>) -> PathWithMetrics {
940        PathWithMetrics {
941            path: vec![],
942            cost: 1.0,
943            total_latency_ms: latency_ms,
944            min_probe_success_rate: None,
945            min_ack_rate: None,
946            capacity_floor,
947        }
948    }
949
950    #[test]
951    fn prune_keeps_all_when_below_floor() {
952        let candidates: Vec<_> = (0..5).map(|i| make_path_with_latency(Some(i * 10))).collect();
953        let result = prune_for_consistency(candidates, 8, 1);
954        assert_eq!(result.len(), 5, "below floor: nothing should be dropped");
955    }
956
957    #[test]
958    fn prune_drops_high_latency_first() {
959        // 30 paths with strictly increasing latency, floor=8 → keep lowest 8
960        let candidates: Vec<_> = (0..30u32)
961            .map(|i| make_path_with_capacity(Some(i * 10), Some(1_000_000)))
962            .collect();
963        let result = prune_for_consistency(candidates, 8, 1);
964        assert_eq!(result.len(), 8);
965        for p in &result {
966            assert!(p.total_latency_ms.unwrap() < 80, "only the 8 lowest should survive");
967        }
968    }
969
970    #[test]
971    fn prune_preserves_populated_paths_over_unpopulated() {
972        // 3 populated + 6 unpopulated, floor=8
973        // total=9 > floor=8: all 3 populated are kept (populated always preferred),
974        // then 5 unpopulated fill the remaining slots.
975        let mut candidates: Vec<_> = vec![
976            make_path_with_capacity(Some(10), Some(1_000)),
977            make_path_with_capacity(Some(30), Some(1_000)),
978            make_path_with_capacity(Some(20), Some(1_000)),
979        ];
980        candidates.extend((0..6).map(|_| make_path_with_latency(None)));
981        let result = prune_for_consistency(candidates, 8, 1);
982        assert_eq!(result.len(), 8);
983        // All 3 populated paths survive; 1 unpopulated is trimmed.
984        let populated: Vec<_> = result.iter().filter(|p| p.total_latency_ms.is_some()).collect();
985        assert_eq!(populated.len(), 3);
986        assert!(populated.iter().any(|p| p.total_latency_ms == Some(10)));
987        assert!(populated.iter().any(|p| p.total_latency_ms == Some(20)));
988        assert!(populated.iter().any(|p| p.total_latency_ms == Some(30)));
989    }
990
991    #[test]
992    fn prune_drops_unpopulated_when_all_populated_exhausted() {
993        // 0 populated, 20 unpopulated, floor=8 → keep first 8
994        let candidates: Vec<_> = (0..20).map(|_| make_path_with_latency(None)).collect();
995        let result = prune_for_consistency(candidates, 8, 1);
996        assert_eq!(result.len(), 8);
997    }
998
999    #[test]
1000    fn prune_keeps_populated_when_unpopulated_exceeds_floor() {
1001        // Regression: 2 populated + 10 unpopulated, floor=8.
1002        // Old formula: target_populated = 8.saturating_sub(10) = 0 → both populated dropped.
1003        // Correct: keep up to 8 populated (only 2 exist), fill 6 remaining with unpopulated.
1004        let mut candidates: Vec<_> = vec![
1005            make_path_with_capacity(Some(10), Some(1_000)),
1006            make_path_with_capacity(Some(20), Some(1_000)),
1007        ];
1008        candidates.extend((0..10).map(|_| make_path_with_latency(None)));
1009        let result = prune_for_consistency(candidates, 8, 1);
1010        assert_eq!(result.len(), 8);
1011        let populated: Vec<_> = result.iter().filter(|p| p.total_latency_ms.is_some()).collect();
1012        assert_eq!(populated.len(), 2, "both measured paths must survive");
1013        assert!(populated.iter().any(|p| p.total_latency_ms == Some(10)));
1014        assert!(populated.iter().any(|p| p.total_latency_ms == Some(20)));
1015    }
1016
1017    #[test]
1018    fn prune_exact_floor_is_unchanged() {
1019        let candidates: Vec<_> = (0..8)
1020            .map(|i| make_path_with_capacity(Some(i * 10), Some(1_000)))
1021            .collect();
1022        let result = prune_for_consistency(candidates, 8, 1);
1023        assert_eq!(result.len(), 8);
1024    }
1025
1026    #[test]
1027    fn prune_0_hop_with_measured_latency_and_no_capacity_is_populated() {
1028        // 0-hop: capacity_floor = None is expected; path should be treated as "fully measured"
1029        // if latency is known.
1030        let mut candidates: Vec<_> = vec![
1031            make_path_with_capacity(Some(50), None), // 0-hop: no capacity, latency known
1032        ];
1033        candidates.extend((0..10).map(|_| make_path_with_latency(None)));
1034        let result = prune_for_consistency(candidates, 8, 0);
1035        assert_eq!(result.len(), 8);
1036        // The 0-hop path must be in the populated bucket and survive.
1037        let has_0_hop = result.iter().any(|p| p.total_latency_ms == Some(50));
1038        assert!(has_0_hop, "0-hop path with measured latency must survive pruning");
1039    }
1040
1041    #[test]
1042    fn prune_multi_hop_without_capacity_floor_is_unpopulated() {
1043        // A 1-hop path with measured latency but NO capacity is unmeasured (unpopulated).
1044        // It should be demoted below paths that have both latency and capacity.
1045        let candidates: Vec<_> = vec![
1046            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1047            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1048            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1049            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1050            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1051            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1052            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1053            make_path_with_capacity(Some(50), Some(1_000)), // fully measured
1054            make_path_with_capacity(Some(40), None),        // missing capacity → unpopulated
1055        ];
1056        let result = prune_for_consistency(candidates, 8, 1);
1057        assert_eq!(result.len(), 8);
1058        // The path with missing capacity should not survive when all 8 slots are filled
1059        // by fully measured paths.
1060        let has_missing_cap = result.iter().any(|p| p.capacity_floor.is_none());
1061        assert!(
1062            !has_missing_cap,
1063            "path without capacity floor must be pruned when fully-measured paths fill the floor"
1064        );
1065    }
1066
1067    #[test]
1068    fn prune_for_consistency_floor_zero_returns_all() {
1069        // floor == 0 must be treated as "no pruning" — all candidates survive unchanged.
1070        let candidates = vec![
1071            make_path_with_capacity(Some(10), Some(1_000)),
1072            make_path_with_capacity(Some(20), None),
1073            make_path_with_capacity(None, None),
1074        ];
1075        let result = prune_for_consistency(candidates, 0, 1);
1076        assert_eq!(result.len(), 3, "floor=0 must return all candidates");
1077    }
1078
1079    // ── path metrics aggregation tests ────────────────────────────────────────
1080
1081    #[tokio::test]
1082    async fn path_metrics_aggregate_latency_correctly() -> anyhow::Result<()> {
1083        // 3-hop path: me → A (30ms) → B (40ms) → dest (50ms)
1084        // Total latency accumulated during DFS should be ~120ms.
1085        let me = pubkey(&SECRET_0);
1086        let a = pubkey(&SECRET_1);
1087        let b = pubkey(&SECRET_2);
1088        let dest = pubkey(&SECRET_3);
1089        let graph = ChannelGraph::new(me);
1090        for n in [a, b, dest] {
1091            graph.add_node(n);
1092        }
1093
1094        let make_edge = |src: &OffchainPublicKey, dst: &OffchainPublicKey, lat_ms: u64| {
1095            graph.upsert_edge(src, dst, |obs| {
1096                obs.record(EdgeWeightType::Connected(true));
1097                obs.record(EdgeWeightType::Immediate(Ok(Duration::from_millis(lat_ms))));
1098                obs.record(EdgeWeightType::Capacity(Some(1000)));
1099            });
1100        };
1101
1102        // Drive EMA to convergence with many samples at the target value.
1103        for _ in 0..20 {
1104            make_edge(&me, &a, 30);
1105            make_edge(&a, &b, 40);
1106            make_edge(&b, &dest, 50);
1107        }
1108
1109        // Edges only in the forward direction (me → a → b → dest)
1110        graph.add_edge(&me, &a).unwrap();
1111        graph.add_edge(&a, &b).unwrap();
1112        graph.add_edge(&b, &dest).unwrap();
1113
1114        let selector = test_selector(me, graph, MAX_PATHS);
1115        let paths = selector.select_path(me, dest, 2).context("forward 2-hop path")?;
1116        assert!(!paths.is_empty());
1117
1118        let total = paths[0].total_latency_ms.expect("latency must be Some");
1119        assert!(
1120            (100..=130).contains(&total),
1121            "expected ~120ms total latency, got {total}ms"
1122        );
1123        Ok(())
1124    }
1125
1126    #[tokio::test]
1127    async fn path_metrics_capacity_floor_is_min() -> anyhow::Result<()> {
1128        let me = pubkey(&SECRET_0);
1129        let hop = pubkey(&SECRET_1);
1130        let dest = pubkey(&SECRET_2);
1131        let graph = ChannelGraph::new(me);
1132        graph.add_node(hop);
1133        graph.add_node(dest);
1134
1135        graph.upsert_edge(&me, &hop, |obs| {
1136            obs.record(EdgeWeightType::Connected(true));
1137            obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
1138            obs.record(EdgeWeightType::Capacity(Some(500)));
1139        });
1140        graph.upsert_edge(&hop, &dest, |obs| {
1141            obs.record(EdgeWeightType::Connected(true));
1142            obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
1143            obs.record(EdgeWeightType::Capacity(Some(200)));
1144        });
1145        graph.add_edge(&me, &hop).unwrap();
1146        graph.add_edge(&hop, &dest).unwrap();
1147
1148        let selector = test_selector(me, graph, MAX_PATHS);
1149        let paths = selector.select_path(me, dest, 1).context("1-hop path")?;
1150        assert!(!paths.is_empty());
1151        assert_eq!(
1152            paths[0].capacity_floor,
1153            Some(200),
1154            "floor must be the smaller of 500 and 200"
1155        );
1156        Ok(())
1157    }
1158
1159    // NOTE: a test for capacity_floor=None is omitted intentionally.
1160    // The forward cost function requires intermediate capacity on all non-last edges, so
1161    // every path the selector returns has capacity data on at least one edge, making
1162    // capacity_floor always Some for reachable paths.
1163}