Skip to main content

hopr_transport/path/
planner.rs

1use std::{sync::Arc, time::Duration};
2
3use futures::{StreamExt as _, TryStreamExt, stream::FuturesUnordered};
4#[cfg(all(feature = "telemetry", not(test)))]
5use hopr_api::types::internal::path::Path;
6use hopr_api::{
7    OffchainPublicKey,
8    chain::{ChainKeyOperations, ChainPathResolver, ChainReadChannelOperations},
9    types::{
10        crypto::crypto_traits::Randomizable,
11        internal::{errors::PathError, prelude::*},
12        primitive::traits::ToHex,
13    },
14};
15use hopr_crypto_packet::prelude::*;
16use hopr_protocol_hopr::{FoundSurb, SurbStore};
17use tracing::trace;
18use validator::{Validate, ValidationError};
19
20use super::{
21    errors::{PathPlannerError, Result},
22    traits::{BackgroundPathCacheRefreshable, PathSelector, PathWithMetrics},
23};
24
25#[cfg(all(feature = "telemetry", not(test)))]
26lazy_static::lazy_static! {
27    static ref METRIC_PATH_LENGTH: hopr_api::types::telemetry::SimpleHistogram = hopr_api::types::telemetry::SimpleHistogram::new(
28        "hopr_path_length",
29        "Distribution of number of hops of sent messages",
30        vec![0.0, 1.0, 2.0, 3.0, 4.0]
31    ).unwrap();
32}
33
34/// Rejects a temper exponent outside `(0, 1]`.
35///
36/// `w^γ` is only a flattening of the weights for `γ ∈ (0, 1]`.
37///
38/// `γ > 1` would sharpen instead — concentrating harder than raw path value, the opposite of the
39/// knob's purpose — and `γ <= 0` inverts or annihilates the ordering, sending traffic *preferentially*
40/// to the worst relays. Both are almost certainly typos, so refuse them rather than silently
41/// degrade every return path the node builds.
42fn validate_weight_temper(temper: f64) -> std::result::Result<(), ValidationError> {
43    if temper > 0.0 && temper <= 1.0 {
44        return Ok(());
45    }
46    let mut err = ValidationError::new("weight_temper_out_of_range");
47    err.message = Some(
48        format!(
49            "return_path_weight_temper ({temper}) must be in (0, 1]: 1.0 samples by raw path value, smaller values \
50             flatten towards uniform"
51        )
52        .into(),
53    );
54    Err(err)
55}
56
57/// Configuration for [`PathPlanner`]'s internal path cache.
58#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault, Validate)]
59pub struct PathPlannerConfig {
60    /// Maximum number of `(source, destination, options)` entries in the path cache.
61    #[default = 10_000]
62    pub max_cache_capacity: u64,
63    /// Time-to-live for a cached path list.  When an entry expires the next
64    /// [`PathPlanner::resolve_routing`] call transparently recomputes it (lazy refresh).
65    ///
66    /// Candidate weights are computed once, when the entry is filled, and frozen in the
67    /// [`hopr_utils::statistics::WeightedCollection`] -- so this bounds how stale the *weights* a
68    /// live session draws from can be, not merely how stale the candidate set is. At the previous
69    /// 60 s a relay that stopped delivering kept its full share of return-path draws for a minute
70    /// after the graph had already scored it down.
71    #[default(Duration::from_secs(10))]
72    pub cache_ttl: Duration,
73    /// Period between proactive background cache-refresh sweeps.
74    ///
75    /// Held at half the TTL so a steady-state session is normally served from an entry that was
76    /// re-weighted rather than one that expired under it.
77    #[default(Duration::from_secs(5))]
78    pub refresh_period: Duration,
79    /// Maximum number of candidate paths the selector may return per query.
80    /// All returned candidates are validated and cached.
81    #[default = 50]
82    pub max_cached_paths: usize,
83    /// Penalty multiplier for edges lacking probe-based quality observations.
84    /// Applied during path cost evaluation to down-weight unprobed edges.
85    /// Must be finite and in `0.0..=1.0`.
86    #[default = 0.5]
87    #[validate(custom(function = "validate_unit_interval"))]
88    pub edge_penalty: f64,
89    /// Minimum acceptable message acknowledgment rate for path selection.
90    /// Edges with an ack rate below this threshold are excluded from candidate paths.
91    /// Must be finite and in `0.0..=1.0`.
92    #[default = 0.1]
93    #[validate(custom(function = "validate_unit_interval"))]
94    pub min_ack_rate: f64,
95    /// Candidate count below which no latency-based pruning occurs.
96    ///
97    /// When fewer paths than this value are found, the selector returns all of them
98    /// unchanged (`min(found_count, floor)` semantics — the floor is never a minimum
99    /// to fabricate).  Set to 0 to disable pruning entirely.
100    #[default = 8]
101    pub min_paths_anonymity_floor: usize,
102    /// Total path latency at which the latency factor in the composite weight equals 0.5.
103    /// Higher values make the weight less sensitive to latency differences.
104    #[default(Duration::from_millis(100))]
105    pub latency_halflife: Duration,
106    /// Reference channel balance used to scale the capacity factor in the composite weight.
107    /// `capacity_factor` saturates at 1.0 near this value.
108    /// Defaults to 10_000_000 (~10 MiB in wxHOPR tokens).
109    #[default = 10_000_000]
110    pub capacity_reference: u128,
111    /// Exponent applied to return-path weights before sampling, flattening the distribution.
112    ///
113    /// Return paths are drawn weighted-random by path value, which concentrates a session's SURBs
114    /// on the few highest-valued relays — losing one then costs far more than the reliable-mode
115    /// loss tolerance. Raising each weight to `γ ∈ (0, 1]` compresses the spread between good and
116    /// bad candidates without changing their order: `w' = w^γ`.
117    ///
118    /// `1.0` samples by raw path value (most traffic on the best relays, largest blast radius when
119    /// one dies). Values approaching `0.0` tend to a uniform draw (smallest blast radius, most
120    /// traffic on poor relays). With weights `(0.4, 0.3, 0.2, 0.1)`, `γ = 0.5` moves the busiest
121    /// relay's share from 40% to 33% and the ratio between busiest and least-busy from 4.0 to 2.0.
122    ///
123    /// Defaults to 0.5.
124    #[validate(custom(function = "validate_weight_temper"))]
125    #[default = 0.5]
126    pub return_path_weight_temper: f64,
127    /// Fraction of return-path draws made uniformly at random instead of by weight.
128    ///
129    /// Weights come from observations, and observations only exist for paths that get selected — a
130    /// closed loop in which a path that falls out of favour stops being measured, so its score can
131    /// never recover and it is never chosen again. Spending a small share of draws uniformly keeps
132    /// every candidate under observation, which is what lets a recovered one climb back.
133    ///
134    /// Costs throughput in proportion: this share of return paths deliberately ignores which
135    /// candidate looks best. Candidates have already passed the selector's gates (open channels,
136    /// `min_ack_rate`, and so on) before reaching here, so an exploratory draw is random only with
137    /// respect to *quality*, never a route the cost function rejected. `0.0` disables it.
138    /// Defaults to 0.1.
139    #[validate(custom(function = "validate_unit_interval"))]
140    #[default = 0.1]
141    pub return_path_exploration: f64,
142    /// Upper bound on a loopback probe's round-trip time considered plausible.
143    /// Measurements above this cap (clock skew, stale telemetry) are discarded
144    /// instead of poisoning the latency EMA with an absurd value.
145    #[default(Duration::from_secs(30))]
146    pub max_plausible_loopback_rtt: Duration,
147}
148
149fn validate_unit_interval(value: f64) -> std::result::Result<(), ValidationError> {
150    if value.is_finite() && (0.0..=1.0).contains(&value) {
151        Ok(())
152    } else {
153        Err(ValidationError::new("value must be finite and in 0.0..=1.0"))
154    }
155}
156
157/// Parameters that shape how per-path aggregates modulate the `WeightedCollection` weight.
158#[derive(Debug, Clone, Copy)]
159struct WeightingParams {
160    latency_halflife: Duration,
161    capacity_reference: u128,
162}
163
164/// Continuous, monotonically decreasing latency factor, bounded in (0, 1].
165///
166/// Returns 1.0 for zero latency and 0.5 when `latency == halflife`.
167fn latency_factor(latency: Duration, halflife: Duration) -> f64 {
168    let ms = latency.as_millis() as f64;
169    let h = halflife.as_millis().max(1) as f64;
170    1.0 / (1.0 + ms / h)
171}
172
173/// Continuous, monotonically increasing capacity factor, bounded in (0.05, 1.0].
174///
175/// Uses a log scale because channel balances span many orders of magnitude.
176/// Saturates at 1.0 near `reference`.
177fn capacity_factor(c: u128, reference: u128) -> f64 {
178    let log = (c as f64).max(1.0).log10();
179    let ref_log = (reference as f64).max(10.0).log10();
180    (log / ref_log).clamp(0.05, 1.0)
181}
182
183/// Composite selection weight for a candidate path.
184///
185/// Refines `pwc.cost` (the `EdgeValueFn` output) with latency and capacity factors
186/// derived from the per-path aggregates.  Factors are neutral (1.0) when the
187/// corresponding aggregate is unavailable to avoid penalising unprobed paths.
188/// For 0-hop routes (`hops == 0`) the capacity factor is always 1.0 — direct
189/// `me -> dest` packets use no payment channel, so `fundable_tickets_floor = None` is expected.
190fn composite_weight(pwc: &PathWithMetrics, hops: usize, params: WeightingParams) -> f64 {
191    let lat = pwc
192        .total_latency_ms
193        .map(|ms| latency_factor(Duration::from_millis(ms as u64), params.latency_halflife))
194        .unwrap_or(1.0);
195    let cap = if hops == 0 {
196        1.0
197    } else {
198        pwc.fundable_tickets_floor
199            .map(|c| capacity_factor(c, params.capacity_reference))
200            .unwrap_or(1.0)
201    };
202    pwc.cost * lat * cap
203}
204
205/// Picks an index into `weights` with probability proportional to the weight; `None` if all are
206/// non-positive.
207///
208/// Mirrors `WeightedCollection::pick_index`, which cannot be reused because the callers below
209/// select over *subsets* of a single collection.
210fn pick_weighted_index(weights: &[f64]) -> Option<usize> {
211    let total: f64 = weights.iter().map(|w| w.max(0.0)).sum();
212    if total <= 0.0 {
213        return None;
214    }
215
216    // Path selection is privacy-relevant, so draw from the CSPRNG rather than a thread RNG.
217    let r = hopr_api::types::crypto_random::random_float_in_range(0.0..total);
218    let mut cumulative = 0.0;
219    for (i, w) in weights.iter().enumerate() {
220        cumulative += w.max(0.0);
221        if r < cumulative {
222            return Some(i);
223        }
224    }
225
226    // Floating-point edge case: fall back to the last positive-weight entry.
227    weights.iter().rposition(|w| *w > 0.0)
228}
229
230/// Whether this draw should explore — ignore the weights and pick uniformly.
231///
232/// Weights are derived from observations, and observations only exist for paths that were selected.
233/// Left alone that is a closed loop: a path that falls out of favour stops being measured, so its
234/// score can never recover and it is never chosen again. Spending a small share of draws uniformly
235/// keeps every candidate under observation, which is what lets one that has recovered climb back.
236fn should_explore(exploration: f64) -> bool {
237    exploration > 0.0 && hopr_api::types::crypto_random::random_float_in_range(0.0..1.0) < exploration
238}
239
240/// Picks a uniformly random index over `len` entries.
241fn pick_uniform_index(len: usize) -> Option<usize> {
242    (len > 0).then(|| (hopr_api::types::crypto_random::random_float_in_range(0.0..len as f64) as usize).min(len - 1))
243}
244
245/// Flattens `weights` by raising each to `temper`, compressing the spread between good and bad
246/// candidates without reordering them.
247///
248/// `x^γ` is monotone for `γ > 0`, so the best candidate stays the best — only the *ratio* between
249/// them shrinks, which is what bounds how much of a session rides on any single relayer. Weights
250/// are clamped at zero first: a negative weight would make `powf` return NaN and poison the draw.
251fn temper_weights(weights: &[f64], temper: f64) -> Vec<f64> {
252    weights.iter().map(|w| w.max(0.0).powf(temper)).collect()
253}
254
255/// Rebuilds the weighted candidate collection for one `(source, destination, hops)` triple from
256/// whatever the graph currently says.
257///
258/// Shared by the lazy cache fill, the background sweep and the on-demand recompute, so all three
259/// necessarily agree: a divergence here would make a session's weights depend on which of the three
260/// happened to run last.
261///
262/// `Ok(None)` means the selector offered nothing, or nothing survived validation. Callers decide
263/// what that means — a fill turns it into `PathNotFound`, a refresh leaves the existing entry
264/// alone. `Err` is reserved for a selector that actually failed.
265#[allow(clippy::too_many_arguments)]
266async fn rebuild_candidates<R, S>(
267    resolver: &R,
268    selector: &S,
269    weighting: WeightingParams,
270    me: OffchainPublicKey,
271    src_key: OffchainPublicKey,
272    dest_key: OffchainPublicKey,
273    hops: usize,
274    kind: &'static str,
275) -> Result<Option<hopr_utils::statistics::WeightedCollection<ValidatedPath>>>
276where
277    R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync,
278    S: PathSelector,
279{
280    let candidates = selector.select_path(src_key, dest_key, hops)?;
281
282    let chain_resolver = ChainPathResolver::from(resolver);
283    let mut valid_paths: Vec<(ValidatedPath, f64)> = Vec::with_capacity(candidates.len());
284    let mut path_metrics: Vec<PathWithMetrics> = Vec::with_capacity(candidates.len());
285    for mut pwc in candidates {
286        let path_nodes = std::mem::take(&mut pwc.path);
287        let node_ids: Vec<NodeId> = path_nodes.into_iter().map(NodeId::Offchain).collect::<Vec<_>>();
288        match ValidatedPath::new(NodeId::Offchain(src_key), node_ids, &chain_resolver).await {
289            Ok(vp) => {
290                valid_paths.push((vp, composite_weight(&pwc, hops, weighting)));
291                path_metrics.push(pwc);
292            }
293            Err(e) => tracing::debug!(kind, error = %e, "path candidate failed validation"),
294        }
295    }
296
297    // A return path that resolves to a single relayer is the blind spot the degradation detector
298    // cannot escape: with no sibling relayer to the same destination, sustained silence is
299    // indistinguishable from a quiet peer, so a dead return relayer is never attributed and never
300    // re-planned. Checked here, after validation, rather than in the selector: validation can reject
301    // every candidate through one of several relayers the selector saw, so only the survivors here
302    // reflect what a session can actually draw from.
303    if src_key != me && hops > 0 && !path_metrics.is_empty() {
304        let relayers = super::selector::distinct_first_relayers(&path_metrics);
305        if relayers <= 1 {
306            tracing::warn!(
307                src = %src_key,
308                kind,
309                distinct_relayers = relayers,
310                candidates = path_metrics.len(),
311                "return-path relayer diversity collapsed to a single relayer; degradation detection \
312                 cannot corroborate a dead relayer for this destination",
313            );
314        }
315    }
316
317    if valid_paths.is_empty() {
318        return Ok(None);
319    }
320
321    let weighted = hopr_utils::statistics::WeightedCollection::new(valid_paths);
322    let total_wt = weighted.total_weight();
323    for ((vp, w), pwm) in weighted.iter().zip(path_metrics.iter()) {
324        tracing::debug!(
325            kind,
326            destination = %dest_key,
327            hops,
328            path = %vp,
329            cost = pwm.cost,
330            composite_weight = w,
331            sampling_probability = if total_wt > 0.0 && *w > 0.0 { *w / total_wt } else { 0.0 },
332            total_latency_ms = ?pwm.total_latency_ms,
333            min_probe_success_rate = ?pwm.min_probe_success_rate,
334            min_ack_rate = ?pwm.min_ack_rate,
335            fundable_tickets_floor = ?pwm.fundable_tickets_floor,
336            "weighted candidate path",
337        );
338    }
339    Ok(Some(weighted))
340}
341
342/// Smallest change in a candidate's share of the draws that counts as traffic having moved.
343///
344/// Weights are recomputed from live observations, so they jitter constantly without meaning
345/// anything. One percentage point of a candidate's share is well below the shift a relayer going
346/// silent produces (measured: 33% to near zero) and well above the noise of an idle graph.
347const MIN_SHARE_SHIFT: f64 = 0.01;
348
349/// Each candidate's share of the total weight, keyed by the route it takes.
350fn shares_by_route(paths: &hopr_utils::statistics::WeightedCollection<ValidatedPath>) -> Vec<(String, f64)> {
351    let total: f64 = paths.iter().map(|(_, w)| w.max(0.0)).sum();
352    paths
353        .iter()
354        .map(|(vp, w)| {
355            let share = if total > 0.0 { w.max(0.0) / total } else { 0.0 };
356            (vp.to_string(), share)
357        })
358        .collect()
359}
360
361/// Whether re-weighting would send a materially different share of the draws somewhere else.
362///
363/// Shares rather than raw weights, because the draw normalises over the collection: every weight
364/// halving changes nothing about where traffic goes. Callers use this to decide whether a re-plan
365/// achieved anything, and a re-plan that moved nothing is a reason *not* to act on it -- so
366/// answering "yes" by default would defeat the check it exists for.
367fn weights_moved(
368    before: &hopr_utils::statistics::WeightedCollection<ValidatedPath>,
369    after: &hopr_utils::statistics::WeightedCollection<ValidatedPath>,
370) -> bool {
371    let (before, after) = (shares_by_route(before), shares_by_route(after));
372    if before.len() != after.len() {
373        return true;
374    }
375    before.iter().any(|(route, was)| {
376        // A route that vanished has lost its whole share, which is the largest move there is.
377        after
378            .iter()
379            .find(|(other, _)| other == route)
380            .is_none_or(|(_, now)| (now - was).abs() >= MIN_SHARE_SHIFT)
381    })
382}
383
384/// Cache key for the path planner: `(source, destination, hops)`.
385///
386/// Only the `Hops` variant of [`RoutingOptions`] is cached (explicit intermediate
387/// paths bypass the cache), so the key stores the hop count as a plain `u32`.
388///
389/// Keyed on resolved offchain keys rather than [`NodeId`], because a `NodeId` naming a node by its
390/// chain address is never equal to one naming the same node by its packet key. Callers hold
391/// whichever form their layer happens to use -- Sessions carry chain addresses, the SURB telemetry
392/// reports packet keys -- so a raw-`NodeId` key silently stores the same route twice. Resolving
393/// first makes lookup and insertion agree by construction.
394type PlannerCacheKey = (OffchainPublicKey, OffchainPublicKey, u32);
395type PlannerCacheValue = Arc<hopr_utils::statistics::WeightedCollection<ValidatedPath>>;
396
397/// Path planner that resolves [`DestinationRouting`] to [`ResolvedTransportRouting`].
398///
399/// The planner delegates path *discovery* to any [`PathSelector`] implementation and
400/// owns the `moka` cache of fully-validated [`ValidatedPath`] objects paired with
401/// their traversal cost, keyed by `(source: NodeId, destination: NodeId, hops: u32)`.
402///
403/// On a cache miss the planner calls the selector, validates every candidate against
404/// the chain resolver, and stores an `Arc<WeightedCollection<ValidatedPath>>` in the
405/// cache. On a cache hit a candidate is picked via weighted random selection (higher
406/// cost = higher quality = higher probability).
407///
408/// A background sweep (`background_refresh`) can be spawned to
409/// proactively re-warm the cache for all previously-seen keys.
410#[derive(Clone)]
411pub struct PathPlanner<Surb, R, S> {
412    me: OffchainPublicKey,
413    pub surb_store: Surb,
414    resolver: Arc<R>,
415    selector: Arc<S>,
416    cache: moka::future::Cache<PlannerCacheKey, PlannerCacheValue>,
417    refresh_period: Duration,
418    weighting: WeightingParams,
419    return_path_weight_temper: f64,
420    return_path_exploration: f64,
421}
422
423impl<Surb, R, S> PathPlanner<Surb, R, S>
424where
425    Surb: SurbStore + Send + Sync + 'static,
426    R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync + 'static,
427    S: PathSelector + Send + Sync + 'static,
428{
429    /// Create a new path planner.
430    ///
431    /// `me` is this node's [`OffchainPublicKey`]; it is used as the source in path queries.
432    pub fn new(me: OffchainPublicKey, surb_store: Surb, resolver: R, selector: S, config: PathPlannerConfig) -> Self {
433        let cache = moka::future::Cache::builder()
434            .max_capacity(config.max_cache_capacity)
435            .time_to_live(config.cache_ttl)
436            .build();
437
438        Self {
439            me,
440            surb_store,
441            resolver: Arc::new(resolver),
442            selector: Arc::new(selector),
443            cache,
444            refresh_period: config.refresh_period,
445            weighting: WeightingParams {
446                latency_halflife: config.latency_halflife,
447                capacity_reference: config.capacity_reference,
448            },
449            return_path_weight_temper: config.return_path_weight_temper,
450            return_path_exploration: config.return_path_exploration,
451        }
452    }
453
454    /// Resolve a [`NodeId`] to an [`OffchainPublicKey`].
455    async fn resolve_node_id_to_offchain_key(&self, node_id: &NodeId) -> Result<OffchainPublicKey> {
456        match node_id {
457            NodeId::Offchain(key) => Ok(*key),
458            NodeId::Chain(addr) => {
459                let resolver = ChainPathResolver::from(&*self.resolver);
460                resolver
461                    .resolve_transport_address(addr)
462                    .await
463                    .map_err(|e| PathPlannerError::Other(anyhow::anyhow!("{e}")))?
464                    .ok_or_else(|| {
465                        PathPlannerError::Other(anyhow::anyhow!("no offchain key found for chain address {addr}"))
466                    })
467            }
468        }
469    }
470
471    #[tracing::instrument(level = "trace", skip(self))]
472    async fn resolve_path(
473        &self,
474        source: NodeId,
475        destination: NodeId,
476        options: RoutingOptions,
477    ) -> Result<ValidatedPath> {
478        let path = match options {
479            RoutingOptions::IntermediatePath(explicit_path) => {
480                tracing::debug!(
481                    direction = "loopback",
482                    ?source,
483                    ?destination,
484                    ?explicit_path,
485                    "resolving intermediate path"
486                );
487                let resolver = ChainPathResolver::from(&*self.resolver);
488                ValidatedPath::new(
489                    source,
490                    explicit_path
491                        .into_iter()
492                        .chain(std::iter::once(destination))
493                        .collect::<Vec<_>>(),
494                    &resolver,
495                )
496                .await?
497            }
498
499            RoutingOptions::Hops(hops) if u32::from(hops) == 0 => {
500                trace!(hops = 0, "resolving zero-hop direct path");
501                let resolver = ChainPathResolver::from(&*self.resolver);
502                ValidatedPath::new(source, vec![destination], &resolver).await?
503            }
504
505            RoutingOptions::Hops(hops) => {
506                let hops_usize: usize = hops.into();
507                let paths = self.cached_paths(source, destination, hops).await?;
508
509                // Format from the `NodeId`s rather than re-resolving them: `cached_paths` has
510                // already done that, and for `NodeId::Chain` each resolution is a resolver lookup.
511                paths.pick_one().ok_or_else(|| {
512                    PathPlannerError::Path(PathError::PathNotFound(
513                        hops_usize,
514                        source.to_string(),
515                        destination.to_string(),
516                    ))
517                })?
518            }
519        };
520
521        #[cfg(all(feature = "telemetry", not(test)))]
522        {
523            hopr_api::types::telemetry::SimpleHistogram::observe(&METRIC_PATH_LENGTH, (path.num_hops() - 1) as f64);
524        }
525
526        trace!(%path, "validated resolved path");
527        Ok(path)
528    }
529
530    /// Cached weighted collection of validated `hops`-hop paths from `source` to `destination`,
531    /// computed on a miss.
532    ///
533    /// Single-path callers draw with `pick_one`; batch callers (see
534    /// [`PathPlanner::resolve_diverse_return_paths`]) work over the collection directly, paying one
535    /// cache lookup instead of one per path.
536    #[tracing::instrument(level = "trace", skip(self))]
537    async fn cached_paths(
538        &self,
539        source: NodeId,
540        destination: NodeId,
541        hops: hopr_api::types::primitive::bounded::BoundedSize<{ RoutingOptions::MAX_INTERMEDIATE_HOPS }>,
542    ) -> Result<PlannerCacheValue> {
543        let hops_usize: usize = hops.into();
544        trace!(hops = hops_usize, "resolving path via planner cache");
545
546        let src_key = self.resolve_node_id_to_offchain_key(&source).await?;
547        let dest_key = self.resolve_node_id_to_offchain_key(&destination).await?;
548
549        let cache_key: PlannerCacheKey = (src_key, dest_key, u32::from(hops));
550
551        let resolver = self.resolver.clone();
552        let selector = self.selector.clone();
553        let weighting = self.weighting;
554        let me = self.me;
555
556        self.cache
557            .try_get_with(cache_key, async move {
558                trace!(hops = hops_usize, "path cache miss, querying selector");
559                rebuild_candidates(
560                    &*resolver, &*selector, weighting, me, src_key, dest_key, hops_usize, "fill",
561                )
562                .await?
563                .map(Arc::new)
564                .ok_or_else(|| {
565                    PathPlannerError::Path(PathError::PathNotFound(hops_usize, src_key.to_hex(), dest_key.to_hex()))
566                })
567            })
568            .await
569            .map_err(PathPlannerError::CacheError)
570    }
571
572    /// Rebuilds every cached entry whose paths originate at `source`, replacing each in place.
573    ///
574    /// Return paths are cached under `(counterparty, me, hops)`, so this is how a caller that has
575    /// just learned a counterparty's return traffic went silent forces those weights to be rebuilt
576    /// from the current graph, instead of waiting up to
577    /// [`PathPlannerConfig::refresh_period`] for the background sweep to reach them.
578    ///
579    /// Entries are replaced, never dropped: one that rebuilds to nothing keeps serving what it
580    /// already holds. Dropping them was measured to collapse a healthy session from 100 % to
581    /// 0.14 %, because a live session draws its next return path from this very entry.
582    ///
583    /// Returns how many entries came back with a materially different **share** of the draws --
584    /// not how many were rebuilt. Callers use this to decide whether the re-plan achieved anything
585    /// worth acting on, and on a healthy graph a rebuild always succeeds, so counting rebuilds
586    /// would answer "something moved" every time.
587    pub async fn recompute_paths_from(&self, source: &OffchainPublicKey) -> usize {
588        // 0-hop entries name a direct route with nothing to re-weight, exactly as in the sweep.
589        let keys = self
590            .cache
591            .iter()
592            .map(|(key, _)| *key.as_ref())
593            .filter(|(src, _, hops)| src == source && *hops > 0)
594            .collect::<Vec<PlannerCacheKey>>();
595
596        let mut moved = 0usize;
597        for (src_key, dest_key, hops) in keys {
598            if let Ok(Some(weighted)) = rebuild_candidates(
599                &*self.resolver,
600                &*self.selector,
601                self.weighting,
602                self.me,
603                src_key,
604                dest_key,
605                hops as usize,
606                "recompute",
607            )
608            .await
609            {
610                let key = (src_key, dest_key, hops);
611                // A fresh entry counts as moved -- there was no previous distribution to compare
612                // against, so nothing here can say the traffic stayed put.
613                let shifted = match self.cache.get(&key).await {
614                    Some(previous) => weights_moved(&previous, &weighted),
615                    None => true,
616                };
617                self.cache.insert(key, Arc::new(weighted)).await;
618                if shifted {
619                    moved += 1;
620                }
621            }
622        }
623
624        tracing::debug!(%source, moved, "recomputed cached paths originating at peer");
625        moved
626    }
627
628    /// Resolves `count` return paths from `destination` back to this node, drawn weighted-random
629    /// over [`PathPlannerConfig::return_path_weight_temper`]-flattened path values.
630    ///
631    /// Tempering is what bounds the blast radius of losing one relay: raw path values concentrate a
632    /// session's SURBs on the few best candidates, so the flatter the effective distribution, the
633    /// smaller any single relay's share of the return stream.
634    ///
635    /// Draws are independent — deliberately. Spreading a *batch* over K distinct relayers was tried
636    /// and cannot work: a batch is the SURBs that fit in one packet, and `HoprPacket::PAYLOAD_SIZE /
637    /// HoprSurb::SIZE` is 2, so K was capped at 2 whatever the configuration said, while each packet
638    /// re-drew independently regardless. Tempering has no such ceiling.
639    ///
640    /// Only `Hops` routing has alternatives to draw over — an explicit path resolves to itself, so
641    /// those fall back to plain repeated resolution.
642    #[tracing::instrument(level = "trace", skip(self))]
643    async fn resolve_diverse_return_paths(
644        &self,
645        destination: NodeId,
646        options: RoutingOptions,
647        count: usize,
648    ) -> Result<Vec<ValidatedPath>> {
649        // No return paths requested (e.g. the message fills the payload, leaving no room for
650        // SURBs). Resolve nothing — querying the planner here would turn "none wanted" into a
651        // `PathNotFound` error.
652        if count == 0 {
653            return Ok(Vec::new());
654        }
655
656        let me = NodeId::Offchain(self.me);
657
658        let hops = match options {
659            RoutingOptions::Hops(hops) if u32::from(hops) > 0 => hops,
660            // A fixed path or a direct return has no alternatives to weight over.
661            other => {
662                return (0..count)
663                    .map(|_| self.resolve_path(destination, me, other.clone()))
664                    .collect::<FuturesUnordered<_>>()
665                    .try_collect::<Vec<_>>()
666                    .await;
667            }
668        };
669
670        let candidates = self.cached_paths(destination, me, hops).await?;
671        let items: Vec<&(ValidatedPath, f64)> = candidates.iter().collect();
672        let weights = temper_weights(
673            &items.iter().map(|(_, w)| *w).collect::<Vec<_>>(),
674            self.return_path_weight_temper,
675        );
676
677        if weights.iter().all(|w| *w <= 0.0) {
678            return Err(PathPlannerError::Path(PathError::PathNotFound(
679                hops.into(),
680                destination.to_string(),
681                me.to_string(),
682            )));
683        }
684
685        // Distinct first relayers in the candidate pool: the return-relayer diversity actually
686        // available for this destination. A value of 1 is the corroboration blind spot (a dead
687        // relayer cannot be told from a quiet peer) — the same condition `rebuild_candidates` WARNs
688        // on. This draw runs per-packet, so both sets below are built only when DEBUG is actually
689        // recorded -- not on every call.
690        let log_relayer_diversity = tracing::enabled!(tracing::Level::DEBUG);
691        let candidate_relayers = log_relayer_diversity.then(|| {
692            items
693                .iter()
694                .filter_map(|item| item.0.first())
695                .collect::<std::collections::HashSet<_>>()
696        });
697
698        tracing::debug!(
699            %destination,
700            count,
701            candidates = items.len(),
702            distinct_relayers = candidate_relayers.as_ref().map_or(0, |r| r.len()),
703            temper = self.return_path_weight_temper,
704            exploration = self.return_path_exploration,
705            "drawing return paths from tempered weights"
706        );
707
708        let drawn = (0..count)
709            .filter_map(|_| {
710                if should_explore(self.return_path_exploration) {
711                    pick_uniform_index(items.len())
712                } else {
713                    pick_weighted_index(&weights)
714                }
715                .map(|i| items[i].0.clone())
716            })
717            .collect::<Vec<_>>();
718
719        // How many distinct relayers the actual SURBs went to. Fewer than the candidate pool means
720        // the weighting concentrated the stream — expected — but a persistent 1 here while
721        // `candidates` > 1 says the draw itself is starving the siblings the detector relies on.
722        let drawn_relayers = log_relayer_diversity.then(|| {
723            drawn
724                .iter()
725                .filter_map(|vp| vp.first())
726                .collect::<std::collections::HashSet<_>>()
727        });
728        tracing::debug!(
729            %destination,
730            drawn = drawn.len(),
731            distinct_relayers = drawn_relayers.as_ref().map_or(0, |r| r.len()),
732            of_candidates = candidate_relayers.as_ref().map_or(0, |r| r.len()),
733            "drew return paths"
734        );
735
736        Ok(drawn)
737    }
738
739    /// Resolve a [`DestinationRouting`] to a [`ResolvedTransportRouting`].
740    ///
741    /// Returns the resolved routing and, for `Return` variants, the number of remaining SURBs.
742    #[tracing::instrument(level = "trace", skip(self))]
743    pub async fn resolve_routing(
744        &self,
745        size_hint: usize,
746        max_surbs: usize,
747        routing: DestinationRouting,
748    ) -> Result<(ResolvedTransportRouting<HoprSurb>, Option<usize>)> {
749        match routing {
750            DestinationRouting::Forward {
751                destination,
752                pseudonym,
753                forward_options,
754                return_options,
755            } => {
756                tracing::debug!(direction = "forward", %destination, "resolving forward path");
757
758                let forward_path = self
759                    .resolve_path(NodeId::Offchain(self.me), *destination, forward_options)
760                    .await?;
761                tracing::debug!(direction = "forward", %destination, path = %forward_path, "resolved path");
762
763                let return_paths = if let Some(return_options) = return_options {
764                    let num_possible_surbs = HoprPacket::max_surbs_with_message(size_hint).min(max_surbs);
765                    trace!(
766                        %destination,
767                        %num_possible_surbs,
768                        data_len = size_hint,
769                        max_surbs,
770                        "resolving packet return paths"
771                    );
772
773                    self.resolve_diverse_return_paths(*destination, return_options, num_possible_surbs)
774                        .await?
775                        .into_iter()
776                        .enumerate()
777                        .inspect(|(i, rp)| {
778                            tracing::debug!(direction = "return", %destination, index = i, path = %rp, "resolved return path");
779                        })
780                        .map(|(_, rp)| rp)
781                        .collect()
782                } else {
783                    vec![]
784                };
785
786                trace!(%destination, num_surbs = return_paths.len(), data_len = size_hint, "resolved packet");
787
788                Ok((
789                    ResolvedTransportRouting::Forward {
790                        pseudonym: pseudonym.unwrap_or_else(HoprPseudonym::random),
791                        forward_path,
792                        return_paths,
793                    },
794                    None,
795                ))
796            }
797
798            DestinationRouting::Return(matcher) => {
799                let FoundSurb {
800                    sender_id,
801                    surb,
802                    remaining,
803                } = self
804                    .surb_store
805                    .find_surb(matcher)
806                    .ok_or_else(|| PathPlannerError::Surb(format!("no surb for pseudonym {}", matcher.pseudonym())))?;
807                Ok((ResolvedTransportRouting::Return(sender_id, surb), Some(remaining)))
808            }
809        }
810    }
811}
812
813impl<Surb, R, S> BackgroundPathCacheRefreshable for PathPlanner<Surb, R, S>
814where
815    Surb: SurbStore + Send + Sync + 'static,
816    R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync + 'static,
817    S: PathSelector + Send + Sync + 'static,
818{
819    /// Returns a future that runs the background path-cache refresh loop.
820    ///
821    /// The returned future iterates over all keys currently in the planner's cache
822    /// and recomputes their paths on a configurable schedule, so that steady-state
823    /// traffic is always served from cache.
824    fn run_background_refresh(&self) -> impl std::future::Future<Output = ()> + Send + 'static {
825        // Clone only the fields we need — avoids requiring R: Clone + S: Clone.
826        let cache = self.cache.clone();
827        let resolver = self.resolver.clone();
828        let selector = self.selector.clone();
829        let refresh_period = self.refresh_period;
830        let weighting = self.weighting;
831        let me = self.me;
832
833        // run at a non-zero interval
834        futures_time::stream::interval(futures_time::time::Duration::from_millis(
835            refresh_period.as_millis() as u64 + 1u64,
836        ))
837        .for_each(move |_| {
838            let cache = cache.clone();
839            let resolver = resolver.clone();
840            let selector = selector.clone();
841            let weighting = weighting;
842
843            async move {
844                for (key, _) in cache.iter() {
845                    let (src_key, dest_key, hops_u32) = {
846                        let k = key.as_ref();
847                        (k.0, k.1, k.2)
848                    };
849
850                    if hops_u32 == 0 {
851                        continue;
852                    }
853
854                    // The key already holds resolved offchain keys, which is what the selector
855                    // wants -- so nothing has to be resolved again here.
856                    if let Ok(Some(weighted)) = rebuild_candidates(
857                        &*resolver,
858                        &*selector,
859                        weighting,
860                        me,
861                        src_key,
862                        dest_key,
863                        hops_u32 as usize,
864                        "background-refresh",
865                    )
866                    .await
867                    {
868                        cache.insert((src_key, dest_key, hops_u32), Arc::new(weighted)).await;
869                    }
870                }
871            }
872        })
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use std::str::FromStr;
879
880    use bimap::BiMap;
881    use futures::stream::{self, BoxStream};
882    use hex_literal::hex;
883    use hopr_api::{
884        chain::{ChainKeyOperations, ChainReadChannelOperations, ChannelSelector, HoprKeyIdent},
885        graph::{NetworkGraphWrite, traits::EdgeObservableWrite},
886        types::{
887            crypto::prelude::{Keypair, OffchainKeypair},
888            internal::channels::{ChannelEntry, ChannelStatus, generate_channel_id},
889            primitive::prelude::*,
890        },
891    };
892    use hopr_network_graph::ChannelGraph;
893
894    use super::*;
895    use crate::path::selector::HoprGraphPathSelector;
896
897    #[derive(Debug)]
898    struct TestError(String);
899
900    impl std::fmt::Display for TestError {
901        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902            f.write_str(&self.0)
903        }
904    }
905
906    impl std::error::Error for TestError {}
907
908    const SECRET_ME: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
909    const SECRET_A: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
910    const SECRET_DEST: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
911    /// A second relayer, so a return fixture can offer a real alternative to the one that dies.
912    const SECRET_B: [u8; 32] = hex!("3d5f2c8a91b4e07d6a3c1f8e2b95d40c7e6a1938f2c5b8d04e7a1c396b2f8d05");
913
914    fn pubkey(secret: &[u8; 32]) -> OffchainPublicKey {
915        *OffchainKeypair::from_secret(secret).expect("valid secret").public()
916    }
917
918    #[derive(Clone)]
919    struct Mapper {
920        map: Arc<BiMap<OffchainPublicKey, HoprKeyIdent>>,
921    }
922
923    impl KeyIdMapping<HoprKeyIdent, OffchainPublicKey> for Mapper {
924        fn map_key_to_id(&self, key: &OffchainPublicKey) -> Option<HoprKeyIdent> {
925            self.map.get_by_left(key).copied()
926        }
927
928        fn map_id_to_public(&self, id: &HoprKeyIdent) -> Option<OffchainPublicKey> {
929            self.map.get_by_right(id).copied()
930        }
931
932        fn map_keys_to_ids(&self, keys: &[OffchainPublicKey]) -> Vec<Option<HoprKeyIdent>> {
933            keys.iter().map(|key| self.map_key_to_id(key)).collect()
934        }
935
936        fn map_ids_to_keys(&self, ids: &[HoprKeyIdent]) -> Vec<Option<OffchainPublicKey>> {
937            ids.iter().map(|id| self.map_id_to_public(id)).collect()
938        }
939    }
940
941    struct TestChainApi {
942        me: Address,
943        key_addr_map: BiMap<OffchainPublicKey, Address>,
944        channels: Vec<ChannelEntry>,
945        id_mapper: Mapper,
946    }
947
948    impl TestChainApi {
949        fn new(me_key: OffchainPublicKey, me_addr: Address, peers: Vec<(OffchainPublicKey, Address)>) -> Self {
950            let mut key_addr_map = BiMap::new();
951            let mut key_id_map: BiMap<OffchainPublicKey, HoprKeyIdent> = BiMap::new();
952            key_addr_map.insert(me_key, me_addr);
953            key_id_map.insert(me_key, 0u32.into());
954            for (i, (k, a)) in peers.iter().enumerate() {
955                key_addr_map.insert(*k, *a);
956                key_id_map.insert(*k, ((i + 1) as u32).into());
957            }
958            Self {
959                me: me_addr,
960                key_addr_map,
961                channels: vec![],
962                id_mapper: Mapper {
963                    map: Arc::new(key_id_map),
964                },
965            }
966        }
967
968        fn with_open_channel(mut self, src: Address, dst: Address) -> Self {
969            self.channels.push(
970                ChannelEntry::builder()
971                    .between(src, dst)
972                    .amount(100)
973                    .ticket_index(1)
974                    .status(ChannelStatus::Open)
975                    .epoch(1)
976                    .build()
977                    .unwrap(),
978            );
979            self
980        }
981    }
982
983    impl ChainKeyOperations for TestChainApi {
984        type Error = TestError;
985        type Mapper = Mapper;
986
987        fn chain_key_to_packet_key(
988            &self,
989            chain: &Address,
990        ) -> std::result::Result<Option<OffchainPublicKey>, TestError> {
991            Ok(self.key_addr_map.get_by_right(chain).copied())
992        }
993
994        fn packet_key_to_chain_key(
995            &self,
996            packet: &OffchainPublicKey,
997        ) -> std::result::Result<Option<Address>, TestError> {
998            Ok(self.key_addr_map.get_by_left(packet).copied())
999        }
1000
1001        fn key_id_mapper_ref(&self) -> &Self::Mapper {
1002            &self.id_mapper
1003        }
1004    }
1005
1006    impl ChainReadChannelOperations for TestChainApi {
1007        type Error = TestError;
1008
1009        fn me(&self) -> &Address {
1010            &self.me
1011        }
1012
1013        fn channel_by_id(&self, channel_id: &ChannelId) -> std::result::Result<Option<ChannelEntry>, TestError> {
1014            Ok(self
1015                .channels
1016                .iter()
1017                .find(|c| generate_channel_id(&c.source, &c.destination) == *channel_id)
1018                .cloned())
1019        }
1020
1021        fn stream_channels<'a>(
1022            &'a self,
1023            _selector: ChannelSelector,
1024        ) -> std::result::Result<BoxStream<'a, ChannelEntry>, TestError> {
1025            Ok(Box::pin(stream::iter(self.channels.clone())))
1026        }
1027    }
1028
1029    #[test]
1030    fn exploration_should_be_off_at_zero_and_certain_at_one() {
1031        // The two endpoints are what callers actually configure, and a mistake at either end is
1032        // silent: 0.0 that still explores wastes throughput, 1.0 that never does starves the
1033        // observations the weights are built from.
1034        assert!((0..200).all(|_| !should_explore(0.0)));
1035        assert!((0..200).all(|_| should_explore(1.0)));
1036    }
1037
1038    #[test]
1039    fn exploration_rate_should_be_near_the_configured_fraction() {
1040        let explored = (0..10_000).filter(|_| should_explore(0.1)).count();
1041        // Loose bounds: this is a CSPRNG draw, so the test is guarding the wiring, not the
1042        // generator's uniformity.
1043        assert!(
1044            (700..1_300).contains(&explored),
1045            "expected roughly 1000 of 10000 draws to explore, got {explored}"
1046        );
1047    }
1048
1049    #[test]
1050    fn uniform_index_should_stay_in_range_and_reach_every_candidate() {
1051        assert_eq!(None, pick_uniform_index(0), "nothing to pick from");
1052
1053        let mut seen = std::collections::HashSet::new();
1054        for _ in 0..1_000 {
1055            let i = pick_uniform_index(4).expect("non-empty");
1056            assert!(i < 4, "index {i} out of range");
1057            seen.insert(i);
1058        }
1059        assert_eq!(4, seen.len(), "every candidate must be reachable");
1060    }
1061
1062    #[test]
1063    fn exploration_should_reach_a_candidate_that_weighting_would_never_pick() {
1064        // The point of the knob: a path whose weight has collapsed still gets traffic occasionally,
1065        // which is the only way it can ever be re-measured and recover.
1066        let weights = [1.0, 0.0];
1067        assert!(
1068            (0..200).all(|_| pick_weighted_index(&weights) == Some(0)),
1069            "a zero weight is never drawn by weight"
1070        );
1071        assert!(
1072            (0..1_000).any(|_| pick_uniform_index(weights.len()) == Some(1)),
1073            "an exploratory draw must be able to reach it"
1074        );
1075    }
1076
1077    /// Cached candidate weights are frozen at fill time, so the TTL bounds how stale the numbers a
1078    /// live session draws from can be -- not just how stale the candidate set is.
1079    #[test]
1080    fn the_path_cache_should_expire_faster_than_a_session_can_be_lost() {
1081        let cfg = PathPlannerConfig::default();
1082
1083        // The SURB round-trip window slices at 5s. A TTL far above that lets a relay keep its full
1084        // share of return-path draws long after the graph has scored it down -- measured as a
1085        // session that stayed degraded for minutes after the evidence was in.
1086        assert!(
1087            cfg.cache_ttl <= Duration::from_secs(15),
1088            "path cache TTL {:?} outlives the evidence that should displace it",
1089            cfg.cache_ttl
1090        );
1091        assert!(
1092            cfg.refresh_period < cfg.cache_ttl,
1093            "the background sweep ({:?}) must re-weight entries before they expire ({:?})",
1094            cfg.refresh_period,
1095            cfg.cache_ttl
1096        );
1097    }
1098
1099    #[test]
1100    fn config_should_reject_a_weight_temper_outside_the_unit_range() {
1101        assert!(PathPlannerConfig::default().validate().is_ok());
1102
1103        // 1.0 is the raw path value — the sharpest permitted, and the pre-tempering behaviour.
1104        assert!(
1105            PathPlannerConfig {
1106                return_path_weight_temper: 1.0,
1107                ..PathPlannerConfig::default()
1108            }
1109            .validate()
1110            .is_ok()
1111        );
1112
1113        // Above 1.0 sharpens instead of flattening, concentrating harder than raw path value.
1114        let sharpening = PathPlannerConfig {
1115            return_path_weight_temper: 1.5,
1116            ..PathPlannerConfig::default()
1117        };
1118        // Assert on the rendered message, which is what an operator actually sees.
1119        let err = sharpening
1120            .validate()
1121            .expect_err("a sharpening exponent must be rejected");
1122        assert!(err.to_string().contains("must be in (0, 1]"), "{err}");
1123
1124        // Zero annihilates the ordering: every candidate would weigh exactly 1.
1125        assert!(
1126            PathPlannerConfig {
1127                return_path_weight_temper: 0.0,
1128                ..PathPlannerConfig::default()
1129            }
1130            .validate()
1131            .is_err()
1132        );
1133    }
1134
1135    #[test]
1136    fn temper_should_compress_the_weight_spread_without_reordering() {
1137        let raw = [0.4, 0.3, 0.2, 0.1];
1138
1139        let untouched = temper_weights(&raw, 1.0);
1140        assert_eq!(raw.to_vec(), untouched, "temper 1.0 must be the identity");
1141
1142        let flattened = temper_weights(&raw, 0.5);
1143        // Order preserved…
1144        assert!(flattened.windows(2).all(|w| w[0] > w[1]), "{flattened:?}");
1145        // …but the best-to-worst ratio shrinks from 4.0 towards 1.0, which is what bounds how much
1146        // of a session rides on the single best relayer.
1147        let raw_ratio = raw[0] / raw[3];
1148        let tempered_ratio = flattened[0] / flattened[3];
1149        assert!(
1150            tempered_ratio < raw_ratio,
1151            "{tempered_ratio} should be below {raw_ratio}"
1152        );
1153        assert!((tempered_ratio - 2.0).abs() < 1e-9, "{tempered_ratio}");
1154    }
1155
1156    #[test]
1157    fn temper_should_clamp_negative_weights_instead_of_producing_nan() {
1158        // `powf` on a negative base with a fractional exponent is NaN, which would poison the
1159        // cumulative sum in `pick_weighted_index` and make selection return nothing.
1160        let out = temper_weights(&[-1.0, 0.0, 4.0], 0.5);
1161        assert!(out.iter().all(|w| w.is_finite()), "{out:?}");
1162        assert_eq!(vec![0.0, 0.0, 2.0], out);
1163    }
1164
1165    #[test]
1166    fn pick_weighted_index_should_reject_a_non_positive_total() {
1167        assert_eq!(None, pick_weighted_index(&[]));
1168        assert_eq!(None, pick_weighted_index(&[0.0, 0.0]));
1169        assert_eq!(Some(1), pick_weighted_index(&[0.0, 1.0]));
1170    }
1171
1172    // ── address fixtures ──────────────────────────────────────────────────────
1173    fn me_addr() -> Address {
1174        Address::from_str("0x1000d5786d9e6799b3297da1ad55605b91e2c882").expect("valid addr")
1175    }
1176    fn a_addr() -> Address {
1177        Address::from_str("0x200060ddced1e33c9647a71f4fc2cf4ed33e4a9d").expect("valid addr")
1178    }
1179    fn dest_addr() -> Address {
1180        Address::from_str("0x30004105095c8c10f804109b4d1199a9ac40ed46").expect("valid addr")
1181    }
1182    fn b_addr() -> Address {
1183        Address::from_str("0x40001a7ec3d5b28f9047c6b1e83d5a2f9c71b0e4").expect("valid addr")
1184    }
1185
1186    // ── graph helpers ──────────────────────────────────────────────────────────
1187    fn mark_edge_full(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
1188        use hopr_api::graph::traits::EdgeWeightType;
1189        graph.upsert_edge(src, dst, |obs| {
1190            obs.record(EdgeWeightType::Connected(true));
1191            obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
1192            obs.record(EdgeWeightType::Intermediate(Ok(std::time::Duration::from_millis(50))));
1193            obs.record(EdgeWeightType::Balance(Some(hopr_api::graph::traits::Balance::from(
1194                1000u64,
1195            ))));
1196        });
1197    }
1198
1199    fn small_config() -> PathPlannerConfig {
1200        PathPlannerConfig {
1201            max_cache_capacity: 100,
1202            cache_ttl: std::time::Duration::from_secs(60),
1203            refresh_period: std::time::Duration::from_secs(60),
1204            max_cached_paths: 2,
1205            ..PathPlannerConfig::default()
1206        }
1207    }
1208
1209    // ── test: zero-hop path ───────────────────────────────────────────────────
1210
1211    /// A Session names its destination by chain address; the SURB telemetry names it by packet key.
1212    ///
1213    /// Regression: the cache used to be keyed on the `NodeId` as handed in, and
1214    /// `NodeId::Chain(addr) != NodeId::Offchain(key)` even for the same node -- so the two layers
1215    /// stored and looked up the same route under different keys without either noticing.
1216    #[tokio::test]
1217    async fn a_return_path_should_cache_under_one_key_whichever_form_names_the_node() {
1218        let me = pubkey(&SECRET_ME);
1219        let a = pubkey(&SECRET_A);
1220        let dest = pubkey(&SECRET_DEST);
1221
1222        let graph = ChannelGraph::new(me);
1223        graph.add_node(a);
1224        graph.add_node(dest);
1225        // A return path runs from the destination back to us.
1226        graph.add_edge(&dest, &a).unwrap();
1227        graph.add_edge(&a, &me).unwrap();
1228        mark_edge_full(&graph, &dest, &a);
1229        mark_edge_full(&graph, &a, &me);
1230
1231        let cfg = small_config();
1232        let selector = HoprGraphPathSelector::new(
1233            me,
1234            graph,
1235            cfg.max_cached_paths,
1236            cfg.edge_penalty,
1237            cfg.min_ack_rate,
1238            cfg.min_paths_anonymity_floor,
1239        );
1240        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1241            .with_open_channel(dest_addr(), a_addr())
1242            .with_open_channel(a_addr(), me_addr());
1243        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1244        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1245
1246        // Populated the way a Session does it: by chain address.
1247        let _ = planner
1248            .resolve_diverse_return_paths(
1249                NodeId::Chain(dest_addr()),
1250                RoutingOptions::Hops(1.try_into().expect("valid 1")),
1251                1,
1252            )
1253            .await
1254            .expect("return path resolution should succeed");
1255
1256        let cache_key: PlannerCacheKey = (dest, me, 1);
1257        assert!(
1258            planner.cache.get(&cache_key).await.is_some(),
1259            "the return path should be cached after resolution"
1260        );
1261
1262        // Asking again the way the SURB telemetry names the node -- by packet key -- must land on
1263        // that same entry rather than resolving and caching a second copy.
1264        planner.cache.run_pending_tasks().await;
1265        assert_eq!(planner.cache.entry_count(), 1, "one resolution, one entry");
1266
1267        let _ = planner
1268            .resolve_diverse_return_paths(
1269                NodeId::Offchain(dest),
1270                RoutingOptions::Hops(1.try_into().expect("valid 1")),
1271                1,
1272            )
1273            .await
1274            .expect("return path resolution should succeed");
1275
1276        planner.cache.run_pending_tasks().await;
1277        assert_eq!(
1278            planner.cache.entry_count(),
1279            1,
1280            "naming the destination by packet key must hit the entry cached from its chain address"
1281        );
1282    }
1283
1284    #[tokio::test]
1285    async fn zero_hop_path_should_bypass_selector() {
1286        let me = pubkey(&SECRET_ME);
1287        let dest = pubkey(&SECRET_DEST);
1288
1289        // Build empty graph (no edges) — selector would fail if called.
1290        let graph = ChannelGraph::new(me);
1291        let cfg = small_config();
1292        let selector = HoprGraphPathSelector::new(
1293            me,
1294            graph,
1295            cfg.max_cached_paths,
1296            cfg.edge_penalty,
1297            cfg.min_ack_rate,
1298            cfg.min_paths_anonymity_floor,
1299        );
1300
1301        let chain_api = TestChainApi::new(me, me_addr(), vec![(dest, dest_addr())]);
1302        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1303
1304        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1305
1306        let routing = DestinationRouting::Forward {
1307            destination: Box::new(NodeId::Offchain(dest)),
1308            pseudonym: None,
1309            forward_options: RoutingOptions::Hops(0.try_into().expect("valid 0")),
1310            return_options: None,
1311        };
1312
1313        let result = planner.resolve_routing(100, 0, routing).await;
1314        assert!(result.is_ok(), "zero-hop should succeed: {:?}", result.err());
1315
1316        let (resolved, rem) = result.unwrap();
1317        assert!(rem.is_none());
1318        if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
1319            assert_eq!(
1320                forward_path.num_hops(),
1321                1,
1322                "zero-hop = 1 node in path (just destination)"
1323            );
1324        } else {
1325            panic!("expected Forward routing");
1326        }
1327    }
1328
1329    // ── test: one-hop path via graph selector ─────────────────────────────────
1330
1331    #[tokio::test]
1332    async fn one_hop_path_should_use_selector() {
1333        let me = pubkey(&SECRET_ME);
1334        let a = pubkey(&SECRET_A);
1335        let dest = pubkey(&SECRET_DEST);
1336
1337        let graph = ChannelGraph::new(me);
1338        graph.add_node(a);
1339        graph.add_node(dest);
1340        graph.add_edge(&me, &a).unwrap();
1341        graph.add_edge(&a, &dest).unwrap();
1342        mark_edge_full(&graph, &me, &a);
1343        mark_edge_full(&graph, &a, &dest);
1344
1345        let cfg = small_config();
1346        let selector = HoprGraphPathSelector::new(
1347            me,
1348            graph,
1349            cfg.max_cached_paths,
1350            cfg.edge_penalty,
1351            cfg.min_ack_rate,
1352            cfg.min_paths_anonymity_floor,
1353        );
1354
1355        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1356            .with_open_channel(me_addr(), a_addr())
1357            .with_open_channel(a_addr(), dest_addr());
1358
1359        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1360        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1361
1362        let routing = DestinationRouting::Forward {
1363            destination: Box::new(NodeId::Offchain(dest)),
1364            pseudonym: None,
1365            forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
1366            return_options: None,
1367        };
1368
1369        let result = planner.resolve_routing(100, 0, routing).await;
1370        assert!(result.is_ok(), "1-hop routing should succeed: {:?}", result.err());
1371
1372        let (resolved, _) = result.unwrap();
1373        if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
1374            assert_eq!(
1375                forward_path.num_hops(),
1376                2,
1377                "1 intermediate hop means path has 2 nodes [a, dest]"
1378            );
1379        } else {
1380            panic!("expected Forward routing");
1381        }
1382    }
1383
1384    #[tokio::test]
1385    async fn explicit_intermediate_path_should_bypass_selector() {
1386        let me = pubkey(&SECRET_ME);
1387        let a = pubkey(&SECRET_A);
1388        let dest = pubkey(&SECRET_DEST);
1389
1390        // Empty graph — selector would fail; explicit path should not use it.
1391        let graph = ChannelGraph::new(me);
1392        let cfg = small_config();
1393        let selector = HoprGraphPathSelector::new(
1394            me,
1395            graph,
1396            cfg.max_cached_paths,
1397            cfg.edge_penalty,
1398            cfg.min_ack_rate,
1399            cfg.min_paths_anonymity_floor,
1400        );
1401
1402        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1403            .with_open_channel(me_addr(), a_addr())
1404            .with_open_channel(a_addr(), dest_addr());
1405
1406        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1407        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1408
1409        use hopr_api::types::primitive::prelude::BoundedVec;
1410        let intermediate_path = BoundedVec::try_from(vec![NodeId::Offchain(a)]).expect("valid");
1411
1412        let routing = DestinationRouting::Forward {
1413            destination: Box::new(NodeId::Offchain(dest)),
1414            pseudonym: None,
1415            forward_options: RoutingOptions::IntermediatePath(intermediate_path),
1416            return_options: None,
1417        };
1418
1419        let result = planner.resolve_routing(100, 0, routing).await;
1420        assert!(result.is_ok(), "explicit path should succeed: {:?}", result.err());
1421
1422        let (resolved, _) = result.unwrap();
1423        if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
1424            assert_eq!(forward_path.num_hops(), 2, "one intermediate + destination = 2 hops");
1425        } else {
1426            panic!("expected Forward routing");
1427        }
1428    }
1429
1430    #[tokio::test]
1431    async fn return_routing_without_surb_should_return_error() {
1432        let me = pubkey(&SECRET_ME);
1433        let graph = ChannelGraph::new(me);
1434        let cfg = small_config();
1435        let selector = HoprGraphPathSelector::new(
1436            me,
1437            graph,
1438            cfg.max_cached_paths,
1439            cfg.edge_penalty,
1440            cfg.min_ack_rate,
1441            cfg.min_paths_anonymity_floor,
1442        );
1443        let chain_api = TestChainApi::new(me, me_addr(), vec![]);
1444        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1445
1446        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1447
1448        use hopr_api::types::internal::routing::SurbMatcher;
1449        let matcher = SurbMatcher::Pseudonym(HoprPseudonym::random());
1450        let routing = DestinationRouting::Return(matcher);
1451
1452        let result = planner.resolve_routing(0, 0, routing).await;
1453        assert!(result.is_err(), "should fail when no SURB exists");
1454        assert!(
1455            matches!(result.unwrap_err(), PathPlannerError::Surb(_)),
1456            "error should be Surb variant"
1457        );
1458    }
1459
1460    // ── test: cache integration ───────────────────────────────────────────────
1461
1462    /// Builds a planner over `me -> a -> dest` with both channels open.
1463    fn diversity_planner(
1464        cfg: PathPlannerConfig,
1465    ) -> PathPlanner<hopr_protocol_hopr::MemorySurbStore, TestChainApi, HoprGraphPathSelector<ChannelGraph>> {
1466        let (me, a, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_A), pubkey(&SECRET_DEST));
1467
1468        let graph = ChannelGraph::new(me);
1469        graph.add_node(a);
1470        graph.add_node(dest);
1471        graph.add_edge(&me, &a).unwrap();
1472        graph.add_edge(&a, &dest).unwrap();
1473        graph.add_edge(&dest, &a).unwrap();
1474        graph.add_edge(&a, &me).unwrap();
1475        for (from, to) in [(me, a), (a, dest), (dest, a), (a, me)] {
1476            mark_edge_full(&graph, &from, &to);
1477        }
1478
1479        let selector = HoprGraphPathSelector::new(
1480            me,
1481            graph,
1482            cfg.max_cached_paths,
1483            cfg.edge_penalty,
1484            cfg.min_ack_rate,
1485            cfg.min_paths_anonymity_floor,
1486        );
1487        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1488            .with_open_channel(me_addr(), a_addr())
1489            .with_open_channel(a_addr(), dest_addr())
1490            .with_open_channel(dest_addr(), a_addr())
1491            .with_open_channel(a_addr(), me_addr());
1492
1493        PathPlanner::new(
1494            me,
1495            hopr_protocol_hopr::MemorySurbStore::default(),
1496            chain_api,
1497            selector,
1498            cfg,
1499        )
1500    }
1501
1502    #[tokio::test]
1503    async fn resolve_diverse_return_paths_should_return_empty_without_querying_when_none_requested() {
1504        let planner = diversity_planner(small_config());
1505        let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1506        let hops = RoutingOptions::Hops(1.try_into().expect("valid 1"));
1507
1508        // `max_surbs_with_message` returns 0 when the message fills the payload. That must yield an
1509        // empty result, not a `PathNotFound` — and must not populate the cache.
1510        let paths = planner
1511            .resolve_diverse_return_paths(dest, hops, 0)
1512            .await
1513            .expect("zero return paths is not an error");
1514        assert!(paths.is_empty());
1515
1516        // Keyed on resolved offchain keys, so the test names the nodes the same way.
1517        let dest_key = match dest {
1518            NodeId::Offchain(k) => k,
1519            NodeId::Chain(_) => unreachable!("the fixture names the destination by its packet key"),
1520        };
1521        let cache_key: PlannerCacheKey = (dest_key, pubkey(&SECRET_ME), 1);
1522        assert!(
1523            planner.cache.get(&cache_key).await.is_none(),
1524            "nothing requested must not query the planner"
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn resolve_diverse_return_paths_should_return_count_paths_at_any_temper() {
1530        // Tempering changes *which* candidates are favoured, never how many paths come back — the
1531        // caller has already sized the batch to the SURBs that fit in its packet.
1532        for temper in [1.0, 0.5, 0.05] {
1533            let planner = diversity_planner(PathPlannerConfig {
1534                return_path_weight_temper: temper,
1535                ..small_config()
1536            });
1537            let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1538            let hops = RoutingOptions::Hops(1.try_into().expect("valid 1"));
1539
1540            let paths = planner
1541                .resolve_diverse_return_paths(dest, hops, 3)
1542                .await
1543                .expect("weighted resolution should succeed");
1544            assert_eq!(3, paths.len(), "temper={temper} must still return `count` paths");
1545        }
1546    }
1547
1548    #[tokio::test]
1549    async fn resolve_diverse_return_paths_should_fall_back_for_routing_without_alternatives() {
1550        let planner = diversity_planner(small_config());
1551        let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1552
1553        // A direct return has no relayer to spread over.
1554        let direct = planner
1555            .resolve_diverse_return_paths(dest, RoutingOptions::Hops(0.try_into().expect("valid 0")), 2)
1556            .await
1557            .expect("zero-hop return should resolve");
1558        assert_eq!(2, direct.len());
1559
1560        // An explicit path resolves to itself.
1561        let explicit = planner
1562            .resolve_diverse_return_paths(
1563                dest,
1564                RoutingOptions::IntermediatePath(vec![NodeId::Offchain(pubkey(&SECRET_A))].try_into().expect("valid")),
1565                2,
1566            )
1567            .await
1568            .expect("explicit return path should resolve");
1569        assert_eq!(2, explicit.len());
1570    }
1571
1572    #[tokio::test]
1573    async fn resolve_diverse_return_paths_should_return_the_requested_count() {
1574        let planner = diversity_planner(small_config());
1575        let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1576        let hops = RoutingOptions::Hops(1.try_into().expect("valid 1"));
1577
1578        // Fewer paths than the configured diversity: capped to `count`, still exactly `count` paths.
1579        for count in [1usize, 2, 5] {
1580            let paths = planner
1581                .resolve_diverse_return_paths(dest, hops.clone(), count)
1582                .await
1583                .expect("should resolve");
1584            assert_eq!(count, paths.len(), "count={count}");
1585        }
1586    }
1587
1588    #[tokio::test]
1589    async fn planner_cache_miss_should_populate_cache() {
1590        let me = pubkey(&SECRET_ME);
1591        let a = pubkey(&SECRET_A);
1592        let dest = pubkey(&SECRET_DEST);
1593
1594        let graph = ChannelGraph::new(me);
1595        graph.add_node(a);
1596        graph.add_node(dest);
1597        graph.add_edge(&me, &a).unwrap();
1598        graph.add_edge(&a, &dest).unwrap();
1599        mark_edge_full(&graph, &me, &a);
1600        mark_edge_full(&graph, &a, &dest);
1601
1602        let cfg = small_config();
1603        let selector = HoprGraphPathSelector::new(
1604            me,
1605            graph,
1606            cfg.max_cached_paths,
1607            cfg.edge_penalty,
1608            cfg.min_ack_rate,
1609            cfg.min_paths_anonymity_floor,
1610        );
1611        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1612            .with_open_channel(me_addr(), a_addr())
1613            .with_open_channel(a_addr(), dest_addr());
1614        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1615        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1616
1617        let cache_key: PlannerCacheKey = (me, dest, 1);
1618
1619        assert!(
1620            planner.cache.get(&cache_key).await.is_none(),
1621            "cache should be empty before first call"
1622        );
1623
1624        let routing = DestinationRouting::Forward {
1625            destination: Box::new(NodeId::Offchain(dest)),
1626            pseudonym: None,
1627            forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
1628            return_options: None,
1629        };
1630        planner.resolve_routing(100, 0, routing).await.expect("should succeed");
1631
1632        let cached = planner.cache.get(&cache_key).await;
1633        assert!(cached.is_some(), "cache should be populated after first call");
1634        let paths = cached.unwrap();
1635        assert!(!paths.is_empty(), "cached paths must be non-empty");
1636        let (first_path, first_cost) = paths.iter().next().expect("at least one cached path");
1637        assert_eq!(first_path.num_hops(), 2, "path should have 2 hops [a, dest]");
1638        assert!(*first_cost > 0.0, "cost should be positive");
1639    }
1640
1641    #[tokio::test]
1642    async fn planner_cache_hit_should_return_valid_path() {
1643        let me = pubkey(&SECRET_ME);
1644        let a = pubkey(&SECRET_A);
1645        let dest = pubkey(&SECRET_DEST);
1646
1647        let graph = ChannelGraph::new(me);
1648        graph.add_node(a);
1649        graph.add_node(dest);
1650        graph.add_edge(&me, &a).unwrap();
1651        graph.add_edge(&a, &dest).unwrap();
1652        mark_edge_full(&graph, &me, &a);
1653        mark_edge_full(&graph, &a, &dest);
1654
1655        let cfg = small_config();
1656        let selector = HoprGraphPathSelector::new(
1657            me,
1658            graph,
1659            cfg.max_cached_paths,
1660            cfg.edge_penalty,
1661            cfg.min_ack_rate,
1662            cfg.min_paths_anonymity_floor,
1663        );
1664        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1665            .with_open_channel(me_addr(), a_addr())
1666            .with_open_channel(a_addr(), dest_addr());
1667        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1668        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1669
1670        let make_routing = || DestinationRouting::Forward {
1671            destination: Box::new(NodeId::Offchain(dest)),
1672            pseudonym: None,
1673            forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
1674            return_options: None,
1675        };
1676
1677        let (r1, _) = planner.resolve_routing(100, 0, make_routing()).await.expect("call 1");
1678        let (r2, _) = planner.resolve_routing(100, 0, make_routing()).await.expect("call 2");
1679
1680        let hops1 = if let ResolvedTransportRouting::Forward { forward_path, .. } = r1 {
1681            forward_path.num_hops()
1682        } else {
1683            panic!("expected Forward");
1684        };
1685        let hops2 = if let ResolvedTransportRouting::Forward { forward_path, .. } = r2 {
1686            forward_path.num_hops()
1687        } else {
1688            panic!("expected Forward");
1689        };
1690        assert_eq!(hops1, 2);
1691        assert_eq!(hops2, 2);
1692    }
1693
1694    #[tokio::test]
1695    async fn background_refresh_should_produce_a_future() {
1696        let me = pubkey(&SECRET_ME);
1697        let graph = ChannelGraph::new(me);
1698        let cfg = small_config();
1699        let selector = HoprGraphPathSelector::new(
1700            me,
1701            graph,
1702            cfg.max_cached_paths,
1703            cfg.edge_penalty,
1704            cfg.min_ack_rate,
1705            cfg.min_paths_anonymity_floor,
1706        );
1707        let chain_api = TestChainApi::new(me, me_addr(), vec![]);
1708        let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1709
1710        let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1711        // Just ensure it compiles and produces a future.
1712        let _future = planner.run_background_refresh();
1713    }
1714
1715    // ── recomputing one cache entry in place ──────────────────────────────────
1716
1717    type TestPlanner =
1718        PathPlanner<hopr_protocol_hopr::MemorySurbStore, TestChainApi, HoprGraphPathSelector<ChannelGraph>>;
1719
1720    /// Return-path fixture with two interchangeable relayers: `dest -> {A, B} -> me`.
1721    ///
1722    /// Both start fully observed and identically good, so any later divergence in their share of
1723    /// the return draws is attributable to what the test recorded and not to the fixture.
1724    ///
1725    /// The graph is returned alongside the planner because it is the handle a test needs to move
1726    /// the evidence *under* an already-populated cache -- which is the whole point of the primitive
1727    /// under test.
1728    fn two_relayer_return_planner() -> (TestPlanner, ChannelGraph) {
1729        two_relayer_return_planner_with_floor(small_config().min_paths_anonymity_floor)
1730    }
1731
1732    /// A `me <- {a,b} <- dest` return topology with the given anonymity floor, so a test can watch
1733    /// the cap collapse the two relayers to one (or, at floor 0, keep both).
1734    fn two_relayer_return_planner_with_floor(floor: usize) -> (TestPlanner, ChannelGraph) {
1735        let me = pubkey(&SECRET_ME);
1736        let a = pubkey(&SECRET_A);
1737        let b = pubkey(&SECRET_B);
1738        let dest = pubkey(&SECRET_DEST);
1739
1740        let graph = ChannelGraph::new(me);
1741        for node in [a, b, dest] {
1742            graph.add_node(node);
1743        }
1744        for (src, dst) in [(dest, a), (a, me), (dest, b), (b, me)] {
1745            graph.add_edge(&src, &dst).expect("edge should be addable");
1746            mark_edge_full(&graph, &src, &dst);
1747        }
1748
1749        let cfg = PathPlannerConfig {
1750            min_paths_anonymity_floor: floor,
1751            ..small_config()
1752        };
1753        let selector = HoprGraphPathSelector::new(
1754            me,
1755            graph.clone(),
1756            cfg.max_cached_paths,
1757            cfg.edge_penalty,
1758            cfg.min_ack_rate,
1759            cfg.min_paths_anonymity_floor,
1760        );
1761        let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (b, b_addr()), (dest, dest_addr())])
1762            .with_open_channel(dest_addr(), a_addr())
1763            .with_open_channel(a_addr(), me_addr())
1764            .with_open_channel(dest_addr(), b_addr())
1765            .with_open_channel(b_addr(), me_addr());
1766
1767        let planner = PathPlanner::new(
1768            me,
1769            hopr_protocol_hopr::MemorySurbStore::default(),
1770            chain_api,
1771            selector,
1772            cfg,
1773        );
1774        (planner, graph)
1775    }
1776
1777    /// Same `me <- {a,b} <- dest` return topology as [`two_relayer_return_planner_with_floor`], but
1778    /// `unresolvable` is left out of the chain resolver's key-to-address map -- present in the graph,
1779    /// so the selector still offers it as a candidate relayer, but `ValidatedPath::new` cannot map it
1780    /// to a chain address and drops every path through it. Floor 0, so pruning does not also remove
1781    /// it before validation gets the chance to.
1782    fn two_relayer_return_planner_with_unresolvable_relayer(
1783        unresolvable: OffchainPublicKey,
1784    ) -> (TestPlanner, ChannelGraph) {
1785        let me = pubkey(&SECRET_ME);
1786        let a = pubkey(&SECRET_A);
1787        let b = pubkey(&SECRET_B);
1788        let dest = pubkey(&SECRET_DEST);
1789
1790        let graph = ChannelGraph::new(me);
1791        for node in [a, b, dest] {
1792            graph.add_node(node);
1793        }
1794        for (src, dst) in [(dest, a), (a, me), (dest, b), (b, me)] {
1795            graph.add_edge(&src, &dst).expect("edge should be addable");
1796            mark_edge_full(&graph, &src, &dst);
1797        }
1798
1799        let cfg = PathPlannerConfig {
1800            min_paths_anonymity_floor: 0,
1801            ..small_config()
1802        };
1803        let selector = HoprGraphPathSelector::new(
1804            me,
1805            graph.clone(),
1806            cfg.max_cached_paths,
1807            cfg.edge_penalty,
1808            cfg.min_ack_rate,
1809            cfg.min_paths_anonymity_floor,
1810        );
1811        let resolvable_peers = [(a, a_addr()), (b, b_addr()), (dest, dest_addr())]
1812            .into_iter()
1813            .filter(|(key, _)| *key != unresolvable)
1814            .collect::<Vec<_>>();
1815        let chain_api = TestChainApi::new(me, me_addr(), resolvable_peers)
1816            .with_open_channel(dest_addr(), a_addr())
1817            .with_open_channel(a_addr(), me_addr())
1818            .with_open_channel(dest_addr(), b_addr())
1819            .with_open_channel(b_addr(), me_addr());
1820
1821        let planner = PathPlanner::new(
1822            me,
1823            hopr_protocol_hopr::MemorySurbStore::default(),
1824            chain_api,
1825            selector,
1826            cfg,
1827        );
1828        (planner, graph)
1829    }
1830
1831    /// Populates the return-path cache the way a Session does and hands back the cached entry.
1832    async fn fill_return_cache(planner: &TestPlanner) -> PlannerCacheValue {
1833        planner
1834            .resolve_diverse_return_paths(
1835                NodeId::Offchain(pubkey(&SECRET_DEST)),
1836                RoutingOptions::Hops(1.try_into().expect("valid 1")),
1837                1,
1838            )
1839            .await
1840            .expect("return path resolution should succeed");
1841        planner
1842            .cache
1843            .get(&(pubkey(&SECRET_DEST), pubkey(&SECRET_ME), 1))
1844            .await
1845            .expect("the return path should be cached after resolution")
1846    }
1847
1848    /// Share of the collection's total weight held by candidates whose first hop is `relayer`.
1849    ///
1850    /// This -- not the raw weight -- is what decides how much of a session's return stream rides on
1851    /// one relay, because the draw normalises over the collection.
1852    fn share_of(paths: &PlannerCacheValue, relayer: &OffchainPublicKey) -> f64 {
1853        let total: f64 = paths.iter().map(|(_, w)| *w).sum();
1854        let held: f64 = paths
1855            .iter()
1856            .filter(|(vp, _)| vp.first() == Some(relayer))
1857            .map(|(_, w)| *w)
1858            .sum();
1859        if total > 0.0 { held / total } else { 0.0 }
1860    }
1861
1862    /// The candidate paths as stable strings, weights discarded.
1863    fn candidate_set(paths: &PlannerCacheValue) -> std::collections::BTreeSet<String> {
1864        paths.iter().map(|(vp, _)| vp.to_string()).collect()
1865    }
1866
1867    /// Records `rounds` SURB round-trips on the edge, of which `observed` per round came back.
1868    fn record_surbs(
1869        graph: &ChannelGraph,
1870        src: &OffchainPublicKey,
1871        dst: &OffchainPublicKey,
1872        expected: u64,
1873        observed: u64,
1874    ) {
1875        use hopr_api::graph::traits::EdgeWeightType;
1876        graph.upsert_edge(src, dst, |obs| {
1877            obs.record(EdgeWeightType::SurbRoundTrips { expected, observed });
1878        });
1879    }
1880
1881    /// A relay that stops delivering must lose its share of the return draws *without* the
1882    /// candidate set changing underneath the live session.
1883    ///
1884    /// Both halves matter and they pull against each other: dropping the candidates is what
1885    /// collapsed a healthy session from 100 % to 0.14 % in the cluster, while leaving the weights
1886    /// alone is what kept minting SURBs onto a dead relay for a full refresh period.
1887    #[tokio::test]
1888    async fn recomputing_an_entry_should_reweight_the_candidates_without_changing_the_set() {
1889        let (planner, graph) = two_relayer_return_planner();
1890        let (me, b, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_B), pubkey(&SECRET_DEST));
1891
1892        let before = fill_return_cache(&planner).await;
1893        let set_before = candidate_set(&before);
1894        let share_before = share_of(&before, &b);
1895
1896        assert_eq!(
1897            set_before.len(),
1898            2,
1899            "the fixture must offer both relayers as candidates"
1900        );
1901        assert!(
1902            share_before > 0.2,
1903            "vacuity guard: B must hold a material share before the collapse, held {share_before}"
1904        );
1905
1906        // A delivers, B stops. Same interval, so the contrast is in the evidence and not in time.
1907        record_surbs(&graph, &dest, &pubkey(&SECRET_A), 1_000, 1_000);
1908        record_surbs(&graph, &dest, &b, 1_000, 1_000);
1909        record_surbs(&graph, &dest, &b, 4_000, 0);
1910
1911        let replaced = planner.recompute_paths_from(&dest).await;
1912        assert_eq!(
1913            replaced, 1,
1914            "exactly the one cached return entry should have been rebuilt"
1915        );
1916
1917        let after = planner
1918            .cache
1919            .get(&(dest, me, 1))
1920            .await
1921            .expect("the entry must still exist after a recompute");
1922
1923        assert_eq!(
1924            candidate_set(&after),
1925            set_before,
1926            "a recompute must re-weight the candidates, never replace the set"
1927        );
1928
1929        let share_after = share_of(&after, &b);
1930        assert!(
1931            share_after < share_before,
1932            "the relay that stopped delivering must lose share: {share_before} -> {share_after}"
1933        );
1934    }
1935
1936    /// A recompute that finds nothing must leave the previous candidates in place.
1937    ///
1938    /// This is the property that separates recomputation from invalidation. A live session draws
1939    /// its next return path from this entry, so an empty result has to mean "no better information"
1940    /// rather than "no route".
1941    #[tokio::test]
1942    async fn recomputing_an_entry_should_keep_the_old_candidates_when_it_finds_none() {
1943        let (planner, graph) = two_relayer_return_planner();
1944        let (me, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_DEST));
1945
1946        let before = fill_return_cache(&planner).await;
1947        let set_before = candidate_set(&before);
1948
1949        // Total blackout: every route out of the destination disappears from the graph, so the
1950        // selector can no longer offer any candidate at all.
1951        for relay in [pubkey(&SECRET_A), pubkey(&SECRET_B)] {
1952            graph.remove_edge(&dest, &relay);
1953        }
1954
1955        let replaced = planner.recompute_paths_from(&dest).await;
1956        assert_eq!(replaced, 0, "a recompute that finds nothing must replace nothing");
1957
1958        let after = planner
1959            .cache
1960            .get(&(dest, me, 1))
1961            .await
1962            .expect("an entry must never be dropped by a recompute that found nothing");
1963        assert_eq!(
1964            candidate_set(&after),
1965            set_before,
1966            "the previous candidates must survive a fruitless recompute"
1967        );
1968    }
1969
1970    /// A recompute is addressed at one counterparty, so it must not sweep the whole cache.
1971    ///
1972    /// Rebuilding every entry on every detection would put the cost of one dead relay onto every
1973    /// other session the node is carrying.
1974    /// A recompute that lands on the same weights has moved no traffic, and the caller uses that
1975    /// answer to decide whether refilling is worth anything.
1976    ///
1977    /// Reporting the number of entries *rebuilt* instead would say "moved" every time, since a
1978    /// rebuild always succeeds on a healthy graph -- and refilling behind a re-plan that changed
1979    /// nothing just mints more SURBs onto the same route.
1980    #[tokio::test]
1981    async fn a_recompute_that_lands_on_the_same_weights_should_report_nothing_moved() {
1982        let (planner, graph) = two_relayer_return_planner();
1983        let dest = pubkey(&SECRET_DEST);
1984
1985        let _ = fill_return_cache(&planner).await;
1986
1987        assert_eq!(
1988            planner.recompute_paths_from(&dest).await,
1989            0,
1990            "nothing about the graph changed, so no traffic can have moved"
1991        );
1992
1993        // Vacuity guard: the same call must report movement once the evidence actually shifts.
1994        // B has to deliver first -- the rate is read against a peak, so a relayer that never had
1995        // one has no rate to fall from and the collapse would be invisible.
1996        record_surbs(&graph, &dest, &pubkey(&SECRET_A), 1_000, 1_000);
1997        record_surbs(&graph, &dest, &pubkey(&SECRET_B), 1_000, 1_000);
1998        record_surbs(&graph, &dest, &pubkey(&SECRET_B), 4_000, 0);
1999        assert_eq!(
2000            planner.recompute_paths_from(&dest).await,
2001            1,
2002            "a collapse on one relayer must register as moved"
2003        );
2004    }
2005
2006    /// End-to-end proof of the fix: a disabled cap keeps every return relayer, a cap of one
2007    /// collapses the stream onto a single relayer. The latter is the blind spot the degradation
2008    /// detector cannot escape, and the shape of the 2026-08-28 outage; the former is what the
2009    /// edge-client `latency_path_planner_config` now requests (`min_paths_anonymity_floor = 0`).
2010    #[tokio::test]
2011    async fn floor_zero_keeps_every_return_relayer_but_a_cap_of_one_collapses_it() {
2012        let (a, b) = (pubkey(&SECRET_A), pubkey(&SECRET_B));
2013
2014        let (uncapped, _g) = two_relayer_return_planner_with_floor(0);
2015        let paths = fill_return_cache(&uncapped).await;
2016        assert_eq!(2, candidate_set(&paths).len(), "floor 0 keeps both return relayers");
2017        assert!(
2018            share_of(&paths, &a) > 0.0,
2019            "relayer a carries part of the return stream"
2020        );
2021        assert!(
2022            share_of(&paths, &b) > 0.0,
2023            "relayer b carries part of the return stream"
2024        );
2025
2026        let (capped, _g) = two_relayer_return_planner_with_floor(1);
2027        let paths = fill_return_cache(&capped).await;
2028        assert_eq!(1, candidate_set(&paths).len(), "floor 1 collapses to a single relayer");
2029    }
2030
2031    /// Regression for the diversity check firing on the wrong set: the selector's raw candidates
2032    /// see both relayers, but chain validation rejects every path through `b` (its key never
2033    /// resolves to a chain address), so what a session can actually draw from has collapsed to `a`
2034    /// alone. A diversity check computed on the selector's pre-validation output would have missed
2035    /// this entirely -- it must be computed on the validated survivors, which is what
2036    /// `rebuild_candidates` now does.
2037    #[tokio::test]
2038    async fn validation_rejecting_one_relayer_should_collapse_diversity_even_though_the_selector_saw_two() {
2039        let (a, b) = (pubkey(&SECRET_A), pubkey(&SECRET_B));
2040
2041        let (planner, _g) = two_relayer_return_planner_with_unresolvable_relayer(b);
2042        let paths = fill_return_cache(&planner).await;
2043
2044        assert_eq!(
2045            1,
2046            candidate_set(&paths).len(),
2047            "only the relayer that resolves on-chain should survive validation"
2048        );
2049        assert!(
2050            share_of(&paths, &a) > 0.0,
2051            "the resolvable relayer must carry the whole return stream"
2052        );
2053        assert_eq!(
2054            0.0,
2055            share_of(&paths, &b),
2056            "the unresolvable relayer must not appear among the validated candidates"
2057        );
2058    }
2059
2060    #[tokio::test]
2061    async fn recomputing_should_rebuild_only_the_entries_originating_at_that_peer() {
2062        let (planner, graph) = two_relayer_return_planner();
2063        let (me, a, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_A), pubkey(&SECRET_DEST));
2064
2065        let _ = fill_return_cache(&planner).await;
2066        // Something has to actually move, or the count below would be zero for the wrong reason.
2067        record_surbs(&graph, &dest, &a, 1_000, 1_000);
2068        record_surbs(&graph, &dest, &pubkey(&SECRET_B), 1_000, 1_000);
2069        record_surbs(&graph, &dest, &pubkey(&SECRET_B), 4_000, 0);
2070        // A second entry in the other direction, which no `dest` recompute may touch.
2071        let _ = planner
2072            .cached_paths(
2073                NodeId::Offchain(me),
2074                NodeId::Offchain(a),
2075                1.try_into().expect("valid 1"),
2076            )
2077            .await;
2078
2079        assert_eq!(
2080            planner.recompute_paths_from(&dest).await,
2081            1,
2082            "only the entry whose paths start at the named peer should be re-weighted"
2083        );
2084        assert_eq!(
2085            planner.recompute_paths_from(&pubkey(&SECRET_B)).await,
2086            0,
2087            "a peer with no cached entries of its own must rebuild nothing"
2088        );
2089    }
2090
2091    // ── composite weight helpers ──────────────────────────────────────────────
2092
2093    fn default_weighting() -> WeightingParams {
2094        WeightingParams {
2095            latency_halflife: Duration::from_millis(100),
2096            capacity_reference: 10_000_000,
2097        }
2098    }
2099
2100    fn make_pwm(cost: f64, latency_ms: Option<u32>, fundable_tickets_floor: Option<u128>) -> PathWithMetrics {
2101        PathWithMetrics {
2102            path: vec![],
2103            cost,
2104            total_latency_ms: latency_ms,
2105            min_probe_success_rate: None,
2106            min_ack_rate: None,
2107            fundable_tickets_floor,
2108        }
2109    }
2110
2111    #[test]
2112    fn latency_factor_is_monotonic_decreasing() {
2113        let halflife = Duration::from_millis(100);
2114        let f0 = latency_factor(Duration::ZERO, halflife);
2115        let f100 = latency_factor(Duration::from_millis(100), halflife);
2116        let f200 = latency_factor(Duration::from_millis(200), halflife);
2117        assert!(
2118            f0 > f100 && f100 > f200,
2119            "must be strictly decreasing: {f0} > {f100} > {f200}"
2120        );
2121        assert!(
2122            (f100 - 0.5).abs() < 1e-9,
2123            "at halflife factor should be 0.5, got {f100}"
2124        );
2125        assert!(f0 <= 1.0, "factor must never exceed 1.0, got {f0}");
2126    }
2127
2128    #[test]
2129    fn capacity_factor_is_monotonic_increasing() {
2130        let reference = 10_000_000u128;
2131        let f_low = capacity_factor(100, reference);
2132        let f_mid = capacity_factor(1_000_000, reference);
2133        let f_ref = capacity_factor(reference, reference);
2134        assert!(
2135            f_low < f_mid && f_mid <= f_ref,
2136            "must be non-decreasing: {f_low} < {f_mid} <= {f_ref}"
2137        );
2138        assert!(f_ref <= 1.0, "factor must not exceed 1.0 at reference, got {f_ref}");
2139        assert!(f_low >= 0.05, "minimum clamp is 0.05, got {f_low}");
2140    }
2141
2142    #[test]
2143    fn composite_weight_for_0_hop_skips_capacity_factor() {
2144        let params = default_weighting();
2145        let pwm = make_pwm(0.6, Some(100), None);
2146        let w = composite_weight(&pwm, 0, params);
2147        let expected = 0.6 * latency_factor(Duration::from_millis(100), params.latency_halflife);
2148        assert!(
2149            (w - expected).abs() < 1e-9,
2150            "0-hop weight should ignore capacity: {w} != {expected}"
2151        );
2152        assert!(w > 0.0, "0-hop weight must be positive");
2153    }
2154
2155    #[test]
2156    fn composite_weight_with_all_aggregates_is_below_cost() {
2157        let params = default_weighting();
2158        let pwm = make_pwm(0.8, Some(150), Some(5_000_000));
2159        let w = composite_weight(&pwm, 1, params);
2160        assert!(
2161            w < pwm.cost,
2162            "composite must be below raw cost when factors < 1.0: {w} >= {}",
2163            pwm.cost
2164        );
2165        assert!(w > 0.0, "composite weight must be positive");
2166    }
2167
2168    #[test]
2169    fn composite_weight_missing_capacity_on_multi_hop_neutral() {
2170        let params = default_weighting();
2171        let pwm_with = make_pwm(0.7, Some(80), Some(8_000_000));
2172        let pwm_without = make_pwm(0.7, Some(80), None);
2173        let w_with = composite_weight(&pwm_with, 2, params);
2174        let w_without = composite_weight(&pwm_without, 2, params);
2175        // Missing capacity is neutral (1.0), so without-capacity weight equals cost * lat only.
2176        let expected_without = 0.7 * latency_factor(Duration::from_millis(80), params.latency_halflife);
2177        assert!((w_without - expected_without).abs() < 1e-9);
2178        // With capacity the factor adds further reduction (capacity_factor < 1.0 here).
2179        assert!(w_with <= w_without, "known capacity should not increase the weight");
2180    }
2181}