Skip to main content

hopr_network_graph/petgraph/
update.rs

1use hopr_api::graph::{MeasurableEdge, MeasurableNode, NetworkGraphWrite, traits::EdgeObservableWrite};
2#[cfg(all(feature = "telemetry", not(test)))]
3use hopr_api::graph::{NetworkGraphView, traits::EdgeObservableRead};
4use petgraph::graph::{EdgeIndex, NodeIndex};
5
6use crate::{ChannelGraph, Observations, graph::InnerGraph};
7
8#[cfg(all(feature = "telemetry", not(test)))]
9lazy_static::lazy_static! {
10    static ref METRIC_PEERS_BY_QUALITY: hopr_api::types::telemetry::SimpleHistogram =
11        hopr_api::types::telemetry::SimpleHistogram::new(
12            "hopr_peers_by_quality",
13            "Distribution of the quality score of the node's directly-probed neighbors",
14            vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
15        )
16        .unwrap();
17}
18
19/// Records the current quality score of a directly-probed neighbor into the
20/// `hopr_peers_by_quality` histogram. Called after each successful or failed neighbor probe
21/// so the distribution tracks quality as it evolves per probe round.
22#[cfg(all(feature = "telemetry", not(test)))]
23fn observe_neighbor_quality(graph: &ChannelGraph, peer: &hopr_api::OffchainPublicKey) {
24    if let Some(obs) = graph.edge(graph.me(), peer) {
25        METRIC_PEERS_BY_QUALITY.observe(obs.score());
26    }
27}
28
29/// Resolves a loopback path from serialized node-index bytes into a validated chain of edge indices.
30///
31/// The `path_bytes` encode a `PathId` where each `u64` is a [`NodeIndex`].
32/// The path is expected to start and end at `me_idx` (a closed loop).
33///
34/// Walks consecutive node pairs, finding the connecting edge for each.
35/// Stops when the loop closes back to `me_idx` or when no edge exists
36/// between a pair. Returns `None` if the path bytes have wrong length,
37/// the first node is not `me_idx`, or fewer than 2 edges can be resolved.
38fn resolve_loopback_edges(inner: &InnerGraph, me_idx: NodeIndex, path_bytes: &[u8]) -> Option<Vec<EdgeIndex>> {
39    if path_bytes.len() != size_of::<hopr_api::ct::PathId>() {
40        tracing::warn!(
41            path_len = path_bytes.len(),
42            expected = size_of::<hopr_api::ct::PathId>(),
43            "invalid loopback path byte length"
44        );
45        return None;
46    }
47
48    let mut path_id = [0u64; 5];
49    for (i, chunk) in path_bytes.chunks_exact(8).enumerate() {
50        path_id[i] = u64::from_le_bytes(chunk.try_into().expect("chunk is 8 bytes"));
51    }
52
53    let me_val = me_idx.index() as u64;
54
55    // First node must be self
56    if path_id[0] != me_val {
57        tracing::warn!("loopback path does not start at self");
58        return None;
59    }
60
61    // Find the closing node: the first reoccurrence of me after position 0
62    let Some(end_pos) = path_id[1..].iter().position(|&v| v == me_val).map(|p| p + 1) else {
63        tracing::warn!("loopback path does not close back to self");
64        return None;
65    };
66
67    // Walk consecutive node pairs up to (and including) the closing node
68    let mut edges = Vec::new();
69
70    for pair in path_id[..=end_pos].windows(2) {
71        let from = NodeIndex::new(pair[0] as usize);
72        let to = NodeIndex::new(pair[1] as usize);
73        let Some(edge) = inner.graph.find_edge(from, to) else {
74            break;
75        };
76        edges.push(edge);
77    }
78
79    if edges.len() < 2 {
80        tracing::warn!(
81            edge_count = edges.len(),
82            "loopback path too short to attribute intermediate measurement"
83        );
84        return None;
85    }
86
87    Some(edges)
88}
89
90impl hopr_api::graph::NetworkGraphUpdate for ChannelGraph {
91    #[tracing::instrument(level = "debug", skip(self, update))]
92    fn record_edge<N, P>(&self, update: MeasurableEdge<N, P>)
93    where
94        N: hopr_api::graph::MeasurablePeer + Send + Clone,
95        P: hopr_api::graph::MeasurablePath + Send + Clone,
96    {
97        use hopr_api::graph::{
98            EdgeLinkObservable,
99            traits::{EdgeObservableRead, EdgeWeightType},
100        };
101
102        match update {
103            MeasurableEdge::Probe(Ok(hopr_api::graph::EdgeTransportTelemetry::Neighbor(ref telemetry))) => {
104                tracing::trace!(
105                    peer = %telemetry.peer(),
106                    latency_ms = telemetry.rtt().as_millis(),
107                    "neighbor probe successful"
108                );
109
110                // Both directions are set for immediate connections, because the graph is directional
111                // and must be directionally complete for looping traffic.
112                self.upsert_edge(&self.me, telemetry.peer(), |obs| {
113                    obs.record(EdgeWeightType::Connected(true));
114                    obs.record(EdgeWeightType::Immediate(Ok(telemetry.rtt() / 2)));
115                });
116                self.upsert_edge(telemetry.peer(), &self.me, |obs| {
117                    obs.record(EdgeWeightType::Connected(true));
118                    obs.record(EdgeWeightType::Immediate(Ok(telemetry.rtt() / 2)));
119                });
120
121                #[cfg(all(feature = "telemetry", not(test)))]
122                observe_neighbor_quality(self, telemetry.peer());
123            }
124            MeasurableEdge::Probe(Ok(hopr_api::graph::EdgeTransportTelemetry::Loopback(telemetry))) => {
125                tracing::trace!("loopback probe successful");
126
127                let mut inner = self.inner.write();
128                let Some(me_idx) = inner.indices.get_by_left(&self.me).copied() else {
129                    tracing::debug!("failed to resolve index of myself for loopback probe attribution");
130                    return;
131                };
132                let Some(edges) = resolve_loopback_edges(&inner, me_idx, telemetry.path()) else {
133                    tracing::debug!("failed to resolve loopback path for probe attribution");
134                    return;
135                };
136
137                let target_idx = edges.len() - 2;
138
139                // Attributed duration = total RTT - sum of all known edge latencies.
140                // For each edge (including the target), use intermediate QoS if available,
141                // otherwise fall back to immediate QoS. The residual is attributed to the
142                // target edge as its new intermediate measurement.
143                //
144                // `timestamp()` is the probe's creation time (unix epoch millis), so the
145                // RTT is the elapsed time until now. A timestamp in the future (backward
146                // clock drift) underflows and is discarded rather than recorded as a
147                // zero-duration RTT. Values above the plausibility cap (clock skew, stale
148                // telemetry) are likewise discarded instead of poisoning the latency EMA.
149                let now_ms = std::time::SystemTime::now()
150                    .duration_since(std::time::UNIX_EPOCH)
151                    .unwrap_or_default()
152                    .as_millis();
153                let Some(elapsed_ms) = now_ms.checked_sub(telemetry.timestamp()) else {
154                    tracing::debug!("loopback probe timestamp in the future, skipping attribution");
155                    return;
156                };
157                let total_rtt = std::time::Duration::from_millis(elapsed_ms as u64);
158                if total_rtt > self.max_plausible_loopback_rtt {
159                    tracing::debug!(
160                        rtt_ms = total_rtt.as_millis(),
161                        "implausible loopback probe RTT, skipping attribution"
162                    );
163                    return;
164                }
165                let mut known_latency = std::time::Duration::ZERO;
166
167                for &edge in &edges {
168                    if let Some(weight) = inner.graph.edge_weight(edge) {
169                        let lat = weight
170                            .intermediate_qos()
171                            .and_then(|q| q.average_latency())
172                            .or_else(|| weight.immediate_qos().and_then(|q| q.average_latency()));
173                        if let Some(lat) = lat {
174                            known_latency += lat;
175                        }
176                    } else {
177                        tracing::debug!("failed to find edge for loopback probe attribution");
178                    }
179                }
180
181                let attributed_duration = total_rtt.saturating_sub(known_latency);
182
183                tracing::trace!(
184                    target_edge = edges[target_idx].index(),
185                    attributed_ms = attributed_duration.as_millis(),
186                    total_rtt_ms = total_rtt.as_millis(),
187                    path_edges = edges.len(),
188                    "loopback probe attributed to intermediate edge"
189                );
190
191                if let Some(weight) = inner.graph.edge_weight_mut(edges[target_idx]) {
192                    weight.record(EdgeWeightType::Intermediate(Ok(attributed_duration)));
193                } else {
194                    tracing::debug!("failed to find target edge for loopback probe attribution");
195                }
196            }
197            MeasurableEdge::Probe(Err(hopr_api::graph::NetworkGraphError::ProbeNeighborTimeout(ref peer))) => {
198                tracing::trace!(
199                    peer = %peer,
200                    reason = "probe timeout",
201                    "neighbor probe failed"
202                );
203
204                // Both directions are set for immediate connections, because the graph is directional
205                // and must be directionally complete for looping traffic.
206                self.upsert_edge(&self.me, peer, |obs| {
207                    obs.record(EdgeWeightType::Immediate(Err(())));
208                });
209                self.upsert_edge(peer, &self.me, |obs| {
210                    obs.record(EdgeWeightType::Immediate(Err(())));
211                });
212
213                #[cfg(all(feature = "telemetry", not(test)))]
214                observe_neighbor_quality(self, peer);
215            }
216            MeasurableEdge::Probe(Err(hopr_api::graph::NetworkGraphError::ProbeLoopbackTimeout(telemetry))) => {
217                tracing::trace!("loopback probe failed");
218
219                let mut inner = self.inner.write();
220                let Some(me_idx) = inner.indices.get_by_left(&self.me).copied() else {
221                    tracing::debug!("failed to resolve index of myself");
222                    return;
223                };
224                let Some(edges) = resolve_loopback_edges(&inner, me_idx, telemetry.path()) else {
225                    tracing::debug!("failed to resolve loopback path for probe timeout, cannot attribute");
226                    return;
227                };
228
229                let target_idx = edges.len() - 2;
230
231                tracing::trace!(
232                    target_edge = edges[target_idx].index(),
233                    path_edges = edges.len(),
234                    "loopback probe timeout attributed to intermediate edge"
235                );
236
237                if let Some(weight) = inner.graph.edge_weight_mut(edges[target_idx]) {
238                    weight.record(EdgeWeightType::Intermediate(Err(())));
239                }
240            }
241            MeasurableEdge::Capacity(update) => {
242                self.upsert_edge(&update.src, &update.dest, |obs: &mut Observations| {
243                    obs.record(EdgeWeightType::Capacity(update.capacity));
244                });
245            }
246            MeasurableEdge::ConnectionStatus { peer, connected } => {
247                tracing::trace!(
248                    peer = %peer,
249                    connected = connected,
250                    "recording connection status update"
251                );
252
253                self.upsert_edge(&self.me, &peer, |obs| {
254                    obs.record(EdgeWeightType::Connected(connected));
255                });
256                self.upsert_edge(&peer, &self.me, |obs| {
257                    obs.record(EdgeWeightType::Connected(connected));
258                });
259            }
260        }
261    }
262
263    #[tracing::instrument(level = "debug", skip(self, update))]
264    fn record_node<N>(&self, update: N)
265    where
266        N: MeasurableNode + Clone + Send + Sync + 'static,
267    {
268        hopr_api::graph::NetworkGraphWrite::add_node(self, update.into());
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use anyhow::Context;
275    use assertables::assert_in_delta;
276    use hex_literal::hex;
277    use hopr_api::{
278        OffchainPublicKey,
279        graph::{
280            EdgeLinkObservable, EdgeTransportTelemetry, MeasurablePath, MeasurablePeer, NetworkGraphError,
281            NetworkGraphUpdate, NetworkGraphView, NetworkGraphWrite,
282            traits::{EdgeObservableRead, EdgeProtocolObservable},
283        },
284        types::crypto::prelude::{Keypair, OffchainKeypair},
285    };
286
287    use super::*;
288
289    /// Fixed test secret keys (reused from the broader codebase).
290    const SECRET_0: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
291    const SECRET_1: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
292    const SECRET_2: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
293    const SECRET_3: [u8; 32] = hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
294
295    /// Creates an OffchainPublicKey from a fixed secret.
296    fn pubkey_from(secret: &[u8; 32]) -> OffchainPublicKey {
297        *OffchainKeypair::from_secret(secret).expect("valid secret key").public()
298    }
299
300    #[derive(Debug, Clone)]
301    struct TestNeighbor {
302        peer: OffchainPublicKey,
303        rtt: std::time::Duration,
304    }
305
306    impl MeasurablePeer for TestNeighbor {
307        fn peer(&self) -> &OffchainPublicKey {
308            &self.peer
309        }
310
311        fn rtt(&self) -> std::time::Duration {
312            self.rtt
313        }
314    }
315
316    #[derive(Debug, Clone)]
317    struct TestPath;
318
319    impl MeasurablePath for TestPath {
320        fn id(&self) -> &[u8] {
321            &[]
322        }
323
324        fn path(&self) -> &[u8] {
325            &[]
326        }
327
328        fn timestamp(&self) -> u128 {
329            0
330        }
331    }
332
333    #[tokio::test]
334    async fn neighbor_probe_should_update_edge_observation() -> anyhow::Result<()> {
335        let me_kp = OffchainKeypair::from_secret(&SECRET_0)?;
336        let me = *me_kp.public();
337        let peer_kp = OffchainKeypair::from_secret(&SECRET_1)?;
338        let peer_key = *peer_kp.public();
339
340        let graph = ChannelGraph::new(me);
341        graph.add_node(peer_key);
342        graph.add_edge(&me, &peer_key)?;
343
344        let rtt = std::time::Duration::from_millis(100);
345        let telemetry: Result<EdgeTransportTelemetry<TestNeighbor, TestPath>, NetworkGraphError<TestPath>> =
346            Ok(EdgeTransportTelemetry::Neighbor(TestNeighbor { peer: peer_key, rtt }));
347        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
348
349        let obs = graph.edge(&me, &peer_key).context("edge observation should exist")?;
350        let immediate = obs
351            .immediate_qos()
352            .context("immediate QoS should be present after probe")?;
353        assert_eq!(immediate.average_latency().context("latency should be set")?, rtt / 2,);
354        Ok(())
355    }
356
357    #[tokio::test]
358    async fn neighbor_probe_should_create_symmetric_edges() -> anyhow::Result<()> {
359        let me = pubkey_from(&SECRET_0);
360        let peer = pubkey_from(&SECRET_1);
361
362        let graph = ChannelGraph::new(me);
363        graph.add_node(peer);
364        // No edges pre-created — upsert should create both directions
365
366        let rtt = std::time::Duration::from_millis(100);
367        let telemetry: Result<EdgeTransportTelemetry<TestNeighbor, TestPath>, NetworkGraphError<TestPath>> =
368            Ok(EdgeTransportTelemetry::Neighbor(TestNeighbor { peer, rtt }));
369        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
370
371        // me → peer
372        let obs_fwd = graph.edge(&me, &peer).context("edge me→peer should exist")?;
373        let imm_fwd = obs_fwd.immediate_qos().context("me→peer should have immediate QoS")?;
374        assert_eq!(
375            imm_fwd.average_latency().context("me→peer latency should be set")?,
376            rtt / 2
377        );
378
379        // peer → me
380        let obs_rev = graph.edge(&peer, &me).context("edge peer→me should exist")?;
381        let imm_rev = obs_rev.immediate_qos().context("peer→me should have immediate QoS")?;
382        assert_eq!(
383            imm_rev.average_latency().context("peer→me latency should be set")?,
384            rtt / 2
385        );
386
387        Ok(())
388    }
389
390    #[tokio::test]
391    async fn neighbor_probe_timeout_should_create_symmetric_edges() -> anyhow::Result<()> {
392        let me = pubkey_from(&SECRET_0);
393        let peer = pubkey_from(&SECRET_1);
394
395        let graph = ChannelGraph::new(me);
396        graph.add_node(peer);
397        // No edges pre-created
398
399        let telemetry: Result<EdgeTransportTelemetry<TestNeighbor, TestPath>, NetworkGraphError<TestPath>> =
400            Err(NetworkGraphError::ProbeNeighborTimeout(Box::new(peer)));
401        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
402
403        // me → peer
404        let obs_fwd = graph
405            .edge(&me, &peer)
406            .context("edge me→peer should exist after timeout")?;
407        let imm_fwd = obs_fwd.immediate_qos().context("me→peer should have immediate QoS")?;
408        assert!(
409            imm_fwd.average_latency().is_none(),
410            "failed probe should not set latency"
411        );
412        assert!(
413            imm_fwd.average_probe_rate() < 1.0,
414            "failed probe should lower success rate"
415        );
416
417        // peer → me
418        let obs_rev = graph
419            .edge(&peer, &me)
420            .context("edge peer→me should exist after timeout")?;
421        let imm_rev = obs_rev.immediate_qos().context("peer→me should have immediate QoS")?;
422        assert!(
423            imm_rev.average_latency().is_none(),
424            "failed probe should not set latency on reverse"
425        );
426        assert!(
427            imm_rev.average_probe_rate() < 1.0,
428            "failed probe should lower success rate on reverse"
429        );
430
431        Ok(())
432    }
433
434    #[tokio::test]
435    async fn probe_timeout_should_record_as_failed_probe() -> anyhow::Result<()> {
436        let me_kp = OffchainKeypair::from_secret(&SECRET_0)?;
437        let me = *me_kp.public();
438        let peer_kp = OffchainKeypair::from_secret(&SECRET_1)?;
439        let peer_key = *peer_kp.public();
440
441        let graph = ChannelGraph::new(me);
442        graph.add_node(peer_key);
443        graph.add_edge(&me, &peer_key)?;
444
445        let telemetry: Result<EdgeTransportTelemetry<TestNeighbor, TestPath>, NetworkGraphError<TestPath>> =
446            Err(NetworkGraphError::ProbeNeighborTimeout(Box::new(peer_key)));
447        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
448
449        let obs = graph.edge(&me, &peer_key).context("edge observation should exist")?;
450        let immediate = obs
451            .immediate_qos()
452            .context("immediate QoS should be present after failed probe")?;
453        assert!(immediate.average_latency().is_none());
454        assert!(immediate.average_probe_rate() < 1.0);
455        Ok(())
456    }
457
458    #[tokio::test]
459    async fn capacity_update_should_set_edge_capacity() -> anyhow::Result<()> {
460        let me = pubkey_from(&SECRET_0);
461        let peer = pubkey_from(&SECRET_1);
462        let graph = ChannelGraph::new(me);
463        graph.add_node(peer);
464        graph.add_edge(&me, &peer)?;
465
466        let capacity_update = hopr_api::graph::EdgeCapacityUpdate {
467            src: me,
468            dest: peer,
469            capacity: Some(1000),
470        };
471        graph.record_edge::<TestNeighbor, TestPath>(hopr_api::graph::MeasurableEdge::Capacity(Box::new(
472            capacity_update,
473        )));
474
475        let obs = graph.edge(&me, &peer).context("edge should exist")?;
476        let intermediate = obs
477            .intermediate_qos()
478            .context("intermediate QoS should be present after capacity update")?;
479        assert_eq!(intermediate.capacity(), Some(1000));
480        Ok(())
481    }
482
483    #[tokio::test]
484    async fn capacity_update_should_accept_none_value() -> anyhow::Result<()> {
485        let me = pubkey_from(&SECRET_0);
486        let peer = pubkey_from(&SECRET_1);
487        let graph = ChannelGraph::new(me);
488        graph.add_node(peer);
489        graph.add_edge(&me, &peer)?;
490
491        let capacity_update = hopr_api::graph::EdgeCapacityUpdate {
492            src: me,
493            dest: peer,
494            capacity: None,
495        };
496        graph.record_edge::<TestNeighbor, TestPath>(hopr_api::graph::MeasurableEdge::Capacity(Box::new(
497            capacity_update,
498        )));
499
500        let obs = graph.edge(&me, &peer).context("edge should exist")?;
501        let intermediate = obs.intermediate_qos().context("intermediate QoS should be present")?;
502        assert_eq!(intermediate.capacity(), None);
503        Ok(())
504    }
505
506    #[tokio::test]
507    async fn record_node_should_add_node_to_graph() {
508        let me = pubkey_from(&SECRET_0);
509        let peer = pubkey_from(&SECRET_1);
510        let graph = ChannelGraph::new(me);
511
512        assert!(!graph.contains_node(&peer));
513        graph.record_node(peer);
514        assert!(graph.contains_node(&peer));
515    }
516
517    #[tokio::test]
518    async fn probe_should_create_edge_if_absent() -> anyhow::Result<()> {
519        let me = pubkey_from(&SECRET_0);
520        let peer = pubkey_from(&SECRET_1);
521        let graph = ChannelGraph::new(me);
522        graph.add_node(peer);
523        // No explicit add_edge — record_edge should upsert
524
525        let rtt = std::time::Duration::from_millis(80);
526        let telemetry: Result<EdgeTransportTelemetry<TestNeighbor, TestPath>, NetworkGraphError<TestPath>> =
527            Ok(EdgeTransportTelemetry::Neighbor(TestNeighbor { peer, rtt }));
528        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
529
530        assert!(graph.has_edge(&me, &peer), "probe should create edge via upsert");
531        let obs = graph.edge(&me, &peer).context("edge should exist")?;
532        assert!(obs.immediate_qos().is_some());
533        Ok(())
534    }
535
536    #[tokio::test]
537    async fn multiple_probes_should_accumulate_in_observations() -> anyhow::Result<()> {
538        let me = pubkey_from(&SECRET_0);
539        let peer = pubkey_from(&SECRET_1);
540        let graph = ChannelGraph::new(me);
541        graph.add_node(peer);
542        graph.add_edge(&me, &peer)?;
543
544        // Send several successful probes
545        for _ in 0..5 {
546            let telemetry: Result<EdgeTransportTelemetry<TestNeighbor, TestPath>, NetworkGraphError<TestPath>> =
547                Ok(EdgeTransportTelemetry::Neighbor(TestNeighbor {
548                    peer,
549                    rtt: std::time::Duration::from_millis(60),
550                }));
551            graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
552        }
553
554        let obs = graph.edge(&me, &peer).context("edge should exist")?;
555        let qos = obs.immediate_qos().context("immediate QoS should exist")?;
556        assert_eq!(
557            qos.average_latency().context("latency should be set")?,
558            std::time::Duration::from_millis(30), // rtt / 2 = 30ms
559        );
560        assert!(qos.average_probe_rate() > 0.9, "all probes succeeded");
561        Ok(())
562    }
563
564    /// A `MeasurablePath` carrying a serialized `PathId` and a timestamp for
565    /// loopback probe telemetry tests.
566    #[derive(Debug, Clone)]
567    struct LoopbackTestPath {
568        path_bytes: Vec<u8>,
569        timestamp_ms: u128,
570    }
571
572    impl LoopbackTestPath {
573        fn new(path_id: [u64; 5], timestamp_ms: u128) -> Self {
574            let path_bytes = path_id.iter().flat_map(|v| v.to_le_bytes()).collect();
575            Self {
576                path_bytes,
577                timestamp_ms,
578            }
579        }
580    }
581
582    impl MeasurablePath for LoopbackTestPath {
583        fn id(&self) -> &[u8] {
584            &[]
585        }
586
587        fn path(&self) -> &[u8] {
588            &self.path_bytes
589        }
590
591        fn timestamp(&self) -> u128 {
592            self.timestamp_ms
593        }
594    }
595
596    /// Current unix epoch time in milliseconds, mirroring how production code derives RTT.
597    fn now_unix_ms() -> u128 {
598        std::time::SystemTime::now()
599            .duration_since(std::time::UNIX_EPOCH)
600            .unwrap_or_default()
601            .as_millis()
602    }
603
604    /// Helper to send a loopback probe with the given path and desired RTT.
605    ///
606    /// The telemetry timestamp is set `rtt_ms` in the past so the receiver
607    /// computes an elapsed RTT of approximately `rtt_ms`.
608    fn send_loopback(graph: &ChannelGraph, path_id: [u64; 5], rtt_ms: u128) {
609        let telemetry: Result<
610            EdgeTransportTelemetry<TestNeighbor, LoopbackTestPath>,
611            NetworkGraphError<LoopbackTestPath>,
612        > = Ok(EdgeTransportTelemetry::Loopback(LoopbackTestPath::new(
613            path_id,
614            now_unix_ms() - rtt_ms,
615        )));
616        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
617    }
618
619    /// Helper to send a loopback timeout with the given path.
620    fn send_loopback_timeout(graph: &ChannelGraph, path_id: [u64; 5]) {
621        let telemetry: Result<
622            EdgeTransportTelemetry<TestNeighbor, LoopbackTestPath>,
623            NetworkGraphError<LoopbackTestPath>,
624        > = Err(NetworkGraphError::ProbeLoopbackTimeout(LoopbackTestPath::new(
625            path_id, 0,
626        )));
627        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
628    }
629
630    #[tokio::test]
631    async fn loopback_three_hop_should_attribute_to_penultimate_edge() -> anyhow::Result<()> {
632        // Loopback: me(0) → a(1) → b(2) → me(0)
633        // PathId nodes: [me=0, a=1, b=2, me=0, 0]
634        // Resolved edges: me→a, a→b, b→me (3 edges)
635        // Target = edges[len-2] = edges[1] = a→b
636        let me = pubkey_from(&SECRET_0);
637        let a = pubkey_from(&SECRET_1);
638        let b = pubkey_from(&SECRET_2);
639
640        let graph = ChannelGraph::new(me);
641        graph.add_node(a);
642        graph.add_node(b);
643        graph.add_edge(&me, &a)?;
644        graph.add_edge(&a, &b)?;
645        graph.add_edge(&b, &me)?; // return edge
646
647        send_loopback(&graph, [0, 1, 2, 0, 0], 200);
648
649        let obs = graph.edge(&a, &b).context("edge a→b should exist")?;
650        let qos = obs
651            .intermediate_qos()
652            .context("intermediate QoS should be present on a→b")?;
653        assert_in_delta!(
654            qos.average_latency().context("latency should be set")?.as_millis(),
655            200,
656            25
657        );
658
659        // me→a should NOT have intermediate QoS from this probe
660        let obs_me_a = graph.edge(&me, &a).context("edge me→a should exist")?;
661        assert!(obs_me_a.intermediate_qos().is_none());
662
663        Ok(())
664    }
665
666    #[tokio::test]
667    async fn loopback_four_hop_should_attribute_to_penultimate_edge() -> anyhow::Result<()> {
668        // Loopback: me(0) → a(1) → b(2) → c(3) → me(0)
669        // PathId nodes: [me=0, a=1, b=2, c=3, me=0]
670        // Resolved edges: me→a, a→b, b→c, c→me (4 edges)
671        // Target = edges[len-2] = edges[2] = b→c
672        let me = pubkey_from(&SECRET_0);
673        let a = pubkey_from(&SECRET_1);
674        let b = pubkey_from(&SECRET_2);
675        let c = pubkey_from(&SECRET_3);
676
677        let graph = ChannelGraph::new(me);
678        graph.add_node(a);
679        graph.add_node(b);
680        graph.add_node(c);
681        graph.add_edge(&me, &a)?;
682        graph.add_edge(&a, &b)?;
683        graph.add_edge(&b, &c)?;
684        graph.add_edge(&c, &me)?; // return edge
685
686        send_loopback(&graph, [0, 1, 2, 3, 0], 300);
687
688        // Edge b→c (target) should have the intermediate QoS
689        let obs = graph.edge(&b, &c).context("edge b→c should exist")?;
690        let qos = obs
691            .intermediate_qos()
692            .context("intermediate QoS should be present on b→c")?;
693        assert_in_delta!(
694            qos.average_latency().context("latency should be set")?.as_millis(),
695            300,
696            25
697        ); // no preceding intermediate latencies, so full RTT is attributed
698
699        // Earlier edges should NOT have intermediate QoS from this probe
700        let obs_me_a = graph.edge(&me, &a).context("edge me→a should exist")?;
701        assert!(obs_me_a.intermediate_qos().is_none());
702        let obs_a_b = graph.edge(&a, &b).context("edge a→b should exist")?;
703        assert!(obs_a_b.intermediate_qos().is_none());
704
705        Ok(())
706    }
707
708    #[tokio::test]
709    async fn loopback_should_subtract_known_preceding_latencies() -> anyhow::Result<()> {
710        // Loopback: me(0) → a(1) → b(2) → c(3) → me(0)
711        // Resolved edges: me→a, a→b, b→c, c→me (4 edges). Target = b→c (idx 2).
712        // Preceding edges = [me→a, a→b]
713        // Pre-set me→a = 80ms, a→b = 40ms
714        // Attributed for b→c = 300 - 80 - 40 = 180ms
715        let me = pubkey_from(&SECRET_0);
716        let a = pubkey_from(&SECRET_1);
717        let b = pubkey_from(&SECRET_2);
718        let c = pubkey_from(&SECRET_3);
719
720        let graph = ChannelGraph::new(me);
721        graph.add_node(a);
722        graph.add_node(b);
723        graph.add_node(c);
724        graph.add_edge(&me, &a)?;
725        graph.add_edge(&a, &b)?;
726        graph.add_edge(&b, &c)?;
727        graph.add_edge(&c, &me)?; // return edge
728
729        // Pre-set intermediate latency on me→a and a→b
730        graph.upsert_edge(&me, &a, |obs| {
731            use hopr_api::graph::traits::EdgeObservableWrite;
732            obs.record(hopr_api::graph::traits::EdgeWeightType::Intermediate(Ok(
733                std::time::Duration::from_millis(80),
734            )));
735        });
736        graph.upsert_edge(&a, &b, |obs| {
737            use hopr_api::graph::traits::EdgeObservableWrite;
738            obs.record(hopr_api::graph::traits::EdgeWeightType::Intermediate(Ok(
739                std::time::Duration::from_millis(40),
740            )));
741        });
742
743        send_loopback(&graph, [0, 1, 2, 3, 0], 300);
744
745        let obs = graph.edge(&b, &c).context("edge b→c should exist")?;
746        let qos = obs
747            .intermediate_qos()
748            .context("intermediate QoS should be present on b→c")?;
749        assert_in_delta!(
750            qos.average_latency().context("latency should be set")?.as_millis(),
751            180,
752            25
753        ); // 300ms total - 80ms (me→a) - 40ms (a→b) = 180ms attributed to b→c
754
755        Ok(())
756    }
757
758    #[tokio::test]
759    async fn loopback_should_subtract_immediate_latency_on_first_edge() -> anyhow::Result<()> {
760        // Loopback: me(0) → a(1) → b(2) → me(0)
761        // Resolved edges: me→a, a→b, b→me (3 edges). Target = a→b (idx 1).
762        // me→a has immediate QoS = 60ms (from my neighbor probing of a), no intermediate yet.
763        // Attributed for a→b = 200 - 60 = 140ms
764        let me = pubkey_from(&SECRET_0);
765        let a = pubkey_from(&SECRET_1);
766        let b = pubkey_from(&SECRET_2);
767
768        let graph = ChannelGraph::new(me);
769        graph.add_node(a);
770        graph.add_node(b);
771        graph.add_edge(&me, &a)?;
772        graph.add_edge(&a, &b)?;
773        graph.add_edge(&b, &me)?;
774
775        // Pre-set immediate QoS on me→a (my direct measurement to neighbor a)
776        graph.upsert_edge(&me, &a, |obs| {
777            use hopr_api::graph::traits::EdgeObservableWrite;
778            obs.record(hopr_api::graph::traits::EdgeWeightType::Immediate(Ok(
779                std::time::Duration::from_millis(60),
780            )));
781        });
782
783        send_loopback(&graph, [0, 1, 2, 0, 0], 200);
784
785        let obs = graph.edge(&a, &b).context("edge a→b should exist")?;
786        let qos = obs
787            .intermediate_qos()
788            .context("intermediate QoS should be present on a→b")?;
789        assert_in_delta!(
790            qos.average_latency().context("latency should be set")?.as_millis(),
791            140,
792            25
793        ); // 200ms total - 60ms (me→a immediate) = 140ms attributed to a→b
794
795        Ok(())
796    }
797
798    #[tokio::test]
799    async fn loopback_invalid_path_length_should_be_ignored() -> anyhow::Result<()> {
800        let me = pubkey_from(&SECRET_0);
801        let a = pubkey_from(&SECRET_1);
802
803        let graph = ChannelGraph::new(me);
804        graph.add_node(a);
805        graph.add_edge(&me, &a)?;
806
807        // Send loopback with wrong-length path bytes (not 40 bytes)
808        let telemetry: Result<
809            EdgeTransportTelemetry<TestNeighbor, LoopbackTestPath>,
810            NetworkGraphError<LoopbackTestPath>,
811        > = Ok(EdgeTransportTelemetry::Loopback(LoopbackTestPath {
812            path_bytes: vec![0u8; 16], // wrong: 16 bytes instead of 40
813            timestamp_ms: 100,
814        }));
815        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
816
817        // Edge should have no intermediate observations
818        let obs = graph.edge(&me, &a).context("edge should exist")?;
819        assert!(
820            obs.intermediate_qos().is_none(),
821            "invalid path bytes should not produce any intermediate measurement"
822        );
823
824        Ok(())
825    }
826
827    #[tokio::test]
828    async fn loopback_implausible_rtt_should_be_ignored() -> anyhow::Result<()> {
829        // Adversarial mitigation: an attacker who withholds a probe past the
830        // plausibility cap before replaying it would otherwise poison the latency
831        // EMA with an inflated measurement. Any computed RTT above the cap is discarded.
832        let me = pubkey_from(&SECRET_0);
833        let a = pubkey_from(&SECRET_1);
834        let b = pubkey_from(&SECRET_2);
835
836        let graph = ChannelGraph::new(me);
837        graph.add_node(a);
838        graph.add_node(b);
839        graph.add_edge(&me, &a)?;
840        graph.add_edge(&a, &b)?;
841        graph.add_edge(&b, &me)?; // return edge
842
843        // Probe withheld 90 s before replay: computed RTT far above the 30 s cap.
844        send_loopback(&graph, [0, 1, 2, 0, 0], 90_000);
845
846        let obs = graph.edge(&a, &b).context("edge a→b should exist")?;
847        assert!(
848            obs.intermediate_qos().is_none(),
849            "implausible RTT must not produce an intermediate measurement"
850        );
851
852        Ok(())
853    }
854
855    #[tokio::test]
856    async fn loopback_future_timestamp_should_be_ignored() -> anyhow::Result<()> {
857        // Backward clock drift can place the probe's creation timestamp in the future,
858        // so `now - timestamp` underflows. Such a probe is discarded rather than
859        // recorded as a zero-duration RTT, which would poison the latency EMA toward 0.
860        let me = pubkey_from(&SECRET_0);
861        let a = pubkey_from(&SECRET_1);
862        let b = pubkey_from(&SECRET_2);
863
864        let graph = ChannelGraph::new(me);
865        graph.add_node(a);
866        graph.add_node(b);
867        graph.add_edge(&me, &a)?;
868        graph.add_edge(&a, &b)?;
869        graph.add_edge(&b, &me)?; // return edge
870
871        let telemetry: Result<
872            EdgeTransportTelemetry<TestNeighbor, LoopbackTestPath>,
873            NetworkGraphError<LoopbackTestPath>,
874        > = Ok(EdgeTransportTelemetry::Loopback(LoopbackTestPath::new(
875            [0, 1, 2, 0, 0],
876            now_unix_ms() + 5_000, // timestamp 5 s in the future
877        )));
878        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
879
880        let obs = graph.edge(&a, &b).context("edge a→b should exist")?;
881        assert!(
882            obs.intermediate_qos().is_none(),
883            "future timestamp must not produce an intermediate measurement"
884        );
885
886        Ok(())
887    }
888
889    #[tokio::test]
890    async fn loopback_single_edge_path_should_be_ignored_if_no_immediate_or_intermediate_result_exists_for_the_edge()
891    -> anyhow::Result<()> {
892        // A path with only 1 edge has no "edge before the last"
893        // me(0) → a(1)
894        // PathId nodes: [me=0, a=1, 0, 0, 0]
895        // Trailing 0 = me which is already visited → stops at 1 edge
896        let me = pubkey_from(&SECRET_0);
897        let a = pubkey_from(&SECRET_1);
898
899        let graph = ChannelGraph::new(me);
900        graph.add_node(a);
901        graph.add_edge(&me, &a)?;
902
903        send_loopback(&graph, [0, 1, 0, 0, 0], 100);
904
905        let obs = graph.edge(&me, &a).context("edge should exist")?;
906        assert!(
907            obs.intermediate_qos().is_none(),
908            "single-edge path should not produce intermediate measurement"
909        );
910
911        Ok(())
912    }
913
914    #[tokio::test]
915    async fn loopback_two_edge_path_should_attribute_when_return_edge_exists() -> anyhow::Result<()> {
916        // Loopback: me(0) → a(1) → me(0)
917        // PathId nodes: [me=0, a=1, me=0, 0, 0]
918        // Resolved edges: me→a, a→me (2 edges). Target = me→a (idx 0).
919        // me→a already has immediate QoS = 50ms (from my neighbor probing).
920        // No known latency on the non-target edge a→me, so attributed = full RTT.
921        let me = pubkey_from(&SECRET_0);
922        let a = pubkey_from(&SECRET_1);
923
924        let graph = ChannelGraph::new(me);
925        graph.add_node(a);
926        graph.add_edge(&me, &a)?;
927        graph.add_edge(&a, &me)?; // return edge
928
929        // Pre-set immediate QoS on me→a (my direct neighbor measurement)
930        graph.upsert_edge(&me, &a, |obs| {
931            use hopr_api::graph::traits::EdgeObservableWrite;
932            obs.record(hopr_api::graph::traits::EdgeWeightType::Immediate(Ok(
933                std::time::Duration::from_millis(50),
934            )));
935        });
936
937        send_loopback(&graph, [0, 1, 0, 0, 0], 100);
938
939        let obs = graph.edge(&me, &a).context("edge me→a should exist")?;
940        let qos = obs
941            .intermediate_qos()
942            .context("intermediate QoS should be present on me→a")?;
943        assert_in_delta!(
944            qos.average_latency().context("latency should be set")?.as_millis(),
945            50,
946            25
947        ); // 100ms total - 50ms (me→a immediate) = 50ms attributed to me→a
948
949        // Immediate QoS should still be intact
950        let imm = obs
951            .immediate_qos()
952            .context("immediate QoS should still be present on me→a")?;
953        assert_eq!(
954            imm.average_latency().context("immediate latency should be set")?,
955            std::time::Duration::from_millis(50),
956        );
957
958        Ok(())
959    }
960
961    #[tokio::test]
962    async fn loopback_broken_chain_should_be_ignored() -> anyhow::Result<()> {
963        // Nodes exist but no edge connects a to c (only b→c exists)
964        // me(0) → a(1), b(2) → c(3)
965        // PathId nodes: [me=0, a=1, c=3, 0, 0]
966        // Edge me→a exists, but edge a→c does NOT → chain breaks, 1 edge < 2
967        let me = pubkey_from(&SECRET_0);
968        let a = pubkey_from(&SECRET_1);
969        let b = pubkey_from(&SECRET_2);
970        let c = pubkey_from(&SECRET_3);
971
972        let graph = ChannelGraph::new(me);
973        graph.add_node(a);
974        graph.add_node(b);
975        graph.add_node(c);
976        graph.add_edge(&me, &a)?;
977        graph.add_edge(&b, &c)?; // b→c, NOT a→c
978
979        send_loopback(&graph, [0, 1, 3, 0, 0], 200);
980
981        let obs_me_a = graph.edge(&me, &a).context("edge me→a should exist")?;
982        assert!(
983            obs_me_a.intermediate_qos().is_none(),
984            "broken chain should not attribute any intermediate measurement"
985        );
986
987        Ok(())
988    }
989
990    #[tokio::test]
991    async fn loopback_wrong_start_node_should_be_ignored() -> anyhow::Result<()> {
992        // PathId starts with node 99 which is not me → early reject
993        let me = pubkey_from(&SECRET_0);
994        let a = pubkey_from(&SECRET_1);
995
996        let graph = ChannelGraph::new(me);
997        graph.add_node(a);
998        graph.add_edge(&me, &a)?;
999
1000        send_loopback(&graph, [99, 1, 0, 0, 0], 200);
1001
1002        let obs = graph.edge(&me, &a).context("edge should exist")?;
1003        assert!(
1004            obs.intermediate_qos().is_none(),
1005            "wrong start node should not produce any measurement"
1006        );
1007
1008        Ok(())
1009    }
1010
1011    #[tokio::test]
1012    async fn loopback_probes_should_accumulate_on_target_edge() -> anyhow::Result<()> {
1013        // Send multiple loopback probes for the same target edge
1014        // Loopback: me(0) → a(1) → b(2) → me(0). Target = a→b.
1015        let me = pubkey_from(&SECRET_0);
1016        let a = pubkey_from(&SECRET_1);
1017        let b = pubkey_from(&SECRET_2);
1018
1019        let graph = ChannelGraph::new(me);
1020        graph.add_node(a);
1021        graph.add_node(b);
1022        graph.add_edge(&me, &a)?;
1023        graph.add_edge(&a, &b)?;
1024        graph.add_edge(&b, &me)?; // return edge
1025
1026        // Send 5 probes all with 100ms RTT.
1027        // After each probe the target's intermediate QoS is subtracted from subsequent
1028        // attributions, so the attributed value converges rather than staying at 100ms.
1029        for _ in 0..5 {
1030            send_loopback(&graph, [0, 1, 2, 0, 0], 100);
1031        }
1032
1033        let obs = graph.edge(&a, &b).context("edge a→b should exist")?;
1034        let qos = obs.intermediate_qos().context("intermediate QoS should be present")?;
1035        assert!(
1036            qos.average_latency().is_some(),
1037            "latency should be set after multiple probes"
1038        );
1039        assert!(
1040            qos.average_probe_rate() > 0.9,
1041            "all probes succeeded, rate should be high"
1042        );
1043
1044        Ok(())
1045    }
1046
1047    // This is handled by the moving average object, but the expectation test can stay here.
1048    #[tokio::test]
1049    async fn loopback_saturating_sub_should_not_underflow() -> anyhow::Result<()> {
1050        // If preceding edge latencies exceed total RTT, duration should saturate at 0
1051        // Loopback: me(0) → a(1) → b(2) → c(3) → me(0). Target = b→c.
1052        // Preceding = [me→a, a→b] with me→a = 500ms
1053        let me = pubkey_from(&SECRET_0);
1054        let a = pubkey_from(&SECRET_1);
1055        let b = pubkey_from(&SECRET_2);
1056        let c = pubkey_from(&SECRET_3);
1057
1058        let graph = ChannelGraph::new(me);
1059        graph.add_node(a);
1060        graph.add_node(b);
1061        graph.add_node(c);
1062        graph.add_edge(&me, &a)?;
1063        graph.add_edge(&a, &b)?;
1064        graph.add_edge(&b, &c)?;
1065        graph.add_edge(&c, &me)?; // return edge
1066
1067        // Pre-set me→a intermediate latency to 500ms
1068        graph.upsert_edge(&me, &a, |obs| {
1069            use hopr_api::graph::traits::EdgeObservableWrite;
1070            obs.record(hopr_api::graph::traits::EdgeWeightType::Intermediate(Ok(
1071                std::time::Duration::from_millis(500),
1072            )));
1073        });
1074
1075        // Total RTT = 100ms, but preceding latency is 500ms → 100 - 500 saturates to 0
1076        send_loopback(&graph, [0, 1, 2, 3, 0], 100);
1077
1078        let obs = graph.edge(&b, &c).context("edge b→c should exist")?;
1079        let qos = obs.intermediate_qos().context("intermediate QoS should be present")?;
1080        // Duration::ZERO means latency_average gets updated with 0ms
1081        // which the EMA may not report as Some(0) but rather None if <= 0
1082        // Let's check the probe rate instead — it should be recorded
1083        assert!(
1084            qos.average_probe_rate() > 0.0,
1085            "probe should still be recorded even with saturated duration"
1086        );
1087
1088        Ok(())
1089    }
1090
1091    #[tokio::test]
1092    async fn loopback_timeout_should_record_failed_intermediate_on_target_edge() -> anyhow::Result<()> {
1093        // Loopback: me(0) → a(1) → b(2) → me(0)
1094        // PathId nodes: [me=0, a=1, b=2, me=0, 0]
1095        // Resolved edges: me→a, a→b, b→me. Target = edges[1] = a→b
1096        let me = pubkey_from(&SECRET_0);
1097        let a = pubkey_from(&SECRET_1);
1098        let b = pubkey_from(&SECRET_2);
1099
1100        let graph = ChannelGraph::new(me);
1101        graph.add_node(a);
1102        graph.add_node(b);
1103        graph.add_edge(&me, &a)?;
1104        graph.add_edge(&a, &b)?;
1105        graph.add_edge(&b, &me)?; // return edge
1106
1107        send_loopback_timeout(&graph, [0, 1, 2, 0, 0]);
1108
1109        let obs = graph.edge(&a, &b).context("edge a→b should exist")?;
1110        let qos = obs
1111            .intermediate_qos()
1112            .context("intermediate QoS should be present on a→b after timeout")?;
1113        assert!(qos.average_latency().is_none(), "failed probe should not set latency");
1114        assert!(qos.average_probe_rate() < 1.0, "failed probe should lower success rate");
1115
1116        // me→a should NOT have intermediate QoS
1117        let obs_me_a = graph.edge(&me, &a).context("edge me→a should exist")?;
1118        assert!(obs_me_a.intermediate_qos().is_none());
1119
1120        Ok(())
1121    }
1122
1123    #[tokio::test]
1124    async fn loopback_timeout_four_hop_should_attribute_to_penultimate_edge() -> anyhow::Result<()> {
1125        // Loopback: me(0) → a(1) → b(2) → c(3) → me(0)
1126        // Target = last resolved edge = b→c
1127        let me = pubkey_from(&SECRET_0);
1128        let a = pubkey_from(&SECRET_1);
1129        let b = pubkey_from(&SECRET_2);
1130        let c = pubkey_from(&SECRET_3);
1131
1132        let graph = ChannelGraph::new(me);
1133        graph.add_node(a);
1134        graph.add_node(b);
1135        graph.add_node(c);
1136        graph.add_edge(&me, &a)?;
1137        graph.add_edge(&a, &b)?;
1138        graph.add_edge(&b, &c)?;
1139        graph.add_edge(&c, &me)?;
1140
1141        send_loopback_timeout(&graph, [0, 1, 2, 3, 0]);
1142
1143        // Edge b→c (target) should have a failed intermediate record
1144        let obs = graph.edge(&b, &c).context("edge b→c should exist")?;
1145        let qos = obs
1146            .intermediate_qos()
1147            .context("intermediate QoS should be present on b→c")?;
1148        assert!(qos.average_latency().is_none());
1149        assert!(qos.average_probe_rate() < 1.0);
1150
1151        // Earlier edges should NOT have intermediate QoS
1152        let obs_me_a = graph.edge(&me, &a).context("edge me→a should exist")?;
1153        assert!(obs_me_a.intermediate_qos().is_none());
1154        let obs_a_b = graph.edge(&a, &b).context("edge a→b should exist")?;
1155        assert!(obs_a_b.intermediate_qos().is_none());
1156
1157        Ok(())
1158    }
1159
1160    #[tokio::test]
1161    async fn loopback_timeout_invalid_path_should_be_ignored() -> anyhow::Result<()> {
1162        let me = pubkey_from(&SECRET_0);
1163        let a = pubkey_from(&SECRET_1);
1164
1165        let graph = ChannelGraph::new(me);
1166        graph.add_node(a);
1167        graph.add_edge(&me, &a)?;
1168
1169        // Wrong-length path
1170        let telemetry: Result<
1171            EdgeTransportTelemetry<TestNeighbor, LoopbackTestPath>,
1172            NetworkGraphError<LoopbackTestPath>,
1173        > = Err(NetworkGraphError::ProbeLoopbackTimeout(LoopbackTestPath {
1174            path_bytes: vec![0u8; 8],
1175            timestamp_ms: 0,
1176        }));
1177        graph.record_edge(hopr_api::graph::MeasurableEdge::Probe(telemetry));
1178
1179        let obs = graph.edge(&me, &a).context("edge should exist")?;
1180        assert!(obs.intermediate_qos().is_none());
1181
1182        Ok(())
1183    }
1184
1185    #[tokio::test]
1186    async fn loopback_timeout_single_edge_should_be_ignored() -> anyhow::Result<()> {
1187        // me(0) → a(1), PathId: [0, 1, 0, 0, 0] → 1 edge < 2
1188        let me = pubkey_from(&SECRET_0);
1189        let a = pubkey_from(&SECRET_1);
1190
1191        let graph = ChannelGraph::new(me);
1192        graph.add_node(a);
1193        graph.add_edge(&me, &a)?;
1194
1195        send_loopback_timeout(&graph, [0, 1, 0, 0, 0]);
1196
1197        let obs = graph.edge(&me, &a).context("edge should exist")?;
1198        assert!(
1199            obs.intermediate_qos().is_none(),
1200            "single-edge timeout should not produce intermediate measurement"
1201        );
1202
1203        Ok(())
1204    }
1205}