Skip to main content

bloq_graph/action_dag/
svg.rs

1//! Standalone SVG export of the cached source action dependencies.
2
3use std::fmt::Write;
4
5use super::ActionDag;
6use crate::{Action, BlockGraphError};
7
8impl ActionDag {
9    /// Renders the stored action DAG as a self-contained SVG.
10    ///
11    /// Nodes retain source ordinals and complete action text. Solid arrows show
12    /// classical variable dependencies, while dashed arrows show dependencies
13    /// inferred from correlation support. Edge titles identify their reasons.
14    /// Rendering does not derive missing dependencies or assign execution times.
15    /// Call [`crate::BlockGraph::analyze_action_graph`] first for an analyzed view.
16    /// The SVG uses the same layout as the physical IR dependency view.
17    ///
18    /// # Errors
19    ///
20    /// Returns an error for invalid action syntax, unresolved names or a cycle.
21    /// No geometry audit or stabilizer analysis is performed during rendering.
22    ///
23    /// ```
24    /// use bloq_graph::GalleryItem;
25    /// let source = GalleryItem::T.build();
26    /// let dag = source.analyze_action_graph()?;
27    /// let svg = dag.to_svg()?;
28    /// assert!(svg.contains("mzz"));
29    /// # Ok::<(), Box<dyn std::error::Error>>(())
30    /// ```
31    pub fn to_svg(&self) -> Result<String, BlockGraphError> {
32        self.validate_semantics()?;
33        let nodes = self.ordered_nodes().collect::<Vec<_>>();
34        let labels = nodes
35            .iter()
36            .map(|node| {
37                let label = match &node.action {
38                    Action::Branch { target, condition } => self
39                        .branch_region(*target)
40                        .map(|region| format!("resolve {} if {condition}", region.name))
41                        .unwrap_or_else(|| node.action.to_string()),
42                    _ => node.action.to_string(),
43                };
44                match &node.owner {
45                    Some(owner) => format!(
46                        "[{} @ {}] {label}",
47                        owner.definition,
48                        if owner.instance_path.is_empty() {
49                            "root"
50                        } else {
51                            &owner.instance_path
52                        }
53                    ),
54                    None => label,
55                }
56            })
57            .collect::<Vec<_>>();
58        let lines = labels
59            .iter()
60            .map(|label| {
61                label
62                    .chars()
63                    .collect::<Vec<_>>()
64                    .chunks(42)
65                    .map(|chunk| chunk.iter().collect::<String>())
66                    .collect::<Vec<_>>()
67            })
68            .collect::<Vec<_>>();
69        let sizes = nodes
70            .iter()
71            .zip(&lines)
72            .map(|(node, lines)| {
73                (
74                    node.ordinal as u32,
75                    (340.0, 38.0 + lines.len() as f32 * 17.0),
76                )
77            })
78            .collect::<Vec<_>>();
79        let mut edges = self.dependencies().collect::<Vec<_>>();
80        edges.sort_by_key(|&(from, to, reason)| (from, to, reason as u8));
81        let pairs = edges
82            .iter()
83            .map(|&(from, to, _)| (from as u32, to as u32))
84            .collect::<Vec<_>>();
85        let placed = bloq_utils::graph_layout::compute_relative(&sizes, &pairs, 48.0);
86        let width = placed.content.0.max(460.0) + 48.0;
87        let height = placed.content.1.max(40.0) + 96.0;
88        let status = if self.is_analyzed() {
89            "Analyzed"
90        } else {
91            "Source"
92        };
93        let mut svg = format!(
94            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" viewBox=\"0 0 {width} {height}\" role=\"img\" aria-label=\"{status} action dependency graph\"><title>{status} action DAG</title><style>text{{font:12px monospace;fill:#202536}}rect{{stroke-width:1.5}}.measure{{fill:#e6edff;stroke:#4a65b8}}.binding{{fill:#e8f6ed;stroke:#4b8c67}}.resolve{{fill:#fff1df;stroke:#b66b21}}.feedback{{fill:#f3e9fc;stroke:#8859af}}.discard{{fill:#fde8e8;stroke:#bc5151}}</style><defs><marker id=\"arrow\" markerWidth=\"7\" markerHeight=\"7\" refX=\"6\" refY=\"3\" orient=\"auto\"><path d=\"M0 0 L6 3 L0 6 Z\" fill=\"#758198\"/></marker></defs><rect width=\"100%\" height=\"100%\" fill=\"white\"/><g transform=\"translate(24 48)\">"
95        );
96        for (from, to, reason) in edges {
97            let (x, y) = placed.positions[&(from as u32)];
98            let (tx, ty) = placed.positions[&(to as u32)];
99            let sx = x + sizes[from].1.0;
100            let sy = y + sizes[from].1.1 / 2.0;
101            let ey = ty + sizes[to].1.1 / 2.0;
102            let middle = sx.midpoint(tx);
103            let dash = if reason.is_implicit() {
104                " stroke-dasharray=\"5 4\""
105            } else {
106                ""
107            };
108            write!(svg, "<path data-from=\"{from}\" data-to=\"{to}\" data-dependency=\"{reason:?}\" d=\"M{sx} {sy} C{middle} {sy} {middle} {ey} {tx} {ey}\" fill=\"none\" stroke=\"#758198\" stroke-width=\"1.5\"{dash} marker-end=\"url(#arrow)\"><title>{reason:?}</title></path>").expect("write SVG string");
109        }
110        for ((node, label), lines) in nodes.iter().zip(&labels).zip(&lines) {
111            let id = node.ordinal;
112            let (x, y) = placed.positions[&(id as u32)];
113            let (w, h) = sizes[id].1;
114            let kind = match node.action {
115                Action::Measure { .. } => "measure",
116                Action::Let { .. } => "binding",
117                Action::Resolve { .. } | Action::Branch { .. } => "resolve",
118                Action::Feedback { .. } => "feedback",
119                Action::DiscardIf(_) => "discard",
120            };
121            let owner = node
122                .owner
123                .as_ref()
124                .map(|owner| {
125                    format!(
126                        " data-definition=\"{}\" data-instance=\"{}\"",
127                        escape(&owner.definition),
128                        escape(&owner.instance_path)
129                    )
130                })
131                .unwrap_or_default();
132            write!(svg, "<g data-node=\"{id}\"{owner}><title>{}</title><rect class=\"{kind}\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" rx=\"7\"/><text x=\"{}\" y=\"{}\">A{id}</text>", escape(label), x + 12.0, y + 20.0).expect("write SVG string");
133            for (line, text) in lines.iter().enumerate() {
134                write!(
135                    svg,
136                    "<text x=\"{}\" y=\"{}\">{}</text>",
137                    x + 12.0,
138                    y + 38.0 + line as f32 * 17.0,
139                    escape(text)
140                )
141                .expect("write SVG string");
142            }
143            svg.push_str("</g>");
144        }
145        if nodes.is_empty() {
146            svg.push_str("<text y=\"20\">No actions</text>");
147        }
148        svg.push_str("</g><text x=\"24\" y=\"24\">");
149        write!(svg, "{status} action DAG · {} actions</text><text x=\"24\" y=\"{}\">Solid: variable dependencies · Dashed: inferred dependencies</text></svg>", nodes.len(), height - 16.0).expect("write SVG string");
150        Ok(svg)
151    }
152}
153
154fn escape(text: &str) -> String {
155    text.replace('&', "&amp;")
156        .replace('<', "&lt;")
157        .replace('>', "&gt;")
158        .replace('"', "&quot;")
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::ActionDependency;
165    use crate::{Expr, GalleryItem, MeasureTarget};
166
167    #[test]
168    fn svg_keeps_isolated_actions_dependency_reasons_and_full_labels() {
169        let mut dag = ActionDag::from_actions(&[
170            Action::Measure {
171                target: MeasureTarget::Node(glam::IVec3::ZERO),
172                name: "m".into(),
173            },
174            Action::Let {
175                name: "both".into(),
176                expr: Expr::Binary(
177                    crate::BinaryOp::And,
178                    Box::new(Expr::Var("m".into())),
179                    Box::new(Expr::Var("m".into())),
180                ),
181            },
182            Action::Feedback {
183                targets: vec![crate::FeedbackTarget {
184                    pauli: crate::PauliBasis::Z,
185                    target: glam::IVec3::ZERO,
186                    direction: None,
187                }],
188                condition: None,
189            },
190        ]);
191        dag.add_dependency(
192            dag.ordered[0],
193            dag.ordered[1],
194            ActionDependency::ReadoutParity,
195        );
196        let svg = dag.to_svg().unwrap();
197        assert_eq!(svg.matches("data-node=").count(), 3);
198        assert!(svg.contains("both = m &amp; m"));
199        assert!(svg.contains("data-dependency=\"Classical\""));
200        assert!(svg.contains("data-dependency=\"ReadoutParity\""));
201        assert!(svg.contains("stroke-dasharray"));
202        assert_eq!(svg, dag.to_svg().unwrap());
203        assert!(
204            ActionDag::default()
205                .to_svg()
206                .unwrap()
207                .contains("No actions")
208        );
209    }
210
211    #[test]
212    fn svg_labels_analyzed_graph_and_rejects_cycles() {
213        let flat = GalleryItem::T.build().flatten().unwrap();
214        let (analyzed, _) = flat.analyze_actions().unwrap();
215        let svg = analyzed.action_graph().to_svg().unwrap();
216        assert!(svg.contains("Analyzed action DAG"));
217        let mut cyclic = analyzed.action_graph().clone();
218        cyclic.add_dependency(
219            cyclic.ordered[1],
220            cyclic.ordered[0],
221            ActionDependency::Classical,
222        );
223        assert!(matches!(
224            cyclic.to_svg().unwrap_err(),
225            BlockGraphError::InvalidAction(
226                crate::validate::InvalidActionError::DependencyCycle { .. }
227            )
228        ));
229    }
230
231    #[test]
232    fn continuing_branch_analysis_waits_for_the_selected_physical_readout() {
233        let source = crate::BlockGraph::from_text(
234            r#"BLOG 1.0
235module main {
236  in driver: data = 0
237  0: Port [2, 0, -1] role=input
238  1: ZXZ [2, 0, 0]
239  0 -> +Z
240  2: ZXZ [0, 0, 0]
241  3: ZXZ [0, 0, 2]
242  branch middle {
243    false {
244      4: ZXZ [0, 0, 1]
245      [0, 0, 0] -> +Z
246      [0, 0, 1] -> +Z
247    }
248    true {
249      5: XZX [0, 0, 1]
250      [0, 0, 0] -H> +Z
251      [0, 0, 1] -H> +Z
252    }
253  }
254  selector = measure 1
255  resolve middle if selector
256  result = measure 3
257}
258"#,
259        )
260        .unwrap();
261        let flat = source.flatten().unwrap();
262        let before = flat.to_blog_text();
263        let dag = flat.analyze_action_graph().unwrap();
264        assert!(dag.is_analyzed());
265        assert!(
266            dag.dependencies()
267                .any(|edge| edge == (1, 2, ActionDependency::BranchSupport))
268        );
269        assert_eq!(flat.to_blog_text(), before);
270        let mut constant = flat.clone();
271        let mut actions = constant.actions();
272        if let Action::Branch { condition, .. } = &mut actions[1] {
273            *condition = Expr::Binary(
274                crate::BinaryOp::Xor,
275                Box::new(Expr::Var("selector".into())),
276                Box::new(Expr::Var("selector".into())),
277            );
278        }
279        constant.set_actions(actions).unwrap();
280        assert!(
281            constant
282                .analyze_action_graph()
283                .unwrap()
284                .dependencies()
285                .any(|edge| edge == (1, 2, ActionDependency::BranchSupport))
286        );
287    }
288}