Skip to main content

hopr_network_graph/
render.rs

1//! DOT (Graphviz) rendering for the channel graph.
2//!
3//! Gated behind the `graph-api` feature.
4
5use std::fmt::Write;
6
7use hopr_api::{
8    OffchainPublicKey,
9    graph::traits::{EdgeLinkObservable, EdgeObservableRead, EdgeProtocolObservable},
10};
11
12use crate::ChannelGraph;
13
14/// Renders the connected subgraph of `graph` as a DOT (Graphviz) digraph.
15///
16/// Isolated nodes (those with no incoming or outgoing edges) are excluded.
17/// Each node is labeled with `OffchainPublicKey` in hex.
18/// Edges carry quality annotations: score, latency (ms), and capacity when available.
19pub fn render_dot(graph: &ChannelGraph) -> String {
20    render_dot_with_labels(graph, |key| format!("{key}"))
21}
22
23/// Renders the connected subgraph of `graph` as a DOT digraph, using the
24/// provided `label_fn` to produce the node label for each [`OffchainPublicKey`].
25///
26/// This allows callers to substitute onchain addresses or any other label format
27/// while keeping the rendering logic shared.
28pub fn render_dot_with_labels(graph: &ChannelGraph, label_fn: impl Fn(&OffchainPublicKey) -> String) -> String {
29    render_edges_as_dot(&graph.connected_edges(), &label_fn)
30}
31
32/// Like [`render_dot_with_labels`], but only includes edges reachable from the
33/// current node via directed BFS. Disconnected subgraphs are excluded.
34pub fn render_dot_reachable_with_labels(
35    graph: &ChannelGraph,
36    label_fn: impl Fn(&OffchainPublicKey) -> String,
37) -> String {
38    render_edges_as_dot(&graph.reachable_edges(), &label_fn)
39}
40
41pub fn render_edges_as_dot(
42    edges: &[(OffchainPublicKey, OffchainPublicKey, crate::Observations)],
43    label_fn: &impl Fn(&OffchainPublicKey) -> String,
44) -> String {
45    let mut out = String::from("digraph hopr {\n");
46
47    for (src, dst, obs) in edges {
48        let src_label = label_fn(src);
49        let dst_label = label_fn(dst);
50
51        let mut attrs = Vec::new();
52        let score = obs.score();
53        attrs.push(format!("score={score:.2}"));
54
55        if let Some(imm) = obs.immediate_qos()
56            && let Some(latency) = imm.average_latency()
57        {
58            attrs.push(format!("lat={}ms", latency.as_millis()));
59        }
60
61        if let Some(inter) = obs.intermediate_qos()
62            && let Some(cap) = inter.capacity()
63        {
64            attrs.push(format!("cap={cap}"));
65        }
66
67        let label = attrs.join(" ");
68        let _ = writeln!(out, "  \"{src_label}\" -> \"{dst_label}\" [label=\"{label}\"];");
69    }
70
71    out.push_str("}\n");
72    out
73}
74
75#[cfg(test)]
76mod tests {
77    use std::collections::HashMap;
78
79    use hopr_api::{
80        graph::{
81            NetworkGraphWrite,
82            traits::{EdgeObservableWrite, EdgeWeightType},
83        },
84        types::crypto::prelude::{Keypair, OffchainKeypair},
85    };
86
87    use super::*;
88    use crate::ChannelGraph;
89
90    fn label_from_map<'a>(
91        addr_map: &'a HashMap<hopr_api::OffchainPublicKey, String>,
92    ) -> impl Fn(&hopr_api::OffchainPublicKey) -> String + 'a {
93        |key| addr_map.get(key).cloned().unwrap_or_else(|| key.to_string())
94    }
95
96    const SECRET_0: [u8; 32] = hex_literal::hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
97    const SECRET_1: [u8; 32] = hex_literal::hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
98    const SECRET_2: [u8; 32] = hex_literal::hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
99    const SECRET_3: [u8; 32] = hex_literal::hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
100
101    fn pubkey(secret: &[u8; 32]) -> hopr_api::OffchainPublicKey {
102        *OffchainKeypair::from_secret(secret).expect("valid secret").public()
103    }
104
105    #[test]
106    fn empty_graph_should_render_empty_digraph() {
107        let me = pubkey(&SECRET_0);
108        let graph = ChannelGraph::new(me);
109        let dot = render_dot(&graph);
110        assert_eq!(dot, "digraph hopr {\n}\n");
111    }
112
113    #[test]
114    fn isolated_nodes_should_be_excluded_from_dot() {
115        let me = pubkey(&SECRET_0);
116        let graph = ChannelGraph::new(me);
117        graph.add_node(pubkey(&SECRET_1));
118        // No edges — only isolated nodes.
119        let dot = render_dot(&graph);
120        assert_eq!(dot, "digraph hopr {\n}\n");
121    }
122
123    #[test]
124    fn diamond_topology_should_render_four_edges() -> anyhow::Result<()> {
125        let me = pubkey(&SECRET_0);
126        let a = pubkey(&SECRET_1);
127        let b = pubkey(&SECRET_2);
128        let dest = pubkey(&SECRET_3);
129        let graph = ChannelGraph::new(me);
130        for n in [a, b, dest] {
131            graph.add_node(n);
132        }
133        graph.add_edge(&me, &a)?;
134        graph.add_edge(&me, &b)?;
135        graph.add_edge(&a, &dest)?;
136        graph.add_edge(&b, &dest)?;
137
138        let dot = render_dot(&graph);
139        // Four "->" occurrences, one per edge.
140        assert_eq!(dot.matches("->").count(), 4);
141        assert!(dot.starts_with("digraph hopr {"));
142        assert!(dot.ends_with("}\n"));
143        Ok(())
144    }
145
146    #[test]
147    fn observations_should_appear_as_edge_labels() {
148        let me = pubkey(&SECRET_0);
149        let peer = pubkey(&SECRET_1);
150        let graph = ChannelGraph::new(me);
151        graph.add_node(peer);
152        graph.upsert_edge(&me, &peer, |obs| {
153            obs.record(EdgeWeightType::Connected(true));
154            obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
155            obs.record(EdgeWeightType::Capacity(Some(1000)));
156        });
157
158        let dot = render_dot(&graph);
159        assert!(dot.contains("lat=50ms"), "should contain latency: {dot}");
160        assert!(dot.contains("cap=1000"), "should contain capacity: {dot}");
161        assert!(dot.contains("score="), "should contain score: {dot}");
162    }
163
164    #[test]
165    fn custom_labels_should_replace_offchain_keys() -> anyhow::Result<()> {
166        let me = pubkey(&SECRET_0);
167        let peer = pubkey(&SECRET_1);
168        let graph = ChannelGraph::new(me);
169        graph.add_node(peer);
170        graph.add_edge(&me, &peer)?;
171
172        let mut addr_map: HashMap<hopr_api::OffchainPublicKey, String> = HashMap::new();
173        addr_map.insert(me, "0xaaaa000000000000000000000000000000000001".into());
174        addr_map.insert(peer, "0xbbbb000000000000000000000000000000000002".into());
175
176        let dot = render_dot_with_labels(&graph, label_from_map(&addr_map));
177
178        assert!(
179            dot.contains("0xaaaa000000000000000000000000000000000001"),
180            "source should use onchain address: {dot}"
181        );
182        assert!(
183            dot.contains("0xbbbb000000000000000000000000000000000002"),
184            "destination should use onchain address: {dot}"
185        );
186        // Offchain keys should NOT appear
187        assert!(
188            !dot.contains(&format!("{me}")),
189            "offchain key for 'me' should be replaced: {dot}"
190        );
191        assert!(
192            !dot.contains(&format!("{peer}")),
193            "offchain key for 'peer' should be replaced: {dot}"
194        );
195        Ok(())
196    }
197
198    #[test]
199    fn custom_labels_should_fall_back_to_offchain_key_when_unmapped() -> anyhow::Result<()> {
200        let me = pubkey(&SECRET_0);
201        let peer = pubkey(&SECRET_1);
202        let graph = ChannelGraph::new(me);
203        graph.add_node(peer);
204        graph.add_edge(&me, &peer)?;
205
206        let mut addr_map: HashMap<hopr_api::OffchainPublicKey, String> = HashMap::new();
207        addr_map.insert(me, "0xcccc000000000000000000000000000000000003".into());
208
209        let dot = render_dot_with_labels(&graph, label_from_map(&addr_map));
210
211        assert!(
212            dot.contains("0xcccc000000000000000000000000000000000003"),
213            "mapped node should use onchain address: {dot}"
214        );
215        assert!(
216            dot.contains(&format!("{peer}")),
217            "unmapped node should fall back to offchain key: {dot}"
218        );
219        Ok(())
220    }
221
222    #[test]
223    fn custom_labels_should_preserve_edge_attributes() {
224        let me = pubkey(&SECRET_0);
225        let peer = pubkey(&SECRET_1);
226        let graph = ChannelGraph::new(me);
227        graph.add_node(peer);
228        graph.upsert_edge(&me, &peer, |obs| {
229            obs.record(EdgeWeightType::Connected(true));
230            obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(120))));
231            obs.record(EdgeWeightType::Capacity(Some(500)));
232        });
233
234        let mut addr_map: HashMap<hopr_api::OffchainPublicKey, String> = HashMap::new();
235        addr_map.insert(me, "0x1111111111111111111111111111111111111111".into());
236        addr_map.insert(peer, "0x2222222222222222222222222222222222222222".into());
237
238        let dot = render_dot_with_labels(&graph, label_from_map(&addr_map));
239
240        assert!(dot.contains("lat=120ms"), "latency should be preserved: {dot}");
241        assert!(dot.contains("cap=500"), "capacity should be preserved: {dot}");
242        assert!(dot.contains("score="), "score should be preserved: {dot}");
243        assert!(
244            dot.contains(
245                "\"0x1111111111111111111111111111111111111111\" -> \"0x2222222222222222222222222222222222222222\""
246            ),
247            "edge should use mapped addresses: {dot}"
248        );
249    }
250
251    #[test]
252    fn render_dot_should_be_unchanged_when_identity_label() -> anyhow::Result<()> {
253        let me = pubkey(&SECRET_0);
254        let peer = pubkey(&SECRET_1);
255        let graph = ChannelGraph::new(me);
256        graph.add_node(peer);
257        graph.add_edge(&me, &peer)?;
258
259        let dot_original = render_dot(&graph);
260        let dot_identity = render_dot_with_labels(&graph, |key| format!("{key}"));
261
262        assert_eq!(
263            dot_original, dot_identity,
264            "identity label_fn should produce identical output"
265        );
266        Ok(())
267    }
268
269    #[test]
270    fn reachable_should_exclude_disconnected_subgraph() -> anyhow::Result<()> {
271        let me = pubkey(&SECRET_0);
272        let a = pubkey(&SECRET_1);
273        let b = pubkey(&SECRET_2);
274        let c = pubkey(&SECRET_3);
275        let graph = ChannelGraph::new(me);
276        for n in [a, b, c] {
277            graph.add_node(n);
278        }
279
280        // me -> a (reachable)
281        graph.add_edge(&me, &a)?;
282        // b -> c (disconnected from me)
283        graph.add_edge(&b, &c)?;
284
285        let all_dot = render_dot(&graph);
286        let reachable_dot = render_dot_reachable_with_labels(&graph, |key| format!("{key}"));
287
288        // All edges should appear in the full graph
289        assert_eq!(
290            all_dot.matches("->").count(),
291            2,
292            "full graph should have 2 edges: {all_dot}"
293        );
294
295        // Only me -> a should appear in the reachable graph
296        assert_eq!(
297            reachable_dot.matches("->").count(),
298            1,
299            "reachable graph should have 1 edge: {reachable_dot}"
300        );
301        assert!(
302            reachable_dot.contains(&format!("{a}")),
303            "reachable peer 'a' should be present: {reachable_dot}"
304        );
305        assert!(
306            !reachable_dot.contains(&format!("{b}")),
307            "unreachable peer 'b' should be absent: {reachable_dot}"
308        );
309        assert!(
310            !reachable_dot.contains(&format!("{c}")),
311            "unreachable peer 'c' should be absent: {reachable_dot}"
312        );
313        Ok(())
314    }
315
316    #[test]
317    fn reachable_should_include_transitive_peers() -> anyhow::Result<()> {
318        let me = pubkey(&SECRET_0);
319        let a = pubkey(&SECRET_1);
320        let b = pubkey(&SECRET_2);
321        let graph = ChannelGraph::new(me);
322        for n in [a, b] {
323            graph.add_node(n);
324        }
325
326        // me -> a -> b (b reachable transitively)
327        graph.add_edge(&me, &a)?;
328        graph.add_edge(&a, &b)?;
329
330        let reachable_dot = render_dot_reachable_with_labels(&graph, |key| format!("{key}"));
331
332        assert_eq!(
333            reachable_dot.matches("->").count(),
334            2,
335            "both edges should be reachable: {reachable_dot}"
336        );
337        assert!(
338            reachable_dot.contains(&format!("{b}")),
339            "transitively reachable peer should be present: {reachable_dot}"
340        );
341        Ok(())
342    }
343
344    #[test]
345    fn reachable_should_be_empty_when_node_has_no_outgoing_edges() -> anyhow::Result<()> {
346        let me = pubkey(&SECRET_0);
347        let a = pubkey(&SECRET_1);
348        let b = pubkey(&SECRET_2);
349        let graph = ChannelGraph::new(me);
350        for n in [a, b] {
351            graph.add_node(n);
352        }
353
354        // a -> b (me has no outgoing edges)
355        graph.add_edge(&a, &b)?;
356
357        let reachable_dot = render_dot_reachable_with_labels(&graph, |key| format!("{key}"));
358
359        assert_eq!(
360            reachable_dot.matches("->").count(),
361            0,
362            "no edges should be reachable from isolated 'me': {reachable_dot}"
363        );
364        Ok(())
365    }
366}