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