open-standards/wai/wai-quantum/src/engine/draw.rs
Transaction Science b8773cf47f Quantum Studio: the circuit as a diagram, from the engine's layout
- engine::draw::layout: the drawing as data. It gives the qubits, the columns (each item's cells as [qubit, label], and whether a line joins them) and the legend of conditions, packed as the text drawing packs them. The packing moves into one function, which both forms use.
- wai-web's engine binding gains engine_layout, and the desktop app gains `layout`.
- The Studio draws the circuit as SVG from it:
  - wires, gate boxes, a control's dot joined to its target's ring, swaps;
  - measurements as meters with their bit, resets;
  - a gate under a condition with a dashed outline and the condition's number.
  Colours come from the page's tokens, each item has a title naming its gates and qubits, and the SVG has a text alternative. "Text" shows the text drawing to copy. Past 400 columns the diagram stops and says so.
- Tests: the layout of a circuit with a shared column, a joined CX and a conditioned gate; the binding's layout. The Studio test adds the diagram's glyphs and text alternative, and the Text toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit c2e89a9fd9)
2026-10-20 17:34:17 -04:00

359 lines
14 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! A program's circuit as text: one wire per qubit, gates packed into the
//! earliest column their qubits are free, a control as `●`, a CX target as
//! `⊕`, a swap as `×`, a measurement into bit `k` as `Mk`, a reset as `|0⟩`.
//! A gate under a condition carries `?n`, and the legend under the drawing
//! says what condition `n` is. Wide circuits wrap at `width` columns.
use std::f64::consts::PI;
use crate::qasm::{Cond, Instr, Kind, Op, Program};
/// One thing drawn in a column: its label on each qubit it touches, and
/// whether a vertical line joins them.
struct Item {
cells: Vec<(u32, String)>,
joined: bool,
}
/// `x` as a multiple of π where it is one with a small denominator, else in
/// decimals.
pub fn angle(x: f64) -> String {
let r = x / PI;
for d in [1i64, 2, 3, 4, 6, 8, 12, 16] {
let n = r * d as f64;
if (n - n.round()).abs() < 1e-9 {
let n = n.round() as i64;
let g = gcd(n.abs(), d);
let (n, d) = (n / g, d / g);
return match (n, d) {
(0, _) => "0".into(),
(1, 1) => "π".into(),
(-1, 1) => "-π".into(),
(n, 1) => format!("{n}π"),
(1, d) => format!("π/{d}"),
(-1, d) => format!("-π/{d}"),
(n, d) => format!("{n}π/{d}"),
};
}
}
let t = format!("{x:.4}");
t.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn gcd(a: i64, b: i64) -> i64 {
if b == 0 { a.max(1) } else { gcd(b, a % b) }
}
fn params(p: &[f64]) -> String {
p.iter().map(|&x| angle(x)).collect::<Vec<_>>().join(",")
}
/// The cells of one gate.
fn gate(op: &Op) -> Item {
use Kind::*;
let q = &op.qubits;
let p = params(&op.params);
let one = |l: &str| Item { cells: vec![(q[0], l.to_string())], joined: false };
let ctl = |rest: Vec<(u32, String)>| {
let mut cells = vec![(q[0], "●".to_string())];
cells.extend(rest);
Item { cells, joined: true }
};
match op.kind {
Id => one("I"),
X => one("X"),
Y => one("Y"),
Z => one("Z"),
H => one("H"),
S => one("S"),
Sdg => one("S†"),
T => one("T"),
Tdg => one("T†"),
Sx => one("√X"),
Sxdg => one("√X†"),
Rx => one(&format!("Rx({p})")),
Ry => one(&format!("Ry({p})")),
Rz => one(&format!("Rz({p})")),
P => one(&format!("P({p})")),
U => one(&format!("U({p})")),
Cx => ctl(vec![(q[1], "⊕".into())]),
Cy => ctl(vec![(q[1], "Y".into())]),
Cz => ctl(vec![(q[1], "●".into())]),
Ch => ctl(vec![(q[1], "H".into())]),
Cp => ctl(vec![(q[1], format!("P({p})"))]),
Crx => ctl(vec![(q[1], format!("Rx({p})"))]),
Cry => ctl(vec![(q[1], format!("Ry({p})"))]),
Crz => ctl(vec![(q[1], format!("Rz({p})"))]),
Cu => ctl(vec![(q[1], format!("U({p})"))]),
Swap => Item { cells: vec![(q[0], "×".into()), (q[1], "×".into())], joined: true },
Iswap => Item { cells: vec![(q[0], "iSWAP".into()), (q[1], "iSWAP".into())], joined: true },
Rxx => Item { cells: vec![(q[0], format!("Rxx({p})")), (q[1], format!("Rxx({p})"))], joined: true },
Ryy => Item { cells: vec![(q[0], format!("Ryy({p})")), (q[1], format!("Ryy({p})"))], joined: true },
Rzz => Item { cells: vec![(q[0], format!("Rzz({p})")), (q[1], format!("Rzz({p})"))], joined: true },
Ccx => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "⊕".into())], joined: true },
Ccz => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "●".into())], joined: true },
Cswap => Item { cells: vec![(q[0], "●".into()), (q[1], "×".into()), (q[2], "×".into())], joined: true },
}
}
/// A condition as the legend writes it: the bits, highest first, read as an
/// unsigned integer.
fn condition(c: &Cond) -> String {
let op = if c.equal { "=" } else { "≠" };
match c.bits.as_slice() {
[b] => format!("if bit {b} {op} {}", c.value),
bits => {
let names: Vec<String> = bits.iter().rev().map(|b| b.to_string()).collect();
format!("if bits ({}) {op} {}", names.join(" "), c.value)
}
}
}
/// The items to draw, in order, and the legend of conditions.
fn items(p: &Program) -> (Vec<Item>, Vec<String>) {
let mut out = Vec::new();
let mut legend = Vec::new();
let Some(steps) = &p.dynamic else {
out.extend(p.ops.iter().map(gate));
out.extend(p.measured.iter().map(|&(q, b)| Item { cells: vec![(q, format!("M{b}"))], joined: false }));
return (out, legend);
};
// Each step's condition: a JumpUnless guards the steps up to its target;
// a Jump at the end of that region skips an else branch, up to its own.
let mut guard: Vec<Option<usize>> = vec![None; steps.len()];
for (i, s) in steps.iter().enumerate() {
if let Instr::JumpUnless(c, to) = s {
legend.push(condition(c));
let n = legend.len();
let then_end = *to;
let else_end = match steps.get(then_end.wrapping_sub(1)) {
Some(Instr::Jump(e)) if then_end > i + 1 => Some(*e),
_ => None,
};
for g in guard.iter_mut().take(then_end.min(steps.len())).skip(i + 1) {
g.get_or_insert(n);
}
if let Some(e) = else_end {
legend.push(format!("else of ?{n}"));
let m = legend.len();
for g in guard.iter_mut().take(e.min(steps.len())).skip(then_end) {
g.get_or_insert(m);
}
}
}
}
for (i, s) in steps.iter().enumerate() {
let mut item = match s {
Instr::Gate(k) => gate(&p.ops[*k]),
Instr::Measure { qubit, bit } => Item { cells: vec![(*qubit, format!("M{bit}"))], joined: false },
Instr::Reset(q) => Item { cells: vec![(*q, "|0⟩".into())], joined: false },
Instr::JumpUnless(..) | Instr::Jump(_) => continue,
};
if let Some(n) = guard[i] {
for (_, l) in item.cells.iter_mut() {
l.push_str(&format!("?{n}"));
}
}
out.push(item);
}
(out, legend)
}
/// The items packed into columns: each goes to the first column after the
/// last one used on any wire it covers. With the legend of conditions.
fn columns(p: &Program) -> (Vec<Vec<Item>>, Vec<String>) {
let n = p.n_qubits as usize;
let (items, legend) = items(p);
let mut free = vec![0usize; n];
let mut columns: Vec<Vec<Item>> = Vec::new();
for it in items {
let lo = it.cells.iter().map(|c| c.0 as usize).min().unwrap_or(0);
let hi = if it.joined { it.cells.iter().map(|c| c.0 as usize).max().unwrap_or(lo) } else { lo };
let col = free[lo..=hi].iter().copied().max().unwrap_or(0);
for f in &mut free[lo..=hi] {
*f = col + 1;
}
if columns.len() <= col {
columns.resize_with(col + 1, Vec::new);
}
columns[col].push(it);
}
(columns, legend)
}
/// The same drawing as data, for a page to draw: the qubits, the columns
/// (each a list of items, each its cells as `[qubit, label]` and whether a
/// line joins them) and the legend of conditions. A label is what the text
/// drawing shows: `●` a control, `⊕` a CX target, `×` a swap, `Mk` a
/// measurement into bit k, `|0⟩` a reset, any other the gate; `?n` after it
/// marks condition n.
pub fn layout(p: &Program) -> serde_json::Value {
let (columns, legend) = columns(p);
serde_json::json!({
"qubits": p.n_qubits,
"columns": columns.iter().map(|col| col.iter().map(|it| serde_json::json!({
"cells": it.cells.iter().map(|(q, l)| serde_json::json!([q, l])).collect::<Vec<_>>(),
"joined": it.joined,
})).collect::<Vec<_>>()).collect::<Vec<_>>(),
"legend": legend,
})
}
/// The drawing, wrapped at `width` columns.
pub fn draw(p: &Program, width: usize) -> String {
let n = p.n_qubits as usize;
if n == 0 {
return "(no qubits)\n".into();
}
let (columns, legend) = columns(p);
let label_w = format!("q{}", n - 1).chars().count();
let prefix = |q: usize| format!("{:>label_w$}: ", format!("q{q}"));
let blank = " ".repeat(label_w + 2);
// Each column's text per wire, and its joins between wire q and q + 1.
let mut blocks: Vec<(usize, Vec<String>, Vec<bool>)> = Vec::new();
for col in &columns {
let w = col.iter().flat_map(|it| it.cells.iter().map(|c| c.1.chars().count())).max().unwrap_or(1).max(1);
let mut wires = vec![None::<String>; n];
let mut through = vec![false; n];
let mut joins = vec![false; n];
for it in col {
for (q, l) in &it.cells {
wires[*q as usize] = Some(l.clone());
}
if it.joined {
let lo = it.cells.iter().map(|c| c.0 as usize).min().unwrap();
let hi = it.cells.iter().map(|c| c.0 as usize).max().unwrap();
for (q, t) in through.iter_mut().enumerate().take(hi).skip(lo + 1) {
*t = wires[q].is_none();
}
for j in joins.iter_mut().take(hi).skip(lo) {
*j = true;
}
}
}
let text: Vec<String> = (0..n)
.map(|q| {
let l = match &wires[q] {
Some(l) => l.clone(),
None if through[q] => "┼".into(),
None => "─".into(),
};
let pad = w - l.chars().count();
format!("─{}{l}{}─", "─".repeat(pad / 2), "─".repeat(pad - pad / 2))
})
.collect();
blocks.push((w + 2, text, joins));
}
let mut out = String::new();
let avail = width.saturating_sub(label_w + 4).max(8);
let mut start = 0;
while start < blocks.len() || (start == 0 && blocks.is_empty()) {
let mut end = start;
let mut used = 0;
while end < blocks.len() && (end == start || used + blocks[end].0 <= avail) {
used += blocks[end].0;
end += 1;
}
if start > 0 {
out.push('\n');
}
for q in 0..n {
out.push_str(&prefix(q));
out.push('─');
for b in &blocks[start..end] {
out.push_str(&b.1[q]);
}
out.push_str("─\n");
if q + 1 < n {
let mut line = blank.clone();
line.push(' ');
for b in &blocks[start..end] {
// Where a one-character label sits in this column.
let w = b.0;
let mid = 1 + (w - 3) / 2;
let mut cell = vec![' '; w];
if b.2[q] {
cell[mid] = '│';
}
line.extend(cell);
}
out.push_str(line.trim_end());
out.push('\n');
}
}
if end == start {
break;
}
start = end;
}
for (i, l) in legend.iter().enumerate() {
out.push_str(&format!("?{} {l}\n", i + 1));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::qasm::parse;
#[test]
fn angles_are_written_as_multiples_of_pi_where_they_are() {
assert_eq!(angle(PI / 4.0), "π/4");
assert_eq!(angle(-PI / 2.0), "-π/2");
assert_eq!(angle(3.0 * PI / 4.0), "3π/4");
assert_eq!(angle(2.0 * PI), "2π");
assert_eq!(angle(0.0), "0");
assert_eq!(angle(0.3), "0.3");
}
#[test]
fn a_bell_pair_is_h_then_a_controlled_x_joined_across_the_wires() {
let d = draw(&parse("OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];").unwrap(), 100);
let lines: Vec<&str> = d.lines().collect();
assert_eq!(lines.len(), 3, "{d}");
assert!(lines[0].starts_with("q0: ") && lines[0].contains('H') && lines[0].contains('●'), "{d}");
assert!(lines[1].contains('│'), "{d}");
assert!(lines[2].starts_with("q1: ") && lines[2].contains('⊕') && !lines[2].contains('H'), "{d}");
// The control sits after H, in the column of the target.
assert_eq!(lines[0].chars().position(|c| c == '●'), lines[2].chars().position(|c| c == '⊕'), "{d}");
}
#[test]
fn gates_on_different_wires_share_a_column_and_a_gate_between_crosses_the_middle() {
let d = draw(&parse("qubit[3] q; h q[0]; h q[1]; h q[2]; cx q[0], q[2];").unwrap(), 100);
let lines: Vec<&str> = d.lines().collect();
let h_at: Vec<Option<usize>> = [0, 2, 4].iter().map(|&i| lines[i].chars().position(|c| c == 'H')).collect();
assert!(h_at.iter().all(|&p| p == h_at[0]), "the three H share a column: {d}");
assert!(lines[2].contains('┼'), "the CX passes through q1: {d}");
}
#[test]
fn a_measurement_names_its_bit_and_a_conditioned_gate_its_condition() {
let d = draw(&parse("OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; c[0] = measure q[0]; if (c[0]) x q[1]; c[1] = measure q[1];").unwrap(), 100);
assert!(d.contains("M0") && d.contains("M1"), "{d}");
assert!(d.contains("X?1") && d.contains("?1 if bit 0 = 1"), "{d}");
}
#[test]
fn the_layout_is_the_drawing_as_data() {
let l = layout(&parse("OPENQASM 3.0; qubit[3] q; bit[3] c; h q[0]; h q[2]; cx q[0], q[2]; c[0] = measure q[0]; if (c[0]) x q[1];").unwrap());
assert_eq!(l["qubits"], 3);
let cols = l["columns"].as_array().unwrap();
// The two H share the first column; the CX joins q0 to q2 in the second.
assert_eq!(cols[0].as_array().unwrap().len(), 2, "{l}");
let cx = &cols[1][0];
assert_eq!(cx["joined"], true);
assert_eq!(cx["cells"], serde_json::json!([[0, "●"], [2, "⊕"]]));
assert!(l.to_string().contains("\"X?1\""), "{l}");
assert_eq!(l["legend"][0], "if bit 0 = 1");
}
#[test]
fn a_long_circuit_wraps_and_keeps_every_gate() {
let src = format!("qubit[2] q; {}", "h q[0]; cx q[0], q[1]; ".repeat(40));
let d = draw(&parse(&src).unwrap(), 60);
assert!(d.lines().all(|l| l.chars().count() <= 60), "{d}");
assert_eq!(d.matches('H').count(), 40, "{d}");
assert_eq!(d.matches('⊕').count(), 40, "{d}");
}
}