diff --git a/Cargo.lock b/Cargo.lock index f1146dc9e..56c02ae28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3734,6 +3734,7 @@ dependencies = [ "num-complex 0.4.6", "pecos-core", "pecos-num", + "pecos-qsim", "smallvec", "tket", ] diff --git a/crates/pecos-core/src/circuit_diagram.rs b/crates/pecos-core/src/circuit_diagram.rs new file mode 100644 index 000000000..4bc77986c --- /dev/null +++ b/crates/pecos-core/src/circuit_diagram.rs @@ -0,0 +1,2787 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License.You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Shared circuit diagram rendering engine. +//! +//! Produces horizontal qubit-wire diagrams with gate columns, used by +//! [`Operator`](crate::Operator), [`TickCircuit`], and [`DagCircuit`]. + +use std::fmt::Write; + +// ============================================================================ +// Types +// ============================================================================ + +/// What occupies a single (row, column) position in the diagram grid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiagramCell { + /// Empty wire segment. + Wire, + /// A gate symbol to render on this qubit wire, with its family style. + Gate(String, GateFamily), + /// Control dot for a multi-qubit gate. + Control, + /// Vertical connector between qubits of a multi-qubit gate. + Connector, + /// Wire crossing: a wire passes through a vertical connector. + Crossing, + /// Labeled connector: a label displayed on the vertical connector between + /// two control dots (e.g. `ZZ` for symmetric two-qubit interactions). + LabeledConnector(String), +} + +/// Color category for a diagram cell. +/// +/// Follows the PECOS color algebra based on Pauli axis interconversion: +/// - Base axes: X = Red, Y = Green, Z = Blue +/// - Mixed axes use additive RGB: X<->Z = Magenta, X<->Y = Yellow, Y<->Z = Cyan +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CellColor { + /// No special color (default terminal color). + #[default] + None, + /// X-axis (red): X, RX, CX target, etc. + XAxis, + /// Y-axis (green): Y, RY, CY target, etc. + YAxis, + /// Z-axis (blue): Z, RZ, T, MZ, PZ, etc. + ZAxis, + /// X<->Z mixing (magenta): H, SY, `SYdg`. + XZMix, + /// X<->Y mixing (yellow): SZ, `SZdg`. + XYMix, + /// Y<->Z mixing (cyan): SX, `SXdg`. + YZMix, + /// All-axis mixing (grey): F-like composites. + XYZMix, + /// Control dot (dark). + ControlDot, +} + +impl CellColor { + /// SVG/DOT fill color (light tint for gates, solid for controls). + #[must_use] + pub fn hex_fill(self) -> &'static str { + match self { + Self::None => "#FFFFFF", + Self::XAxis => "#FFB0B0", + Self::YAxis => "#B0E8B0", + Self::ZAxis => "#A8C8F0", + Self::XZMix => "#E0B0E0", + Self::XYMix => "#F0E0A0", + Self::YZMix => "#A0E0E8", + Self::XYZMix => "#D0D0D0", + Self::ControlDot => "#333333", + } + } + + /// SVG/TikZ border/stroke color. + #[must_use] + pub fn hex_stroke(self) -> &'static str { + match self { + Self::XAxis => "#AA2222", + Self::YAxis => "#226622", + Self::ZAxis => "#2255AA", + Self::XZMix => "#882288", + Self::XYMix => "#AA8800", + Self::YZMix => "#008888", + Self::XYZMix => "#666666", + Self::None | Self::ControlDot => "#222222", + } + } + + /// Text color inside gates (SVG/TikZ). + #[must_use] + pub fn hex_text(self) -> &'static str { + match self { + Self::XAxis => "#7A1A1A", + Self::YAxis => "#1A4A1A", + Self::ZAxis => "#1A3A7A", + Self::XZMix => "#5A1A5A", + Self::XYMix => "#6A5500", + Self::YZMix => "#005A5A", + Self::None | Self::XYZMix => "#333333", + Self::ControlDot => "#FFFFFF", + } + } + + /// Short name for `\definecolor` in `TikZ`. + #[must_use] + pub fn tikz_name(self) -> &'static str { + match self { + Self::None => "cellNone", + Self::XAxis => "cellX", + Self::YAxis => "cellY", + Self::ZAxis => "cellZ", + Self::XZMix => "cellXZ", + Self::XYMix => "cellXY", + Self::YZMix => "cellYZ", + Self::XYZMix => "cellXYZ", + Self::ControlDot => "cellCtrl", + } + } +} + +/// Gate family classification for visual bracket/stroke styling. +/// +/// This provides a second visual dimension (shape/stroke) orthogonal to the +/// existing color dimension. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GateFamily { + /// Default bracket style `[T]`, solid stroke. + #[default] + Default, + /// Pauli gates `(X)`, solid stroke. + Pauli, + /// S-like gates `[SZ]`, dashed stroke. + SLike, + /// Hadamard-like gates ``, dotted stroke. + HLike, + /// F-like composites `{F}`, dash-dot stroke (reserved). + FLike, + /// Measurement gates `|MZ)`, solid stroke. + Measurement, + /// Preparation gates `(PZ|`, solid stroke. + Preparation, +} + +impl GateFamily { + /// Opening bracket for text rendering. + #[must_use] + pub fn open_bracket(self) -> &'static str { + match self { + Self::Default | Self::SLike => "[", + Self::Pauli | Self::Preparation => "(", + Self::HLike => "<", + Self::FLike => "{", + Self::Measurement => "|", + } + } + + /// Closing bracket for text rendering. + #[must_use] + pub fn close_bracket(self) -> &'static str { + match self { + Self::Default | Self::SLike => "]", + Self::Pauli | Self::Measurement => ")", + Self::HLike => ">", + Self::FLike => "}", + Self::Preparation => "|", + } + } + + /// SVG `stroke-dasharray` value. Empty string means solid. + #[must_use] + pub fn svg_dasharray(self) -> &'static str { + match self { + Self::Default | Self::Pauli | Self::Measurement | Self::Preparation => "", + Self::SLike => "4,3", + Self::HLike => "2,2", + Self::FLike => "6,2,2,2", + } + } + + /// `TikZ` dash pattern name. Empty string means solid. + #[must_use] + pub fn tikz_dash(self) -> &'static str { + match self { + Self::Default | Self::Pauli | Self::Measurement | Self::Preparation => "", + Self::SLike => "dashed", + Self::HLike => "dotted", + Self::FLike => "dashdotted", + } + } + + /// DOT/Graphviz `style` value. Empty string means default (solid). + #[must_use] + pub fn dot_style(self) -> &'static str { + match self { + Self::Default | Self::Pauli | Self::Measurement | Self::Preparation => "", + Self::SLike | Self::FLike => "dashed", + Self::HLike => "dotted", + } + } +} + +/// How to display rotation angles in gate labels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AngleUnit { + /// Display as multiples of pi with fractions, e.g. `\u{03C0}/4`, `3\u{03C0}/2`. + /// Falls back to decimal radians for non-nice fractions. + #[default] + Radians, + /// Display as fractional turns, e.g. `.25`, `.125`. + Turns, +} + +/// Which character set to use for rendering. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SymbolSet { + /// Plain ASCII: `-`, `|`, `.`, `+` + #[default] + Ascii, + /// Unicode box-drawing: `─`, `│`, `●`, `+` + Unicode, +} + +// ============================================================================ +// Color palette types +// ============================================================================ + +/// Fill, stroke, and text colors for a single diagram element category. +#[derive(Clone, Debug)] +pub struct ColorTriplet { + pub fill: String, + pub stroke: String, + pub text: String, +} + +impl ColorTriplet { + /// Create a new triplet from string slices. + #[must_use] + pub fn new(fill: &str, stroke: &str, text: &str) -> Self { + Self { + fill: fill.to_string(), + stroke: stroke.to_string(), + text: text.to_string(), + } + } +} + +/// Complete color palette for all diagram cell categories. +#[derive(Clone, Debug)] +pub struct ColorPalette { + pub none: ColorTriplet, + pub x_axis: ColorTriplet, + pub y_axis: ColorTriplet, + pub z_axis: ColorTriplet, + pub xz_mix: ColorTriplet, + pub xy_mix: ColorTriplet, + pub yz_mix: ColorTriplet, + pub xyz_mix: ColorTriplet, + pub control_dot: ColorTriplet, +} + +impl Default for ColorPalette { + fn default() -> Self { + Self { + none: ColorTriplet::new("#FFFFFF", "#222222", "#222222"), + x_axis: ColorTriplet::new("#FFB0B0", "#AA2222", "#7A1A1A"), + y_axis: ColorTriplet::new("#B0E8B0", "#226622", "#1A4A1A"), + z_axis: ColorTriplet::new("#A8C8F0", "#2255AA", "#1A3A7A"), + xz_mix: ColorTriplet::new("#E0B0E0", "#882288", "#5A1A5A"), + xy_mix: ColorTriplet::new("#F0E0A0", "#AA8800", "#6A5500"), + yz_mix: ColorTriplet::new("#A0E0E8", "#008888", "#005A5A"), + xyz_mix: ColorTriplet::new("#D0D0D0", "#666666", "#333333"), + control_dot: ColorTriplet::new("#333333", "#222222", "#FFFFFF"), + } + } +} + +impl ColorPalette { + /// Look up the color triplet for a given cell color category. + #[must_use] + pub fn get(&self, color: CellColor) -> &ColorTriplet { + match color { + CellColor::None => &self.none, + CellColor::XAxis => &self.x_axis, + CellColor::YAxis => &self.y_axis, + CellColor::ZAxis => &self.z_axis, + CellColor::XZMix => &self.xz_mix, + CellColor::XYMix => &self.xy_mix, + CellColor::YZMix => &self.yz_mix, + CellColor::XYZMix => &self.xyz_mix, + CellColor::ControlDot => &self.control_dot, + } + } +} + +// ============================================================================ +// DiagramStyle +// ============================================================================ + +/// Full configuration for diagram rendering. +/// +/// Controls text symbol set, color modes, dash patterns, and the color palette. +/// Use [`DiagramStyle::builder()`] for convenient construction. +#[derive(Clone, Debug)] +pub struct DiagramStyle { + pub symbols: SymbolSet, + /// Whether to emit ANSI color codes in text output. + pub ansi_color: bool, + /// Whether graphical outputs (SVG, `TikZ`, DOT) use color. When false, + /// all gates use the `none` palette entry (monochrome). + pub color: bool, + /// Whether to render stroke dash patterns. When false, all strokes are solid. + pub show_dashes: bool, + /// How to display rotation angles in gate labels. + pub angle_unit: AngleUnit, + pub palette: ColorPalette, +} + +impl Default for DiagramStyle { + fn default() -> Self { + Self { + symbols: SymbolSet::Ascii, + ansi_color: false, + color: true, + show_dashes: true, + angle_unit: AngleUnit::Radians, + palette: ColorPalette::default(), + } + } +} + +impl DiagramStyle { + /// Create a builder for constructing a custom `DiagramStyle`. + #[must_use] + pub fn builder() -> DiagramStyleBuilder { + DiagramStyleBuilder::new() + } + + /// Look up the effective color triplet for a cell, respecting the `color` flag. + /// Control dots are always filled (even in monochrome) so they remain visible. + #[must_use] + pub fn triplet(&self, color: CellColor) -> &ColorTriplet { + if self.color || color == CellColor::ControlDot { + self.palette.get(color) + } else { + self.palette.get(CellColor::None) + } + } + + /// Effective SVG dasharray for a gate family, respecting `show_dashes`. + #[must_use] + pub fn svg_dasharray(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.svg_dasharray() + } else { + "" + } + } + + /// Effective `TikZ` dash pattern for a gate family, respecting `show_dashes`. + #[must_use] + pub fn tikz_dash(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.tikz_dash() + } else { + "" + } + } + + /// Effective DOT style for a gate family, respecting `show_dashes`. + #[must_use] + pub fn dot_style(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.dot_style() + } else { + "" + } + } +} + +// ============================================================================ +// DiagramStyleBuilder +// ============================================================================ + +/// Builder for [`DiagramStyle`]. +#[derive(Clone, Debug)] +pub struct DiagramStyleBuilder { + style: DiagramStyle, +} + +impl DiagramStyleBuilder { + /// Create a new builder with default settings. + #[must_use] + pub fn new() -> Self { + Self { + style: DiagramStyle::default(), + } + } + + /// Preset: plain ASCII, no ANSI color. + #[must_use] + pub fn ascii() -> Self { + Self::new() + } + + /// Preset: ASCII with ANSI color. + #[must_use] + pub fn color_ascii() -> Self { + let mut b = Self::new(); + b.style.ansi_color = true; + b + } + + /// Preset: Unicode box-drawing, no ANSI color. + #[must_use] + pub fn unicode() -> Self { + let mut b = Self::new(); + b.style.symbols = SymbolSet::Unicode; + b + } + + /// Preset: Unicode with ANSI color. + #[must_use] + pub fn color_unicode() -> Self { + let mut b = Self::new(); + b.style.symbols = SymbolSet::Unicode; + b.style.ansi_color = true; + b + } + + /// Set the character symbol set. + #[must_use] + pub fn symbols(mut self, s: SymbolSet) -> Self { + self.style.symbols = s; + self + } + + /// Enable or disable ANSI color in text output. + #[must_use] + pub fn ansi_color(mut self, b: bool) -> Self { + self.style.ansi_color = b; + self + } + + /// Enable or disable color in graphical output (SVG, `TikZ`, DOT). + #[must_use] + pub fn color(mut self, b: bool) -> Self { + self.style.color = b; + self + } + + /// Enable or disable dash stroke patterns. + #[must_use] + pub fn show_dashes(mut self, b: bool) -> Self { + self.style.show_dashes = b; + self + } + + /// Set the angle display unit for rotation gate labels. + #[must_use] + pub fn angle_unit(mut self, u: AngleUnit) -> Self { + self.style.angle_unit = u; + self + } + + /// Set the entire color palette. + #[must_use] + pub fn palette(mut self, p: ColorPalette) -> Self { + self.style.palette = p; + self + } + + /// Set X-axis colors. + #[must_use] + pub fn x_axis(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.x_axis = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set Y-axis colors. + #[must_use] + pub fn y_axis(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.y_axis = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set Z-axis colors. + #[must_use] + pub fn z_axis(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.z_axis = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set X-Z mix colors. + #[must_use] + pub fn xz_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.xz_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set X-Y mix colors. + #[must_use] + pub fn xy_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.xy_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set Y-Z mix colors. + #[must_use] + pub fn yz_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.yz_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set XYZ mix colors. + #[must_use] + pub fn xyz_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.xyz_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set control dot colors. + #[must_use] + pub fn control_dot(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.control_dot = ColorTriplet::new(fill, stroke, text); + self + } + + /// Build the final `DiagramStyle`. + #[must_use] + pub fn build(self) -> DiagramStyle { + self.style + } +} + +impl Default for DiagramStyleBuilder { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// DiagramRenderer +// ============================================================================ + +/// A prepared diagram bound to a style, ready to render in any output format. +/// +/// Obtained from `render_with` on [`crate::Operator`], `TickCircuit`, or `DagCircuit`. +pub struct DiagramRenderer<'a> { + diagram: CircuitDiagram, + header: String, + style: &'a DiagramStyle, +} + +impl<'a> DiagramRenderer<'a> { + /// Create a new renderer from a pre-built diagram and style. + #[must_use] + pub fn new(diagram: CircuitDiagram, header: String, style: &'a DiagramStyle) -> Self { + Self { + diagram, + header, + style, + } + } + + /// Render as a text wire diagram using the style's symbol set. + #[must_use] + pub fn text(&self) -> String { + self.diagram.render_text(&self.header, self.style) + } + + /// Render as an ASCII text diagram (overrides symbols to ASCII). + #[must_use] + pub fn ascii(&self) -> String { + let mut s = self.style.clone(); + s.symbols = SymbolSet::Ascii; + self.diagram.render_text(&self.header, &s) + } + + /// Render as a Unicode text diagram (overrides symbols to Unicode). + #[must_use] + pub fn unicode(&self) -> String { + let mut s = self.style.clone(); + s.symbols = SymbolSet::Unicode; + self.diagram.render_text(&self.header, &s) + } + + /// Render as an SVG string. + #[must_use] + pub fn svg(&self) -> String { + self.diagram.render_svg_with(&self.header, self.style) + } + + /// Render as a `TikZ` `tikzpicture`. + #[must_use] + pub fn tikz(&self) -> String { + self.diagram.render_tikz_with(&self.header, self.style) + } + + /// Render as a Graphviz DOT digraph. + #[must_use] + pub fn dot(&self) -> String { + self.diagram.render_dot_with(&self.header, self.style) + } +} + +// ============================================================================ +// ANSI color codes +// ============================================================================ + +const ANSI_RESET: &str = "\x1b[0m"; + +fn ansi_code(color: CellColor) -> &'static str { + match color { + CellColor::None => "", + CellColor::XAxis => "\x1b[31m", + CellColor::YAxis => "\x1b[32m", + CellColor::ZAxis => "\x1b[34m", + CellColor::XZMix => "\x1b[35m", + CellColor::XYMix => "\x1b[33m", + CellColor::YZMix => "\x1b[36m", + CellColor::XYZMix => "\x1b[37m", + CellColor::ControlDot => "\x1b[1m", + } +} + +// ============================================================================ +// CircuitDiagram builder +// ============================================================================ + +/// A grid-based circuit diagram builder. +/// +/// The diagram is organized as a grid of `columns x rows`, where each row +/// corresponds to a qubit wire and each column to a time step / layer. +pub struct CircuitDiagram { + labels: Vec, + columns: Vec>, + current_col: usize, + /// Explicit vertical connector spans: `(column, top_row, bottom_row, optional_label)`. + connector_spans: Vec<(usize, usize, usize, Option)>, + /// Groups of columns representing a single logical tick: `(label, start_col, end_col)`. + column_groups: Vec<(String, usize, usize)>, +} + +impl CircuitDiagram { + /// Create a new diagram for `n` qubits with default labels `q0`, `q1`, ... + #[must_use] + pub fn new(n: usize) -> Self { + let labels: Vec = (0..n).map(|i| format!("q{i}")).collect(); + Self { + labels, + columns: vec![vec![(DiagramCell::Wire, CellColor::None); n]], + current_col: 0, + connector_spans: Vec::new(), + column_groups: Vec::new(), + } + } + + /// Create a new diagram with custom labels. + #[must_use] + pub fn with_labels(labels: Vec) -> Self { + let n = labels.len(); + Self { + labels, + columns: vec![vec![(DiagramCell::Wire, CellColor::None); n]], + current_col: 0, + connector_spans: Vec::new(), + column_groups: Vec::new(), + } + } + + /// Number of qubit rows. + #[must_use] + pub fn num_rows(&self) -> usize { + self.labels.len() + } + + /// Current column index. + #[must_use] + pub fn current_col(&self) -> usize { + self.current_col + } + + /// Register a group of columns that represent a single logical tick. + pub fn add_column_group(&mut self, label: String, start: usize, end: usize) { + self.column_groups.push((label, start, end)); + } + + fn ensure_column(&mut self) { + while self.current_col >= self.columns.len() { + self.columns + .push(vec![(DiagramCell::Wire, CellColor::None); self.num_rows()]); + } + } + + /// Set a cell at the given row in the current column. + pub fn set_cell(&mut self, row: usize, cell: DiagramCell, color: CellColor) { + self.ensure_column(); + if row < self.num_rows() { + self.columns[self.current_col][row] = (cell, color); + } + } + + /// Place a gate symbol on a row with a family bracket/stroke style. + pub fn add_gate(&mut self, row: usize, name: &str, color: CellColor, family: GateFamily) { + self.set_cell(row, DiagramCell::Gate(name.to_string(), family), color); + } + + /// Place a control dot on a row. + pub fn add_control(&mut self, row: usize) { + self.set_cell(row, DiagramCell::Control, CellColor::ControlDot); + } + + /// Fill vertical connectors/crossings between `top` and `bottom` (exclusive) + /// and record the span for vertical line rendering. + /// + /// Rows that are qubit wires get `Crossing`; other rows get `Connector`. + /// Since every row in a `CircuitDiagram` is a qubit wire, this always + /// places `Crossing` cells. + pub fn connect_vertical(&mut self, top: usize, bottom: usize, color: CellColor) { + self.ensure_column(); + let (lo, hi) = if top < bottom { + (top, bottom) + } else { + (bottom, top) + }; + self.connector_spans.push((self.current_col, lo, hi, None)); + for row in (lo + 1)..hi { + if row < self.num_rows() { + // All rows in CircuitDiagram are qubit wires -> Crossing. + self.columns[self.current_col][row] = (DiagramCell::Crossing, color); + } + } + } + + /// Record a vertical connector span without setting intermediate cells. + /// + /// Use this when intermediate `Crossing` cells are set separately + /// (e.g. by `set_cell` in circuit display code). + pub fn add_connector(&mut self, top: usize, bottom: usize) { + let (lo, hi) = if top < bottom { + (top, bottom) + } else { + (bottom, top) + }; + self.connector_spans.push((self.current_col, lo, hi, None)); + } + + /// Record a labeled vertical connector span without setting intermediate cells. + /// + /// The label is rendered on the connector line between the two endpoints + /// (e.g. "ZZ" for symmetric two-qubit interactions). + pub fn add_labeled_connector(&mut self, top: usize, bottom: usize, label: String) { + let (lo, hi) = if top < bottom { + (top, bottom) + } else { + (bottom, top) + }; + self.connector_spans + .push((self.current_col, lo, hi, Some(label))); + } + + /// Advance to the next column. + pub fn advance(&mut self) { + self.current_col += 1; + } + + /// Render the diagram to a text string using a full [`DiagramStyle`]. + #[must_use] + pub fn render_text(&self, header: &str, style: &DiagramStyle) -> String { + let num_rows = self.num_rows(); + if num_rows == 0 { + return if header.is_empty() { + String::new() + } else { + format!("{header}\n") + }; + } + + // Strip trailing all-Wire columns. + let num_cols = self.effective_columns(); + if num_cols == 0 { + return if header.is_empty() { + String::new() + } else { + format!("{header}\n") + }; + } + + // Column widths (based on widest cell content). + let mut col_widths: Vec = (0..num_cols) + .map(|c| { + self.columns[c] + .iter() + .map(|(cell, _)| cell_content_width(cell)) + .max() + .unwrap_or(1) + }) + .collect(); + + // Widen columns that carry a connector label so the label text fits. + for (col, _top, _bottom, label) in &self.connector_spans { + if let Some(text) = label { + let text_len = text.chars().count() + 2; // +2 for brackets + if *col < col_widths.len() { + col_widths[*col] = col_widths[*col].max(text_len); + } + } + } + + let label_width = self.labels.iter().map(String::len).max().unwrap_or(2); + + let wire_char = match style.symbols { + SymbolSet::Ascii => '-', + SymbolSet::Unicode => '\u{2500}', // ─ + }; + + let mut out = String::new(); + if !header.is_empty() { + writeln!(out, "{header}").unwrap(); + writeln!(out).unwrap(); + } + + // Bracket annotation line for column groups. + if !self.column_groups.is_empty() { + let mut col_offsets = Vec::with_capacity(num_cols); + let mut offset = 0usize; + for &w in &col_widths { + col_offsets.push(offset); + offset += w + 2; + } + let total_width = offset; + + let mut bracket_chars: Vec = vec![' '; total_width]; + + let (open_bracket, close_bracket, dash) = match style.symbols { + SymbolSet::Ascii => ('|', '|', '-'), + SymbolSet::Unicode => ('\u{251C}', '\u{2524}', '\u{2500}'), + }; + + for (label, start, end) in &self.column_groups { + if *start >= num_cols || *end >= num_cols { + continue; + } + let char_start = col_offsets[*start]; + let char_end = col_offsets[*end] + col_widths[*end] + 2; + + if char_end <= char_start { + continue; + } + + for c in &mut bracket_chars[char_start..char_end] { + *c = dash; + } + bracket_chars[char_start] = open_bracket; + bracket_chars[char_end - 1] = close_bracket; + + let span_len = char_end - char_start; + let label_len = label.chars().count(); + if label_len < span_len.saturating_sub(2) { + let pad = (span_len - label_len) / 2; + for (i, ch) in label.chars().enumerate() { + let pos = char_start + pad + i; + if pos < char_end { + bracket_chars[pos] = ch; + } + } + } + } + + write!(out, "{:>width$} ", "", width = label_width).unwrap(); + let bracket_line: String = bracket_chars.into_iter().collect(); + writeln!(out, "{}", bracket_line.trim_end()).unwrap(); + } + + for row in 0..num_rows { + write!(out, "{:>label_width$}: ", self.labels[row]).unwrap(); + + for (col_idx, &width) in col_widths.iter().enumerate() { + let (ref cell, color) = self.columns[col_idx][row]; + let rendered = render_cell(cell, width, wire_char, style); + + if style.ansi_color && !matches!(cell, DiagramCell::Wire) { + let code = ansi_code(color); + if code.is_empty() { + write!(out, "{wire_char}{rendered}{wire_char}").unwrap(); + } else { + write!(out, "{wire_char}{code}{rendered}{ANSI_RESET}{wire_char}").unwrap(); + } + } else { + write!(out, "{wire_char}{rendered}{wire_char}").unwrap(); + } + } + + writeln!(out).unwrap(); + + // Connector row between qubit wires. + if row + 1 < num_rows { + let has_adjacent_label = + self.connector_spans.iter().any(|(_, top, bottom, label)| { + label.is_some() && *bottom - *top == 1 && *top == row + }); + if has_adjacent_label { + // | row above label + if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, false) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + // label row + if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, true) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + // | row below label + if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, false) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + } else if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, true) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + } + } + + out + } + + // ======================================================================== + // SVG rendering + // ======================================================================== + + /// Render the diagram as a standalone SVG string. + /// + /// If `header` is non-empty it is rendered as a `` title at the top. + #[must_use] + pub fn render_svg(&self, header: &str) -> String { + self.render_svg_with(header, &DiagramStyle::default()) + } + + /// Render the diagram as a standalone SVG string using a full [`DiagramStyle`]. + #[must_use] + pub fn render_svg_with(&self, header: &str, style: &DiagramStyle) -> String { + const ROW_SPACING: f64 = 40.0; + const MIN_COL_SPACING: f64 = 40.0; + const COL_PAD: f64 = 10.0; + const CHAR_WIDTH: f64 = 9.0; + const BOX_PAD_SHORT: f64 = 18.0; + const BOX_PAD: f64 = 12.0; + const BOX_PAD_LONG: f64 = 6.0; + const GATE_H: f64 = 24.0; + const LABEL_MARGIN: f64 = 50.0; + const CTRL_RADIUS: f64 = 3.5; + const FONT_SIZE: f64 = 13.0; + const GATE_RX: f64 = 4.0; + + let num_rows = self.num_rows(); + let num_cols = self.effective_columns(); + + if num_rows == 0 || num_cols == 0 { + return if header.is_empty() { + "".to_string() + } else { + format!( + "\ + {header}\ + " + ) + }; + } + + // Column widths in characters (used to compute gate box widths). + // Uses gate name length without bracket padding for tighter SVG boxes. + let col_widths: Vec = (0..num_cols) + .map(|c| { + self.columns[c] + .iter() + .map(|(cell, _)| cell_svg_width(cell)) + .max() + .unwrap_or(1) + }) + .collect(); + + let box_pad_for = |char_count: usize| -> f64 { + match char_count { + 0..=1 => BOX_PAD_SHORT, + 2..=4 => BOX_PAD, + _ => BOX_PAD_LONG, + } + }; + + // Gate box pixel widths per column. + let mut gate_ws: Vec = col_widths + .iter() + .map(|&w| ((w as f64) * CHAR_WIDTH + box_pad_for(w)).max(GATE_H)) + .collect(); + + // Widen columns that carry a connector label (e.g. "RZZ" on the line + // between two control dots) so the label box doesn't overlap neighbours. + for (col, _top, _bottom, label) in &self.connector_spans { + if let Some(text) = label { + let cc = text.chars().count(); + let label_w = ((cc as f64) * CHAR_WIDTH + box_pad_for(cc)).max(GATE_H); + if *col < gate_ws.len() { + gate_ws[*col] = gate_ws[*col].max(label_w); + } + } + } + + // Per-column spacing: enough for the gate box plus padding, at least MIN_COL_SPACING. + let col_spacings: Vec = gate_ws + .iter() + .map(|&gw| (gw + COL_PAD).max(MIN_COL_SPACING)) + .collect(); + + // Column center x-positions, placed edge-to-edge. + let mut col_cx: Vec = Vec::with_capacity(num_cols); + let mut x_cursor = LABEL_MARGIN; + for &spacing in &col_spacings { + col_cx.push(x_cursor + spacing / 2.0); + x_cursor += spacing; + } + + let header_offset: f64 = if header.is_empty() { 0.0 } else { 30.0 }; + let svg_width = x_cursor + 20.0; + let svg_height = header_offset + (num_rows as f64) * ROW_SPACING + ROW_SPACING * 0.5; + + let mut out = String::new(); + writeln!( + out, + "" + ) + .unwrap(); + writeln!(out, "").unwrap(); + + if !header.is_empty() { + writeln!( + out, + "{header}" + ) + .unwrap(); + } + + // Layer 0: Column group backgrounds. + for (label, start, end) in &self.column_groups { + if *start >= num_cols || *end >= num_cols { + continue; + } + let x1 = col_cx[*start] - col_spacings[*start] / 2.0; + let x2 = col_cx[*end] + col_spacings[*end] / 2.0; + let y1 = header_offset + ROW_SPACING * 0.5 - ROW_SPACING * 0.4; + let y2 = + header_offset + ROW_SPACING * ((num_rows - 1) as f64 + 0.5) + ROW_SPACING * 0.4; + let w = x2 - x1; + let h = y2 - y1; + writeln!( + out, + "", + ) + .unwrap(); + let lx = f64::midpoint(x1, x2); + let ly = y1 - 2.0; + writeln!( + out, + "{label}", + ) + .unwrap(); + } + + // Layer 1: Qubit labels and horizontal wires. + for row in 0..num_rows { + let y = header_offset + ROW_SPACING * (row as f64 + 0.5); + writeln!( + out, + "{label}", + x = LABEL_MARGIN - 6.0, + ty = y, + label = self.labels[row], + ) + .unwrap(); + writeln!( + out, + "", + ) + .unwrap(); + } + + // Layer 2: Vertical connector lines (drawn before gates so gates sit on top). + for (col, top, bottom, label) in &self.connector_spans { + let col = *col; + let top = *top; + let bottom = *bottom; + if col >= num_cols { + continue; + } + let cx = col_cx[col]; + let y1 = header_offset + ROW_SPACING * (top as f64 + 0.5); + let y2 = header_offset + ROW_SPACING * (bottom as f64 + 0.5); + let conn_color = if !style.color { + CellColor::ControlDot + } else if label.is_some() { + self.columns[col][top].1 + } else { + CellColor::ControlDot + }; + let conn_stroke = style.triplet(conn_color).stroke.clone(); + writeln!( + out, + "", + ) + .unwrap(); + // Render label on the midpoint of the connector line. + if let Some(text) = label { + let mid_y = f64::midpoint(y1, y2); + let lbl_color = self.columns[col][top].1; + let t = style.triplet(lbl_color); + let char_count = text.chars().count(); + let pad = if char_count <= 1 { + BOX_PAD_SHORT + } else if char_count <= 4 { + BOX_PAD + } else { + BOX_PAD_LONG + }; + let lw = ((char_count as f64) * CHAR_WIDTH + pad).max(GATE_H); + let lh = GATE_H * 0.85; + writeln!( + out, + "", + rx = cx - lw / 2.0, + ry = mid_y - lh / 2.0, + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + writeln!( + out, + "{text}", + fs = FONT_SIZE - 1.0, + fill = t.text, + ) + .unwrap(); + } + } + + // Layer 3: Gate boxes, control dots, and crossing markers (on top of wires). + for (col_idx, &cx) in col_cx.iter().enumerate().take(num_cols) { + for row in 0..num_rows { + let cy = header_offset + ROW_SPACING * (row as f64 + 0.5); + let (ref cell, color) = self.columns[col_idx][row]; + + match cell { + DiagramCell::Wire | DiagramCell::LabeledConnector(_) => {} + DiagramCell::Gate(s, family) => { + let t = style.triplet(color); + // Per-gate width: sized to its own label, centered in the column. + let char_count = s.chars().count(); + let gw = ((char_count as f64) * CHAR_WIDTH + box_pad_for(char_count)) + .max(GATE_H); + let dash = style.svg_dasharray(*family); + let dash_attr = if dash.is_empty() { + String::new() + } else { + format!(" stroke-dasharray=\"{dash}\"") + }; + let x1 = cx - gw / 2.0; + let y1 = cy - GATE_H / 2.0; + let x2 = x1 + gw; + let y2 = y1 + GATE_H; + let r = GATE_H / 2.0; // curve radius + match family { + GateFamily::Preparation => { + // Curved left side, flat right side. + writeln!( + out, + "", + lx = x1 + r, + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + GateFamily::Measurement => { + // Flat left side, curved right side. + writeln!( + out, + "", + rx_pt = x2 - r, + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + _ => { + writeln!( + out, + "", + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + } + writeln!( + out, + "{s}", + fill = t.text, + ) + .unwrap(); + } + DiagramCell::Control => { + let effective = if style.color { + color + } else { + CellColor::ControlDot + }; + let t = style.triplet(effective); + writeln!( + out, + "", + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + DiagramCell::Crossing | DiagramCell::Connector => { + writeln!( + out, + "", + ) + .unwrap(); + } + } + } + } + + writeln!(out, "").unwrap(); + out + } + + // ======================================================================== + // TikZ rendering + // ======================================================================== + + /// Render the diagram as a `TikZ` `tikzpicture` environment. + /// + /// Requires only `\usepackage{tikz}` -- no quantikz. If `header` is + /// non-empty it is emitted as a `TikZ` comment. + #[must_use] + pub fn render_tikz(&self, header: &str) -> String { + self.render_tikz_with(header, &DiagramStyle::default()) + } + + /// Render the diagram as a `TikZ` `tikzpicture` using a full [`DiagramStyle`]. + #[must_use] + pub fn render_tikz_with(&self, header: &str, style: &DiagramStyle) -> String { + const ROW_STEP: f64 = 0.8; + const COL_STEP: f64 = 1.2; + const GATE_W: f64 = 0.7; + const GATE_H: f64 = 0.5; + const CTRL_R: f64 = 0.08; + + let num_rows = self.num_rows(); + let num_cols = self.effective_columns(); + + let mut out = String::new(); + + if !header.is_empty() { + writeln!(out, "% {header}").unwrap(); + } + + writeln!(out, "\\begin{{tikzpicture}}").unwrap(); + + // Color definitions from the style palette. + for &c in &[ + CellColor::None, + CellColor::XAxis, + CellColor::YAxis, + CellColor::ZAxis, + CellColor::XZMix, + CellColor::XYMix, + CellColor::YZMix, + CellColor::XYZMix, + CellColor::ControlDot, + ] { + let name = c.tikz_name(); + let t = style.palette.get(c); + let fill_hex = t.fill.strip_prefix('#').unwrap_or(&t.fill); + let stroke_hex = t.stroke.strip_prefix('#').unwrap_or(&t.stroke); + let text_hex = t.text.strip_prefix('#').unwrap_or(&t.text); + writeln!(out, " \\definecolor{{{name}Fill}}{{HTML}}{{{fill_hex}}}",).unwrap(); + writeln!( + out, + " \\definecolor{{{name}Stroke}}{{HTML}}{{{stroke_hex}}}", + ) + .unwrap(); + writeln!(out, " \\definecolor{{{name}Text}}{{HTML}}{{{text_hex}}}",).unwrap(); + } + + // Styles. + writeln!( + out, + " \\tikzstyle{{gate}}=[draw, rounded corners=2pt, minimum width={GATE_W}cm, \ + minimum height={GATE_H}cm, inner sep=1pt, font=\\footnotesize\\ttfamily]" + ) + .unwrap(); + writeln!( + out, + " \\tikzstyle{{ctrl}}=[circle, fill, inner sep=0pt, minimum size={r}cm]", + r = CTRL_R * 2.0, + ) + .unwrap(); + + if num_rows == 0 || num_cols == 0 { + writeln!(out, "\\end{{tikzpicture}}").unwrap(); + return out; + } + + // Column group backgrounds. + for (label, start, end) in &self.column_groups { + if *start >= num_cols || *end >= num_cols { + continue; + } + let x1 = (*start as f64 + 0.5) * COL_STEP - GATE_W / 2.0 - 0.1; + let x2 = (*end as f64 + 0.5) * COL_STEP + GATE_W / 2.0 + 0.1; + let y1 = ROW_STEP * 0.3; + let y2 = -((num_rows - 1) as f64) * ROW_STEP - ROW_STEP * 0.3; + writeln!( + out, + " \\fill[black!10, rounded corners=2pt] ({x1:.2},{y1:.2}) rectangle ({x2:.2},{y2:.2});", + ) + .unwrap(); + let mid_x = f64::midpoint(x1, x2); + let label_y = y1 + 0.2; + writeln!( + out, + " \\node[font=\\tiny\\ttfamily, gray] at ({mid_x:.2},{label_y:.2}) {{{label}}};", + ) + .unwrap(); + } + + // Wires and labels. + for row in 0..num_rows { + let y = -(row as f64) * ROW_STEP; + let x_start = -0.5; + let x_end = (num_cols as f64) * COL_STEP + 0.3; + writeln!( + out, + " \\draw[gray] ({x_start:.2},{y:.2}) -- ({x_end:.2},{y:.2});", + ) + .unwrap(); + writeln!( + out, + " \\node[anchor=east, font=\\footnotesize\\ttfamily] at ({lx:.2},{y:.2}) {{{label}}};", + lx = x_start - 0.15, + label = self.labels[row], + ) + .unwrap(); + } + + // Gates, controls, connectors. + for col_idx in 0..num_cols { + let x = (col_idx as f64 + 0.5) * COL_STEP; + + for row in 0..num_rows { + let y = -(row as f64) * ROW_STEP; + let (ref cell, color) = self.columns[col_idx][row]; + // When style.color is false, use CellColor::None for all gates. + let effective = if style.color { color } else { CellColor::None }; + let name = effective.tikz_name(); + + match cell { + DiagramCell::Wire | DiagramCell::LabeledConnector(_) => {} + DiagramCell::Gate(s, family) => { + let dash = style.tikz_dash(*family); + let dash_opt = if dash.is_empty() { + String::new() + } else { + format!(", {dash}") + }; + writeln!( + out, + " \\node[gate, fill={name}Fill, draw={name}Stroke, text={name}Text{dash_opt}] at ({x:.2},{y:.2}) {{{s}}};", + ) + .unwrap(); + } + DiagramCell::Control => { + let ctrl_effective = if style.color { + color + } else { + CellColor::ControlDot + }; + let ctrl_name = ctrl_effective.tikz_name(); + writeln!( + out, + " \\node[ctrl, fill={ctrl_name}Fill, draw={ctrl_name}Stroke] at ({x:.2},{y:.2}) {{}};", + ) + .unwrap(); + } + DiagramCell::Crossing | DiagramCell::Connector => { + writeln!( + out, + " \\node[circle, fill=gray, inner sep=0pt, minimum size=0.06cm] at ({x:.2},{y:.2}) {{}};", + ) + .unwrap(); + } + } + } + } + + // Vertical connector lines (from explicit spans). + for (col, top, bottom, label) in &self.connector_spans { + let col = *col; + let top = *top; + let bottom = *bottom; + if col >= num_cols { + continue; + } + let x = (col as f64 + 0.5) * COL_STEP; + let y1 = -(top as f64) * ROW_STEP; + let y2 = -(bottom as f64) * ROW_STEP; + let conn_color = if !style.color { + CellColor::ControlDot + } else if label.is_some() { + self.columns[col][top].1 + } else { + CellColor::ControlDot + }; + let conn_name = conn_color.tikz_name(); + writeln!( + out, + " \\draw[{conn_name}Stroke] ({x:.2},{y1:.2}) -- ({x:.2},{y2:.2});", + ) + .unwrap(); + if let Some(text) = label { + let mid_y = f64::midpoint(y1, y2); + let lbl_color = self.columns[col][top].1; + let effective = if style.color { + lbl_color + } else { + CellColor::None + }; + let name = effective.tikz_name(); + writeln!( + out, + " \\node[gate, fill={name}Fill, draw={name}Stroke, text={name}Text] at ({x:.2},{mid_y:.2}) {{\\footnotesize {text}}};", + ) + .unwrap(); + } + } + + writeln!(out, "\\end{{tikzpicture}}").unwrap(); + out + } + + // ======================================================================== + // DOT / Graphviz rendering + // ======================================================================== + + /// Render the diagram as a Graphviz DOT `digraph` with `rankdir=LR`. + /// + /// If `header` is non-empty it is set as the graph `label`. + #[must_use] + pub fn render_dot(&self, header: &str) -> String { + self.render_dot_with(header, &DiagramStyle::default()) + } + + /// Render the diagram as a Graphviz DOT `digraph` using a full [`DiagramStyle`]. + #[must_use] + pub fn render_dot_with(&self, header: &str, style: &DiagramStyle) -> String { + let num_rows = self.num_rows(); + let num_cols = self.effective_columns(); + + let mut out = String::new(); + writeln!(out, "digraph circuit {{").unwrap(); + writeln!(out, " rankdir=LR;").unwrap(); + writeln!(out, " node [fontname=\"Courier\", fontsize=11];").unwrap(); + writeln!(out, " edge [arrowhead=none];").unwrap(); + + if !header.is_empty() { + writeln!(out, " label=\"{header}\";").unwrap(); + writeln!(out, " labelloc=t;").unwrap(); + } + + if num_rows == 0 || num_cols == 0 { + writeln!(out, "}}").unwrap(); + return out; + } + + // Node IDs: "r{row}c{col}" for gate cells, "r{row}_in"/"r{row}_out" for endpoints. + + // Input label nodes. + writeln!(out, " // Input labels").unwrap(); + writeln!(out, " {{ rank=same;").unwrap(); + for row in 0..num_rows { + writeln!( + out, + " r{row}_in [label=\"{label}\", shape=plaintext];", + label = self.labels[row], + ) + .unwrap(); + } + writeln!(out, " }}").unwrap(); + + // Output nodes (invisible). + writeln!(out, " // Output nodes").unwrap(); + writeln!(out, " {{ rank=same;").unwrap(); + for row in 0..num_rows { + writeln!( + out, + " r{row}_out [label=\"\", shape=none, width=0, height=0];", + ) + .unwrap(); + } + writeln!(out, " }}").unwrap(); + + // Gate columns. + for col_idx in 0..num_cols { + writeln!(out, " // Column {col_idx}").unwrap(); + writeln!(out, " {{ rank=same;").unwrap(); + for row in 0..num_rows { + let (ref cell, color) = self.columns[col_idx][row]; + let node_id = format!("r{row}c{col_idx}"); + + match cell { + DiagramCell::Wire => { + writeln!(out, " {node_id} [label=\"\", shape=point, width=0.01];",) + .unwrap(); + } + DiagramCell::Gate(s, family) => { + let t = style.triplet(color); + let dot_style = style.dot_style(*family); + let style_val = if dot_style.is_empty() { + "filled".to_string() + } else { + format!("\"filled,{dot_style}\"") + }; + writeln!( + out, + " {node_id} [label=\"{s}\", shape=box, style={style_val}, \ + fillcolor=\"{fill}\", color=\"{stroke}\", fontcolor=\"{text}\"];", + fill = t.fill, + stroke = t.stroke, + text = t.text, + ) + .unwrap(); + } + DiagramCell::Control => { + let t = style.triplet(color); + writeln!( + out, + " {node_id} [label=\"\", shape=point, width=0.12, \ + style=filled, fillcolor=\"{fill}\"];", + fill = t.fill, + ) + .unwrap(); + } + DiagramCell::Crossing | DiagramCell::Connector => { + writeln!(out, " {node_id} [label=\"\", shape=point, width=0.05];",) + .unwrap(); + } + DiagramCell::LabeledConnector(s) => { + writeln!( + out, + " {node_id} [label=\"{s}\", shape=box, style=filled, fillcolor=white, fontsize=10];", + ) + .unwrap(); + } + } + } + writeln!(out, " }}").unwrap(); + } + + // Column group clusters. + for (i, (label, start, end)) in self.column_groups.iter().enumerate() { + if *start >= num_cols || *end >= num_cols { + continue; + } + writeln!(out, " subgraph cluster_group{i} {{").unwrap(); + writeln!(out, " style=filled;").unwrap(); + writeln!(out, " color=\"#D0D8E0\";").unwrap(); + writeln!(out, " fillcolor=\"#D0D8E080\";").unwrap(); + writeln!(out, " label=\"{label}\";").unwrap(); + writeln!(out, " fontname=\"Courier\";").unwrap(); + writeln!(out, " fontsize=9;").unwrap(); + writeln!(out, " fontcolor=\"#888888\";").unwrap(); + for col_idx in *start..=*end { + for row in 0..num_rows { + writeln!(out, " r{row}c{col_idx};").unwrap(); + } + } + writeln!(out, " }}").unwrap(); + } + + // Wire edges. + writeln!(out, " // Wires").unwrap(); + for row in 0..num_rows { + let mut prev = format!("r{row}_in"); + for col_idx in 0..num_cols { + let cur = format!("r{row}c{col_idx}"); + writeln!(out, " {prev} -> {cur};").unwrap(); + prev = cur; + } + writeln!(out, " {prev} -> r{row}_out;").unwrap(); + } + + // Vertical connector edges (from explicit spans). + writeln!(out, " // Vertical connectors").unwrap(); + for (col, top, bottom, _label) in &self.connector_spans { + let col = *col; + let top = *top; + let bottom = *bottom; + if col >= num_cols { + continue; + } + // Connect top to bottom through all intermediate non-Wire rows. + let mut prev_row = top; + for row in (top + 1)..=bottom { + if row < num_rows && !matches!(self.columns[col][row].0, DiagramCell::Wire) { + writeln!( + out, + " r{prev_row}c{col} -> r{row}c{col} [style=dashed, dir=none, constraint=false];", + ) + .unwrap(); + prev_row = row; + } + } + } + + writeln!(out, "}}").unwrap(); + out + } + + /// Count effective columns (strip trailing all-Wire columns). + fn effective_columns(&self) -> usize { + let mut n = self.columns.len(); + while n > 0 { + let all_wire = self.columns[n - 1] + .iter() + .all(|(cell, _)| matches!(cell, DiagramCell::Wire)); + if all_wire { + n -= 1; + } else { + break; + } + } + n + } + + /// Render the connector row between `row` and `row + 1`. + /// Returns `None` if no connectors are needed. + fn render_connector_row( + &self, + row: usize, + num_cols: usize, + col_widths: &[usize], + style: &DiagramStyle, + show_labels: bool, + ) -> Option { + let label_width = self.labels.iter().map(String::len).max().unwrap_or(2); + let mut line = String::new(); + write!(line, "{:>width$} ", "", width = label_width).unwrap(); + let mut has_connector = false; + + for (col_idx, &width) in col_widths.iter().enumerate() { + if col_idx >= num_cols { + break; + } + // Show a vertical connector when this row and the next are both + // inside a connector span for this column. + // Find the connector span for this column/row, if any. + let span = self + .connector_spans + .iter() + .find(|(col, top, bottom, _)| *col == col_idx && row >= *top && row < *bottom); + let show = span.is_some(); + + if show { + has_connector = true; + // Check if this connector row is the midpoint and has a label. + let label_here = if show_labels { + span.and_then(|(_, top, bottom, label)| { + label.as_ref().filter(|_| { + // Place label on the midpoint connector row. + let mid = (top + bottom - 1) / 2; + row == mid + }) + }) + } else { + None + }; + // Center a `|` (or label text) within the column width + 2 + // surrounding spaces, matching the cell rendering padding. + let total = width + 2; + let content = if let Some(text) = label_here { + format!("[{text}]") + } else { + "|".to_string() + }; + let content_len = content.chars().count(); + let pad_total = total.saturating_sub(content_len); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + let left: String = std::iter::repeat_n(' ', pad_left).collect(); + let right: String = std::iter::repeat_n(' ', pad_right).collect(); + // Labeled spans use the endpoint cell color; unlabeled use ControlDot. + let connector_color = if span.is_some_and(|(_, _, _, label)| label.is_some()) { + let &(col, top, _, _) = span.unwrap(); + self.columns + .get(col) + .and_then(|c| c.get(top)) + .map_or(CellColor::ControlDot, |&(_, color)| color) + } else { + CellColor::ControlDot + }; + let code = ansi_code(connector_color); + if style.ansi_color && !code.is_empty() { + write!(line, "{left}{code}{content}{ANSI_RESET}{right}").unwrap(); + } else { + write!(line, "{left}{content}{right}").unwrap(); + } + } else { + let spaces: String = std::iter::repeat_n(' ', width + 2).collect(); + write!(line, "{spaces}").unwrap(); + } + } + + if has_connector { Some(line) } else { None } + } +} + +// ============================================================================ +// Rendering helpers +// ============================================================================ + +/// Content width of a cell in characters (before padding). +fn cell_content_width(cell: &DiagramCell) -> usize { + match cell { + DiagramCell::Gate(s, _) | DiagramCell::LabeledConnector(s) => s.chars().count() + 2, // +2 for brackets + DiagramCell::Wire + | DiagramCell::Control + | DiagramCell::Crossing + | DiagramCell::Connector => 1, + } +} + +/// Gate name width in characters without bracket padding (for SVG box sizing). +fn cell_svg_width(cell: &DiagramCell) -> usize { + match cell { + DiagramCell::Gate(s, _) | DiagramCell::LabeledConnector(s) => s.chars().count(), + DiagramCell::Wire + | DiagramCell::Control + | DiagramCell::Crossing + | DiagramCell::Connector => 1, + } +} + +/// Render a single cell into the given column width. +fn render_cell(cell: &DiagramCell, width: usize, wire_char: char, style: &DiagramStyle) -> String { + match cell { + DiagramCell::Wire => std::iter::repeat_n(wire_char, width).collect(), + DiagramCell::Gate(s, family) => { + let bracketed = format!("{}{s}{}", family.open_bracket(), family.close_bracket()); + pad_center(&bracketed, width, wire_char) + } + DiagramCell::Control => { + let dot = match style.symbols { + SymbolSet::Ascii => ".", + SymbolSet::Unicode => "\u{25CF}", // ● + }; + pad_center(dot, width, wire_char) + } + DiagramCell::Crossing => pad_center("+", width, wire_char), + DiagramCell::Connector => { + // Connector on a qubit wire row -- treat as crossing. + pad_center("|", width, wire_char) + } + DiagramCell::LabeledConnector(s) => { + let bracketed = format!("[{s}]"); + pad_center(&bracketed, width, ' ') + } + } +} + +/// Center `s` within `width` characters, padding with `pad_char`. +fn pad_center(s: &str, width: usize, pad_char: char) -> String { + let content_width = s.chars().count(); + let pad_total = width.saturating_sub(content_width); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + let left: String = std::iter::repeat_n(pad_char, pad_left).collect(); + let right: String = std::iter::repeat_n(pad_char, pad_right).collect(); + format!("{left}{s}{right}") +} + +// ============================================================================ +// Graph state style types +// ============================================================================ + +/// Blend two `#RRGGBB` hex colors at ratio `t` (0.0 = a, 1.0 = b). +/// +/// Returns a new `#RRGGBB` string. Clamps `t` to `[0.0, 1.0]`. +#[must_use] +pub fn blend_hex(a: &str, b: &str, t: f64) -> String { + let t = t.clamp(0.0, 1.0); + let parse = |hex: &str| -> (u8, u8, u8) { + let h = hex.strip_prefix('#').unwrap_or(hex); + let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(0); + let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(0); + let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(0); + (r, g, b) + }; + let (r1, g1, b1) = parse(a); + let (r2, g2, b2) = parse(b); + let mix = + |c1: u8, c2: u8| -> u8 { (f64::from(c1) * (1.0 - t) + f64::from(c2) * t).round() as u8 }; + format!("#{:02X}{:02X}{:02X}", mix(r1, r2), mix(g1, g2), mix(b1, b2)) +} + +/// Fill pattern overlay for graph state vertices. +/// +/// Provides a third visual dimension (pattern) beyond color (fill hue) +/// and stroke style (dash pattern), useful for monochrome rendering +/// where cosets would otherwise be indistinguishable. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum FillPattern { + /// No pattern overlay (plain solid fill). + #[default] + Solid, + /// Diagonal lines going up-right (/). + DiagonalUp, + /// Crosshatch pattern (X). + Crosshatch, + /// Small dots. + Dots, + /// Horizontal lines (-). + HorizontalLines, +} + +impl FillPattern { + /// SVG pattern element ID. Empty for `Solid`. + #[must_use] + pub fn svg_id(self) -> &'static str { + match self { + Self::Solid => "", + Self::DiagonalUp => "pat-diag", + Self::Crosshatch => "pat-cross", + Self::Dots => "pat-dots", + Self::HorizontalLines => "pat-hlines", + } + } + + /// Full SVG `` element definition. Empty for `Solid`. + #[must_use] + pub fn svg_pattern_def(self) -> &'static str { + match self { + Self::Solid => "", + Self::DiagonalUp => concat!( + "\n", + " \n", + " " + ), + Self::Crosshatch => concat!( + "\n", + " \n", + " \n", + " " + ), + Self::Dots => concat!( + "\n", + " \n", + " " + ), + Self::HorizontalLines => concat!( + "\n", + " \n", + " " + ), + } + } + + /// `TikZ` pattern name for `postaction`. Empty for `Solid`. + #[must_use] + pub fn tikz_pattern(self) -> &'static str { + match self { + Self::Solid => "", + Self::DiagonalUp => "north east lines", + Self::Crosshatch => "crosshatch", + Self::Dots => "crosshatch dots", + Self::HorizontalLines => "horizontal lines", + } + } +} + +/// Fill patterns per axis-permutation coset. +/// +/// Each coset can have an independent pattern overlay to distinguish +/// them when fill colors are similar or identical (e.g. monochrome). +#[derive(Clone, Debug)] +pub struct CosetPatterns { + pub identity: FillPattern, + pub xz_mix: FillPattern, + pub xy_mix: FillPattern, + pub yz_mix: FillPattern, + pub xyz_mix: FillPattern, +} + +impl Default for CosetPatterns { + fn default() -> Self { + Self { + identity: FillPattern::Solid, + xz_mix: FillPattern::Solid, + xy_mix: FillPattern::Solid, + yz_mix: FillPattern::Solid, + xyz_mix: FillPattern::Solid, + } + } +} + +impl CosetPatterns { + /// Look up the fill pattern for a given coset. + #[must_use] + pub fn get(&self, coset: CellColor) -> FillPattern { + match coset { + CellColor::ZAxis => self.identity, + CellColor::XZMix => self.xz_mix, + CellColor::XYMix => self.xy_mix, + CellColor::YZMix => self.yz_mix, + CellColor::XYZMix => self.xyz_mix, + _ => FillPattern::Solid, + } + } +} + +/// Stroke colors for graph state gate families (rotation types). +/// +/// These encode geometric rotation type on the Bloch sphere, orthogonal +/// to the coset fill colors. +#[derive(Clone, Debug)] +pub struct FamilyPalette { + /// Pauli gates (identity / pi-rotations). Default: navy `#1E3A8A`. + pub pauli: String, + /// sqrt-of-Pauli / S-like (pi/2 rotations). Default: green `#2D6A2E`. + pub s_like: String, + /// Hadamard-like (pi rotations about face diagonals). Default: maroon `#8B1A1A`. + pub h_like: String, + /// Face-like / cyclic (2pi/3 rotations). Default: charcoal `#404040`. + pub f_like: String, +} + +impl Default for FamilyPalette { + fn default() -> Self { + Self { + pauli: "#1E3A8A".to_string(), + s_like: "#2D6A2E".to_string(), + h_like: "#8B1A1A".to_string(), + f_like: "#404040".to_string(), + } + } +} + +impl FamilyPalette { + /// Look up the stroke color for a gate family. + #[must_use] + pub fn get(&self, family: GateFamily) -> &str { + match family { + GateFamily::Pauli + | GateFamily::Default + | GateFamily::Measurement + | GateFamily::Preparation => &self.pauli, + GateFamily::SLike => &self.s_like, + GateFamily::HLike => &self.h_like, + GateFamily::FLike => &self.f_like, + } + } +} + +/// Full configuration for graph state visualization. +/// +/// Controls fill colors (from [`ColorPalette`]), family stroke colors +/// (from [`FamilyPalette`]), and ANSI color output. Use +/// [`GraphStyle::builder()`] for convenient construction. +#[derive(Clone, Debug, Default)] +pub struct GraphStyle { + pub palette: ColorPalette, + pub family_strokes: FamilyPalette, + pub ansi_color: bool, + /// Whether to render stroke dash patterns on vertices. + /// When false, all strokes are solid. + pub show_dashes: bool, + /// Fill pattern overlays per coset (for monochrome differentiation). + pub coset_patterns: CosetPatterns, +} + +impl GraphStyle { + /// Create a builder for constructing a custom `GraphStyle`. + #[must_use] + pub fn builder() -> GraphStyleBuilder { + GraphStyleBuilder::new() + } + + /// Compute the fill color for a VOP vertex. + /// + /// Saturated vertices (even sign parity) get a midpoint blend of + /// the palette's fill and stroke colors; light vertices get the + /// palette's fill directly. + #[must_use] + pub fn vop_fill(&self, coset: CellColor, saturated: bool) -> String { + let triplet = self.palette.get(coset); + if saturated { + blend_hex(&triplet.fill, &triplet.stroke, 0.5) + } else { + triplet.fill.clone() + } + } + + /// Look up the stroke color for a gate family. + #[must_use] + pub fn vop_stroke(&self, family: GateFamily) -> &str { + self.family_strokes.get(family) + } + + /// Compute the text color for a VOP vertex. + /// + /// Saturated vertices get white text; light vertices get the + /// palette's text color. + #[must_use] + pub fn vop_text(&self, coset: CellColor, saturated: bool) -> &str { + if saturated { + "white" + } else { + &self.palette.get(coset).text + } + } + + /// Effective SVG `stroke-dasharray` for a gate family, respecting `show_dashes`. + #[must_use] + pub fn vop_dasharray(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.svg_dasharray() + } else { + "" + } + } + + /// Effective `TikZ` dash pattern for a gate family, respecting `show_dashes`. + #[must_use] + pub fn vop_tikz_dash(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.tikz_dash() + } else { + "" + } + } + + /// Effective DOT style for a gate family, respecting `show_dashes`. + #[must_use] + pub fn vop_dot_style(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.dot_style() + } else { + "" + } + } + + /// Look up the fill pattern for a coset. + #[must_use] + pub fn vop_pattern(&self, coset: CellColor) -> FillPattern { + self.coset_patterns.get(coset) + } +} + +/// Builder for [`GraphStyle`]. +#[derive(Clone, Debug)] +pub struct GraphStyleBuilder { + style: GraphStyle, +} + +impl GraphStyleBuilder { + /// Create a new builder with default settings. + #[must_use] + pub fn new() -> Self { + Self { + style: GraphStyle::default(), + } + } + + /// Set the entire color palette. + #[must_use] + pub fn palette(mut self, p: ColorPalette) -> Self { + self.style.palette = p; + self + } + + /// Set the entire family stroke palette. + #[must_use] + pub fn family_strokes(mut self, f: FamilyPalette) -> Self { + self.style.family_strokes = f; + self + } + + /// Enable or disable ANSI color in text output. + #[must_use] + pub fn ansi_color(mut self, b: bool) -> Self { + self.style.ansi_color = b; + self + } + + /// Enable or disable stroke dash patterns on vertices. + #[must_use] + pub fn show_dashes(mut self, b: bool) -> Self { + self.style.show_dashes = b; + self + } + + /// Set the fill pattern overlays per coset. + #[must_use] + pub fn coset_patterns(mut self, p: CosetPatterns) -> Self { + self.style.coset_patterns = p; + self + } + + /// Build the final `GraphStyle`. + #[must_use] + pub fn build(self) -> GraphStyle { + self.style + } +} + +impl Default for GraphStyleBuilder { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blend_hex_endpoints() { + assert_eq!(blend_hex("#FF0000", "#0000FF", 0.0), "#FF0000"); + assert_eq!(blend_hex("#FF0000", "#0000FF", 1.0), "#0000FF"); + } + + #[test] + fn blend_hex_midpoint() { + // Midpoint of red and blue + assert_eq!(blend_hex("#FF0000", "#0000FF", 0.5), "#800080"); + } + + #[test] + fn graph_style_default_vop_fill() { + let style = GraphStyle::default(); + // ZAxis saturated: blend of fill #A8C8F0 and stroke #2255AA + let sat = style.vop_fill(CellColor::ZAxis, true); + assert!(sat.starts_with('#')); + assert_eq!(sat.len(), 7); + // ZAxis light: just the fill + let light = style.vop_fill(CellColor::ZAxis, false); + assert_eq!(light, "#A8C8F0"); + } + + #[test] + fn graph_style_vop_text() { + let style = GraphStyle::default(); + assert_eq!(style.vop_text(CellColor::ZAxis, true), "white"); + assert_eq!(style.vop_text(CellColor::ZAxis, false), "#1A3A7A"); + } + + #[test] + fn family_palette_get() { + let fp = FamilyPalette::default(); + assert_eq!(fp.get(GateFamily::Pauli), "#1E3A8A"); + assert_eq!(fp.get(GateFamily::SLike), "#2D6A2E"); + assert_eq!(fp.get(GateFamily::HLike), "#8B1A1A"); + assert_eq!(fp.get(GateFamily::FLike), "#404040"); + } + + #[test] + fn empty_diagram() { + let d = CircuitDiagram::new(0); + let out = d.render_text("test", &DiagramStyle::default()); + assert_eq!(out, "test\n"); + } + + #[test] + fn single_gate_ascii() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains("[H]")); + // q1 should be just wire + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(!q1_line.contains('[')); + } + + #[test] + fn single_gate_unicode() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_text( + "", + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ); + assert!(out.contains("[H]")); + assert!(out.contains('\u{2500}')); // ─ + assert!(!out.contains('-')); + } + + #[test] + fn control_dot_ascii_vs_unicode() { + let mut d = CircuitDiagram::new(2); + d.add_control(0); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.connect_vertical(0, 1, CellColor::XAxis); + + let ascii = d.render_text("", &DiagramStyle::default()); + assert!(ascii.contains('.')); + + let unicode = d.render_text( + "", + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ); + assert!(unicode.contains('\u{25CF}')); // ● + } + + #[test] + fn color_output_contains_ansi() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + + let plain = d.render_text("", &DiagramStyle::default()); + let color = d.render_text("", &DiagramStyle::builder().ansi_color(true).build()); + + assert!(!plain.contains("\x1b[")); + assert!(color.contains("\x1b[34m")); // blue + assert!(color.contains(ANSI_RESET)); + } + + #[test] + fn crossing_between_qubits() { + let mut d = CircuitDiagram::new(3); + d.add_control(0); + d.add_gate(2, "X", CellColor::XAxis, GateFamily::Default); + d.connect_vertical(0, 2, CellColor::XAxis); + + let out = d.render_text("", &DiagramStyle::default()); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q1_line.contains('+')); + } + + #[test] + fn multi_column_advance() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::ZAxis, GateFamily::Default); + + let out = d.render_text("", &DiagramStyle::default()); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1 = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0.contains("[H]")); + assert!(!q0.contains("[X]")); + assert!(q1.contains("[X]")); + assert!(!q1.contains("[H]")); + } + + #[test] + fn header_is_printed() { + let d = CircuitDiagram::new(1); + // Single wire column is all-Wire, so effective_columns == 0 + let out = d.render_text("My Header", &DiagramStyle::default()); + assert!(out.starts_with("My Header\n")); + } + + #[test] + fn connector_row_between_multi_qubit() { + let mut d = CircuitDiagram::new(2); + d.add_control(0); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_connector(0, 1); + + let out = d.render_text("", &DiagramStyle::default()); + // Should have a | connector between q0 and q1 + assert!(out.contains('|')); + } + + #[test] + fn lines_have_equal_length() { + let mut d = CircuitDiagram::new(3); + d.add_gate(0, "SX", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_control(0); + d.add_gate(2, "X", CellColor::XAxis, GateFamily::Default); + d.connect_vertical(0, 2, CellColor::XAxis); + + let out = d.render_text("", &DiagramStyle::default()); + let qubit_lines: Vec<&str> = out.lines().filter(|l| l.starts_with('q')).collect(); + assert!(qubit_lines.len() >= 2); + let len0 = qubit_lines[0].len(); + for line in &qubit_lines { + assert_eq!(line.len(), len0, "qubit lines should have equal length"); + } + } + + // ====================== SVG tests ====================== + + #[test] + fn svg_empty_diagram() { + let d = CircuitDiagram::new(0); + let out = d.render_svg(""); + assert!(out.contains("")); + } + + #[test] + fn svg_single_gate() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_svg(""); + assert!(out.contains("H")); + assert!(out.contains("q0")); + assert!(out.contains("q1")); + assert!(out.contains("#A8C8F0")); // SingleQubit fill + } + + #[test] + fn svg_control_and_connector() { + let mut d = CircuitDiagram::new(2); + d.add_control(0); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + let out = d.render_svg(""); + assert!(out.contains("")); + } + + #[test] + fn slike_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains("[SZ]")); + } + + #[test] + fn flike_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "F", CellColor::ZAxis, GateFamily::FLike); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains("{F}")); + } + + #[test] + fn measurement_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "MZ", CellColor::ZAxis, GateFamily::Measurement); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains("|MZ)")); + } + + #[test] + fn preparation_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "PZ", CellColor::ZAxis, GateFamily::Preparation); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains("(PZ|")); + } + + // ====================== Gate family stroke tests ====================== + + #[test] + fn svg_slike_dasharray() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let out = d.render_svg(""); + assert!(out.contains("stroke-dasharray=\"4,3\"")); + } + + #[test] + fn svg_hlike_dasharray() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::HLike); + let out = d.render_svg(""); + assert!(out.contains("stroke-dasharray=\"2,2\"")); + } + + #[test] + fn svg_default_no_dasharray() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "T", CellColor::ZAxis, GateFamily::Default); + let out = d.render_svg(""); + assert!(!out.contains("stroke-dasharray")); + } + + #[test] + fn tikz_slike_dashed() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let out = d.render_tikz(""); + assert!(out.contains(", dashed]")); + } + + #[test] + fn dot_hlike_dotted() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::HLike); + let out = d.render_dot(""); + assert!(out.contains("filled,dotted")); + } + + // ==================== DiagramStyle tests ==================== + + #[test] + fn default_style_matches_old_text_output() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_text("header", &DiagramStyle::default()); + let new = d.render_text("header", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn default_style_matches_old_svg_output() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_svg(""); + let new = d.render_svg_with("", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn default_style_matches_old_tikz_output() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_tikz(""); + let new = d.render_tikz_with("", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn default_style_matches_old_dot_output() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_dot(""); + let new = d.render_dot_with("", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn custom_palette_appears_in_svg() { + let style = DiagramStyle::builder() + .x_axis("#FF0000", "#CC0000", "#880000") + .build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "X", CellColor::XAxis, GateFamily::Pauli); + let svg = d.render_svg_with("", &style); + assert!(svg.contains("#FF0000")); // custom fill + assert!(svg.contains("#CC0000")); // custom stroke + assert!(svg.contains("#880000")); // custom text + } + + #[test] + fn color_false_monochrome_svg() { + let style = DiagramStyle::builder().color(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "X", CellColor::XAxis, GateFamily::Pauli); + let svg = d.render_svg_with("", &style); + // When color is false, should use the None palette (white fill, black stroke). + assert!(svg.contains("#FFFFFF")); // none fill + assert!(svg.contains("#222222")); // none stroke + // Should NOT contain the XAxis fill. + assert!(!svg.contains("#FFB0B0")); + } + + #[test] + fn show_dashes_false_no_dasharray_in_svg() { + let style = DiagramStyle::builder().show_dashes(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let svg = d.render_svg_with("", &style); + assert!(!svg.contains("stroke-dasharray")); + } + + #[test] + fn show_dashes_true_has_dasharray_in_svg() { + let style = DiagramStyle::builder().show_dashes(true).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let svg = d.render_svg_with("", &style); + assert!(svg.contains("stroke-dasharray")); + } + + #[test] + fn builder_presets() { + let s = DiagramStyleBuilder::ascii().build(); + assert_eq!(s.symbols, SymbolSet::Ascii); + assert!(!s.ansi_color); + + let s = DiagramStyleBuilder::color_ascii().build(); + assert_eq!(s.symbols, SymbolSet::Ascii); + assert!(s.ansi_color); + + let s = DiagramStyleBuilder::unicode().build(); + assert_eq!(s.symbols, SymbolSet::Unicode); + assert!(!s.ansi_color); + + let s = DiagramStyleBuilder::color_unicode().build(); + assert_eq!(s.symbols, SymbolSet::Unicode); + assert!(s.ansi_color); + } + + #[test] + fn diagram_renderer_text_and_svg() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let style = DiagramStyle::default(); + let r = DiagramRenderer::new(d, String::new(), &style); + let text = r.text(); + let svg = r.svg(); + assert!(text.contains("[H]")); + assert!(svg.contains(">H")); + } + + #[test] + fn diagram_renderer_ascii_and_unicode() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let style = DiagramStyle::default(); + let r = DiagramRenderer::new(d, String::new(), &style); + let ascii = r.ascii(); + let unicode = r.unicode(); + assert!(ascii.contains('-')); + assert!(unicode.contains('\u{2500}')); + } + + #[test] + fn color_false_monochrome_dot() { + let style = DiagramStyle::builder().color(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "X", CellColor::XAxis, GateFamily::Pauli); + let dot = d.render_dot_with("", &style); + // Should use the None palette colors. + assert!(dot.contains("#FFFFFF")); + assert!(dot.contains("#222222")); + assert!(!dot.contains("#FFB0B0")); + } + + #[test] + fn show_dashes_false_no_dashed_in_tikz() { + let style = DiagramStyle::builder().show_dashes(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let tikz = d.render_tikz_with("", &style); + assert!(!tikz.contains(", dashed]")); + } + + // ==================== Column group tests ==================== + + #[test] + fn column_group_bracket_ascii() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_column_group("t0".to_string(), 0, 1); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains('|'), "bracket should use | chars: {out}"); + assert!(out.contains("t0"), "bracket should contain label: {out}"); + } + + #[test] + fn column_group_bracket_unicode() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_column_group("t0".to_string(), 0, 1); + let out = d.render_text( + "", + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ); + assert!( + out.contains('\u{251C}'), + "bracket should use unicode open: {out}" + ); + assert!( + out.contains('\u{2524}'), + "bracket should use unicode close: {out}" + ); + assert!(out.contains("t0"), "bracket should contain label: {out}"); + } + + #[test] + fn no_groups_no_bracket_row() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_text("", &DiagramStyle::default()); + let lines: Vec<&str> = out.lines().collect(); + // Should have only qubit rows and connector row, no bracket line. + assert!( + lines + .iter() + .all(|l| l.starts_with('q') || l.trim().is_empty() || l.contains('|')), + "no bracket line expected: {out}" + ); + } + + #[test] + fn svg_column_group_background() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_column_group("t0".to_string(), 0, 1); + let svg = d.render_svg(""); + assert!( + svg.contains("fill=\"#D0D8E0\""), + "SVG should have group background: {svg}" + ); + assert!( + svg.contains("fill-opacity=\"0.5\""), + "SVG should have opacity: {svg}" + ); + assert!( + svg.contains(">t0"), + "SVG should have group label: {svg}" + ); + } +} diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index f42e02d57..3eb595acd 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -33,8 +33,10 @@ pub enum GateType { // H4 = 13 // H5 = 14 // H6 = 15 - // F = 16 - // Fdg = 17 + /// F gate (face gate) + F = 16, + /// F-dagger gate + Fdg = 17, // F2 = 18 // F2dg = 19 // F3 = 20 @@ -53,10 +55,14 @@ pub enum GateType { CX = 50, CY = 51, CZ = 52, - // SXX = 53 - // SXXdg = 54 - // SYY = 55 - // SYYdg = 56 + /// sqrt(XX) gate + SXX = 53, + /// sqrt(XX)-dagger gate + SXXdg = 54, + /// sqrt(YY) gate + SYY = 55, + /// sqrt(YY)-dagger gate + SYYdg = 56, SZZ = 57, SZZdg = 58, SWAP = 59, @@ -119,6 +125,8 @@ impl From for GateType { 8 => GateType::SZ, 9 => GateType::SZdg, 10 => GateType::H, + 16 => GateType::F, + 17 => GateType::Fdg, 30 => GateType::RX, 31 => GateType::RY, 32 => GateType::RZ, @@ -129,6 +137,10 @@ impl From for GateType { 50 => GateType::CX, 51 => GateType::CY, 52 => GateType::CZ, + 53 => GateType::SXX, + 54 => GateType::SXXdg, + 55 => GateType::SYY, + 56 => GateType::SYYdg, 57 => GateType::SZZ, 58 => GateType::SZZdg, 59 => GateType::SWAP, @@ -174,12 +186,18 @@ impl GateType { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::T | GateType::Tdg | GateType::CX | GateType::CY | GateType::CZ | GateType::CH + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP @@ -233,6 +251,8 @@ impl GateType { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::RX | GateType::RY | GateType::RZ @@ -256,6 +276,10 @@ impl GateType { | GateType::CY | GateType::CZ | GateType::CH + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP @@ -333,6 +357,8 @@ impl fmt::Display for GateType { GateType::SZ => write!(f, "SZ"), GateType::SZdg => write!(f, "SZdg"), GateType::H => write!(f, "H"), + GateType::F => write!(f, "F"), + GateType::Fdg => write!(f, "Fdg"), GateType::RX => write!(f, "RX"), GateType::RY => write!(f, "RY"), GateType::RZ => write!(f, "RZ"), @@ -344,6 +370,10 @@ impl fmt::Display for GateType { GateType::CY => write!(f, "CY"), GateType::CZ => write!(f, "CZ"), GateType::CH => write!(f, "CH"), + GateType::SXX => write!(f, "SXX"), + GateType::SXXdg => write!(f, "SXXdg"), + GateType::SYY => write!(f, "SYY"), + GateType::SYYdg => write!(f, "SYYdg"), GateType::SZZ => write!(f, "SZZ"), GateType::SZZdg => write!(f, "SZZdg"), GateType::RXX => write!(f, "RXX"), @@ -366,6 +396,66 @@ impl fmt::Display for GateType { } } +impl std::str::FromStr for GateType { + type Err = String; + + fn from_str(s: &str) -> Result { + // Try exact match first for multi-word aliases with specific casing + match s { + "init |0>" | "Init |0>" => return Ok(GateType::Prep), + "measure Z" => return Ok(GateType::Measure), + _ => {} + } + + // Case-insensitive match for all standard gate names + let upper = s.to_ascii_uppercase(); + match upper.as_str() { + "I" => Ok(GateType::I), + "X" => Ok(GateType::X), + "Y" => Ok(GateType::Y), + "Z" => Ok(GateType::Z), + "H" => Ok(GateType::H), + "F" => Ok(GateType::F), + "FDG" => Ok(GateType::Fdg), + "SX" | "Q" => Ok(GateType::SX), + "SXDG" | "QD" => Ok(GateType::SXdg), + "SY" | "R" => Ok(GateType::SY), + "SYDG" | "RD" => Ok(GateType::SYdg), + "SZ" | "S" => Ok(GateType::SZ), + "SZDG" | "SD" | "SDG" => Ok(GateType::SZdg), + "T" => Ok(GateType::T), + "TDG" => Ok(GateType::Tdg), + "RX" => Ok(GateType::RX), + "RY" => Ok(GateType::RY), + "RZ" => Ok(GateType::RZ), + "R1XY" => Ok(GateType::R1XY), + "U" => Ok(GateType::U), + "CX" | "CNOT" => Ok(GateType::CX), + "CY" => Ok(GateType::CY), + "CZ" => Ok(GateType::CZ), + "CH" => Ok(GateType::CH), + "SXX" => Ok(GateType::SXX), + "SXXDG" => Ok(GateType::SXXdg), + "SYY" => Ok(GateType::SYY), + "SYYDG" => Ok(GateType::SYYdg), + "SZZ" => Ok(GateType::SZZ), + "SZZDG" => Ok(GateType::SZZdg), + "RXX" => Ok(GateType::RXX), + "RYY" => Ok(GateType::RYY), + "RZZ" => Ok(GateType::RZZ), + "CRZ" => Ok(GateType::CRZ), + "CCX" | "TOFFOLI" => Ok(GateType::CCX), + "SWAP" => Ok(GateType::SWAP), + "MEASURE" | "MZ" | "MEASURE Z" => Ok(GateType::Measure), + "PREP" | "INIT" | "INIT |0>" | "RESET" => Ok(GateType::Prep), + "QALLOC" => Ok(GateType::QAlloc), + "QFREE" => Ok(GateType::QFree), + "IDLE" => Ok(GateType::Idle), + _ => Err(format!("Unknown gate type: {s}")), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -377,7 +467,13 @@ mod tests { assert_eq!(GateType::Z as u8, 2); assert_eq!(GateType::Y as u8, 3); assert_eq!(GateType::H as u8, 10); + assert_eq!(GateType::F as u8, 16); + assert_eq!(GateType::Fdg as u8, 17); assert_eq!(GateType::CX as u8, 50); + assert_eq!(GateType::SXX as u8, 53); + assert_eq!(GateType::SXXdg as u8, 54); + assert_eq!(GateType::SYY as u8, 55); + assert_eq!(GateType::SYYdg as u8, 56); assert_eq!(GateType::SZZ as u8, 57); assert_eq!(GateType::RZ as u8, 32); assert_eq!(GateType::R1XY as u8, 36); @@ -397,7 +493,13 @@ mod tests { assert_eq!(GateType::from(2u8), GateType::Z); assert_eq!(GateType::from(3u8), GateType::Y); assert_eq!(GateType::from(10u8), GateType::H); + assert_eq!(GateType::from(16u8), GateType::F); + assert_eq!(GateType::from(17u8), GateType::Fdg); assert_eq!(GateType::from(50u8), GateType::CX); + assert_eq!(GateType::from(53u8), GateType::SXX); + assert_eq!(GateType::from(54u8), GateType::SXXdg); + assert_eq!(GateType::from(55u8), GateType::SYY); + assert_eq!(GateType::from(56u8), GateType::SYYdg); assert_eq!(GateType::from(57u8), GateType::SZZ); assert_eq!(GateType::from(32u8), GateType::RZ); assert_eq!(GateType::from(36u8), GateType::R1XY); @@ -413,6 +515,50 @@ mod tests { assert_eq!(GateType::from(255u8), GateType::Custom); } + #[test] + fn test_from_str() { + use std::str::FromStr; + + // Standard names + assert_eq!(GateType::from_str("H").unwrap(), GateType::H); + assert_eq!(GateType::from_str("X").unwrap(), GateType::X); + assert_eq!(GateType::from_str("CX").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("F").unwrap(), GateType::F); + assert_eq!(GateType::from_str("Fdg").unwrap(), GateType::Fdg); + assert_eq!(GateType::from_str("SXX").unwrap(), GateType::SXX); + assert_eq!(GateType::from_str("SXXdg").unwrap(), GateType::SXXdg); + assert_eq!(GateType::from_str("SYY").unwrap(), GateType::SYY); + assert_eq!(GateType::from_str("SYYdg").unwrap(), GateType::SYYdg); + assert_eq!(GateType::from_str("SWAP").unwrap(), GateType::SWAP); + assert_eq!(GateType::from_str("CCX").unwrap(), GateType::CCX); + + // Aliases + assert_eq!(GateType::from_str("CNOT").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("Q").unwrap(), GateType::SX); + assert_eq!(GateType::from_str("S").unwrap(), GateType::SZ); + assert_eq!(GateType::from_str("TOFFOLI").unwrap(), GateType::CCX); + assert_eq!(GateType::from_str("init |0>").unwrap(), GateType::Prep); + + // Case-insensitive matching + assert_eq!(GateType::from_str("h").unwrap(), GateType::H); + assert_eq!(GateType::from_str("cx").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("Cx").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("cX").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("cnot").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("Cnot").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("fdg").unwrap(), GateType::Fdg); + assert_eq!(GateType::from_str("sxxdg").unwrap(), GateType::SXXdg); + assert_eq!(GateType::from_str("r").unwrap(), GateType::SY); + assert_eq!(GateType::from_str("R").unwrap(), GateType::SY); + assert_eq!(GateType::from_str("q").unwrap(), GateType::SX); + assert_eq!(GateType::from_str("s").unwrap(), GateType::SZ); + assert_eq!(GateType::from_str("toffoli").unwrap(), GateType::CCX); + assert_eq!(GateType::from_str("Toffoli").unwrap(), GateType::CCX); + + // Unknown + assert!(GateType::from_str("FOOBAR").is_err()); + } + #[test] fn test_classical_arity() { // Gates with no parameters diff --git a/crates/pecos-core/src/gates.rs b/crates/pecos-core/src/gates.rs index 95eb66fdc..b60546250 100644 --- a/crates/pecos-core/src/gates.rs +++ b/crates/pecos-core/src/gates.rs @@ -215,6 +215,24 @@ impl Gate { ) } + /// Create F gate on multiple qubits + #[must_use] + pub fn f(qubits: &[impl Into + Copy]) -> Self { + Self::simple( + GateType::F, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create Fdg gate on multiple qubits + #[must_use] + pub fn fdg(qubits: &[impl Into + Copy]) -> Self { + Self::simple( + GateType::Fdg, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + /// Create T gate on multiple qubits #[must_use] pub fn t(qubits: &[impl Into + Copy]) -> Self { @@ -353,6 +371,194 @@ impl Gate { Self::szzdg_vec(&flat_qubits) } + /// Create SXX gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn sxx_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SXX gate requires an even number of qubits" + ); + Self::simple( + GateType::SXX, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SXX gate on multiple qubit pairs + #[must_use] + pub fn sxx(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::sxx_vec(&flat_qubits) + } + + /// Create `SXXdg` gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn sxxdg_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SXXdg gate requires an even number of qubits" + ); + Self::simple( + GateType::SXXdg, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create `SXXdg` gate on multiple qubit pairs + #[must_use] + pub fn sxxdg(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::sxxdg_vec(&flat_qubits) + } + + /// Create SYY gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn syy_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SYY gate requires an even number of qubits" + ); + Self::simple( + GateType::SYY, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SYY gate on multiple qubit pairs + #[must_use] + pub fn syy(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::syy_vec(&flat_qubits) + } + + /// Create `SYYdg` gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn syydg_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SYYdg gate requires an even number of qubits" + ); + Self::simple( + GateType::SYYdg, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create `SYYdg` gate on multiple qubit pairs + #[must_use] + pub fn syydg(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::syydg_vec(&flat_qubits) + } + + /// Create SWAP gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn swap_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SWAP gate requires an even number of qubits" + ); + Self::simple( + GateType::SWAP, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SWAP gate on multiple qubit pairs + #[must_use] + pub fn swap(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::swap_vec(&flat_qubits) + } + + /// Create CH gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn ch_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "CH gate requires an even number of qubits" + ); + Self::simple( + GateType::CH, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create CH gate on multiple qubit pairs + #[must_use] + pub fn ch(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::ch_vec(&flat_qubits) + } + + /// Create CRZ gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn crz_vec(theta: Angle64, qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "CRZ gate requires an even number of qubits" + ); + Self::with_angles( + GateType::CRZ, + vec![theta], + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create CRZ gate on multiple qubit pairs + #[must_use] + pub fn crz( + theta: Angle64, + qubit_pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::crz_vec(theta, &flat_qubits) + } + + /// Create CCX (Toffoli) gate on qubit triples + #[must_use] + pub fn ccx( + triples: &[( + impl Into + Copy, + impl Into + Copy, + impl Into + Copy, + )], + ) -> Self { + let qubits: GateQubits = triples + .iter() + .flat_map(|&(c1, c2, t)| [c1.into(), c2.into(), t.into()]) + .collect(); + Self::simple(GateType::CCX, qubits) + } + /// Create RXX gate from flat qubit list (`qubit1_1`, `qubit2_1`, `qubit1_2`, `qubit2_2`, ...) /// /// # Panics diff --git a/crates/pecos-core/src/lib.rs b/crates/pecos-core/src/lib.rs index a8604af38..794895c8e 100644 --- a/crates/pecos-core/src/lib.rs +++ b/crates/pecos-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod bit; pub mod bit_int; pub mod bitset; pub mod bitvec; +pub mod circuit_diagram; pub mod clifford_rep; pub mod duration; pub mod element; @@ -68,5 +69,11 @@ pub use phase::Phase; pub use rng::choices::Choices; pub use value::Value; +// Circuit diagram styling +pub use circuit_diagram::{ + AngleUnit, ColorPalette, ColorTriplet, CosetPatterns, DiagramRenderer, DiagramStyle, + DiagramStyleBuilder, FamilyPalette, FillPattern, GraphStyle, GraphStyleBuilder, blend_hex, +}; + // Operator algebra pub use operator::{I, Is, Operator, X, Xs, Y, Ys, Z, Zs}; diff --git a/crates/pecos-core/src/operator.rs b/crates/pecos-core/src/operator.rs index a344abe2c..918694e5e 100644 --- a/crates/pecos-core/src/operator.rs +++ b/crates/pecos-core/src/operator.rs @@ -2516,28 +2516,141 @@ impl Mul for Operator { // Circuit diagram generation // ============================================================================ +use crate::circuit_diagram::{ + CellColor, CircuitDiagram, DiagramRenderer, DiagramStyle, GateFamily, SymbolSet, +}; + +/// Map a `GateType` to its axis color using PECOS color algebra. +fn gate_type_color(gt: GateType) -> CellColor { + match gt { + GateType::X | GateType::RX | GateType::RXX => CellColor::XAxis, + GateType::Y | GateType::RY | GateType::RYY => CellColor::YAxis, + GateType::Z + | GateType::RZ + | GateType::T + | GateType::Tdg + | GateType::RZZ + | GateType::Measure + | GateType::Prep + | GateType::SZZ + | GateType::SZZdg + | GateType::CRZ => CellColor::ZAxis, + GateType::SX | GateType::SXdg => CellColor::YZMix, + GateType::SY | GateType::SYdg | GateType::H | GateType::CH => CellColor::XZMix, + GateType::SZ | GateType::SZdg => CellColor::XYMix, + _ => CellColor::None, + } +} + +/// Map a `GateType` to its `GateFamily` for diagram bracket/stroke styling. +/// +/// Most gates use `Default` brackets (`[G]`). Only measurement and preparation +/// gates keep their asymmetric brackets (`|MZ)` and `(PZ|`). +fn gate_type_family(gt: GateType) -> GateFamily { + match gt { + GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { + GateFamily::Measurement + } + GateType::Prep | GateType::QAlloc | GateType::QFree => GateFamily::Preparation, + _ => GateFamily::Default, + } +} + impl Operator { - /// Generates an ASCII circuit diagram for this expression. + /// Generates a Unicode circuit diagram for this expression. + /// + /// This is an alias for [`to_unicode`](Self::to_unicode). #[must_use] pub fn to_diagram(&self, num_qubits: usize) -> String { + self.to_unicode(num_qubits) + } + + /// Plain ASCII circuit diagram. + #[must_use] + pub fn to_ascii(&self, num_qubits: usize) -> String { + self.render_with(num_qubits, &DiagramStyle::default()) + .ascii() + } + + /// ASCII circuit diagram with ANSI colors. + #[must_use] + pub fn to_color_ascii(&self, num_qubits: usize) -> String { + self.render_with( + num_qubits, + &DiagramStyle::builder().ansi_color(true).build(), + ) + .ascii() + } + + /// Unicode circuit diagram. + #[must_use] + pub fn to_unicode(&self, num_qubits: usize) -> String { + self.render_with( + num_qubits, + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ) + .unicode() + } + + /// Unicode circuit diagram with ANSI colors. + #[must_use] + pub fn to_color_unicode(&self, num_qubits: usize) -> String { + self.render_with( + num_qubits, + &DiagramStyle::builder() + .symbols(SymbolSet::Unicode) + .ansi_color(true) + .build(), + ) + .unicode() + } + + /// Export as an SVG circuit diagram. + #[must_use] + pub fn to_svg(&self, num_qubits: usize) -> String { + self.render_with(num_qubits, &DiagramStyle::default()).svg() + } + + /// Export as a `TikZ` `tikzpicture`. + #[must_use] + pub fn to_tikz(&self, num_qubits: usize) -> String { + self.render_with(num_qubits, &DiagramStyle::default()) + .tikz() + } + + /// Export as a Graphviz DOT digraph. + #[must_use] + pub fn to_dot(&self, num_qubits: usize) -> String { + self.render_with(num_qubits, &DiagramStyle::default()).dot() + } + + /// Create a [`DiagramRenderer`] bound to a custom [`DiagramStyle`]. + /// + /// The renderer can produce text, SVG, `TikZ`, or DOT output using the + /// given style configuration. + #[must_use] + pub fn render_with<'a>( + &self, + num_qubits: usize, + style: &'a DiagramStyle, + ) -> DiagramRenderer<'a> { let mut diagram = CircuitDiagram::new(num_qubits); self.add_to_diagram(&mut diagram); - diagram.render() + DiagramRenderer::new(diagram, String::new(), style) } fn add_to_diagram(&self, diagram: &mut CircuitDiagram) { match self { Self::Pauli(ps) => { - // Draw each Pauli on its qubit for (pauli, qubit) in ps.iter_pairs() { let q = usize::from(qubit); - let name = match pauli { + let (name, color) = match pauli { crate::Pauli::I => continue, - crate::Pauli::X => "X", - crate::Pauli::Y => "Y", - crate::Pauli::Z => "Z", + crate::Pauli::X => ("X", CellColor::XAxis), + crate::Pauli::Y => ("Y", CellColor::YAxis), + crate::Pauli::Z => ("Z", CellColor::ZAxis), }; - diagram.add_single_gate(q, name); + diagram.add_gate(q, name, color, GateFamily::Default); } } Self::Rotation { @@ -2545,253 +2658,75 @@ impl Operator { angle, qubits, } => { - let name = if let Some(gate_type) = rotation_to_gate_type(*rotation_type, *angle) { - format!("{gate_type:?}") + let resolved_gt = rotation_to_gate_type(*rotation_type, *angle); + let name = if let Some(gt) = resolved_gt { + format!("{gt:?}") } else { format!("{rotation_type:?}") }; + let family = resolved_gt.map_or(GateFamily::Default, gate_type_family); + let color = resolved_gt.map_or(CellColor::None, gate_type_color); if qubits.len() == 1 { - diagram.add_single_gate(qubits[0], &name); + diagram.add_gate(qubits[0], &name, color, family); } else if qubits.len() == 2 { - diagram.add_two_qubit_gate(qubits[0], qubits[1], &name); + diagram.add_gate(qubits[0], &name, color, family); + diagram.add_gate(qubits[1], &name, color, family); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } } Self::Gate { gate_type, qubits } => match gate_type { GateType::CX => { - diagram.add_controlled_gate(qubits[0], qubits[1], "X"); + diagram.add_control(qubits[0]); + diagram.add_gate(qubits[1], "X", CellColor::XAxis, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::CY => { - diagram.add_controlled_gate(qubits[0], qubits[1], "Y"); + diagram.add_control(qubits[0]); + diagram.add_gate(qubits[1], "Y", CellColor::YAxis, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::CZ => { - diagram.add_controlled_gate(qubits[0], qubits[1], "Z"); + diagram.add_control(qubits[0]); + diagram.add_gate(qubits[1], "Z", CellColor::ZAxis, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::SWAP => { - diagram.add_swap(qubits[0], qubits[1]); + diagram.add_gate(qubits[0], "x", CellColor::None, GateFamily::Default); + diagram.add_gate(qubits[1], "x", CellColor::None, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::CCX => { - diagram.add_toffoli(qubits[0], qubits[1], qubits[2]); + diagram.add_control(qubits[0]); + diagram.add_control(qubits[1]); + diagram.add_gate(qubits[2], "X", CellColor::XAxis, GateFamily::Default); + let min_q = qubits[0].min(qubits[1]).min(qubits[2]); + let max_q = qubits[0].max(qubits[1]).max(qubits[2]); + diagram.connect_vertical(min_q, max_q, CellColor::None); } _ => { if qubits.len() == 1 { - diagram.add_single_gate(qubits[0], &format!("{gate_type:?}")); + let family = gate_type_family(*gate_type); + let color = gate_type_color(*gate_type); + diagram.add_gate(qubits[0], &format!("{gate_type:?}"), color, family); } } }, Self::Tensor(parts) => { - // Tensor products can be drawn simultaneously for part in parts { part.add_to_diagram(diagram); } } Self::Compose(parts) => { - // Sequential composition: draw in order for part in parts { part.add_to_diagram(diagram); diagram.advance(); } } - Self::Adjoint(inner) => { - // Mark as adjoint somehow? + Self::Adjoint(inner) | Self::Phase { inner, .. } => { inner.add_to_diagram(diagram); } - Self::Phase { inner, .. } => { - // Global phase doesn't appear in circuit diagrams - inner.add_to_diagram(diagram); - } - } - } -} - -struct CircuitDiagram { - num_qubits: usize, - columns: Vec>, - current_col: usize, -} - -impl CircuitDiagram { - fn new(num_qubits: usize) -> Self { - Self { - num_qubits, - columns: vec![vec![String::new(); num_qubits * 2 - 1]], - current_col: 0, - } - } - - fn ensure_column(&mut self) { - if self.current_col >= self.columns.len() { - self.columns - .push(vec![String::new(); self.num_qubits * 2 - 1]); - } - } - - fn advance(&mut self) { - self.current_col += 1; - } - - fn add_single_gate(&mut self, qubit: usize, name: &str) { - self.ensure_column(); - let row = qubit * 2; - if row < self.columns[self.current_col].len() { - self.columns[self.current_col][row] = format!("[{name}]"); - } - } - - fn add_controlled_gate(&mut self, control: usize, target: usize, target_name: &str) { - self.ensure_column(); - let ctrl_row = control * 2; - let targ_row = target * 2; - - if ctrl_row < self.columns[self.current_col].len() { - self.columns[self.current_col][ctrl_row] = "●".to_string(); - } - if targ_row < self.columns[self.current_col].len() { - self.columns[self.current_col][targ_row] = format!("[{target_name}]"); - } - - // Draw vertical line - let (min_row, max_row) = if ctrl_row < targ_row { - (ctrl_row, targ_row) - } else { - (targ_row, ctrl_row) - }; - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_swap(&mut self, q0: usize, q1: usize) { - self.ensure_column(); - let row0 = q0 * 2; - let row1 = q1 * 2; - - if row0 < self.columns[self.current_col].len() { - self.columns[self.current_col][row0] = "×".to_string(); - } - if row1 < self.columns[self.current_col].len() { - self.columns[self.current_col][row1] = "×".to_string(); - } - - // Draw vertical line - let (min_row, max_row) = (row0.min(row1), row0.max(row1)); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_toffoli(&mut self, c0: usize, c1: usize, target: usize) { - self.ensure_column(); - let c0_row = c0 * 2; - let c1_row = c1 * 2; - let targ_row = target * 2; - - if c0_row < self.columns[self.current_col].len() { - self.columns[self.current_col][c0_row] = "●".to_string(); - } - if c1_row < self.columns[self.current_col].len() { - self.columns[self.current_col][c1_row] = "●".to_string(); - } - if targ_row < self.columns[self.current_col].len() { - self.columns[self.current_col][targ_row] = "[X]".to_string(); - } - - // Draw vertical lines - let min_row = c0_row.min(c1_row).min(targ_row); - let max_row = c0_row.max(c1_row).max(targ_row); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_two_qubit_gate(&mut self, q0: usize, q1: usize, name: &str) { - self.ensure_column(); - let row0 = q0 * 2; - let row1 = q1 * 2; - - if row0 < self.columns[self.current_col].len() { - self.columns[self.current_col][row0] = format!("[{name}]"); - } - if row1 < self.columns[self.current_col].len() { - self.columns[self.current_col][row1] = format!("[{name}]"); - } - - // Draw vertical line - let (min_row, max_row) = (row0.min(row1), row0.max(row1)); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn render(&self) -> String { - let lines: Vec = (0..self.num_qubits).map(|q| format!("q{q}: ")).collect(); - - // Add spacing lines between qubits - let mut all_lines: Vec = Vec::new(); - for (idx, line) in lines.iter().enumerate() { - all_lines.push(line.clone()); - if idx < self.num_qubits - 1 { - all_lines.push(" ".to_string()); // spacing line - } - } - - // Process each column - for col in &self.columns { - // Find max width in this column - let max_width = col - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(0) - .max(3); - - for (row, cell) in col.iter().enumerate() { - if row < all_lines.len() { - if cell.is_empty() { - // Wire or empty - if row % 2 == 0 { - all_lines[row].push_str(&"─".repeat(max_width)); - } else { - all_lines[row].push_str(&" ".repeat(max_width)); - } - } else { - // Center the cell content - let padding = max_width.saturating_sub(cell.chars().count()); - let left_pad = padding / 2; - let right_pad = padding - left_pad; - - if row % 2 == 0 { - // Qubit line - all_lines[row].push_str(&"─".repeat(left_pad)); - all_lines[row].push_str(cell); - all_lines[row].push_str(&"─".repeat(right_pad)); - } else { - // Spacing line - all_lines[row].push_str(&" ".repeat(left_pad)); - all_lines[row].push_str(cell); - all_lines[row].push_str(&" ".repeat(right_pad)); - } - } - } - } - } - - // Add trailing wire - for (idx, line) in all_lines.iter_mut().enumerate() { - if idx % 2 == 0 { - line.push('─'); - } } - - all_lines.join("\n") } } @@ -2903,15 +2838,15 @@ mod tests { fn test_diagram_single_qubit() { let h = H(0); let diagram = h.to_diagram(1); - assert!(diagram.contains("[H]")); + assert!(diagram.contains("[H]")); // Default family } #[test] fn test_diagram_cx() { let cx = CX(0, 1); let diagram = cx.to_diagram(2); - assert!(diagram.contains("●")); - assert!(diagram.contains("[X]")); + assert!(diagram.contains("\u{25CF}")); // control dot + assert!(diagram.contains("[X]")); // Default family for controlled target } #[test] @@ -4095,4 +4030,39 @@ mod tests { assert_eq!(ps.get(0), crate::Pauli::X); assert_eq!(ps.phase(), QuarterPhase::PlusI); } + + // ====================== SVG/TikZ/DOT export ====================== + + #[test] + fn operator_svg() { + let op = H(0); + let svg = op.to_svg(2); + assert!(svg.contains("H")); + assert!(svg.contains("q0")); + } + + #[test] + fn operator_tikz() { + let op = H(0); + let tikz = op.to_tikz(2); + assert!(tikz.contains("\\begin{tikzpicture}")); + assert!(tikz.contains("{H}")); + } + + #[test] + fn operator_dot() { + let op = H(0); + let dot = op.to_dot(2); + assert!(dot.contains("digraph circuit")); + assert!(dot.contains("label=\"H\"")); + } + + #[test] + fn operator_cx_svg() { + let op = CX(0, 1); + let svg = op.to_svg(2); + assert!(svg.contains(" {} - // Invalid cases (not enough qubits, missing parameters, etc.) - _ => panic!( - "Invalid gate type {:?} or insufficient parameters/qubits", - gate.gate_type - ), + // All other gates: use generic serialization (gate type + qubits + angles/params). + _ => { + builder.add_gate_command(gate); + } } } diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index ba8ac761b..320a9b395 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -369,6 +369,62 @@ where debug!("Processing SZZdg gate on qubits {:?}", cmd.qubits); self.simulator.szzdg(&cmd.qubits); } + GateType::F => { + debug!("Processing F gate on qubits {:?}", cmd.qubits); + self.simulator.f(&cmd.qubits); + } + GateType::Fdg => { + debug!("Processing Fdg gate on qubits {:?}", cmd.qubits); + self.simulator.fdg(&cmd.qubits); + } + GateType::SY => { + debug!("Processing SY gate on qubits {:?}", cmd.qubits); + self.simulator.sy(&cmd.qubits); + } + GateType::SYdg => { + debug!("Processing SYdg gate on qubits {:?}", cmd.qubits); + self.simulator.sydg(&cmd.qubits); + } + GateType::SXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SXX gate on qubits {:?}", cmd.qubits); + self.simulator.sxx(&cmd.qubits); + } + GateType::SXXdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SXXdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SXXdg gate on qubits {:?}", cmd.qubits); + self.simulator.sxxdg(&cmd.qubits); + } + GateType::SYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SYY gate on qubits {:?}", cmd.qubits); + self.simulator.syy(&cmd.qubits); + } + GateType::SYYdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SYYdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SYYdg gate on qubits {:?}", cmd.qubits); + self.simulator.syydg(&cmd.qubits); + } GateType::SWAP => { if cmd.qubits.len() % 2 != 0 { return Err(quantum_error(format!( @@ -503,11 +559,33 @@ where // QFree is a no-op for state vector simulation (qubit tracking is handled elsewhere) // Custom is a no-op placeholder (actual gate name is in metadata) } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { - return Err(quantum_error(format!( - "Gate type {:?} is not yet supported by StateVecEngine", - cmd.gate_type - ))); + GateType::RXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "RXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + if cmd.angles.is_empty() { + return Err(quantum_error("RXX gate requires at least one angle")); + } + let angle = cmd.angles[0]; + debug!("Processing RXX gate on qubits {:?}", cmd.qubits); + self.simulator.rxx(angle, &cmd.qubits); + } + GateType::RYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "RYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + if cmd.angles.is_empty() { + return Err(quantum_error("RYY gate requires at least one angle")); + } + let angle = cmd.angles[0]; + debug!("Processing RYY gate on qubits {:?}", cmd.qubits); + self.simulator.ryy(angle, &cmd.qubits); } GateType::QAlloc => { // Allocate qubits in |0⟩ state - for state vector sim, same as Prep diff --git a/crates/pecos-experimental/src/hugr_executor.rs b/crates/pecos-experimental/src/hugr_executor.rs index 3db8bc720..d1980915e 100644 --- a/crates/pecos-experimental/src/hugr_executor.rs +++ b/crates/pecos-experimental/src/hugr_executor.rs @@ -303,6 +303,12 @@ where | GateType::Tdg | GateType::U | GateType::R1XY + | GateType::F + | GateType::Fdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP diff --git a/crates/pecos-qasm/src/engine.rs b/crates/pecos-qasm/src/engine.rs index 2d62d3ff3..63f70f4e8 100644 --- a/crates/pecos-qasm/src/engine.rs +++ b/crates/pecos-qasm/src/engine.rs @@ -639,13 +639,21 @@ impl QASMEngine { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::T | GateType::Tdg | GateType::Prep | GateType::QAlloc => self.process_single_qubit_gate(gate.gate_type, &qubits), - GateType::CX | GateType::CY | GateType::CZ | GateType::SZZ | GateType::SZZdg => { - self.process_two_qubit_gate(gate.gate_type, &qubits) - } + GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg => self.process_two_qubit_gate(gate.gate_type, &qubits), // Gates not yet supported in QASM engine GateType::SWAP | GateType::CCX | GateType::CRZ | GateType::CH => { Err(PecosError::Processing(format!( diff --git a/crates/pecos-qsim/src/circuit_executor.rs b/crates/pecos-qsim/src/circuit_executor.rs index ef0f587e2..6af601a74 100644 --- a/crates/pecos-qsim/src/circuit_executor.rs +++ b/crates/pecos-qsim/src/circuit_executor.rs @@ -145,6 +145,12 @@ fn execute_single_batch( GateType::H => { sim.h(qubits); } + GateType::F => { + sim.f(qubits); + } + GateType::Fdg => { + sim.fdg(qubits); + } GateType::SX => { sim.sx(qubits); } @@ -172,6 +178,18 @@ fn execute_single_batch( GateType::CZ => { sim.cz(qubits); } + GateType::SXX => { + sim.sxx(qubits); + } + GateType::SXXdg => { + sim.sxxdg(qubits); + } + GateType::SYY => { + sim.syy(qubits); + } + GateType::SYYdg => { + sim.syydg(qubits); + } GateType::SZZ => { sim.szz(qubits); } diff --git a/crates/pecos-qsim/src/clifford_frame.rs b/crates/pecos-qsim/src/clifford_frame.rs index 2db01d237..7be4c363a 100644 --- a/crates/pecos-qsim/src/clifford_frame.rs +++ b/crates/pecos-qsim/src/clifford_frame.rs @@ -276,6 +276,262 @@ const COMPOSE: [[u8; 24]; 24] = compute_compose(); const INVERSE: [u8; 24] = compute_inverse(); const DECOMPOSE: [(u8, u8); 24] = compute_decompose(); +// ============================================================================ +// VOP removal decomposition table (for graph state simulator) +// ============================================================================ + +/// Maximum length of a VOP removal sequence. +const VOP_DECOMP_MAX_LEN: usize = 5; + +/// Decomposition of each Clifford into a sequence of LC generators. +/// +/// For the graph state simulator's `remove_vop`, each of the 24 Cliffords +/// can be decomposed as a product of two generators: +/// U = local complement on vertex v (right-multiplies v's VOP by SXDG, index 12) +/// V = local complement on neighbor vb (right-multiplies v's VOP by SZ, index 4) +/// +/// The sequence is stored as (length, [steps]), where each step is 0=U or 1=V. +/// Steps are applied in reverse order (last step first). +const fn compute_vop_decomp() -> [(u8, [u8; VOP_DECOMP_MAX_LEN]); 24] { + // U = SXDG (index 12), V = SZ (index 4) + // Right-multiplying element e by SXDG: compose[12][e] = e * SXDG as element + // Right-multiplying element e by SZ: compose[4][e] = e * SZ as element + + // BFS from identity (0) through right-multiplication by SXDG^{-1} and SZ^{-1} + // (equivalently, searching backward: which elements can reach 0?) + // Actually: we do forward BFS from 0, applying right-mult by SXDG and SZ. + // If we reach element C via path g1, g2, ..., gn, it means + // 0 * g1 * g2 * ... * gn = C, i.e. I * g1 * ... * gn = C + // So C = g1 * g2 * ... * gn. + // To go from C back to I: C * gn^{-1} * ... * g1^{-1} = I. + // For the remove_vop algorithm, we need to apply LCs that right-multiply by + // the generators (not their inverses). So we need a different approach. + + // Better: BFS from each element toward identity. + // From element e, applying generator U (right-mult by SXDG): next = compose[12][e] + // From element e, applying generator V (right-mult by SZ): next = compose[4][e] + // We want the shortest path from e to 0. + + // Reverse BFS from 0: predecessors of element `next` under U are elements e + // such that compose[12][e] = next (e * SXDG = next, so e = next * SXDG^{-1}). + // Similarly for V. Since SXDG^4 = I (order 4), SXDG^{-1} = SXDG^3. + // SZ^4 = I, SZ^{-1} = SZ^3 = SZDG. + + // Simpler: compute inverse of generators + let inv = compute_inverse(); + let sxdg_inv = inv[12]; // SXDG^{-1} + let sz_inv = inv[4]; // SZ^{-1} + + // For element e: predecessor via U is compose[sxdg_inv][e]? No. + // If applying U to element p gives e (i.e., compose[12][p] = e, meaning p * SXDG = e), + // then p = e * SXDG^{-1} = compose[sxdg_inv][e]... wait: + // compose[a][b] = COMPOSE[a][b] = element of b * a. + // Hmm no: compose(self=a, gate=b) = COMPOSE[a][b] = element of (b * a). + // We want p * SXDG = e, so p = e * SXDG^{-1}. + // e * SXDG^{-1}: this is right-mult of e by SXDG^{-1} = COMPOSE[sxdg_inv][e]. + // Wait: COMPOSE[self][gate] = gate * self. So COMPOSE[sxdg_inv][e] = e * sxdg_inv. + // Yes! + + // BFS from 0 (identity), expanding via inverse generators. + // visited[e] = true if we've found the path to e. + // parent_gen[e] = which generator (0=U, 1=V) was applied to reach e from its parent. + // parent[e] = the parent element. + + let mut result = [(0u8, [0u8; VOP_DECOMP_MAX_LEN]); 24]; + let mut visited = [false; 24]; + let mut parent = [255u8; 24]; // parent element + let mut parent_gen = [255u8; 24]; // 0=U, 1=V + let mut queue = [0u8; 24]; + let mut q_head = 0usize; + let mut q_tail = 0usize; + + // Start BFS from identity + visited[0] = true; + queue[q_tail] = 0; + q_tail += 1; + + while q_head < q_tail { + let current = queue[q_head] as usize; + q_head += 1; + + // Try expanding via U (predecessor under U is: compose[sxdg_inv][current]) + // This represents: neighbor = current * SXDG^{-1} + // If we go from neighbor by applying U, we get neighbor * SXDG = current + let u_nbr = COMPOSE[sxdg_inv as usize][current] as usize; + if !visited[u_nbr] { + visited[u_nbr] = true; + parent[u_nbr] = current as u8; + parent_gen[u_nbr] = 0; // U + queue[q_tail] = u_nbr as u8; + q_tail += 1; + } + + // Try expanding via V + let v_nbr = COMPOSE[sz_inv as usize][current] as usize; + if !visited[v_nbr] { + visited[v_nbr] = true; + parent[v_nbr] = current as u8; + parent_gen[v_nbr] = 1; // V + queue[q_tail] = v_nbr as u8; + q_tail += 1; + } + } + + // Reconstruct paths. For element e, trace back to 0 to get the sequence. + // The sequence represents: applying gen at e brings us closer to I. + // parent_gen[e] = the generator that was applied to reach parent[e] from e (so to say). + // Wait: actually parent[e] is closer to I, and parent_gen[e] is the generator + // that when applied to e gives parent[e]. + let mut e = 0; + while e < 24 { + if e == 0 { + result[0] = (0, [0; VOP_DECOMP_MAX_LEN]); + } else { + let mut path = [0u8; VOP_DECOMP_MAX_LEN]; + let mut len = 0usize; + let mut cur = e; + while cur != 0 { + path[len] = parent_gen[cur]; + len += 1; + cur = parent[cur] as usize; + } + // path[0..len] is the sequence from e toward I (forward order). + // The remove_vop algorithm should apply these in order: + // first path[0], then path[1], etc. + result[e] = (len as u8, path); + } + e += 1; + } + + result +} + +/// VOP removal decomposition table. +/// +/// `VOP_DECOMP[i]` = `(len, steps)` where `steps[0..len]` are the generators +/// (0=U on vertex, 1=V on neighbor) to apply in order to reduce element `i` to identity. +pub const VOP_DECOMP: [(u8, [u8; VOP_DECOMP_MAX_LEN]); 24] = compute_vop_decomp(); + +// ============================================================================ +// CZ (cphase) lookup table +// ============================================================================ + +/// Mapping from reference (`GraphSim`) Clifford indices to our `CliffordFrame` indices. +/// Derived by generating all 24 elements from H and S in both systems. +const REF_TO_OURS: [u8; 24] = [ + 0, 1, 2, 3, 20, 5, 4, 23, 18, 10, 6, 9, 17, 19, 12, 13, 14, 15, 22, 8, 7, 11, 21, 16, +]; + +/// Mapping from our `CliffordFrame` indices to reference (`GraphSim`) indices. +const OURS_TO_REF: [u8; 24] = [ + 0, 1, 2, 3, 6, 5, 10, 20, 19, 11, 9, 21, 14, 15, 16, 17, 23, 12, 8, 13, 4, 22, 18, 7, +]; + +/// Reference CZ table from `GraphSim` (Anders & Briegel), indexed by reference indices. +/// Layout: `REF_CPHASE[was_edge][v1_ref][v2_ref]` = `[new_edge, new_v1_ref, new_v2_ref]`. +/// This is the verified table from `cphase.tbl` in the `GraphSim` reference implementation. +#[rustfmt::skip] +const REF_CPHASE: [[[[u8; 3]; 24]; 24]; 2] = [ + // was_edge = 0 + [ + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,3,8],[0,3,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,3,8],[0,3,8]], + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,2,8],[0,2,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,2,8],[0,2,8]], + [[1,2,3],[1,0,1],[1,0,2],[1,2,0],[1,0,4],[1,2,6],[1,2,5],[1,0,7],[0,0,8],[0,0,8],[0,2,10],[0,2,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,2,10],[0,2,10],[0,0,8],[0,0,8]], + [[1,3,0],[1,0,1],[1,0,2],[1,3,3],[1,0,4],[1,3,5],[1,3,6],[1,0,7],[0,0,8],[0,0,8],[0,3,10],[0,3,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,3,10],[0,3,10],[0,0,8],[0,0,8]], + [[1,4,3],[1,4,3],[1,4,0],[1,4,0],[1,4,6],[1,4,6],[1,4,5],[1,4,5],[0,6,8],[0,6,8],[0,4,10],[0,4,10],[1,4,0],[1,4,0],[1,4,3],[1,4,3],[1,4,5],[1,4,5],[1,4,6],[1,4,6],[0,4,10],[0,4,10],[0,6,8],[0,6,8]], + [[1,5,0],[1,5,0],[1,5,3],[1,5,3],[1,5,5],[1,5,5],[1,5,6],[1,5,6],[0,6,8],[0,6,8],[0,5,10],[0,5,10],[1,5,3],[1,5,3],[1,5,0],[1,5,0],[1,5,6],[1,5,6],[1,5,5],[1,5,5],[0,5,10],[0,5,10],[0,6,8],[0,6,8]], + [[1,6,0],[1,5,1],[1,5,2],[1,6,3],[1,5,4],[1,6,5],[1,6,6],[1,5,7],[0,5,8],[0,5,8],[0,6,10],[0,6,10],[1,5,2],[1,5,2],[1,5,1],[1,5,1],[1,5,7],[1,5,7],[1,5,4],[1,5,4],[0,6,10],[0,6,10],[0,5,8],[0,5,8]], + [[1,6,0],[1,4,2],[1,4,1],[1,6,3],[1,4,7],[1,6,5],[1,6,6],[1,4,4],[0,4,8],[0,4,8],[0,6,10],[0,6,10],[1,4,1],[1,4,1],[1,4,2],[1,4,2],[1,4,4],[1,4,4],[1,4,7],[1,4,7],[0,6,10],[0,6,10],[0,4,8],[0,4,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[1,2,3],[1,0,1],[1,0,2],[1,2,0],[1,0,4],[1,2,6],[1,2,5],[1,0,7],[0,0,8],[0,0,8],[0,2,10],[0,2,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,2,10],[0,2,10],[0,0,8],[0,0,8]], + [[1,2,3],[1,0,1],[1,0,2],[1,2,0],[1,0,4],[1,2,6],[1,2,5],[1,0,7],[0,0,8],[0,0,8],[0,2,10],[0,2,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,2,10],[0,2,10],[0,0,8],[0,0,8]], + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,2,8],[0,2,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,2,8],[0,2,8]], + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,2,8],[0,2,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,2,8],[0,2,8]], + [[1,6,0],[1,4,2],[1,4,1],[1,6,3],[1,4,7],[1,6,5],[1,6,6],[1,4,4],[0,4,8],[0,4,8],[0,6,10],[0,6,10],[1,4,1],[1,4,1],[1,4,2],[1,4,2],[1,4,4],[1,4,4],[1,4,7],[1,4,7],[0,6,10],[0,6,10],[0,4,8],[0,4,8]], + [[1,6,0],[1,4,2],[1,4,1],[1,6,3],[1,4,7],[1,6,5],[1,6,6],[1,4,4],[0,4,8],[0,4,8],[0,6,10],[0,6,10],[1,4,1],[1,4,1],[1,4,2],[1,4,2],[1,4,4],[1,4,4],[1,4,7],[1,4,7],[0,6,10],[0,6,10],[0,4,8],[0,4,8]], + [[1,4,3],[1,4,3],[1,4,0],[1,4,0],[1,4,6],[1,4,6],[1,4,5],[1,4,5],[0,6,8],[0,6,8],[0,4,10],[0,4,10],[1,4,0],[1,4,0],[1,4,3],[1,4,3],[1,4,5],[1,4,5],[1,4,6],[1,4,6],[0,4,10],[0,4,10],[0,6,8],[0,6,8]], + [[1,4,3],[1,4,3],[1,4,0],[1,4,0],[1,4,6],[1,4,6],[1,4,5],[1,4,5],[0,6,8],[0,6,8],[0,4,10],[0,4,10],[1,4,0],[1,4,0],[1,4,3],[1,4,3],[1,4,5],[1,4,5],[1,4,6],[1,4,6],[0,4,10],[0,4,10],[0,6,8],[0,6,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + ], + // was_edge = 1 + [ + [[0,0,0],[0,3,0],[0,3,2],[0,0,3],[0,3,4],[0,0,5],[0,0,6],[0,3,6],[1,5,23],[1,5,22],[1,5,21],[1,5,20],[0,5,2],[0,6,2],[0,5,0],[0,6,0],[0,6,6],[0,5,6],[0,6,4],[0,5,4],[1,5,10],[1,5,11],[1,5,8],[1,5,9]], + [[0,0,3],[0,2,2],[0,2,0],[0,0,0],[0,2,6],[0,0,6],[0,0,5],[0,2,4],[1,4,23],[1,4,22],[1,4,21],[1,4,20],[0,6,0],[0,4,0],[0,6,2],[0,4,2],[0,4,4],[0,6,4],[0,4,6],[0,6,6],[1,4,10],[1,4,11],[1,4,8],[1,4,9]], + [[0,2,3],[0,0,2],[0,0,0],[0,2,0],[0,0,6],[0,2,6],[0,2,5],[0,0,4],[1,4,22],[1,4,23],[1,4,20],[1,4,21],[0,4,0],[0,6,0],[0,4,2],[0,6,2],[0,6,4],[0,4,4],[0,6,6],[0,4,6],[1,4,11],[1,4,10],[1,4,9],[1,4,8]], + [[0,3,0],[0,0,0],[0,0,2],[0,3,3],[0,0,4],[0,3,5],[0,3,6],[0,0,6],[1,5,22],[1,5,23],[1,5,20],[1,5,21],[0,6,2],[0,5,2],[0,6,0],[0,5,0],[0,5,6],[0,6,6],[0,5,4],[0,6,4],[1,5,11],[1,5,10],[1,5,9],[1,5,8]], + [[0,4,3],[0,6,2],[0,6,0],[0,4,0],[0,6,6],[0,4,6],[0,4,5],[0,6,4],[1,0,21],[1,0,20],[1,0,23],[1,0,22],[0,0,0],[0,2,0],[0,0,2],[0,2,2],[0,2,4],[0,0,4],[0,2,6],[0,0,6],[1,0,8],[1,0,9],[1,0,10],[1,0,11]], + [[0,5,0],[0,6,0],[0,6,2],[0,5,3],[0,6,4],[0,5,5],[0,5,6],[0,6,6],[1,0,22],[1,0,23],[1,0,20],[1,0,21],[0,3,2],[0,0,2],[0,3,0],[0,0,0],[0,0,6],[0,3,6],[0,0,4],[0,3,4],[1,0,11],[1,0,10],[1,0,9],[1,0,8]], + [[0,6,0],[0,5,0],[0,5,2],[0,6,3],[0,5,4],[0,6,5],[0,6,6],[0,5,6],[1,0,23],[1,0,22],[1,0,21],[1,0,20],[0,0,2],[0,3,2],[0,0,0],[0,3,0],[0,3,6],[0,0,6],[0,3,4],[0,0,4],[1,0,10],[1,0,11],[1,0,8],[1,0,9]], + [[0,6,3],[0,4,2],[0,4,0],[0,6,0],[0,4,6],[0,6,6],[0,6,5],[0,4,4],[1,0,20],[1,0,21],[1,0,22],[1,0,23],[0,2,0],[0,0,0],[0,2,2],[0,0,2],[0,0,4],[0,2,4],[0,0,6],[0,2,6],[1,0,9],[1,0,8],[1,0,11],[1,0,10]], + [[1,22,6],[1,20,5],[1,20,6],[1,22,5],[1,20,3],[1,22,0],[1,22,3],[1,20,0],[0,0,0],[0,0,2],[0,2,2],[0,2,0],[0,6,6],[0,4,4],[0,6,4],[0,4,6],[0,4,2],[0,6,0],[0,4,0],[0,6,2],[0,2,4],[0,2,6],[0,0,6],[0,0,4]], + [[1,22,5],[1,20,6],[1,20,5],[1,22,6],[1,20,0],[1,22,3],[1,22,0],[1,20,3],[0,2,0],[0,2,2],[0,0,2],[0,0,0],[0,4,6],[0,6,4],[0,4,4],[0,6,6],[0,6,2],[0,4,0],[0,6,0],[0,4,2],[0,0,4],[0,0,6],[0,2,6],[0,2,4]], + [[1,20,6],[1,20,7],[1,20,4],[1,20,5],[1,20,1],[1,20,0],[1,20,3],[1,20,2],[0,2,2],[0,2,0],[0,0,0],[0,0,2],[0,6,4],[0,4,6],[0,6,6],[0,4,4],[0,4,0],[0,6,2],[0,4,2],[0,6,0],[0,0,6],[0,0,4],[0,2,4],[0,2,6]], + [[1,20,5],[1,20,4],[1,20,7],[1,20,6],[1,20,2],[1,20,3],[1,20,0],[1,20,1],[0,0,2],[0,0,0],[0,2,0],[0,2,2],[0,4,4],[0,6,6],[0,4,6],[0,6,4],[0,6,0],[0,4,2],[0,6,2],[0,4,0],[0,2,6],[0,2,4],[0,0,4],[0,0,6]], + [[0,2,5],[0,0,6],[0,0,4],[0,2,6],[0,0,0],[0,2,3],[0,2,0],[0,0,2],[0,6,6],[0,6,4],[0,4,6],[0,4,4],[1,16,18],[1,16,19],[1,16,16],[1,16,17],[1,16,12],[1,16,13],[1,16,14],[1,16,15],[0,4,2],[0,4,0],[0,6,2],[0,6,0]], + [[0,2,6],[0,0,4],[0,0,6],[0,2,5],[0,0,2],[0,2,0],[0,2,3],[0,0,0],[0,4,4],[0,4,6],[0,6,4],[0,6,6],[1,16,17],[1,16,16],[1,16,19],[1,16,18],[1,16,15],[1,16,14],[1,16,13],[1,16,12],[0,6,0],[0,6,2],[0,4,0],[0,4,2]], + [[0,0,5],[0,2,6],[0,2,4],[0,0,6],[0,2,0],[0,0,3],[0,0,0],[0,2,2],[0,4,6],[0,4,4],[0,6,6],[0,6,4],[1,16,16],[1,16,17],[1,16,18],[1,16,19],[1,16,14],[1,16,15],[1,16,12],[1,16,13],[0,6,2],[0,6,0],[0,4,2],[0,4,0]], + [[0,0,6],[0,2,4],[0,2,6],[0,0,5],[0,2,2],[0,0,0],[0,0,3],[0,2,0],[0,6,4],[0,6,6],[0,4,4],[0,4,6],[1,16,19],[1,16,18],[1,16,17],[1,16,16],[1,16,13],[1,16,12],[1,16,15],[1,16,14],[0,4,0],[0,4,2],[0,6,0],[0,6,2]], + [[0,6,6],[0,4,4],[0,4,6],[0,6,5],[0,4,2],[0,6,0],[0,6,3],[0,4,0],[0,2,4],[0,2,6],[0,0,4],[0,0,6],[1,12,16],[1,12,17],[1,12,18],[1,12,19],[1,12,14],[1,12,15],[1,12,12],[1,12,13],[0,0,0],[0,0,2],[0,2,0],[0,2,2]], + [[0,6,5],[0,4,6],[0,4,4],[0,6,6],[0,4,0],[0,6,3],[0,6,0],[0,4,2],[0,0,6],[0,0,4],[0,2,6],[0,2,4],[1,12,19],[1,12,18],[1,12,17],[1,12,16],[1,12,13],[1,12,12],[1,12,15],[1,12,14],[0,2,2],[0,2,0],[0,0,2],[0,0,0]], + [[0,4,6],[0,6,4],[0,6,6],[0,4,5],[0,6,2],[0,4,0],[0,4,3],[0,6,0],[0,0,4],[0,0,6],[0,2,4],[0,2,6],[1,12,18],[1,12,19],[1,12,16],[1,12,17],[1,12,12],[1,12,13],[1,12,14],[1,12,15],[0,2,0],[0,2,2],[0,0,0],[0,0,2]], + [[0,4,5],[0,6,6],[0,6,4],[0,4,6],[0,6,0],[0,4,3],[0,4,0],[0,6,2],[0,2,6],[0,2,4],[0,0,6],[0,0,4],[1,12,17],[1,12,16],[1,12,19],[1,12,18],[1,12,15],[1,12,14],[1,12,13],[1,12,12],[0,0,2],[0,0,0],[0,2,2],[0,2,0]], + [[1,10,5],[1,8,6],[1,8,5],[1,10,6],[1,8,0],[1,10,3],[1,10,0],[1,8,3],[0,4,2],[0,4,0],[0,6,0],[0,6,2],[0,2,4],[0,0,6],[0,2,6],[0,0,4],[0,0,0],[0,2,2],[0,0,2],[0,2,0],[0,6,6],[0,6,4],[0,4,4],[0,4,6]], + [[1,10,6],[1,8,5],[1,8,6],[1,10,5],[1,8,3],[1,10,0],[1,10,3],[1,8,0],[0,6,2],[0,6,0],[0,4,0],[0,4,2],[0,0,4],[0,2,6],[0,0,6],[0,2,4],[0,2,0],[0,0,2],[0,2,2],[0,0,0],[0,4,6],[0,4,4],[0,6,4],[0,6,6]], + [[1,8,5],[1,8,4],[1,8,7],[1,8,6],[1,8,2],[1,8,3],[1,8,0],[1,8,1],[0,6,0],[0,6,2],[0,4,2],[0,4,0],[0,2,6],[0,0,4],[0,2,4],[0,0,6],[0,0,2],[0,2,0],[0,0,0],[0,2,2],[0,4,4],[0,4,6],[0,6,6],[0,6,4]], + [[1,8,6],[1,8,7],[1,8,4],[1,8,5],[1,8,1],[1,8,0],[1,8,3],[1,8,2],[0,4,0],[0,4,2],[0,6,2],[0,6,0],[0,0,6],[0,2,4],[0,0,4],[0,2,6],[0,2,2],[0,0,0],[0,2,0],[0,0,2],[0,6,4],[0,6,6],[0,4,6],[0,4,4]], + ], +]; + +/// Compute the CZ lookup table by remapping the reference `GraphSim` table +/// to our `CliffordFrame` index system. +/// +/// For each `(was_edge, v1, v2)`, finds `(new_edge, v1', v2')` such that +/// after applying CZ to state `(V1 x V2) |G_{was_edge}>`, +/// the result is `(V1' x V2') |G_{new_edge}>`. +/// +/// Layout: `[was_edge * 24 + v1][v2]` = `[new_edge, v1', v2']`. +const fn compute_cphase_table() -> [[[u8; 3]; 24]; 48] { + let mut table = [[[0u8; 3]; 24]; 48]; + + let mut we: usize = 0; + while we < 2 { + let mut v1: usize = 0; + while v1 < 24 { + let mut v2: usize = 0; + while v2 < 24 { + // Map our indices to reference indices + let rv1 = OURS_TO_REF[v1] as usize; + let rv2 = OURS_TO_REF[v2] as usize; + + // Look up in reference table + let [ne, rnv1, rnv2] = REF_CPHASE[we][rv1][rv2]; + + // Map reference result indices back to our indices + let onv1 = REF_TO_OURS[rnv1 as usize]; + let onv2 = REF_TO_OURS[rnv2 as usize]; + + table[we * 24 + v1][v2] = [ne, onv1, onv2]; + v2 += 1; + } + v1 += 1; + } + we += 1; + } + + table +} + +/// CZ lookup table, computed at compile time. +/// +/// `CPHASE_TBL[was_edge * 24 + vop1][vop2]` = `[new_edge, new_vop1, new_vop2]` +pub const CPHASE_TBL: [[[u8; 3]; 24]; 48] = compute_cphase_table(); + // ============================================================================ // Phase cocycle tables for exact phase tracking // ============================================================================ @@ -502,6 +758,16 @@ impl CliffordFrame { self.0 == 0 } + /// Whether this Clifford is diagonal in the computational basis. + /// + /// A Clifford is diagonal iff its Z-image axis is Z (maps Z to +/-Z). + /// The four diagonal Cliffords are: I, Z, S, Sdg. + #[inline] + #[must_use] + pub fn is_diagonal(self) -> bool { + HEIS[self.0 as usize].2 == 2 // z_axis == Z + } + /// Decompose into Pauli × Coset: `self_matrix` = pauli · coset. /// /// The coset representative is one of {I, S, H, SH, HS, SHS}. @@ -520,6 +786,14 @@ impl CliffordFrame { self.0 } + /// Construct from a raw index. Only valid for indices 0..24. + #[inline] + #[must_use] + pub fn from_index(idx: u8) -> Self { + debug_assert!(idx < 24, "CliffordFrame index out of range: {idx}"); + Self(idx) + } + /// Pauli symplectic representation: (`x_bit`, `z_bit`). /// Only valid for Pauli elements (index 0-3). /// I=(false,false), X=(true,false), Y=(true,true), Z=(false,true). diff --git a/crates/pecos-qsim/src/graph_state.rs b/crates/pecos-qsim/src/graph_state.rs new file mode 100644 index 000000000..f67a44d09 --- /dev/null +++ b/crates/pecos-qsim/src/graph_state.rs @@ -0,0 +1,817 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License.You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Graph state stabilizer simulator inspired by the Anders & Briegel algorithm. +//! +//! Any stabilizer state can be written as local Cliffords applied to a graph state: +//! `|psi> = (tensor_v VOP_v) |G>` where `|G>` is the graph state defined by an +//! adjacency graph. Single-qubit Clifford gates are O(1) VOP updates. Two-qubit +//! gates and measurements require local complementation operations that are +//! O(degree) or O(degree^2). +//! +//! # References +//! - Anders & Briegel, "Fast simulation of stabilizer circuits using a graph-state +//! representation", [quant-ph/0504117](https://arxiv.org/abs/quant-ph/0504117) + +use crate::clifford_frame::{CliffordFrame, PauliAxis}; +use crate::{CliffordGateable, MeasurementResult, QuantumSimulator}; +use core::fmt::Debug; +use pecos_core::{BitSet, QubitId, RngManageable}; +use pecos_rng::rng_ext::RngProbabilityExt; +use pecos_rng::{PecosRng, Rng, SeedableRng}; + +use crate::stabilizer_test_utils::{ForcedMeasurement, StabilizerSimulator}; + +/// Graph state stabilizer simulator. +/// +/// Represents a stabilizer state as `|psi> = (tensor_v VOP_v) |G>` where +/// `VOP_v` is a single-qubit Clifford (vertex operator) on each qubit and +/// `|G>` is the graph state defined by the adjacency graph. +/// +/// Single-qubit gates are O(1). Two-qubit gates are O(degree) amortized. +/// Measurements are O(degree^2) in the worst case. +#[derive(Clone, Debug)] +pub struct GraphStateSim { + num_qubits: usize, + /// Vertex operators: one single-qubit Clifford per qubit. + pub(crate) vops: Vec, + /// Adjacency lists: `neighbors[v]` is the set of vertices adjacent to v. + pub(crate) neighbors: Vec, + rng: R, +} + +// ============================================================================ +// Constructors +// ============================================================================ + +impl GraphStateSim { + /// Create a new graph state simulator with the default RNG. + #[inline] + #[must_use] + pub fn new(num_qubits: usize) -> Self { + let rng = rand::make_rng(); + Self::with_rng(num_qubits, rng) + } + + /// Create a new graph state simulator with a specific seed. + #[inline] + #[must_use] + pub fn with_seed(num_qubits: usize, seed: u64) -> Self { + let rng = PecosRng::seed_from_u64(seed); + Self::with_rng(num_qubits, rng) + } +} + +impl GraphStateSim { + /// Create a new graph state simulator with a custom RNG. + #[inline] + pub fn with_rng(num_qubits: usize, rng: R) -> Self { + let mut state = Self { + num_qubits, + vops: vec![CliffordFrame::IDENTITY; num_qubits], + neighbors: vec![BitSet::new(); num_qubits], + rng, + }; + state.reset(); + state + } + + /// Returns the number of qubits. + #[inline] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Extract the graph state representation (cloning VOPs and neighbors). + #[must_use] + pub fn to_graph_state(&self) -> crate::graph_state_repr::GraphState { + crate::graph_state_repr::GraphState::from_parts(self.vops.clone(), self.neighbors.clone()) + } + + /// Consume this simulator and return the graph state representation. + #[must_use] + pub fn into_graph_state(self) -> crate::graph_state_repr::GraphState { + crate::graph_state_repr::GraphState::from_parts(self.vops, self.neighbors) + } + + // ======================================================================== + // Internal: adjacency helpers + // ======================================================================== + + /// Toggle edge (a, b) in the graph. + #[inline] + fn toggle_edge(&mut self, a: usize, b: usize) { + self.neighbors[a].toggle(b); + self.neighbors[b].toggle(a); + } + + /// Disconnect vertex a from all neighbors. + fn disconnect(&mut self, a: usize) { + // Collect neighbors to avoid borrow issues + let nbrs: Vec = self.neighbors[a].iter().collect(); + for &u in &nbrs { + self.neighbors[u].toggle(a); + } + self.neighbors[a].clear(); + } + + // ======================================================================== + // Internal: local complementation + // ======================================================================== + + /// Perform local complementation about vertex `a`. + /// + /// This complements all edges among neighbors of `a`, then updates VOPs: + /// - Prepend sqrt(-iX) to `VOP_a` + /// - Prepend sqrt(iZ) to each neighbor's VOP + fn local_complement(&mut self, a: usize) { + let nbrs: Vec = self.neighbors[a].iter().collect(); + + // Complement edges among N(a) + for i in 0..nbrs.len() { + for j in (i + 1)..nbrs.len() { + self.toggle_edge(nbrs[i], nbrs[j]); + } + } + + // Update VOPs: prepend sqrt(-iX) = SXDG to vertex a + self.vops[a] = CliffordFrame::SXDG.compose(self.vops[a]); + + // Prepend sqrt(iZ) = SZ to each neighbor + for &u in &nbrs { + self.vops[u] = CliffordFrame::SZ.compose(self.vops[u]); + } + } + + // ======================================================================== + // Internal: CZ implementation + // ======================================================================== + + /// Check whether vertex `v` has any neighbor other than `other`. + fn has_non_operand_neighbors(&self, v: usize, other: usize) -> bool { + let nbrs = &self.neighbors[v]; + if nbrs.contains(other) { + nbrs.len() >= 2 + } else { + !nbrs.is_empty() + } + } + + /// Remove the VOP on vertex `v` by decomposing it into a sequence of + /// local complementations on `v` and a chosen neighbor `vb`. + /// + /// Uses a precomputed decomposition table (BFS over the 24-element Clifford + /// group using the two generators: LC on v appends SXDG, LC on vb appends SZ). + fn remove_vop(&mut self, v: usize, avoid: usize) { + use crate::clifford_frame::VOP_DECOMP; + + debug_assert!( + !self.neighbors[v].is_empty(), + "remove_vop called with isolated vertex" + ); + + // Pick a neighbor that isn't `avoid` (if possible) + let mut vb = self.neighbors[v].iter().next().unwrap(); + if vb == avoid + && let Some(alt) = self.neighbors[v].iter().find(|&u| u != avoid) + { + vb = alt; + } + // If avoid is the only neighbor, we'll use it anyway + + let (len, steps) = VOP_DECOMP[self.vops[v].index() as usize]; + + // Apply steps in forward order: each step reduces the VOP toward identity + for &step in &steps[..len as usize] { + if step == 0 { + // U: local complement on v + self.local_complement(v); + } else { + // V: local complement on neighbor vb + self.local_complement(vb); + } + } + + debug_assert!( + self.vops[v].is_identity(), + "remove_vop failed: VOP is {:?} (expected identity)", + self.vops[v] + ); + } + + /// Internal CZ implementation using the reference's 3-pass structure. + /// + /// Follows the Anders & Briegel `cphase` algorithm: + /// 1. If v1 has non-operand neighbors, remove its VOP. + /// 2. If v2 has non-operand neighbors, remove its VOP. + /// 3. If v1 still has non-operand neighbors and non-diagonal VOP, remove again. + /// 4. Apply CZ via lookup table. + fn cz_internal(&mut self, v1: usize, v2: usize) { + use crate::clifford_frame::CPHASE_TBL; + + if self.has_non_operand_neighbors(v1, v2) { + self.remove_vop(v1, v2); + } + if self.has_non_operand_neighbors(v2, v1) { + self.remove_vop(v2, v1); + } + if self.has_non_operand_neighbors(v1, v2) && !self.vops[v1].is_diagonal() { + self.remove_vop(v1, v2); + } + + // Use the CZ lookup table + let was_edge = self.neighbors[v1].contains(v2); + let op1 = self.vops[v1].index() as usize; + let op2 = self.vops[v2].index() as usize; + + let we_idx = usize::from(was_edge); + let [new_edge, new_op1, new_op2] = CPHASE_TBL[we_idx * 24 + op1][op2]; + + // Set edge state + let should_have_edge = new_edge == 1; + if was_edge && !should_have_edge { + // Remove edge + self.neighbors[v1].toggle(v2); + self.neighbors[v2].toggle(v1); + } else if !was_edge && should_have_edge { + // Add edge + self.neighbors[v1].toggle(v2); + self.neighbors[v2].toggle(v1); + } + + self.vops[v1] = CliffordFrame::from_index(new_op1); + self.vops[v2] = CliffordFrame::from_index(new_op2); + } + + // ======================================================================== + // Internal: measurement + // ======================================================================== + + /// Measure qubit `a` in the Z basis with a given outcome for non-deterministic cases. + /// + /// Follows the reference's `measure` function: conjugate the Z basis through the VOP + /// to determine which graph-state measurement to perform. If the conjugation produces + /// a negative sign, flip the forced outcome before and the result after. + fn measure_z_internal(&mut self, a: usize, forced_outcome: Option) -> MeasurementResult { + // The effective Pauli being measured on the graph state + let sigma = self.vops[a].z_image(); + let negative = !sigma.positive; + + // If the VOP conjugation gives a negative sign, flip the forced outcome + let adjusted_forced = if negative { + forced_outcome.map(|f| !f) + } else { + forced_outcome + }; + + let mut result = match sigma.axis { + PauliAxis::X => self.measure_x_on_graph(a, adjusted_forced), + PauliAxis::Y => self.measure_y_on_graph(a, adjusted_forced), + PauliAxis::Z => self.measure_z_on_graph(a, adjusted_forced), + }; + + // If the sign was negative, flip the result + if negative { + result.outcome = !result.outcome; + } + + result + } + + /// Measure X on the graph state at vertex `v`. + /// + /// Follows the reference's `graph_X_measure` algorithm. + /// If N(v) is empty: deterministic, outcome = 0 (always +1 eigenvalue). + /// Otherwise: non-deterministic with 3-step edge toggling. + fn measure_x_on_graph(&mut self, v: usize, forced_outcome: Option) -> MeasurementResult { + if self.neighbors[v].is_empty() { + // Deterministic: isolated graph state vertex is |+>, X eigenvalue +1 + return MeasurementResult { + outcome: false, + is_deterministic: true, + }; + } + + // Non-deterministic + let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); + + // Pick a neighbor vb + let vb = self.neighbors[v].iter().next().unwrap(); + + // Save neighborhoods BEFORE modifications + let vn: Vec = self.neighbors[v].iter().collect(); + let vbn: Vec = self.neighbors[vb].iter().collect(); + + // Build sets for fast lookup + let vn_set: BitSet = self.neighbors[v].clone(); + let vbn_set: BitSet = self.neighbors[vb].clone(); + + // VOP updates + if outcome { + // Measured -1 (|->): SY on vb, Z on v, Z on N(vb) \ N(v) \ {v} + self.vops[vb] = CliffordFrame::SY.compose(self.vops[vb]); + self.vops[v] = CliffordFrame::Z.compose(self.vops[v]); + for &u in &vbn { + if u != v && !vn_set.contains(u) { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } + } else { + // Measured +1 (|+>): SYDG on vb, Z on N(v) \ N(vb) \ {vb} + self.vops[vb] = CliffordFrame::SYDG.compose(self.vops[vb]); + for &u in &vn { + if u != vb && !vbn_set.contains(u) { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } + } + + // Edge toggles (using saved neighborhoods) + // STEP 1: Toggle edges between N(v) and N(vb), avoiding double-toggling + { + let mut processed = BitSet::new(); + for &i in &vn { + for &j in &vbn { + if i != j { + let edge = if i < j { (i, j) } else { (j, i) }; + let edge_key = edge.0 * self.num_qubits + edge.1; + if !processed.contains(edge_key) { + processed.insert(edge_key); + self.toggle_edge(i, j); + } + } + } + } + } + + // STEP 2: Toggle complete subgraph on N(v) intersect N(vb) + { + let intersection: Vec = vn + .iter() + .filter(|&&u| vbn_set.contains(u)) + .copied() + .collect(); + for i in 0..intersection.len() { + for j in (i + 1)..intersection.len() { + self.toggle_edge(intersection[i], intersection[j]); + } + } + } + + // STEP 3: Toggle edges from vb to N(v) \ {vb} + for &u in &vn { + if u != vb { + self.toggle_edge(vb, u); + } + } + + MeasurementResult { + outcome, + is_deterministic: false, + } + } + + /// Measure Y on the graph state at vertex `v`. + /// + /// Follows the reference's `graph_Y_measure` algorithm (direct, no reduction to X). + /// Always non-deterministic. + fn measure_y_on_graph(&mut self, v: usize, forced_outcome: Option) -> MeasurementResult { + let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); + + // Right-multiply each neighbor's VOP by SZDG (outcome=1) or SZ (outcome=0) + let vnbg: Vec = self.neighbors[v].iter().collect(); + for &u in &vnbg { + if outcome { + self.vops[u] = CliffordFrame::SZDG.compose(self.vops[u]); + } else { + self.vops[u] = CliffordFrame::SZ.compose(self.vops[u]); + } + } + + // Toggle all edges in complete subgraph of {v} union N(v) + let mut all_vertices = vnbg.clone(); + all_vertices.push(v); + for i in 0..all_vertices.len() { + for j in (i + 1)..all_vertices.len() { + self.toggle_edge(all_vertices[i], all_vertices[j]); + } + } + + // Right-multiply v's VOP by SZ (outcome=0) or SZDG (outcome=1) + if outcome { + self.vops[v] = CliffordFrame::SZDG.compose(self.vops[v]); + } else { + self.vops[v] = CliffordFrame::SZ.compose(self.vops[v]); + } + + MeasurementResult { + outcome, + is_deterministic: false, + } + } + + /// Measure Z on the graph state at vertex `v`. + /// + /// Follows the reference's `graph_Z_measure` algorithm. + /// Disconnects v from all neighbors (no edge complement among neighbors). + /// If outcome=1, right-multiplies each neighbor's VOP by Z. + /// Sets v's VOP by right-multiplying by H (outcome=0) or X*H=SY (outcome=1). + fn measure_z_on_graph(&mut self, v: usize, forced_outcome: Option) -> MeasurementResult { + let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); + + let nbrs: Vec = self.neighbors[v].iter().collect(); + + // Disconnect v from all neighbors (no edge complement) + self.disconnect(v); + + // If outcome=1, right-multiply each neighbor's VOP by Z + if outcome { + for &u in &nbrs { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } + + // Set v's VOP: right-multiply by H (outcome=0) or X*H=SY (outcome=1) + if outcome { + // X * H = SY (index 10). Right-multiply: compose(SY, VOP) = VOP * SY + self.vops[v] = CliffordFrame::SY.compose(self.vops[v]); + } else { + self.vops[v] = CliffordFrame::H.compose(self.vops[v]); + } + + // Determine if deterministic: isolated vertices (no neighbors) have + // deterministic Z measurement result. But after graph_Z_measure, + // the result is always "non-deterministic" from the graph measurement + // perspective. The determinism is handled by the caller (measure_z_internal). + // + // Actually: if the vertex had no neighbors to begin with, the graph state + // X stabilizer means Z is non-deterministic. + MeasurementResult { + outcome, + is_deterministic: false, + } + } +} + +// ============================================================================ +// Trait implementations +// ============================================================================ + +impl QuantumSimulator for GraphStateSim { + fn reset(&mut self) -> &mut Self { + // |0>^n = H^n |+>^n = H^n |G_empty> + // So all VOPs are H, and the graph has no edges. + for v in &mut self.vops { + *v = CliffordFrame::H; + } + for n in &mut self.neighbors { + n.clear(); + } + self + } +} + +impl RngManageable for GraphStateSim { + type Rng = R; + + fn set_rng(&mut self, rng: Self::Rng) { + self.rng = rng; + } + + #[inline] + fn rng(&self) -> &Self::Rng { + &self.rng + } + + #[inline] + fn rng_mut(&mut self) -> &mut Self::Rng { + &mut self.rng + } +} + +impl CliffordGateable for GraphStateSim { + // -- Single-qubit gates: O(1) VOP composition -- + + fn x(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::X); + } + self + } + + fn y(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::Y); + } + self + } + + fn z(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::Z); + } + self + } + + fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SZ); + } + self + } + + fn szdg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SZDG); + } + self + } + + fn h(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::H); + } + self + } + + fn sx(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SX); + } + self + } + + fn sxdg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SXDG); + } + self + } + + fn sy(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SY); + } + self + } + + fn sydg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SYDG); + } + self + } + + // -- Two-qubit gates -- + + fn cx(&mut self, qubits: &[QubitId]) -> &mut Self { + debug_assert!( + qubits.len().is_multiple_of(2), + "CX requires pairs of qubits" + ); + for pair in qubits.chunks_exact(2) { + let ctrl = pair[0].index(); + let targ = pair[1].index(); + // CX = (I x H) CZ (I x H) + self.vops[targ] = self.vops[targ].compose(CliffordFrame::H); + self.cz_internal(ctrl, targ); + self.vops[targ] = self.vops[targ].compose(CliffordFrame::H); + } + self + } + + fn cz(&mut self, qubits: &[QubitId]) -> &mut Self { + debug_assert!( + qubits.len().is_multiple_of(2), + "CZ requires pairs of qubits" + ); + for pair in qubits.chunks_exact(2) { + self.cz_internal(pair[0].index(), pair[1].index()); + } + self + } + + // -- Measurement -- + + fn mz(&mut self, qubits: &[QubitId]) -> Vec { + qubits + .iter() + .map(|&q| self.measure_z_internal(q.index(), None)) + .collect() + } +} + +// ============================================================================ +// ForcedMeasurement & StabilizerSimulator +// ============================================================================ + +impl ForcedMeasurement for GraphStateSim { + fn mz_forced(&mut self, qubit: usize, forced_outcome: bool) -> MeasurementResult { + self.measure_z_internal(qubit, Some(forced_outcome)) + } +} + +impl crate::StabilizerTableauSimulator for GraphStateSim { + fn stab_tableau(&self) -> String { + let gs = self.to_graph_state(); + let n = gs.num_qubits(); + let gens = gs.stabilizer_generators(); + let mut result = String::with_capacity(n * (n + 3)); + for g in &gens { + pauli_string_to_tableau_line(g, n, &mut result); + } + result + } + + fn destab_tableau(&self) -> String { + let n = self.num_qubits; + let mut result = String::with_capacity(n * (n + 3)); + for v in 0..n { + let z_img = self.vops[v].z_image(); + let pauli = match z_img.axis { + PauliAxis::X => pecos_core::Pauli::X, + PauliAxis::Y => pecos_core::Pauli::Y, + PauliAxis::Z => pecos_core::Pauli::Z, + }; + let phase = if z_img.positive { + pecos_core::QuarterPhase::PlusOne + } else { + pecos_core::QuarterPhase::MinusOne + }; + let mut paulis = vec![pecos_core::Pauli::I; n]; + paulis[v] = pauli; + let ps = pecos_core::PauliString::from_paulis_with_phase(phase, &paulis); + pauli_string_to_tableau_line(&ps, n, &mut result); + } + result + } + + fn num_qubits(&self) -> usize { + self.num_qubits + } +} + +/// Format a `PauliString` as a tableau line matching the `DenseStab` format. +/// +/// Produces e.g. `"+ZI\n"` or `"-iXY\n"`. +fn pauli_string_to_tableau_line(ps: &pecos_core::PauliString, n: usize, out: &mut String) { + use std::fmt::Write; + let phase_str = match ps.phase() { + pecos_core::QuarterPhase::PlusOne => "+", + pecos_core::QuarterPhase::MinusOne => "-", + pecos_core::QuarterPhase::PlusI => "+i", + pecos_core::QuarterPhase::MinusI => "-i", + }; + writeln!(out, "{}{}", phase_str, ps.pauli_str(Some(n))).unwrap(); +} + +impl StabilizerSimulator for GraphStateSim { + fn with_seed(num_qubits: usize, seed: u64) -> Self { + Self::with_seed(num_qubits, seed) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::stabilizer_test_suite; + use pecos_core::qid; + + stabilizer_test_suite!(GraphStateSim); + + #[test] + fn test_initial_state_is_all_zero() { + let mut sim = GraphStateSim::with_seed(3, 42); + for i in 0..3 { + let result = sim.mz(&[QubitId::new(i)]); + assert!( + result[0].is_deterministic, + "qubit {i} should be deterministic" + ); + assert!(!result[0].outcome, "qubit {i} should be |0>"); + } + } + + #[test] + fn test_single_qubit_x_flips() { + let mut sim = GraphStateSim::with_seed(1, 42); + sim.x(&qid(0)); + let result = sim.mz(&qid(0)); + assert!(result[0].is_deterministic); + assert!(result[0].outcome, "X|0> = |1>"); + } + + #[test] + fn test_hadamard_creates_superposition() { + let mut sim = GraphStateSim::with_seed(1, 42); + sim.h(&qid(0)); + let result = sim.mz(&qid(0)); + assert!( + !result[0].is_deterministic, + "H|0> = |+> should be non-deterministic for mz" + ); + } + + #[test] + fn test_bell_state_correlations() { + // Create Bell state and verify correlations over many seeds + for seed in 0..20 { + let mut sim = GraphStateSim::with_seed(2, seed); + sim.h(&qid(0)); + sim.cx(&[QubitId::new(0), QubitId::new(1)]); + + let r0 = sim.mz(&qid(0)); + let r1 = sim.mz(&qid(1)); + assert!(!r0[0].is_deterministic); + assert!( + r1[0].is_deterministic, + "second qubit should be deterministic after first measured" + ); + assert_eq!( + r0[0].outcome, r1[0].outcome, + "Bell state qubits should be correlated" + ); + } + } + + #[test] + fn test_cz_creates_cluster_state() { + let mut sim = GraphStateSim::with_seed(2, 42); + sim.h(&qid(0)); + sim.h(&[QubitId::new(1)]); + sim.cz(&[QubitId::new(0), QubitId::new(1)]); + + // CZ|++> should give a 2-qubit cluster state + // Measuring Z on qubit 0 should be non-deterministic + let r = sim.mz(&qid(0)); + assert!(!r[0].is_deterministic); + } + + #[test] + fn test_ghz_state() { + for seed in 0..20 { + let mut sim = GraphStateSim::with_seed(3, seed); + sim.h(&qid(0)); + sim.cx(&[QubitId::new(0), QubitId::new(1)]); + sim.cx(&[QubitId::new(1), QubitId::new(2)]); + + let r0 = sim.mz(&qid(0)); + let r1 = sim.mz(&[QubitId::new(1)]); + let r2 = sim.mz(&[QubitId::new(2)]); + + assert!(!r0[0].is_deterministic); + assert_eq!(r0[0].outcome, r1[0].outcome, "GHZ: q0 == q1"); + assert_eq!(r1[0].outcome, r2[0].outcome, "GHZ: q1 == q2"); + } + } + + #[test] + fn test_measurement_idempotent() { + let mut sim = GraphStateSim::with_seed(1, 42); + sim.h(&qid(0)); + let r1 = sim.mz(&qid(0)); + let r2 = sim.mz(&qid(0)); + assert!( + r2[0].is_deterministic, + "second measurement should be deterministic" + ); + assert_eq!( + r1[0].outcome, r2[0].outcome, + "repeated measurement should give same result" + ); + } + + #[test] + fn test_sz_gate() { + let mut sim = GraphStateSim::with_seed(1, 42); + // SZ SZ = Z, and Z|0> = |0> + sim.sz(&qid(0)); + sim.sz(&qid(0)); + let result = sim.mz(&qid(0)); + assert!(result[0].is_deterministic); + assert!(!result[0].outcome, "Z|0> = |0>"); + } + + #[test] + fn test_cross_validation_random_circuits() { + use crate::SparseStab; + use crate::stabilizer_test_utils::compare_simulators_on_random_circuits_direct; + + let mut gs = GraphStateSim::with_seed(6, 0); + let mut ss = SparseStab::with_seed(6, 0); + compare_simulators_on_random_circuits_direct(&mut gs, &mut ss, 6, 30, 50, 98765); + } +} diff --git a/crates/pecos-qsim/src/graph_state_repr.rs b/crates/pecos-qsim/src/graph_state_repr.rs new file mode 100644 index 000000000..bbc03769e --- /dev/null +++ b/crates/pecos-qsim/src/graph_state_repr.rs @@ -0,0 +1,2353 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License.You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Graph state representation and manipulation API. +//! +//! This module provides [`GraphState`], a mathematical representation of graph states +//! for QEC researchers. Unlike [`GraphStateSim`](crate::GraphStateSim), which is a +//! circuit simulator (taking gates and measurements), `GraphState` is for constructing, +//! manipulating, and analyzing graph states as mathematical objects. +//! +//! # Graph states +//! +//! A graph state `|G>` is defined by an undirected graph G = (V, E). Each vertex +//! starts in `|+>`, then a CZ gate is applied for each edge. The stabilizer +//! generators are `K_v` = `X_v` * prod_{u in N(v)} `Z_u`. +//! +//! Any stabilizer state can be written as local Cliffords applied to a graph state: +//! `|psi> = (tensor_v VOP_v) |G>`. The VOP (vertex operator) on each qubit is a +//! single-qubit Clifford tracked as a [`CliffordFrame`]. +//! +//! # Examples +//! +//! ``` +//! use pecos_qsim::GraphState; +//! +//! // Create a 3-qubit linear cluster state: 0 - 1 - 2 +//! let gs = GraphState::linear_cluster(3); +//! assert_eq!(gs.num_qubits(), 3); +//! assert_eq!(gs.num_edges(), 2); +//! assert!(gs.has_edge(0, 1)); +//! assert!(gs.has_edge(1, 2)); +//! assert!(!gs.has_edge(0, 2)); +//! ``` +//! +//! # References +//! +//! - Hein, Eisert, Briegel, "Multi-party entanglement in graph states", +//! [quant-ph/0307130](https://arxiv.org/abs/quant-ph/0307130) +//! - Van den Nest, Dehaene, De Moor, "Graphical description of the action of +//! local Clifford transformations on graph states", +//! [quant-ph/0308151](https://arxiv.org/abs/quant-ph/0308151) + +use crate::clifford_frame::{CliffordFrame, PauliAxis}; +use core::fmt::{self, Write as _}; +use pecos_core::circuit_diagram::{CellColor, FillPattern, GateFamily, GraphStyle, blend_hex}; +use pecos_core::{BitSet, Pauli, PauliString, Phase, QuarterPhase}; +use pecos_rng::{PecosRng, SeedableRng}; +use std::collections::{BTreeSet, VecDeque}; + +// ============================================================================ +// Core type +// ============================================================================ + +/// A graph state representation for mathematical manipulation. +/// +/// Stores vertex operators (VOPs) and an adjacency graph. The quantum state is +/// `|psi> = (tensor_v VOP_v) |G>` where `|G>` is the graph state. +/// +/// Unlike [`GraphStateSim`](crate::GraphStateSim), this type has no RNG and is +/// not a circuit simulator. It is for constructing, transforming, and analyzing +/// graph states as mathematical objects. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphState { + vops: Vec, + neighbors: Vec, +} + +// ============================================================================ +// Constructors +// ============================================================================ + +impl GraphState { + /// Create an n-qubit graph state with all VOPs identity and no edges. + /// + /// This represents `|+>^n` (the tensor product of n `|+>` states). + #[must_use] + pub fn new(n: usize) -> Self { + Self { + vops: vec![CliffordFrame::IDENTITY; n], + neighbors: vec![BitSet::new(); n], + } + } + + /// Create a pure graph state from an edge list. + /// + /// All VOPs are identity. Panics if any vertex index is >= n. + #[must_use] + pub fn from_edges(n: usize, edges: &[(usize, usize)]) -> Self { + let mut gs = Self::new(n); + for &(u, v) in edges { + assert!(u < n && v < n, "vertex index out of range"); + assert!(u != v, "self-loops not allowed"); + gs.neighbors[u].insert(v); + gs.neighbors[v].insert(u); + } + gs + } + + /// Create a graph state from a symmetric boolean adjacency matrix. + /// + /// Panics if the matrix is not square or not symmetric. + #[must_use] + pub fn from_adjacency_matrix(matrix: &[Vec]) -> Self { + let n = matrix.len(); + for row in matrix { + assert_eq!(row.len(), n, "adjacency matrix must be square"); + } + let mut gs = Self::new(n); + for (i, row) in matrix.iter().enumerate() { + for j in (i + 1)..n { + assert_eq!(row[j], matrix[j][i], "adjacency matrix must be symmetric"); + if row[j] { + gs.neighbors[i].insert(j); + gs.neighbors[j].insert(i); + } + } + } + gs + } + + /// Create a graph state from raw parts (VOPs and adjacency lists). + /// + /// Panics if the lengths do not match. + #[must_use] + pub fn from_parts(vops: Vec, neighbors: Vec) -> Self { + assert_eq!( + vops.len(), + neighbors.len(), + "vops and neighbors must have the same length" + ); + Self { vops, neighbors } + } + + // ======================================================================== + // Pattern factories + // ======================================================================== + + /// Linear cluster state: 0-1-2-..-(n-1). + #[must_use] + pub fn linear_cluster(n: usize) -> Self { + if n == 0 { + return Self::new(0); + } + let edges: Vec<(usize, usize)> = (0..n - 1).map(|i| (i, i + 1)).collect(); + Self::from_edges(n, &edges) + } + + /// Ring graph state: 0-1-..-(n-1)-0. + /// + /// Requires n >= 3. + #[must_use] + pub fn ring(n: usize) -> Self { + assert!(n >= 3, "ring requires at least 3 vertices"); + let mut edges: Vec<(usize, usize)> = (0..n - 1).map(|i| (i, i + 1)).collect(); + edges.push((n - 1, 0)); + Self::from_edges(n, &edges) + } + + /// Star graph state: vertex 0 connected to all others. + #[must_use] + pub fn star(n: usize) -> Self { + assert!(n >= 2, "star requires at least 2 vertices"); + let edges: Vec<(usize, usize)> = (1..n).map(|i| (0, i)).collect(); + Self::from_edges(n, &edges) + } + + /// 2D rectangular lattice graph state. + #[must_use] + pub fn lattice_2d(rows: usize, cols: usize) -> Self { + let n = rows * cols; + let mut edges = Vec::new(); + for r in 0..rows { + for c in 0..cols { + let v = r * cols + c; + if c + 1 < cols { + edges.push((v, v + 1)); + } + if r + 1 < rows { + edges.push((v, v + cols)); + } + } + } + Self::from_edges(n, &edges) + } + + /// Complete graph state `K_n`. + #[must_use] + pub fn complete(n: usize) -> Self { + let mut edges = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + edges.push((i, j)); + } + } + Self::from_edges(n, &edges) + } +} + +// ============================================================================ +// Accessors +// ============================================================================ + +impl GraphState { + /// Returns the number of qubits (vertices). + #[inline] + #[must_use] + pub fn num_qubits(&self) -> usize { + self.vops.len() + } + + /// Returns the VOP (vertex operator) for vertex v. + #[inline] + #[must_use] + pub fn vop(&self, v: usize) -> CliffordFrame { + self.vops[v] + } + + /// Returns the neighbor set of vertex v. + #[inline] + #[must_use] + pub fn neighbors(&self, v: usize) -> &BitSet { + &self.neighbors[v] + } + + /// Returns true if there is an edge between u and v. + #[inline] + #[must_use] + pub fn has_edge(&self, u: usize, v: usize) -> bool { + self.neighbors[u].contains(v) + } + + /// Returns the degree of vertex v. + #[inline] + #[must_use] + pub fn degree(&self, v: usize) -> usize { + self.neighbors[v].len() + } + + /// Returns the total number of edges. + #[must_use] + pub fn num_edges(&self) -> usize { + let total: usize = self.neighbors.iter().map(BitSet::len).sum(); + total / 2 + } + + /// Iterate over all edges (u, v) with u < v. + pub fn edges(&self) -> impl Iterator + '_ { + let n = self.num_qubits(); + (0..n).flat_map(move |u| { + self.neighbors[u] + .iter() + .filter(move |&v| v > u) + .map(move |v| (u, v)) + }) + } + + /// Returns true if all VOPs are identity (a "pure" graph state). + #[must_use] + pub fn is_pure_graph_state(&self) -> bool { + self.vops.iter().all(|v| v.is_identity()) + } + + /// Returns the adjacency matrix as a vector of vectors. + #[must_use] + pub fn adjacency_matrix(&self) -> Vec> { + let n = self.num_qubits(); + let mut matrix = vec![vec![false; n]; n]; + for (u, v) in self.edges() { + matrix[u][v] = true; + matrix[v][u] = true; + } + matrix + } +} + +// ============================================================================ +// Mutators +// ============================================================================ + +impl GraphState { + /// Set the VOP for vertex v. + #[inline] + pub fn set_vop(&mut self, v: usize, cliff: CliffordFrame) { + self.vops[v] = cliff; + } + + /// Apply a local Clifford gate to vertex v (right-composes with existing VOP). + #[inline] + pub fn apply_local_clifford(&mut self, v: usize, gate: CliffordFrame) { + self.vops[v] = self.vops[v].compose(gate); + } + + /// Toggle edge (u, v): add if absent, remove if present. + pub fn toggle_edge(&mut self, u: usize, v: usize) { + assert_ne!(u, v, "self-loops not allowed"); + self.neighbors[u].toggle(v); + self.neighbors[v].toggle(u); + } + + /// Add edge (u, v). No-op if already present. + pub fn add_edge(&mut self, u: usize, v: usize) { + assert_ne!(u, v, "self-loops not allowed"); + self.neighbors[u].insert(v); + self.neighbors[v].insert(u); + } + + /// Remove edge (u, v). No-op if not present. + pub fn remove_edge(&mut self, u: usize, v: usize) { + self.neighbors[u].remove(v); + self.neighbors[v].remove(u); + } +} + +// ============================================================================ +// Local complementation +// ============================================================================ + +impl GraphState { + /// Perform local complementation about vertex v. + /// + /// This complements all edges among N(v) and updates VOPs: + /// - Prepend sqrt(-iX) = SXDG to `VOP_v` + /// - Prepend sqrt(iZ) = SZ to each neighbor's VOP + pub fn local_complement(&mut self, v: usize) { + let nbrs: Vec = self.neighbors[v].iter().collect(); + + // Complement edges among N(v) + for i in 0..nbrs.len() { + for j in (i + 1)..nbrs.len() { + self.neighbors[nbrs[i]].toggle(nbrs[j]); + self.neighbors[nbrs[j]].toggle(nbrs[i]); + } + } + + // Update VOPs: prepend SXDG to vertex v + self.vops[v] = CliffordFrame::SXDG.compose(self.vops[v]); + + // Prepend SZ to each neighbor + for &u in &nbrs { + self.vops[u] = CliffordFrame::SZ.compose(self.vops[u]); + } + } + + /// Perform a pivot on edge (u, v): LC(u), LC(v), LC(u). + /// + /// Panics if u and v are not adjacent. + pub fn pivot(&mut self, u: usize, v: usize) { + assert!(self.has_edge(u, v), "pivot requires u and v to be adjacent"); + self.local_complement(u); + self.local_complement(v); + self.local_complement(u); + } + + /// Graph-only local complementation: complement edges among N(v). + /// + /// Unlike [`local_complement`](Self::local_complement), this does NOT update VOPs. + /// Used internally for LC-orbit enumeration where we work with graphs only. + fn graph_local_complement(&mut self, v: usize) { + let nbrs: Vec = self.neighbors[v].iter().collect(); + for i in 0..nbrs.len() { + for j in (i + 1)..nbrs.len() { + self.neighbors[nbrs[i]].toggle(nbrs[j]); + self.neighbors[nbrs[j]].toggle(nbrs[i]); + } + } + } + + /// Absorb all VOPs into the graph, producing an equivalent pure graph state. + /// + /// Computes the stabilizer generators, then extracts the equivalent graph + /// from the canonical stabilizer form. For each generator, the X position + /// identifies the vertex, and Z positions identify its neighbors. + /// + /// Note: isolated vertices with non-identity VOPs cannot be fully absorbed + /// since there are no neighbors to use for LC operations. Their VOPs + /// remain unchanged. + pub fn absorb_vops(&mut self) { + if self.is_pure_graph_state() { + return; + } + + let n = self.num_qubits(); + + // Compute stabilizer generators for the current state + let gens = self.stabilizer_generators(); + + // Build a new pure graph state from the stabilizer generators. + // For a graph state, each stabilizer generator has exactly one X + // (or can be brought to that form). The generator for vertex v + // is: (+/-)X_v * prod_{u in N(v)} Z_u + // + // We need to find generators that have a single X and the rest Z/I. + // This works when the state is equivalent to a graph state (which + // any stabilizer state is, up to local Cliffords -- and our state + // IS local Cliffords applied to a graph state). + + // Try to extract graph structure from generators. + // For each generator, check if it has the form (+/-)X_v * (Z terms). + // If all generators have this form, we can directly read off the graph. + let mut new_neighbors = vec![BitSet::new(); n]; + let mut success = true; + + for (idx, g) in gens.iter().enumerate() { + // Find the single X position + let mut x_pos = None; + let mut valid = true; + + for q in 0..n { + match g.get(q) { + Pauli::X => { + if x_pos.is_some() { + valid = false; + break; + } + x_pos = Some(q); + } + Pauli::Y => { + valid = false; + break; + } + Pauli::Z | Pauli::I => {} + } + } + + if !valid || x_pos.is_none() { + success = false; + break; + } + + let v = x_pos.unwrap(); + if v != idx { + // Generator ordering doesn't match vertex ordering + // This could happen but shouldn't for our construction + success = false; + break; + } + + for q in 0..n { + if g.get(q) == Pauli::Z { + new_neighbors[v].insert(q); + } + } + } + + if success { + self.neighbors = new_neighbors; + for v in 0..n { + self.vops[v] = CliffordFrame::IDENTITY; + } + } + // If not successful (state has Y terms in generators), the VOPs + // cannot be trivially absorbed. This is fine for LC-equivalence + // which uses graph-only operations. + } +} + +// ============================================================================ +// Stabilizer extraction (Phase 3) +// ============================================================================ + +impl GraphState { + /// Compute the stabilizer generator for vertex v. + /// + /// The bare generator is `K_v` = `X_v` * prod_{u in N(v)} `Z_u`. + /// The conjugated generator is `VOP_v(X_v)` * prod_{u in N(v)} `VOP_u(Z_u)`. + #[must_use] + pub fn stabilizer_generator(&self, v: usize) -> PauliString { + let n = self.num_qubits(); + let mut paulis = vec![Pauli::I; n]; + let mut phase = QuarterPhase::PlusOne; + + // Vertex v contributes: VOP_v maps X + let x_img = self.vops[v].x_image(); + paulis[v] = pauli_axis_to_pauli(x_img.axis); + if !x_img.positive { + phase = phase.multiply(&QuarterPhase::MinusOne); + } + + // Each neighbor u contributes: VOP_u maps Z + for u in &self.neighbors[v] { + let z_img = self.vops[u].z_image(); + let u_pauli = pauli_axis_to_pauli(z_img.axis); + + if !z_img.positive { + phase = phase.multiply(&QuarterPhase::MinusOne); + } + + // Multiply with existing Pauli at position u (could overlap if u == v's neighbor + // and there's already something there from a previous neighbor -- but neighbors + // are distinct from v, and each neighbor contributes to its own position) + if paulis[u] == Pauli::I { + paulis[u] = u_pauli; + } else { + // Two non-identity Paulis at same position: multiply them + let (result_pauli, extra_phase) = multiply_paulis(paulis[u], u_pauli); + paulis[u] = result_pauli; + phase = phase.multiply(&extra_phase); + } + } + + PauliString::from_paulis_with_phase(phase, &paulis) + } + + /// Compute all n stabilizer generators. + #[must_use] + pub fn stabilizer_generators(&self) -> Vec { + (0..self.num_qubits()) + .map(|v| self.stabilizer_generator(v)) + .collect() + } +} + +// ============================================================================ +// Conversions (Phase 4) +// ============================================================================ + +impl GraphState { + /// Convert into a simulator by providing an RNG. + #[must_use] + pub fn into_sim( + self, + rng: R, + ) -> crate::graph_state::GraphStateSim { + crate::graph_state::GraphStateSim::from_graph_state(self, rng) + } + + /// Convert into a simulator with a specific seed. + #[must_use] + pub fn into_sim_with_seed(self, seed: u64) -> crate::graph_state::GraphStateSim { + let rng = PecosRng::seed_from_u64(seed); + self.into_sim(rng) + } + + /// Tensor product of two graph states. + /// + /// The second graph state's vertex indices are shifted by `self.num_qubits()`. + #[must_use] + pub fn tensor_product(&self, other: &Self) -> Self { + let n1 = self.num_qubits(); + let n2 = other.num_qubits(); + let n = n1 + n2; + + let mut vops = self.vops.clone(); + vops.extend_from_slice(&other.vops); + + let mut neighbors = self.neighbors.clone(); + // Shift other's neighbor indices by n1 + for nbrs in &other.neighbors { + let mut shifted = BitSet::new(); + for u in nbrs { + shifted.insert(u + n1); + } + neighbors.push(shifted); + } + + debug_assert_eq!(vops.len(), n); + debug_assert_eq!(neighbors.len(), n); + + Self { vops, neighbors } + } + + /// Disconnect vertex v from all neighbors and reset its VOP to identity. + pub fn delete_vertex(&mut self, v: usize) { + let nbrs: Vec = self.neighbors[v].iter().collect(); + for &u in &nbrs { + self.neighbors[u].remove(v); + } + self.neighbors[v].clear(); + self.vops[v] = CliffordFrame::IDENTITY; + } + + /// Extract the induced subgraph on the given vertices, re-indexed 0, 1, 2, ... + #[must_use] + pub fn induced_subgraph(&self, vertices: &[usize]) -> Self { + let n = vertices.len(); + // Build mapping from old index to new index + let mut old_to_new = vec![None; self.num_qubits()]; + for (new_idx, &old_idx) in vertices.iter().enumerate() { + old_to_new[old_idx] = Some(new_idx); + } + + let mut vops = Vec::with_capacity(n); + let mut neighbors = vec![BitSet::new(); n]; + + for (new_idx, &old_idx) in vertices.iter().enumerate() { + vops.push(self.vops[old_idx]); + for u in &self.neighbors[old_idx] { + if let Some(new_u) = old_to_new[u] { + neighbors[new_idx].insert(new_u); + } + } + } + + Self { vops, neighbors } + } +} + +// ============================================================================ +// LC-equivalence (Phase 5) +// ============================================================================ + +impl GraphState { + /// Enumerate the entire LC orbit of this graph state. + /// + /// Returns all pure graph states (identity VOPs) reachable by graph-level + /// local complementations from this one's underlying graph. VOPs are + /// irrelevant for LC-equivalence since they are local Cliffords. + /// + /// Only practical for small graphs (the orbit can be exponential in size). + #[must_use] + pub fn lc_orbit(&self) -> Vec { + // Start from the underlying graph (ignoring VOPs) + let start = GraphState::from_parts( + vec![CliffordFrame::IDENTITY; self.num_qubits()], + self.neighbors.clone(), + ); + + let mut visited: BTreeSet>> = BTreeSet::new(); + let mut queue: VecDeque = VecDeque::new(); + let mut orbit: Vec = Vec::new(); + + visited.insert(start.adjacency_matrix()); + queue.push_back(start); + + while let Some(current) = queue.pop_front() { + let n = current.num_qubits(); + orbit.push(current.clone()); + + for v in 0..n { + if current.neighbors[v].is_empty() { + continue; + } + let mut next = current.clone(); + // Graph-only LC: just complement edges among N(v) + next.graph_local_complement(v); + + let adj = next.adjacency_matrix(); + if visited.insert(adj) { + queue.push_back(next); + } + } + } + + orbit + } + + /// Compute a canonical form for LC-equivalence. + /// + /// Returns the lexicographically smallest adjacency matrix reachable by + /// graph-level LC. Two graph states are LC-equivalent iff their canonical + /// forms are equal. VOPs are irrelevant (they are local Cliffords). + /// + /// Uses orbit enumeration, so only practical for small graphs. + #[must_use] + pub fn lc_canonical_form(&self) -> GraphState { + let orbit = self.lc_orbit(); + orbit + .into_iter() + .min_by(|a, b| { + let adj_a = a.adjacency_matrix(); + let adj_b = b.adjacency_matrix(); + adj_a.cmp(&adj_b) + }) + .expect("orbit is never empty") + } + + /// Check if two graph states are LC-equivalent. + /// + /// Two graph states are LC-equivalent if their underlying graphs are in + /// the same LC orbit. VOPs are irrelevant since they are local Cliffords. + #[must_use] + pub fn is_lc_equivalent(&self, other: &Self) -> bool { + let canon_self = self.lc_canonical_form(); + let canon_other = other.lc_canonical_form(); + canon_self.adjacency_matrix() == canon_other.adjacency_matrix() + } +} + +// ============================================================================ +// Export / Display (Phase 6) +// ============================================================================ + +/// Names for the 24 single-qubit Cliffords. +const CLIFFORD_NAMES: [&str; 24] = [ + "I", "X", "Y", "Z", "S", "Sdg", "H", "SH", "HS", "S2H", "HS2", "S3H", "SHS", "HSH", "SHSH", + "S2HS", "SHS2", "S3HS", "S2HS2", "S2HSH", "HS2HS", "S3HS2", "S3HSH", "HS2HS3", +]; + +// ============================================================================ +// VOP Color Algebra +// ============================================================================ +// +// Three independent visual dimensions encode Clifford structure: +// +// 1. **Fill hue** — axis permutation coset (which pair of Pauli axes +// the Clifford interconverts, ignoring signs): +// Blue — identity perm (X->X, Z->Z) -> CellColor::ZAxis +// Purple — X<->Z swap (H-type) -> CellColor::XZMix +// Gold — X<->Y swap (S-type) -> CellColor::XYMix +// Cyan — Y<->Z swap (SX-type) -> CellColor::YZMix +// Gray — 3-cycle -> CellColor::XYZMix +// +// 2. **Fill brightness** — sign parity of the Heisenberg action: +// Saturated — even parity (0 or 2 negative signs) +// Light — odd parity (1 negative sign) +// +// 3. **Stroke colour** — gate family (geometric rotation type on the +// Bloch sphere): +// Navy — Pauli (identity / pi-rotations) -> GateFamily::Pauli +// Green — sqrt-of-Pauli / S-like -> GateFamily::SLike +// Maroon — Hadamard-like -> GateFamily::HLike +// Charcoal — Face-like / cyclic -> GateFamily::FLike + +/// Map a Clifford index to its axis permutation coset [`CellColor`]. +#[rustfmt::skip] +fn vop_cell_color(idx: u8) -> CellColor { + match idx { + 0..=3 => CellColor::ZAxis, // Identity/Pauli + 4 | 5 | 20 | 23 => CellColor::XYMix, // X<->Y (S-type) + 6 | 9 | 10 | 18 => CellColor::XZMix, // X<->Z (H-type) + 12 | 13 | 17 | 19 => CellColor::YZMix, // Y<->Z (SX-type) + 7 | 8 | 11 | 14 | 15 | 16 | 21 | 22 => CellColor::XYZMix, // Cyclic + _ => panic!("invalid Clifford index: {idx} (expected 0..24)"), + } +} + +/// Map a Clifford index to its gate family ([`GateFamily`]). +#[rustfmt::skip] +fn vop_gate_family(idx: u8) -> GateFamily { + match idx { + 0..=3 => GateFamily::Pauli, + 4 | 5 | 9 | 10 | 12 | 13 => GateFamily::SLike, + 6 | 17 | 18 | 19 | 20 | 23 => GateFamily::HLike, + 7 | 8 | 11 | 14 | 15 | 16 | 21 | 22 => GateFamily::FLike, + _ => panic!("invalid Clifford index: {idx} (expected 0..24)"), + } +} + +/// Returns true if the Clifford at this index has even sign parity (saturated fill). +/// +/// Even parity = 0 or 2 negative signs in the Heisenberg image. +/// For cyclic coset: forward (7,11,16,21) = saturated, inverse (8,14,15,22) = light. +#[rustfmt::skip] +fn vop_saturated(idx: u8) -> bool { + match idx { + 0 | 2 | 5 | 6 | 7 | 11 | 13 | 16 | 17 | 18 | 20 | 21 => true, + 1 | 3 | 4 | 8 | 9 | 10 | 12 | 14 | 15 | 19 | 22 | 23 => false, + _ => panic!("invalid Clifford index: {idx} (expected 0..24)"), + } +} + +/// ANSI SGR escape codes for each of the 24 single-qubit Cliffords. +/// +/// Encodes coset (colour) and sign parity (bold/normal): +/// Identity -> blue (34), X<->Z -> magenta (35), X<->Y -> yellow (33), +/// Y<->Z -> cyan (36), cyclic fwd -> white (37), cyclic inv -> bright black (90). +/// Even parity (saturated) -> bold; odd parity (light) -> normal. +#[rustfmt::skip] +const VOP_ANSI: [&str; 24] = [ + "\x1b[1;34m", // 0: I Identity even + "\x1b[34m", // 1: X Identity odd + "\x1b[1;34m", // 2: Y Identity even + "\x1b[34m", // 3: Z Identity odd + "\x1b[33m", // 4: S X<->Y odd + "\x1b[1;33m", // 5: Sdg X<->Y even + "\x1b[1;35m", // 6: H X<->Z even + "\x1b[1;37m", // 7: SH Cyclic fwd + "\x1b[90m", // 8: HS Cyclic inv + "\x1b[35m", // 9: S2H X<->Z odd + "\x1b[35m", // 10: HS2 X<->Z odd + "\x1b[1;37m", // 11: S3H Cyclic fwd + "\x1b[36m", // 12: SHS Y<->Z odd + "\x1b[1;36m", // 13: HSH Y<->Z even + "\x1b[90m", // 14: SHSH Cyclic inv + "\x1b[90m", // 15: S2HS Cyclic inv + "\x1b[1;37m", // 16: SHS2 Cyclic fwd + "\x1b[1;36m", // 17: S3HS Y<->Z even + "\x1b[1;35m", // 18: S2HS2 X<->Z even + "\x1b[36m", // 19: S2HSH Y<->Z odd + "\x1b[1;33m", // 20: HS2HS X<->Y even + "\x1b[1;37m", // 21: S3HS2 Cyclic fwd + "\x1b[90m", // 22: S3HSH Cyclic inv + "\x1b[33m", // 23: HS2HS3 X<->Y odd +]; + +/// Bracket pairs for each of the 24 Cliffords, encoding gate family. +/// +/// Pauli -> `( )`, S-like -> `[ ]`, H-like -> `< >`, F-like -> `{ }`. +#[rustfmt::skip] +const VOP_BRACKETS: [(&str, &str); 24] = [ + ("(", ")"), // 0: I Pauli + ("(", ")"), // 1: X Pauli + ("(", ")"), // 2: Y Pauli + ("(", ")"), // 3: Z Pauli + ("[", "]"), // 4: S S-like + ("[", "]"), // 5: Sdg S-like + ("<", ">"), // 6: H H-like + ("{", "}"), // 7: SH F-like + ("{", "}"), // 8: HS F-like + ("[", "]"), // 9: S2H S-like + ("[", "]"), // 10: HS2 S-like + ("{", "}"), // 11: S3H F-like + ("[", "]"), // 12: SHS S-like + ("[", "]"), // 13: HSH S-like + ("{", "}"), // 14: SHSH F-like + ("{", "}"), // 15: S2HS F-like + ("{", "}"), // 16: SHS2 F-like + ("<", ">"), // 17: S3HS H-like + ("<", ">"), // 18: S2HS2 H-like + ("<", ">"), // 19: S2HSH H-like + ("<", ">"), // 20: HS2HS H-like + ("{", "}"), // 21: S3HS2 F-like + ("{", "}"), // 22: S3HSH F-like + ("<", ">"), // 23: HS2HS3 H-like +]; + +/// `TikZ` color name for a [`CellColor`] coset. +fn tikz_coset_name(color: CellColor, saturated: bool) -> &'static str { + match (color, saturated) { + (CellColor::ZAxis, true) => "vopIdentity", + (CellColor::ZAxis, false) => "vopIdentityLt", + (CellColor::XZMix, true) => "vopXZ", + (CellColor::XZMix, false) => "vopXZLt", + (CellColor::XYMix, true) => "vopXY", + (CellColor::XYMix, false) => "vopXYLt", + (CellColor::YZMix, true) => "vopYZ", + (CellColor::YZMix, false) => "vopYZLt", + (CellColor::XYZMix, true) => "vopCyclicFwd", + (CellColor::XYZMix, false) => "vopCyclicInv", + (other, _) => panic!("unexpected CellColor for VOP coset: {other:?}"), + } +} + +/// `TikZ` color name for a [`GateFamily`] stroke. +fn tikz_family_name(family: GateFamily) -> &'static str { + match family { + GateFamily::Pauli + | GateFamily::Default + | GateFamily::Measurement + | GateFamily::Preparation => "famPauli", + GateFamily::SLike => "famSqrt", + GateFamily::HLike => "famHadamard", + GateFamily::FLike => "famCyclic", + } +} + +impl GraphState { + /// Create a renderer bound to a [`GraphStyle`]. + /// + /// # Examples + /// ``` + /// use pecos_qsim::GraphState; + /// use pecos_core::GraphStyle; + /// + /// let gs = GraphState::linear_cluster(3); + /// let svg = gs.render_with(&GraphStyle::default()).svg(); + /// assert!(svg.contains("(&'a self, style: &'a GraphStyle) -> GraphStateRenderer<'a> { + GraphStateRenderer { graph: self, style } + } + + /// Export to DOT format with default style. + #[must_use] + pub fn to_dot(&self) -> String { + self.render_with(&GraphStyle::default()).dot() + } + + /// Compute vertex positions using a circular layout. + /// + /// Returns (x, y) pairs for each vertex, centered at (`cx`, `cy`) with + /// the given `radius`. Single-vertex graphs place the vertex at center. + fn circular_layout(n: usize, cx: f64, cy: f64, radius: f64) -> Vec<(f64, f64)> { + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![(cx, cy)]; + } + (0..n) + .map(|i| { + let angle = -std::f64::consts::FRAC_PI_2 + + 2.0 * std::f64::consts::PI * (i as f64) / (n as f64); + (cx + radius * angle.cos(), cy + radius * angle.sin()) + }) + .collect() + } + + /// Export to SVG with default style. + #[must_use] + pub fn to_svg(&self) -> String { + self.render_with(&GraphStyle::default()).svg() + } + + /// Export to `TikZ` with default style. + #[must_use] + pub fn to_tikz(&self) -> String { + self.render_with(&GraphStyle::default()).tikz() + } + + /// Export as plain ASCII text (no escape codes). + #[must_use] + pub fn to_ascii(&self) -> String { + self.render_with(&GraphStyle::default()).ascii() + } + + /// ASCII text with ANSI color codes. + #[must_use] + pub fn to_color_ascii(&self) -> String { + self.render_with(&GraphStyle::builder().ansi_color(true).build()) + .ascii() + } + + /// Unicode text (no escape codes). + #[must_use] + pub fn to_unicode(&self) -> String { + self.render_with(&GraphStyle::default()).unicode() + } + + /// Unicode text with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.render_with(&GraphStyle::builder().ansi_color(true).build()) + .unicode() + } +} + +// ============================================================================ +// GraphStateRenderer +// ============================================================================ + +/// A graph state bound to a [`GraphStyle`], ready to render in any output format. +/// +/// Obtained via [`GraphState::render_with`]. +pub struct GraphStateRenderer<'a> { + graph: &'a GraphState, + style: &'a GraphStyle, +} + +impl GraphStateRenderer<'_> { + /// Render as a Graphviz DOT graph. + #[must_use] + pub fn dot(&self) -> String { + let n = self.graph.num_qubits(); + let mut dot = String::from("graph G {\n"); + dot.push_str(" node [shape=circle, style=filled, fontsize=12];\n"); + + for v in 0..n { + let idx = self.graph.vops[v].index(); + let name = CLIFFORD_NAMES[idx as usize]; + let coset = vop_cell_color(idx); + let family = vop_gate_family(idx); + let sat = vop_saturated(idx); + let fill = self.style.vop_fill(coset, sat); + let stroke = self.style.vop_stroke(family); + let text = self.style.vop_text(coset, sat); + let dot_style = self.style.vop_dot_style(family); + let style_attr = if dot_style.is_empty() { + "filled".to_string() + } else { + format!("filled,{dot_style}") + }; + writeln!( + dot, + " {v} [label=\"{v}\\n{name}\" fillcolor=\"{fill}\" \ + color=\"{stroke}\" fontcolor=\"{text}\" style=\"{style_attr}\"];", + ) + .unwrap(); + } + + for (u, v) in self.graph.edges() { + writeln!(dot, " {u} -- {v};").unwrap(); + } + + dot.push_str("}\n"); + dot + } + + /// Render as a standalone SVG string. + #[must_use] + pub fn svg(&self) -> String { + let n = self.graph.num_qubits(); + let node_radius = 20.0; + let layout_radius = if n <= 2 { 60.0 } else { 40.0 + 25.0 * n as f64 }; + let margin = node_radius + 40.0; + let width = 2.0 * (layout_radius + margin); + let legend_height = 50.0; + let height = width + legend_height; + let center = layout_radius + margin; + + let positions = GraphState::circular_layout(n, center, center, layout_radius); + + let mut svg = format!( + "\n" + ); + writeln!( + svg, + " " + ) + .unwrap(); + + // Collect needed fill patterns and emit + let mut needed_patterns = BTreeSet::new(); + for v in 0..n { + let idx = self.graph.vops[v].index(); + let pattern = self.style.vop_pattern(vop_cell_color(idx)); + if pattern != FillPattern::Solid { + needed_patterns.insert(pattern); + } + } + // Also include patterns used by legend cosets + for coset in [ + CellColor::ZAxis, + CellColor::XZMix, + CellColor::XYMix, + CellColor::YZMix, + CellColor::XYZMix, + ] { + let pattern = self.style.vop_pattern(coset); + if pattern != FillPattern::Solid { + needed_patterns.insert(pattern); + } + } + if !needed_patterns.is_empty() { + svg.push_str(" \n"); + for pat in &needed_patterns { + writeln!(svg, " {}", pat.svg_pattern_def()).unwrap(); + } + svg.push_str(" \n"); + } + + // Draw edges + for (u, v) in self.graph.edges() { + let (x1, y1) = positions[u]; + let (x2, y2) = positions[v]; + writeln!( + svg, + " " + ) + .unwrap(); + } + + // Draw vertices + for (v, &(x, y)) in positions.iter().enumerate() { + let idx = self.graph.vops[v].index(); + let vop_name = CLIFFORD_NAMES[idx as usize]; + let coset = vop_cell_color(idx); + let family = vop_gate_family(idx); + let sat = vop_saturated(idx); + let fill = self.style.vop_fill(coset, sat); + let stroke = self.style.vop_stroke(family); + let text = self.style.vop_text(coset, sat); + let dash = self.style.vop_dasharray(family); + let dash_attr = if dash.is_empty() { + String::new() + } else { + format!(" stroke-dasharray=\"{dash}\"") + }; + + writeln!( + svg, + " " + ) + .unwrap(); + + // Pattern overlay + let pattern = self.style.vop_pattern(coset); + if pattern != FillPattern::Solid { + let pat_r = node_radius - 1.0; + writeln!( + svg, + " ", + pattern.svg_id() + ) + .unwrap(); + } + + // Vertex index label + writeln!( + svg, + " {v}" + ) + .unwrap(); + + // VOP label (below the node, only if non-identity) + if !self.graph.vops[v].is_identity() { + let label_y = y + node_radius + 14.0; + writeln!( + svg, + " {vop_name}" + ) + .unwrap(); + } + } + + // Legend + self.svg_legend(&mut svg, width, height, legend_height); + + svg.push_str("\n"); + svg + } + + /// Append an SVG legend derived from the style palette. + fn svg_legend(&self, svg: &mut String, width: f64, height: f64, legend_height: f64) { + let y_top = height - legend_height + 8.0; + let r = 6.0; + + // Row 1: fill hues (cosets) -- show saturated fill + family stroke + let cosets: &[(CellColor, &str)] = &[ + (CellColor::ZAxis, "I/Pauli"), + (CellColor::XZMix, "X\u{2194}Z"), + (CellColor::XYMix, "X\u{2194}Y"), + (CellColor::YZMix, "Y\u{2194}Z"), + (CellColor::XYZMix, "Cyclic"), + ]; + + let spacing = width / (cosets.len() as f64 + 1.0); + for (i, &(coset, label)) in cosets.iter().enumerate() { + let cx = spacing * (i as f64 + 1.0); + let fill = self.style.vop_fill(coset, true); + let stroke = blend_hex(&fill, "#000000", 0.4); + writeln!( + svg, + " " + ) + .unwrap(); + let pattern = self.style.vop_pattern(coset); + if pattern != FillPattern::Solid { + let pr = r - 0.5; + writeln!( + svg, + " ", + pattern.svg_id() + ) + .unwrap(); + } + let tx = cx + r + 4.0; + let ty = y_top + 3.0; + writeln!( + svg, + " \ + {label}" + ) + .unwrap(); + } + + // Row 2: stroke colours (gate families) + let families: &[(GateFamily, &str)] = &[ + (GateFamily::Pauli, "Pauli"), + (GateFamily::SLike, "S-like"), + (GateFamily::HLike, "H-like"), + (GateFamily::FLike, "F-like"), + ]; + + let y_row2 = y_top + 18.0; + let fam_spacing = width / (families.len() as f64 + 1.0); + for (i, &(family, label)) in families.iter().enumerate() { + let cx = fam_spacing * (i as f64 + 1.0); + let stroke_col = self.style.vop_stroke(family); + let dash = self.style.vop_dasharray(family); + let dash_attr = if dash.is_empty() { + String::new() + } else { + format!(" stroke-dasharray=\"{dash}\"") + }; + writeln!( + svg, + " " + ) + .unwrap(); + let tx = cx + r + 4.0; + let ty = y_row2 + 3.0; + writeln!( + svg, + " \ + {label}" + ) + .unwrap(); + } + } + + /// Render as a `TikZ` `tikzpicture` environment. + #[must_use] + pub fn tikz(&self) -> String { + let n = self.graph.num_qubits(); + let radius = if n <= 2 { 1.5 } else { 1.0 + 0.5 * n as f64 }; + let positions = GraphState::circular_layout(n, 0.0, 0.0, radius); + + let mut tikz = String::from("\\begin{tikzpicture}\n"); + + // Colour definitions derived from style + tikz.push_str(" % Fill: axis permutation coset (bright / light)\n"); + for &(coset, sat, name) in &[ + (CellColor::ZAxis, true, "vopIdentity"), + (CellColor::ZAxis, false, "vopIdentityLt"), + (CellColor::XZMix, true, "vopXZ"), + (CellColor::XZMix, false, "vopXZLt"), + (CellColor::XYMix, true, "vopXY"), + (CellColor::XYMix, false, "vopXYLt"), + (CellColor::YZMix, true, "vopYZ"), + (CellColor::YZMix, false, "vopYZLt"), + (CellColor::XYZMix, true, "vopCyclicFwd"), + (CellColor::XYZMix, false, "vopCyclicInv"), + ] { + let hex = self.style.vop_fill(coset, sat); + let hex = hex.strip_prefix('#').unwrap_or(&hex); + writeln!(tikz, " \\definecolor{{{name}}}{{HTML}}{{{hex}}}").unwrap(); + } + tikz.push_str(" % Stroke: gate family\n"); + for &(family, name) in &[ + (GateFamily::Pauli, "famPauli"), + (GateFamily::SLike, "famSqrt"), + (GateFamily::HLike, "famHadamard"), + (GateFamily::FLike, "famCyclic"), + ] { + let hex = self.style.vop_stroke(family); + let hex = hex.strip_prefix('#').unwrap_or(hex); + writeln!(tikz, " \\definecolor{{{name}}}{{HTML}}{{{hex}}}").unwrap(); + } + + // Check if any coset uses patterns (need patterns library) + let any_pattern = [ + CellColor::ZAxis, + CellColor::XZMix, + CellColor::XYMix, + CellColor::YZMix, + CellColor::XYZMix, + ] + .iter() + .any(|&c| self.style.vop_pattern(c) != FillPattern::Solid); + if any_pattern { + tikz.push_str(" % Requires: \\usetikzlibrary{patterns}\n"); + } + + // Base vertex style + tikz.push_str( + " \\tikzstyle{vertex}=[circle, minimum size=20pt, \ + inner sep=0pt, font=\\small, line width=1.5pt]\n", + ); + tikz.push_str(" \\tikzstyle{vop label}=[font=\\scriptsize, text=gray]\n"); + + // Draw vertices + for (v, &(x, y)) in positions.iter().enumerate() { + let idx = self.graph.vops[v].index(); + let coset = vop_cell_color(idx); + let family = vop_gate_family(idx); + let sat = vop_saturated(idx); + let fill_name = tikz_coset_name(coset, sat); + let draw_name = tikz_family_name(family); + let text_opt = if self.style.vop_text(coset, sat) == "white" { + ", text=white" + } else { + "" + }; + let tikz_dash = self.style.vop_tikz_dash(family); + let dash_opt = if tikz_dash.is_empty() { + String::new() + } else { + format!(", {tikz_dash}") + }; + let tikz_pat = self.style.vop_pattern(coset).tikz_pattern(); + let pat_opt = if tikz_pat.is_empty() { + String::new() + } else { + format!(", postaction={{pattern={tikz_pat}, pattern color=black!30}}") + }; + + writeln!( + tikz, + " \\node[vertex, fill={fill_name}, draw={draw_name}{text_opt}{dash_opt}{pat_opt}] \ + (v{v}) at ({x:.2}, {y:.2}) {{{v}}};", + ) + .unwrap(); + + // VOP annotation + if !self.graph.vops[v].is_identity() { + let vop_name = CLIFFORD_NAMES[idx as usize]; + let label_y = y - 0.45; + writeln!( + tikz, + " \\node[vop label] at ({x:.2}, {label_y:.2}) {{${vop_name}$}};", + ) + .unwrap(); + } + } + + // Draw edges + for (u, v) in self.graph.edges() { + writeln!(tikz, " \\draw (v{u}) -- (v{v});").unwrap(); + } + + tikz.push_str("\\end{tikzpicture}\n"); + tikz + } + + /// Render as plain ASCII text. + /// + /// Produces ANSI color codes when `style.ansi_color` is true. + #[must_use] + pub fn ascii(&self) -> String { + self.format_text("--") + } + + /// Render as Unicode text. + /// + /// Produces ANSI color codes when `style.ansi_color` is true. + #[must_use] + pub fn unicode(&self) -> String { + self.format_text("\u{2500}\u{2500}") + } + + /// Shared text layout logic. + fn format_text(&self, separator: &str) -> String { + let color = self.style.ansi_color; + let n = self.graph.num_qubits(); + let num_edges = self.graph.num_edges(); + let mut out = format!("GraphState: {n} qubits, {num_edges} edges\n\n"); + + if n == 0 { + return out; + } + + let idx_width = (n - 1).to_string().len(); + let show_vops = !self.graph.is_pure_graph_state(); + + // Compute maximum bracketed VOP width across non-identity vertices. + let max_vop_width = if show_vops { + (0..n) + .filter(|&v| !self.graph.vops[v].is_identity()) + .map(|v| { + let idx = self.graph.vops[v].index() as usize; + CLIFFORD_NAMES[idx].len() + 2 // +2 for brackets + }) + .max() + .unwrap_or(0) + } else { + 0 + }; + + for v in 0..n { + write!(out, " {v:>idx_width$}").unwrap(); + + if show_vops { + let idx = self.graph.vops[v].index() as usize; + if self.graph.vops[v].is_identity() { + write!(out, " {: = self.graph.neighbors[v].iter().collect(); + if !nbrs.is_empty() { + let nbr_str: Vec = nbrs.iter().map(ToString::to_string).collect(); + write!(out, " {separator} {}", nbr_str.join(", ")).unwrap(); + } + + out.push('\n'); + } + + if color && show_vops { + out.push('\n'); + out.push_str( + " \x1b[1;34mIdentity\x1b[0m \ + \x1b[1;35mX\u{2194}Z\x1b[0m \ + \x1b[1;33mX\u{2194}Y\x1b[0m \ + \x1b[1;36mY\u{2194}Z\x1b[0m \ + \x1b[1;37mCyc.fwd\x1b[0m \ + \x1b[90mCyc.inv\x1b[0m \ + (bold=even)\n", + ); + out.push_str(" ()Pauli []S-like <>H-like {}F-like\n"); + } + + out + } +} + +impl fmt::Display for GraphState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let n = self.num_qubits(); + write!(f, "GraphState({n} qubits")?; + + // Show non-identity VOPs + let non_id: Vec = (0..n) + .filter(|&v| !self.vops[v].is_identity()) + .map(|v| { + let name = CLIFFORD_NAMES[self.vops[v].index() as usize]; + format!("v{v}={name}") + }) + .collect(); + + if !non_id.is_empty() { + write!(f, ", VOPs: {}", non_id.join(", "))?; + } + + // Show edges + let edges: Vec = self.edges().map(|(u, v)| format!("{u}-{v}")).collect(); + if !edges.is_empty() { + write!(f, ", edges: {}", edges.join(", "))?; + } + + write!(f, ")") + } +} + +// ============================================================================ +// GraphStateSim conversion support +// ============================================================================ + +impl crate::graph_state::GraphStateSim { + /// Create a simulator from a graph state representation with a seed. + #[must_use] + pub fn from_graph_state_with_seed(gs: GraphState, seed: u64) -> Self { + let rng = PecosRng::seed_from_u64(seed); + Self::from_graph_state(gs, rng) + } +} + +impl crate::graph_state::GraphStateSim { + /// Create a simulator from a graph state representation. + #[must_use] + pub fn from_graph_state(gs: GraphState, rng: R) -> Self { + let num_qubits = gs.num_qubits(); + let mut sim = Self::with_rng(num_qubits, rng); + sim.vops = gs.vops; + sim.neighbors = gs.neighbors; + sim + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn pauli_axis_to_pauli(axis: PauliAxis) -> Pauli { + match axis { + PauliAxis::X => Pauli::X, + PauliAxis::Y => Pauli::Y, + PauliAxis::Z => Pauli::Z, + } +} + +/// Multiply two single-qubit Paulis, returning (result, phase). +/// P1 * P2 = phase * result +fn multiply_paulis(a: Pauli, b: Pauli) -> (Pauli, QuarterPhase) { + use Pauli::{I, X, Y, Z}; + match (a, b) { + (I, p) | (p, I) => (p, QuarterPhase::PlusOne), + (X, X) | (Y, Y) | (Z, Z) => (I, QuarterPhase::PlusOne), + (X, Y) => (Z, QuarterPhase::PlusI), + (Y, X) => (Z, QuarterPhase::MinusI), + (Y, Z) => (X, QuarterPhase::PlusI), + (Z, Y) => (X, QuarterPhase::MinusI), + (Z, X) => (Y, QuarterPhase::PlusI), + (X, Z) => (Y, QuarterPhase::MinusI), + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::CliffordGateable; + + // ======================================================================== + // Phase 1: Core type tests + // ======================================================================== + + #[test] + fn test_new_creates_plus_state() { + let gs = GraphState::new(3); + assert_eq!(gs.num_qubits(), 3); + assert_eq!(gs.num_edges(), 0); + assert!(gs.is_pure_graph_state()); + for v in 0..3 { + assert!(gs.vop(v).is_identity()); + assert_eq!(gs.degree(v), 0); + } + } + + #[test] + fn test_from_edges() { + let gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + assert_eq!(gs.num_qubits(), 3); + assert_eq!(gs.num_edges(), 2); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + assert!(!gs.has_edge(0, 2)); + assert_eq!(gs.degree(0), 1); + assert_eq!(gs.degree(1), 2); + assert_eq!(gs.degree(2), 1); + } + + #[test] + fn test_from_adjacency_matrix() { + let matrix = vec![ + vec![false, true, false], + vec![true, false, true], + vec![false, true, false], + ]; + let gs = GraphState::from_adjacency_matrix(&matrix); + assert_eq!(gs.num_edges(), 2); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + } + + #[test] + fn test_adjacency_matrix_roundtrip() { + let gs = GraphState::from_edges(4, &[(0, 1), (1, 2), (2, 3), (0, 3)]); + let matrix = gs.adjacency_matrix(); + let gs2 = GraphState::from_adjacency_matrix(&matrix); + assert_eq!(gs, gs2); + } + + #[test] + fn test_edges_iterator() { + let gs = GraphState::from_edges(4, &[(0, 1), (2, 3), (0, 3)]); + let mut edges: Vec<(usize, usize)> = gs.edges().collect(); + edges.sort_unstable(); + assert_eq!(edges, vec![(0, 1), (0, 3), (2, 3)]); + } + + #[test] + fn test_mutators() { + let mut gs = GraphState::new(3); + gs.add_edge(0, 1); + assert!(gs.has_edge(0, 1)); + gs.toggle_edge(0, 1); + assert!(!gs.has_edge(0, 1)); + gs.toggle_edge(1, 2); + assert!(gs.has_edge(1, 2)); + gs.remove_edge(1, 2); + assert!(!gs.has_edge(1, 2)); + } + + #[test] + fn test_set_vop_and_apply_local_clifford() { + let mut gs = GraphState::new(2); + gs.set_vop(0, CliffordFrame::H); + assert_eq!(gs.vop(0), CliffordFrame::H); + assert!(!gs.is_pure_graph_state()); + + gs.apply_local_clifford(0, CliffordFrame::H); + // H * H = I + assert!(gs.vop(0).is_identity()); + assert!(gs.is_pure_graph_state()); + } + + // ======================================================================== + // Phase 2: Patterns and local complementation + // ======================================================================== + + #[test] + fn test_linear_cluster() { + let gs = GraphState::linear_cluster(4); + assert_eq!(gs.num_qubits(), 4); + assert_eq!(gs.num_edges(), 3); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + assert!(gs.has_edge(2, 3)); + assert!(!gs.has_edge(0, 2)); + } + + #[test] + fn test_ring() { + let gs = GraphState::ring(4); + assert_eq!(gs.num_edges(), 4); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + assert!(gs.has_edge(2, 3)); + assert!(gs.has_edge(3, 0)); + } + + #[test] + fn test_star() { + let gs = GraphState::star(4); + assert_eq!(gs.num_edges(), 3); + for i in 1..4 { + assert!(gs.has_edge(0, i)); + } + assert!(!gs.has_edge(1, 2)); + } + + #[test] + fn test_lattice_2d() { + let gs = GraphState::lattice_2d(2, 3); + assert_eq!(gs.num_qubits(), 6); + // 2x3 grid: 7 edges (3 horizontal + 2 rows * 2 vertical-ish... actually: + // row 0: 0-1, 1-2 (2 horiz) + // row 1: 3-4, 4-5 (2 horiz) + // cols: 0-3, 1-4, 2-5 (3 vert) + // total = 7 + assert_eq!(gs.num_edges(), 7); + } + + #[test] + fn test_complete() { + let gs = GraphState::complete(4); + assert_eq!(gs.num_edges(), 6); // C(4,2) = 6 + for i in 0..4 { + for j in (i + 1)..4 { + assert!(gs.has_edge(i, j)); + } + } + } + + #[test] + fn test_local_complement_toggles_neighbor_edges() { + // Star on 4 vertices: 0 connected to 1, 2, 3 + let mut gs = GraphState::star(4); + assert!(!gs.has_edge(1, 2)); + assert!(!gs.has_edge(1, 3)); + assert!(!gs.has_edge(2, 3)); + + // LC on vertex 0: complement edges among {1, 2, 3} + gs.local_complement(0); + + // Now 1-2, 1-3, 2-3 should all exist (complete among neighbors) + assert!(gs.has_edge(1, 2)); + assert!(gs.has_edge(1, 3)); + assert!(gs.has_edge(2, 3)); + + // Original edges 0-1, 0-2, 0-3 should still exist + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(0, 2)); + assert!(gs.has_edge(0, 3)); + } + + #[test] + fn test_local_complement_double_is_identity_on_graph() { + // Two LCs on the same vertex should restore the graph (but change VOPs) + let gs_orig = GraphState::star(4); + let mut gs = gs_orig.clone(); + + gs.local_complement(0); + gs.local_complement(0); + + // Graph should be restored + assert_eq!(gs.adjacency_matrix(), gs_orig.adjacency_matrix()); + } + + #[test] + fn test_pivot() { + let mut gs = GraphState::from_edges(4, &[(0, 1), (0, 2), (1, 3)]); + gs.pivot(0, 1); + // Pivot is LC(0), LC(1), LC(0) - it should complete without panicking + // and maintain valid state + assert_eq!(gs.num_qubits(), 4); + } + + #[test] + fn test_absorb_vops_on_pure_graph_state() { + // A pure graph state should remain unchanged + let gs_orig = GraphState::linear_cluster(4); + let mut gs = gs_orig.clone(); + gs.absorb_vops(); + assert!(gs.is_pure_graph_state()); + assert_eq!(gs.adjacency_matrix(), gs_orig.adjacency_matrix()); + } + + #[test] + fn test_absorb_vops_on_identity_vops() { + // Pure graph states with identity VOPs: generators have X_v Z_neighbors form + let gs = GraphState::linear_cluster(3); + let gens = gs.stabilizer_generators(); + + // Each generator should have exactly one X + for (v, g) in gens.iter().enumerate() { + assert_eq!(g.get(v), Pauli::X); + for u in 0..3 { + if u != v { + if gs.has_edge(v, u) { + assert_eq!(g.get(u), Pauli::Z); + } else { + assert_eq!(g.get(u), Pauli::I); + } + } + } + } + } + + #[test] + fn test_absorb_vops_produces_pure_graph_state() { + // Pure graph state: absorb is a no-op + let mut gs = GraphState::linear_cluster(4); + let adj_before = gs.adjacency_matrix(); + gs.absorb_vops(); + assert!(gs.is_pure_graph_state()); + assert_eq!(gs.adjacency_matrix(), adj_before); + } + + #[test] + fn test_absorb_vops_preserves_stabilizers() { + // Verify that absorb_vops preserves the stabilizer group + use pecos_core::PauliOperator; + + let mut gs = GraphState::linear_cluster(4); + gs.set_vop(1, CliffordFrame::SZ); + + // Compute stabilizers before absorb + let gens_before = gs.stabilizer_generators(); + + gs.absorb_vops(); + + // Compute stabilizers after absorb + let gens_after = gs.stabilizer_generators(); + + // All generators should commute across the two sets + // (same stabilizer group means mutual commutativity) + for ga in &gens_after { + for gb in &gens_before { + assert!( + ga.commutes_with(gb), + "absorb_vops should preserve stabilizer group" + ); + } + } + } + + // ======================================================================== + // Phase 3: Stabilizer extraction + // ======================================================================== + + #[test] + fn test_stabilizer_generator_single_qubit() { + // Single qubit |+> state: stabilizer is +X + let gs = GraphState::new(1); + let stab = gs.stabilizer_generator(0); + assert_eq!(stab.get(0), Pauli::X); + assert_eq!(stab.phase(), QuarterPhase::PlusOne); + } + + #[test] + fn test_stabilizer_generators_two_qubit_graph() { + // Two qubits with edge 0-1: |G> has stabilizers X_0 Z_1 and Z_0 X_1 + let gs = GraphState::from_edges(2, &[(0, 1)]); + let gens = gs.stabilizer_generators(); + + // Generator for vertex 0: X_0 * Z_1 + assert_eq!(gens[0].get(0), Pauli::X); + assert_eq!(gens[0].get(1), Pauli::Z); + assert_eq!(gens[0].phase(), QuarterPhase::PlusOne); + + // Generator for vertex 1: Z_0 * X_1 + assert_eq!(gens[1].get(0), Pauli::Z); + assert_eq!(gens[1].get(1), Pauli::X); + assert_eq!(gens[1].phase(), QuarterPhase::PlusOne); + } + + #[test] + fn test_stabilizer_generators_linear_cluster() { + // 3-qubit linear cluster 0-1-2 + // K_0 = X_0 Z_1 I_2 + // K_1 = Z_0 X_1 Z_2 + // K_2 = I_0 Z_1 X_2 + let gs = GraphState::linear_cluster(3); + let gens = gs.stabilizer_generators(); + + assert_eq!(gens[0].get(0), Pauli::X); + assert_eq!(gens[0].get(1), Pauli::Z); + assert_eq!(gens[0].get(2), Pauli::I); + + assert_eq!(gens[1].get(0), Pauli::Z); + assert_eq!(gens[1].get(1), Pauli::X); + assert_eq!(gens[1].get(2), Pauli::Z); + + assert_eq!(gens[2].get(0), Pauli::I); + assert_eq!(gens[2].get(1), Pauli::Z); + assert_eq!(gens[2].get(2), Pauli::X); + } + + #[test] + fn test_stabilizer_generators_commute() { + // All stabilizer generators of a graph state must commute + use pecos_core::PauliOperator; + + let gs = GraphState::linear_cluster(4); + let gens = gs.stabilizer_generators(); + + for i in 0..gens.len() { + for j in (i + 1)..gens.len() { + assert!( + gens[i].commutes_with(&gens[j]), + "generators {i} and {j} should commute" + ); + } + } + } + + #[test] + fn test_stabilizer_generators_with_vops() { + // Apply H to vertex 0 of a 2-qubit graph state + // This should conjugate the generator at vertex 0 + let mut gs = GraphState::from_edges(2, &[(0, 1)]); + gs.set_vop(0, CliffordFrame::H); + + let gens = gs.stabilizer_generators(); + + // H maps X->Z, Z->X. So: + // Generator for v0: H(X_0) * Z_1 = Z_0 * Z_1 + assert_eq!(gens[0].get(0), Pauli::Z); + assert_eq!(gens[0].get(1), Pauli::Z); + + // Generator for v1: H(Z_0) * X_1 = X_0 * X_1 + assert_eq!(gens[1].get(0), Pauli::X); + assert_eq!(gens[1].get(1), Pauli::X); + } + + #[test] + fn test_lc_preserves_stabilizer_group() { + // Local complementation should preserve the stabilizer group + // (generators may change but they should generate the same group). + // We verify by checking that all new generators commute with all old generators + // AND that new generators are in the stabilizer group of the original state. + use pecos_core::PauliOperator; + + let gs_before = GraphState::linear_cluster(3); + let gens_before = gs_before.stabilizer_generators(); + + let mut gs_after = gs_before.clone(); + gs_after.local_complement(1); + let gens_after = gs_after.stabilizer_generators(); + + // All generators after LC should commute with all generators before + for ga in &gens_after { + for gb in &gens_before { + assert!( + ga.commutes_with(gb), + "LC should preserve stabilizer group commutativity" + ); + } + } + } + + // ======================================================================== + // Phase 4: Conversions + // ======================================================================== + + #[test] + fn test_roundtrip_graph_state_to_sim() { + let gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + + let sim = gs.clone().into_sim_with_seed(42); + let gs2 = sim.to_graph_state(); + + assert_eq!(gs, gs2); + } + + #[test] + fn test_tensor_product() { + let a = GraphState::from_edges(2, &[(0, 1)]); + let b = GraphState::from_edges(2, &[(0, 1)]); + let ab = a.tensor_product(&b); + + assert_eq!(ab.num_qubits(), 4); + assert_eq!(ab.num_edges(), 2); + assert!(ab.has_edge(0, 1)); + assert!(ab.has_edge(2, 3)); + assert!(!ab.has_edge(1, 2)); + } + + #[test] + fn test_delete_vertex() { + let mut gs = GraphState::star(4); + gs.delete_vertex(0); + assert_eq!(gs.degree(0), 0); + assert!(gs.vop(0).is_identity()); + for i in 1..4 { + assert!(!gs.has_edge(0, i)); + } + } + + #[test] + fn test_induced_subgraph() { + let gs = GraphState::linear_cluster(5); // 0-1-2-3-4 + let sub = gs.induced_subgraph(&[1, 2, 3]); + + assert_eq!(sub.num_qubits(), 3); + assert_eq!(sub.num_edges(), 2); + assert!(sub.has_edge(0, 1)); // was 1-2 + assert!(sub.has_edge(1, 2)); // was 2-3 + } + + // ======================================================================== + // Phase 5: LC-equivalence + // ======================================================================== + + #[test] + fn test_lc_orbit_single_qubit() { + let gs = GraphState::new(1); + let orbit = gs.lc_orbit(); + // Single isolated qubit: LC is a no-op on graph structure + assert_eq!(orbit.len(), 1); + } + + #[test] + fn test_lc_orbit_two_qubit_edge() { + let gs = GraphState::from_edges(2, &[(0, 1)]); + let orbit = gs.lc_orbit(); + // Two vertices with one edge: LC on either vertex just toggles + // the edges among neighbors (which is empty for the non-target), + // so the graph stays the same. + assert_eq!(orbit.len(), 1); + } + + #[test] + fn test_lc_equivalence_star_complete() { + // K_4 and star on 4 vertices should be LC-equivalent + // (well-known result) + let star = GraphState::star(4); + let complete = GraphState::complete(4); + + // LC on center of star produces K_4 + assert!(star.is_lc_equivalent(&complete)); + } + + #[test] + fn test_lc_inequivalence() { + // 4-qubit linear cluster and 4-qubit ring are NOT LC-equivalent + // (they have different interlace polynomials) + let linear = GraphState::linear_cluster(4); + let ring = GraphState::ring(4); + assert!(!linear.is_lc_equivalent(&ring)); + } + + #[test] + fn test_lc_canonical_form_deterministic() { + let gs = GraphState::star(4); + let canon1 = gs.lc_canonical_form(); + let canon2 = gs.lc_canonical_form(); + assert_eq!(canon1, canon2); + } + + // ======================================================================== + // Phase 6: Export + // ======================================================================== + + #[test] + fn test_display() { + let gs = GraphState::linear_cluster(3); + let s = format!("{gs}"); + assert!(s.contains("3 qubits")); + assert!(s.contains("0-1")); + assert!(s.contains("1-2")); + } + + #[test] + fn test_to_dot() { + let gs = GraphState::from_edges(2, &[(0, 1)]); + let dot = gs.to_dot(); + assert!(dot.contains("graph G {")); + assert!(dot.contains("0 -- 1")); + assert!(dot.contains('}')); + } + + #[test] + fn test_to_svg() { + let gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + let svg = gs.to_svg(); + assert!(svg.contains("")); + // 3 vertex circles + 9 legend circles = 12, and 2 edge lines + assert_eq!(svg.matches("")); + // No vertex circles, but legend has 5 coset + 4 family = 9 circles + assert_eq!(svg.matches(". Apply H to get |+>, then CZ for edges. + sim.h(&[QubitId::new(0), QubitId::new(1), QubitId::new(2)]); + sim.cz(&[QubitId::new(0), QubitId::new(1)]); + sim.cz(&[QubitId::new(1), QubitId::new(2)]); + + let sim_gs = sim.to_graph_state(); + let sim_gens = sim_gs.stabilizer_generators(); + + // Both should have the same stabilizer generators + // (possibly in different order or with different signs, but same Paulis) + assert_eq!(math_gens.len(), sim_gens.len()); + + // For a pure graph state with the same graph, generators should match exactly + for (i, (mg, sg)) in math_gens.iter().zip(sim_gens.iter()).enumerate() { + assert_eq!(mg.phase(), sg.phase(), "generator {i}: phase mismatch"); + for q in 0..3 { + assert_eq!( + mg.get(q), + sg.get(q), + "generator {i}, qubit {q}: Pauli mismatch" + ); + } + } + } + + #[test] + fn test_cross_validate_roundtrip_preserves_measurement() { + // Build a state via simulator, convert to GraphState and back, + // verify measurements give same results. + use pecos_core::QubitId; + + let mut sim1 = crate::GraphStateSim::with_seed(3, 42); + sim1.h(&[QubitId::new(0), QubitId::new(1), QubitId::new(2)]); + sim1.cz(&[QubitId::new(0), QubitId::new(1)]); + sim1.cz(&[QubitId::new(1), QubitId::new(2)]); + + // Round-trip through GraphState + let gs = sim1.to_graph_state(); + let mut sim2 = gs.into_sim_with_seed(42); + + // Both sims should produce the same measurement outcomes (same seed) + let r1 = sim1.mz(&[QubitId::new(0)]); + let r2 = sim2.mz(&[QubitId::new(0)]); + assert_eq!(r1[0].outcome, r2[0].outcome); + } + + // ======================================================================== + // ASCII export + // ======================================================================== + + #[test] + fn test_to_ascii_pure_graph_state() { + let gs = GraphState::linear_cluster(3); + let ascii = gs.to_ascii(); + + // Header + assert!(ascii.contains("GraphState: 3 qubits, 2 edges")); + + // Pure graph state: VOP column is omitted entirely + assert!( + !ascii.contains("(I)"), + "identity VOPs should be hidden: {ascii}" + ); + + // Edge info + assert!(ascii.contains("-- 1")); + assert!(ascii.contains("-- 0, 2")); + + // No ANSI escapes + assert!(!ascii.contains("\x1b[")); + } + + #[test] + fn test_to_color_ascii_contains_ansi() { + // Need non-identity VOPs for color output (pure states have no VOPs to color) + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let colored = gs.to_color_ascii(); + + // Should contain ANSI escape codes and resets + assert!(colored.contains("\x1b["), "missing ANSI codes: {colored}"); + assert!(colored.contains("\x1b[0m")); + + // Should still have structure + assert!(colored.contains("GraphState: 3 qubits, 2 edges")); + assert!(colored.contains("")); + + // Legend + assert!(colored.contains("()Pauli")); + assert!(colored.contains("bold=even")); + } + + #[test] + fn test_to_color_ascii_pure_has_no_ansi() { + // Pure graph state: nothing to color, no legend + let gs = GraphState::linear_cluster(3); + let colored = gs.to_color_ascii(); + assert!( + !colored.contains("\x1b["), + "pure state should have no ANSI: {colored}" + ); + assert!( + !colored.contains("Pauli"), + "pure state should have no legend" + ); + } + + #[test] + fn render_with_ansi_color_matches_to_color_ascii() { + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let via_convenience = gs.to_color_ascii(); + let via_render_with = gs + .render_with(&GraphStyle::builder().ansi_color(true).build()) + .ascii(); + assert_eq!(via_convenience, via_render_with); + } + + #[test] + fn render_with_ansi_color_matches_to_color_unicode() { + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let via_convenience = gs.to_color_unicode(); + let via_render_with = gs + .render_with(&GraphStyle::builder().ansi_color(true).build()) + .unicode(); + assert_eq!(via_convenience, via_render_with); + } + + #[test] + fn test_to_ascii_isolated_vertices() { + let gs = GraphState::new(2); + let ascii = gs.to_ascii(); + + // Isolated pure graph: no edges, no VOP column + assert!(!ascii.contains("--")); + assert!(ascii.contains("2 qubits")); + assert!(ascii.contains("0 edges")); + } + + #[test] + fn test_to_ascii_non_identity_vops() { + let mut gs = GraphState::from_edges(2, &[(0, 1)]); + gs.set_vop(0, CliffordFrame::H); + let ascii = gs.to_ascii(); + + // H is H-like family -> angle brackets + assert!(ascii.contains(""), "H bracket missing: {ascii}"); + // Vertex 1 is identity -> blank VOP column (no brackets) + assert!(!ascii.contains("(I)"), "identity should be blank: {ascii}"); + } + + #[test] + fn test_to_ascii_bracket_families() { + let mut gs = GraphState::new(4); + gs.set_vop(0, CliffordFrame::from_index(1)); // idx 1: X, Pauli -> () + gs.set_vop(1, CliffordFrame::SZ); // idx 4: S-like -> [] + gs.set_vop(2, CliffordFrame::H); // idx 6: H-like -> <> + gs.set_vop(3, CliffordFrame::from_index(7)); // idx 7: F-like -> {} + let ascii = gs.to_ascii(); + + assert!(ascii.contains("(X)"), "Pauli bracket missing: {ascii}"); + assert!(ascii.contains("[S]"), "S-like bracket missing: {ascii}"); + assert!(ascii.contains(""), "H-like bracket missing: {ascii}"); + assert!(ascii.contains("{SH}"), "F-like bracket missing: {ascii}"); + } + + #[test] + fn test_to_ascii_identity_alignment() { + // When mixed VOPs are present, identity and non-identity rows + // should have `--` at the same column. + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let ascii = gs.to_ascii(); + + // Find the `--` column for each line that has neighbors + let dash_cols: Vec = ascii.lines().filter_map(|line| line.find("--")).collect(); + assert!(dash_cols.len() >= 2, "expected at least 2 lines with --"); + assert!( + dash_cols.windows(2).all(|w| w[0] == w[1]), + "-- columns should align: {dash_cols:?}\n{ascii}" + ); + } + + #[test] + fn test_to_ascii_empty_graph() { + let gs = GraphState::new(0); + let ascii = gs.to_ascii(); + assert!(ascii.contains("0 qubits, 0 edges")); + } + + // ======================================================================== + // render_with tests + // ======================================================================== + + #[test] + fn render_with_default_matches_to_svg() { + use pecos_core::GraphStyle; + + let gs = GraphState::linear_cluster(3); + let default_style = GraphStyle::default(); + assert_eq!(gs.render_with(&default_style).svg(), gs.to_svg()); + } + + #[test] + fn render_with_custom_palette() { + use pecos_core::{ColorPalette, ColorTriplet, GraphStyle}; + + let palette = ColorPalette { + z_axis: ColorTriplet::new("#FF0000", "#880000", "#440000"), + ..ColorPalette::default() + }; + let style = GraphStyle::builder().palette(palette).build(); + + let gs = GraphState::linear_cluster(3); // pure: all identity (ZAxis coset) + let svg = gs.render_with(&style).svg(); + + // Saturated ZAxis fill = blend("#FF0000", "#880000", 0.5) + let expected_fill = pecos_core::blend_hex("#FF0000", "#880000", 0.5); + assert!( + svg.contains(&expected_fill), + "custom ZAxis fill {expected_fill} not found in SVG" + ); + } + + #[test] + fn render_with_monochrome() { + use pecos_core::{ColorPalette, ColorTriplet, GraphStyle}; + + // Set all cosets to the same color + let grey = ColorTriplet::new("#CCCCCC", "#666666", "#333333"); + let palette = ColorPalette { + z_axis: grey.clone(), + xz_mix: grey.clone(), + xy_mix: grey.clone(), + yz_mix: grey.clone(), + xyz_mix: grey.clone(), + ..ColorPalette::default() + }; + let style = GraphStyle::builder().palette(palette).build(); + + let mut gs = GraphState::from_edges(2, &[(0, 1)]); + gs.set_vop(0, CliffordFrame::H); // XZMix coset + + let svg = gs.render_with(&style).svg(); + // Both vertices should use the same grey palette + let sat_fill = pecos_core::blend_hex("#CCCCCC", "#666666", 0.5); + // Count occurrences of the saturated fill (both vertices are saturated: I=even, H=even) + assert!( + svg.matches(&sat_fill).count() >= 2, + "monochrome fill {sat_fill} should appear at least twice" + ); + } + + #[test] + fn render_with_ascii_matches_to_ascii() { + use pecos_core::GraphStyle; + + let gs = GraphState::linear_cluster(4); + let default_style = GraphStyle::default(); + assert_eq!(gs.render_with(&default_style).ascii(), gs.to_ascii()); + } +} diff --git a/crates/pecos-qsim/src/lib.rs b/crates/pecos-qsim/src/lib.rs index e9960756c..8e7deaf2b 100644 --- a/crates/pecos-qsim/src/lib.rs +++ b/crates/pecos-qsim/src/lib.rs @@ -25,6 +25,8 @@ pub mod gens; pub mod gpu_stab; pub mod gpu_stab_opt; pub mod gpu_stab_parallel; +pub mod graph_state; +pub mod graph_state_repr; pub mod measurement_sampler; pub mod pauli_prop; // pub mod paulis; @@ -69,6 +71,8 @@ pub use gens::{Gens, GensBitSet, GensGeneric, GensHybrid, GensVecSet, PauliClass pub use gpu_stab::GpuStab; pub use gpu_stab_opt::GpuStabOpt; pub use gpu_stab_parallel::GpuStabParallel; +pub use graph_state::GraphStateSim; +pub use graph_state_repr::{GraphState, GraphStateRenderer}; // pub use paulis::Paulis; pub use measurement_sampler::{ MeasurementKind, MeasurementSampler, MeasurementValidationError, SampleResult, diff --git a/crates/pecos-qsim/src/prelude.rs b/crates/pecos-qsim/src/prelude.rs index be2d45a5d..76205bcfc 100644 --- a/crates/pecos-qsim/src/prelude.rs +++ b/crates/pecos-qsim/src/prelude.rs @@ -17,6 +17,8 @@ pub use crate::{ clifford_gateable::{CliffordGateable, MeasurementResult}, coin_toss::CoinToss, gens::Gens, + graph_state::GraphStateSim, + graph_state_repr::GraphState, measurement_sampler::{MeasurementSampler, SampleResult, SequentialMeasurementSampler}, pauli_prop::PauliProp, quantum_simulator::QuantumSimulator, diff --git a/crates/pecos-qsim/src/stabilizer_test_utils.rs b/crates/pecos-qsim/src/stabilizer_test_utils.rs index a1a09894f..24ba1f218 100644 --- a/crates/pecos-qsim/src/stabilizer_test_utils.rs +++ b/crates/pecos-qsim/src/stabilizer_test_utils.rs @@ -119,14 +119,14 @@ macro_rules! stabilizer_test_suite { paste::paste! { #[test] fn []() { - use $crate::stabilizer_test_utils::{run_basic_stabilizer_test_suite, StabilizerSimulator}; + use $crate::stabilizer_test_utils::run_basic_stabilizer_test_suite; let mut sim = <$sim_type>::with_seed($num_qubits, 42); run_basic_stabilizer_test_suite(&mut sim, $num_qubits); } #[test] fn []() { - use $crate::stabilizer_test_utils::{run_full_stabilizer_test_suite, StabilizerSimulator}; + use $crate::stabilizer_test_utils::run_full_stabilizer_test_suite; let mut sim = <$sim_type>::with_seed($num_qubits, 42); run_full_stabilizer_test_suite(&mut sim, $num_qubits); } diff --git a/crates/pecos-quantum/Cargo.toml b/crates/pecos-quantum/Cargo.toml index f4ce0c663..55f6a663d 100644 --- a/crates/pecos-quantum/Cargo.toml +++ b/crates/pecos-quantum/Cargo.toml @@ -20,6 +20,9 @@ smallvec.workspace = true tket = { workspace = true, optional = true } log.workspace = true +[dev-dependencies] +pecos-qsim.workspace = true + [features] default = [] hugr = ["tket"] diff --git a/crates/pecos-quantum/examples/style_demo.rs b/crates/pecos-quantum/examples/style_demo.rs new file mode 100644 index 000000000..10a7a010c --- /dev/null +++ b/crates/pecos-quantum/examples/style_demo.rs @@ -0,0 +1,771 @@ +// Standalone binary to generate an HTML demo of all diagram styles. +// Run from the PECOS workspace root: +// cargo run --example style_demo -p pecos-quantum + +use pecos_core::circuit_diagram::{AngleUnit, DiagramStyle, GraphStyle}; +use pecos_core::{Angle64, ColorPalette, ColorTriplet, CosetPatterns, FamilyPalette, FillPattern}; +use pecos_qsim::GraphState; +use pecos_quantum::TickCircuit; +use pecos_quantum::pass::{ + AbsorbBasisGates, CancelInverses, CircuitPass, CompactTicks, MergeAdjacentRotations, + PassPipeline, PeepholeOptimize, RemoveIdentity, SimplifyRotations, +}; +use std::fmt::Write as _; +use std::fs; + +fn build_circuit() -> TickCircuit { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2, 3]); + tc.tick().x(&[0]).y(&[1]).z(&[2]); + tc.tick().sx(&[0]).sy(&[1]).sz(&[2]); + tc.tick().h(&[0, 1, 2, 3]); + tc.tick().t(&[0]).tdg(&[1]).rz(Angle64::QUARTER_TURN, &[2]); + tc.tick().cx(&[(0, 1)]).cz(&[(2, 3)]); + let eighth = Angle64::QUARTER_TURN / 2u64; + tc.tick() + .rzz(Angle64::QUARTER_TURN, &[(0, 1)]) + .rzz(eighth, &[(2, 3)]); + tc.tick().mz(&[0, 1, 2, 3]); + tc +} + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn ansi_to_html(s: &str) -> String { + let mut out = String::new(); + let mut in_span = false; + let mut i = 0; + let bytes = s.as_bytes(); + + while i < bytes.len() { + if bytes[i] == b'\x1b' && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // Parse ANSI escape + let start = i + 2; + let mut end = start; + while end < bytes.len() && bytes[end] != b'm' { + end += 1; + } + if end < bytes.len() { + let code = &s[start..end]; + if in_span { + out.push_str(""); + in_span = false; + } + // Handle compound codes like "1;34" (bold + color) + let style = ansi_code_to_css(code); + if let Some(css) = style { + write!(out, "").unwrap(); + in_span = true; + } + i = end + 1; + continue; + } + } + + let ch = s[i..].chars().next().unwrap(); + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(ch), + } + i += ch.len_utf8(); + } + if in_span { + out.push_str(""); + } + out +} + +fn ansi_code_to_css(code: &str) -> Option { + if code == "0" { + return None; + } + let parts: Vec<&str> = code.split(';').collect(); + let mut bold = false; + let mut color = None; + for part in &parts { + match *part { + "1" => bold = true, + "31" => color = Some("#AA2222"), + "32" => color = Some("#226622"), + "33" => color = Some("#AA8800"), + "34" => color = Some("#2255AA"), + "35" => color = Some("#882288"), + "36" => color = Some("#008888"), + "37" => color = Some("#666666"), + "90" => color = Some("#888888"), + _ => {} + } + } + match (bold, color) { + (true, Some(c)) => Some(format!("font-weight:bold;color:{c}")), + (true, None) => Some("font-weight:bold".to_string()), + (false, Some(c)) => Some(format!("color:{c}")), + (false, None) => None, + } +} + +fn section(title: &str, body: &str) -> String { + format!("

{title}

\n{body}\n") +} + +fn pre_block(content: &str) -> String { + format!("
{content}
") +} + +fn svg_block(svg: &str) -> String { + format!("
{svg}
") +} + +fn code_block(lang: &str, content: &str) -> String { + format!( + "
Show {lang} source
{}
", + escape_html(content) + ) +} + +fn main() { + let tc = build_circuit(); + + let default_style = DiagramStyle::default(); + + let custom_palette = DiagramStyle::builder() + .x_axis("#FF6666", "#CC0000", "#660000") + .z_axis("#6666FF", "#0000CC", "#000066") + .xz_mix("#CC66CC", "#990099", "#660066") + .build(); + + let monochrome = DiagramStyle::builder().color(false).build(); + + let no_dashes = DiagramStyle::builder().show_dashes(false).build(); + + let mono_no_dashes = DiagramStyle::builder() + .color(false) + .show_dashes(false) + .build(); + + let mut html = String::from( + r#" + + + +PECOS Visualization Demo + + + +

PECOS Visualization Demo

+

Circuit diagrams and graph state visualizations with configurable styles.

+

Circuit Diagrams

+

All gate families: Prep, Pauli, S-like, H-like, Default (T), multi-qubit (CX/CZ), Measure.

+"#, + ); + + // -- Text outputs -- + html.push_str(§ion( + "ASCII (plain)", + &pre_block(&escape_html(&tc.to_ascii())), + )); + + html.push_str(§ion( + "ASCII (ANSI color)", + &pre_block(&ansi_to_html(&tc.to_color_ascii())), + )); + + html.push_str(§ion( + "Unicode (plain)", + &pre_block(&escape_html(&tc.to_unicode())), + )); + + html.push_str(§ion( + "Unicode (ANSI color)", + &pre_block(&ansi_to_html(&tc.to_color_unicode())), + )); + + // -- SVG outputs -- + html.push_str("

SVG Outputs

\n
\n"); + + let r_default = tc.render_with(&default_style); + write!( + html, + "

Default

{}
", + svg_block(&r_default.svg()) + ) + .unwrap(); + + let r_custom = tc.render_with(&custom_palette); + write!( + html, + "

Custom Palette

{}
", + svg_block(&r_custom.svg()) + ) + .unwrap(); + + let r_mono = tc.render_with(&monochrome); + write!( + html, + "

Monochrome (color: false)

{}
", + svg_block(&r_mono.svg()) + ) + .unwrap(); + + let r_nodash = tc.render_with(&no_dashes); + write!( + html, + "

No Dashes (show_dashes: false)

{}
", + svg_block(&r_nodash.svg()) + ) + .unwrap(); + + let r_mono_nodash = tc.render_with(&mono_no_dashes); + write!( + html, + "

Monochrome + No Dashes

{}
", + svg_block(&r_mono_nodash.svg()) + ) + .unwrap(); + + html.push_str("
\n"); + + // -- TikZ -- + html.push_str(§ion( + "TikZ (default)", + &code_block("TikZ", &r_default.tikz()), + )); + html.push_str(§ion( + "TikZ (monochrome)", + &code_block("TikZ", &r_mono.tikz()), + )); + + // -- DOT -- + html.push_str(§ion( + "DOT / Graphviz (default)", + &code_block("DOT", &r_default.dot()), + )); + html.push_str(§ion( + "DOT / Graphviz (monochrome)", + &code_block("DOT", &r_mono.dot()), + )); + + // -- Angle unit comparison -- + html.push_str("

Angle Units: Radians vs Turns

\n"); + { + // Build a circuit with several parameterized gates + let mut angle_tc = TickCircuit::new(); + angle_tc.tick().pz(&[0, 1, 2]); + let eighth = Angle64::QUARTER_TURN / 2u64; + angle_tc + .tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(eighth, &[1]) + .rz(Angle64::HALF_TURN, &[2]); + angle_tc + .tick() + .rx(Angle64::QUARTER_TURN, &[0]) + .ry(eighth, &[1]); + angle_tc.tick().mz(&[0, 1, 2]); + + let radians_style = DiagramStyle::builder() + .angle_unit(AngleUnit::Radians) + .build(); + let turns_style = DiagramStyle::builder().angle_unit(AngleUnit::Turns).build(); + + let r_rad = angle_tc.render_with(&radians_style); + let r_turns = angle_tc.render_with(&turns_style); + + html.push_str("
\n"); + write!( + html, + "

Radians (default)

{}{}
", + pre_block(&escape_html(&r_rad.ascii())), + svg_block(&r_rad.svg()), + ) + .unwrap(); + write!( + html, + "

Turns

{}{}
", + pre_block(&escape_html(&r_turns.ascii())), + svg_block(&r_turns.svg()), + ) + .unwrap(); + html.push_str("
\n"); + } + + // -- Rotation simplification comparison (pass-based) -- + html.push_str("

Rotation Simplification (Circuit Pass)

\n"); + { + let mut rot_tc = TickCircuit::new(); + rot_tc.tick().pz(&[0, 1, 2, 3]); + rot_tc + .tick() + .rz(Angle64::HALF_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]) + .rx(Angle64::QUARTER_TURN, &[2]) + .ry(Angle64::QUARTER_TURN, &[3]); + let eighth = Angle64::QUARTER_TURN / 2u64; + rot_tc + .tick() + .rz(eighth, &[0]) + .rx(Angle64::HALF_TURN, &[1]) + .ry(Angle64::HALF_TURN, &[2]) + .rz(Angle64::THREE_QUARTERS_TURN, &[3]); + rot_tc.tick().mz(&[0, 1, 2, 3]); + + // Clone and apply the SimplifyRotations pass to one copy. + let mut simplified_tc = rot_tc.clone(); + SimplifyRotations.apply_tick(&mut simplified_tc); + + let style = DiagramStyle::default(); + let r_before = rot_tc.render_with(&style); + let r_after = simplified_tc.render_with(&style); + + html.push_str("
\n"); + write!( + html, + "

Before pass

{}{}
", + pre_block(&escape_html(&r_before.ascii())), + svg_block(&r_before.svg()), + ) + .unwrap(); + write!( + html, + "

After SimplifyRotations

{}{}
", + pre_block(&escape_html(&r_after.ascii())), + svg_block(&r_after.svg()), + ) + .unwrap(); + html.push_str("
\n"); + } + + // -- Peephole optimization comparison -- + html.push_str("

Peephole Optimization (Circuit Pass)

\n"); + { + let mut peep_tc = TickCircuit::new(); + peep_tc.tick().pz(&[0, 1, 2, 3]); + // H-CX-H on target -> CZ + peep_tc.tick().h(&[1]); + peep_tc.tick().cx(&[(0, 1)]); + peep_tc.tick().h(&[1]); + // H-CZ-H on one qubit -> CX + peep_tc.tick().h(&[2]); + peep_tc.tick().cz(&[(2, 3)]); + peep_tc.tick().h(&[2]); + peep_tc.tick().mz(&[0, 1, 2, 3]); + + let mut optimized_tc = peep_tc.clone(); + PeepholeOptimize.apply_tick(&mut optimized_tc); + + let style = DiagramStyle::default(); + let r_before = peep_tc.render_with(&style); + let r_after = optimized_tc.render_with(&style); + + html.push_str("
\n"); + write!( + html, + "

Before pass

{}{}
", + pre_block(&escape_html(&r_before.ascii())), + svg_block(&r_before.svg()), + ) + .unwrap(); + write!( + html, + "

After PeepholeOptimize

{}{}
", + pre_block(&escape_html(&r_after.ascii())), + svg_block(&r_after.svg()), + ) + .unwrap(); + html.push_str("
\n"); + } + + // -- Full pass pipeline comparison -- + html.push_str("

Full Pass Pipeline

\n"); + { + let mut pipe_tc = TickCircuit::new(); + pipe_tc.tick().pz(&[0, 1, 2, 3]); + // Z-diagonal after prep (absorbed) + pipe_tc.tick().t(&[0]).sz(&[1]).cz(&[(2, 3)]); + // Mergeable rotations + pipe_tc.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + pipe_tc.tick().rz(Angle64::QUARTER_TURN, &[0]).cx(&[(1, 2)]); + // Cancellable pair + pipe_tc.tick().h(&[1]); + // Z-diagonal before measure (absorbed) + pipe_tc.tick().tdg(&[2]).sz(&[3]); + pipe_tc.tick().mz(&[0, 1, 2, 3]); + + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(MergeAdjacentRotations) + .then(RemoveIdentity) + .then(SimplifyRotations) + .then(CancelInverses) + .then(PeepholeOptimize) + .then(CompactTicks); + + let mut optimized_tc = pipe_tc.clone(); + pipeline.apply_tick(&mut optimized_tc); + + let style = DiagramStyle::default(); + let r_before = pipe_tc.render_with(&style); + let r_after = optimized_tc.render_with(&style); + + html.push_str("
\n"); + write!( + html, + "

Before pipeline

{}{}
", + pre_block(&escape_html(&r_before.ascii())), + svg_block(&r_before.svg()), + ) + .unwrap(); + write!( + html, + "

After pipeline

{}{}
", + pre_block(&escape_html(&r_after.ascii())), + svg_block(&r_after.svg()), + ) + .unwrap(); + html.push_str("
\n"); + } + + // -- Operator example -- + html.push_str("

Operator Algebra

\n"); + { + use pecos_core::operator::{CX, H, T}; + let circuit = T(1) * CX(0, 1) * H(0); + let op_renderer = circuit.render_with(2, &default_style); + write!( + html, + "

T(1) * CX(0,1) * H(0)

\n{}{}", + pre_block(&escape_html(&op_renderer.ascii())), + svg_block(&op_renderer.svg()), + ) + .unwrap(); + } + + // -- Overlapping multi-qubit gates -- + html.push_str("

Overlapping Multi-qubit Gates (Sub-column Splitting)

\n"); + { + let mut overlap_tc = TickCircuit::new(); + overlap_tc.tick().h(&[0, 1, 2, 3]); + let mut t = overlap_tc.tick(); + t.cx(&[(0, 2)]); + t.cz(&[(1, 3)]); + overlap_tc.tick().mz(&[0, 1, 2, 3]); + + let r = overlap_tc.render_with(&default_style); + html.push_str("

CX(0,2) and CZ(1,3) in the same tick have overlapping visual ranges, \ + so they are split into separate sub-columns with a bracket annotation.

\n"); + write!( + html, + "{}{}", + pre_block(&escape_html(&r.ascii())), + svg_block(&r.svg()), + ) + .unwrap(); + } + + // ================================================================ + // Graph State Visualization + // ================================================================ + + html.push_str( + "

\ + Graph State Visualization

\n", + ); + html.push_str("

Graph states visualized with the PECOS color algebra: \ + fill hue = axis permutation coset, brightness = sign parity, \ + stroke = gate family. All formats share the same GraphStyle palette.

\n"); + + let gs_default = GraphStyle::default(); + + // -- Pattern gallery -- + html.push_str("

Graph State Patterns

\n
\n"); + + let patterns: &[(&str, GraphState)] = &[ + ("Linear Cluster (5)", GraphState::linear_cluster(5)), + ("Ring (6)", GraphState::ring(6)), + ("Star (5)", GraphState::star(5)), + ("Complete K4", GraphState::complete(4)), + ("2D Lattice (2x3)", GraphState::lattice_2d(2, 3)), + ]; + + for (label, gs) in patterns { + write!( + html, + "

{label}

{}{}
", + pre_block(&escape_html(&gs.to_ascii())), + svg_block(&gs.render_with(&gs_default).svg()), + ) + .unwrap(); + } + html.push_str("
\n"); + + // -- Graph state with non-identity VOPs -- + html.push_str("

Graph States with VOPs

\n"); + html.push_str( + "

When local Cliffords (VOPs) are applied to vertices, \ + the fill color encodes the axis permutation coset and \ + the stroke encodes the gate family.

\n", + ); + { + use pecos_qsim::clifford_frame::CliffordFrame; + + let mut gs = GraphState::ring(6); + gs.set_vop(0, CliffordFrame::H); // H-like, X<->Z coset + gs.set_vop(1, CliffordFrame::SZ); // S-like, X<->Y coset + gs.set_vop(2, CliffordFrame::SX); // S-like, Y<->Z coset + gs.set_vop(4, CliffordFrame::from_index(7)); // F-like, cyclic fwd + gs.set_vop(5, CliffordFrame::from_index(8)); // F-like, cyclic inv + + html.push_str("
\n"); + write!( + html, + "

ASCII

{}
", + pre_block(&escape_html(&gs.to_ascii())), + ) + .unwrap(); + write!( + html, + "

Color ASCII

{}
", + pre_block(&ansi_to_html(&gs.to_color_ascii())), + ) + .unwrap(); + write!( + html, + "

Unicode

{}
", + pre_block(&escape_html(&gs.to_unicode())), + ) + .unwrap(); + write!( + html, + "

Color Unicode

{}
", + pre_block(&ansi_to_html(&gs.to_color_unicode())), + ) + .unwrap(); + html.push_str("
\n"); + + let r = gs.render_with(&gs_default); + html.push_str("
\n"); + write!(html, "

SVG

{}
", svg_block(&r.svg())).unwrap(); + write!( + html, + "

DOT

{}
", + code_block("DOT", &r.dot()), + ) + .unwrap(); + html.push_str("
\n"); + html.push_str(§ion("TikZ", &code_block("TikZ", &r.tikz()))); + } + + // -- All 24 Cliffords showcase -- + html.push_str("

All 24 Single-Qubit Cliffords

\n"); + html.push_str( + "

Each vertex has a different Clifford VOP, showing the full \ + color algebra: 5 coset hues, 2 brightness levels (saturated/light), \ + 4 gate-family strokes.

\n", + ); + { + use pecos_qsim::clifford_frame::CliffordFrame; + + // Build a 24-vertex graph with each vertex having a unique Clifford VOP. + // No edges -- just showcasing the VOP colors. + let mut gs = GraphState::new(24); + for i in 0..24 { + gs.set_vop(i, CliffordFrame::from_index(i as u8)); + } + + let r = gs.render_with(&gs_default); + write!( + html, + "{}{}", + pre_block(&escape_html(&gs.to_ascii())), + svg_block(&r.svg()), + ) + .unwrap(); + } + + // -- SVG style variations -- + html.push_str("

Graph Style Variations

\n
\n"); + { + use pecos_qsim::clifford_frame::CliffordFrame; + + let mut gs = GraphState::star(5); + gs.set_vop(1, CliffordFrame::H); + gs.set_vop(2, CliffordFrame::SZ); + gs.set_vop(3, CliffordFrame::SX); + gs.set_vop(4, CliffordFrame::from_index(7)); + + // Default + write!( + html, + "

Default

{}
", + svg_block(&gs.render_with(&gs_default).svg()), + ) + .unwrap(); + + // Custom palette: warm tones + let warm_palette = ColorPalette { + z_axis: ColorTriplet::new("#FFB0B0", "#AA2222", "#7A1A1A"), + xz_mix: ColorTriplet::new("#E0B0E0", "#882288", "#5A1A5A"), + xy_mix: ColorTriplet::new("#F0E0A0", "#AA8800", "#6A5500"), + yz_mix: ColorTriplet::new("#FFD0B0", "#CC6600", "#884400"), + xyz_mix: ColorTriplet::new("#E0D0C0", "#887766", "#554433"), + ..ColorPalette::default() + }; + let warm_style = GraphStyle::builder().palette(warm_palette).build(); + write!( + html, + "

Custom Palette (warm)

{}
", + svg_block(&gs.render_with(&warm_style).svg()), + ) + .unwrap(); + + // Monochrome: varying grey levels, uniform strokes, dashes + patterns + let mono_palette = ColorPalette { + z_axis: ColorTriplet::new("#D8D8D8", "#555555", "#333333"), + xz_mix: ColorTriplet::new("#C4C4C4", "#555555", "#333333"), + xy_mix: ColorTriplet::new("#B0B0B0", "#555555", "#333333"), + yz_mix: ColorTriplet::new("#9C9C9C", "#555555", "#222222"), + xyz_mix: ColorTriplet::new("#888888", "#555555", "#222222"), + ..ColorPalette::default() + }; + let mono_families = FamilyPalette { + pauli: "#555555".to_string(), + s_like: "#555555".to_string(), + h_like: "#555555".to_string(), + f_like: "#555555".to_string(), + }; + let mono_patterns = CosetPatterns { + identity: FillPattern::Solid, + xz_mix: FillPattern::DiagonalUp, + xy_mix: FillPattern::Crosshatch, + yz_mix: FillPattern::Dots, + xyz_mix: FillPattern::HorizontalLines, + }; + let mono_style = GraphStyle::builder() + .palette(mono_palette) + .family_strokes(mono_families) + .show_dashes(true) + .coset_patterns(mono_patterns) + .build(); + write!( + html, + "

Monochrome (grey + patterns + dashes)

{}
", + svg_block(&gs.render_with(&mono_style).svg()), + ) + .unwrap(); + + // Custom family strokes + let bold_families = FamilyPalette { + pauli: "#0000AA".to_string(), + s_like: "#00AA00".to_string(), + h_like: "#AA0000".to_string(), + f_like: "#AA00AA".to_string(), + }; + let bold_style = GraphStyle::builder().family_strokes(bold_families).build(); + write!( + html, + "

Bold Family Strokes

{}
", + svg_block(&gs.render_with(&bold_style).svg()), + ) + .unwrap(); + } + html.push_str("
\n"); + + // -- Local complementation -- + html.push_str("

Local Complementation

\n"); + html.push_str( + "

Applying local complementation to vertex 0 of a star graph \ + complements edges among its neighbors and updates VOPs.

\n", + ); + { + let gs_before = GraphState::star(5); + let mut gs_after = gs_before.clone(); + gs_after.local_complement(0); + + html.push_str("
\n"); + write!( + html, + "

Before LC(0)

{}{}
", + pre_block(&escape_html(&gs_before.to_ascii())), + svg_block(&gs_before.render_with(&gs_default).svg()), + ) + .unwrap(); + write!( + html, + "

After LC(0)

{}{}
", + pre_block(&escape_html(&gs_after.to_ascii())), + svg_block(&gs_after.render_with(&gs_default).svg()), + ) + .unwrap(); + html.push_str("
\n"); + } + + html.push_str("\n\n"); + + let path = "/tmp/pecos_style_demo.html"; + fs::write(path, &html).unwrap(); + println!("Written to {path}"); + + // Open in default browser + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(path).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("cmd") + .args(["/C", "start", "", path]) + .spawn(); +} diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs new file mode 100644 index 000000000..972fb419b --- /dev/null +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -0,0 +1,1194 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Circuit diagram rendering for [`TickCircuit`] and [`DagCircuit`]. +//! +//! Produces horizontal qubit-wire diagrams with gate symbols placed at +//! tick/layer columns, suitable for terminal display. Delegates the actual +//! grid layout and character rendering to +//! [`pecos_core::circuit_diagram::CircuitDiagram`]. + +use pecos_core::circuit_diagram::{AngleUnit, CellColor, CircuitDiagram, DiagramCell, GateFamily}; +use pecos_core::gate_type::GateType; +use pecos_core::{Gate, QubitId}; +use std::collections::BTreeSet; + +// ==================== Gate symbols ==================== + +/// Short symbol for a gate type. +fn gate_symbol(gate_type: GateType) -> &'static str { + match gate_type { + GateType::H => "H", + GateType::F => "F", + GateType::Fdg => "Fdg", + GateType::X => "X", + GateType::Y => "Y", + GateType::Z => "Z", + GateType::SX => "SX", + GateType::SXdg => "SXdg", + GateType::SY => "SY", + GateType::SYdg => "SYdg", + GateType::SZ => "SZ", + GateType::SZdg => "SZdg", + GateType::T => "T", + GateType::Tdg => "Tdg", + GateType::RX => "RX", + GateType::RY => "RY", + GateType::RZ => "RZ", + GateType::U => "U", + GateType::R1XY => "R1XY", + GateType::CX => "CX", + GateType::CY => "CY", + GateType::CZ => "CZ", + GateType::CH => "CH", + GateType::SXX => "SXX", + GateType::SXXdg => "SXXdg", + GateType::SYY => "SYY", + GateType::SYYdg => "SYYdg", + GateType::SZZ => "SZZ", + GateType::SZZdg => "SZZdg", + GateType::SWAP => "SWAP", + GateType::CRZ => "CRZ", + GateType::RXX => "RXX", + GateType::RYY => "RYY", + GateType::RZZ => "RZZ", + GateType::CCX => "CCX", + GateType::Measure => "MZ", + GateType::MeasureLeaked => "ML", + GateType::MeasureFree => "MF", + GateType::Prep => "PZ", + GateType::QAlloc => "QA", + GateType::QFree => "QF", + GateType::I | GateType::Idle => "I", + GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload => "XT", + GateType::Custom => "?", + } +} + +/// Format an angle according to the given unit. +fn format_angle(angle: pecos_core::Angle64, unit: AngleUnit) -> String { + match unit { + AngleUnit::Radians => format_angle_radians(angle), + AngleUnit::Turns => format_angle_turns(angle), + } +} + +/// Format an angle as a compact string using pi notation with fractions where +/// possible, e.g. `\u{03C0}/4`, `3\u{03C0}/2`, `\u{03C0}`. +/// +/// The internal fixed-point representation stores angles as `fraction / 2^64` +/// turns, so the coefficient of pi is `fraction / 2^63`. Since the denominator +/// is a power of two, we reduce by extracting trailing zeros to get an exact +/// `p/q` ratio. When the fraction is not "nice" we fall back to decimal radians. +fn format_angle_radians(angle: pecos_core::Angle64) -> String { + let fraction = angle.fraction(); + if fraction == 0 { + return "0".to_string(); + } + + // coefficient of pi = fraction / 2^63 + let k = fraction.trailing_zeros(); // 0..=63 for non-zero u64 + let p = fraction >> k; // numerator (odd, >= 1) + let q_exp = 63_u32.saturating_sub(k); // denominator = 2^q_exp + let q: u64 = 1_u64.checked_shl(q_exp).unwrap_or(0); + + // Only use pi notation if the fraction is "nice". + if q == 0 || q > 128 || p > 512 { + let radians = angle.to_radians(); + return format!("{radians:.4}"); + } + + let pi = '\u{03C0}'; + match (p, q) { + (1, 1) => format!("{pi}"), + (2, 1) => format!("2{pi}"), + (p, 1) => format!("{p}{pi}"), + (1, q) => format!("{pi}/{q}"), + (p, q) => format!("{p}{pi}/{q}"), + } +} + +/// Format an angle as a compact string in turns using fractions where possible, +/// e.g. `1/4`, `3/8`, `1`. Falls back to decimal for non-nice fractions. +/// +/// The internal representation stores angles as `fraction / 2^64` turns. +/// Since the denominator is a power of two we reduce by extracting trailing +/// zeros to get an exact `p/q` ratio. +fn format_angle_turns(angle: pecos_core::Angle64) -> String { + let fraction = angle.fraction(); + if fraction == 0 { + return "0".to_string(); + } + + // turns = fraction / 2^64 + let k = fraction.trailing_zeros(); // 0..=63 for non-zero u64 + let p = fraction >> k; // numerator (odd, >= 1) + let q_exp = 64_u32.saturating_sub(k); // denominator = 2^q_exp + let q: u128 = 1_u128.checked_shl(q_exp).unwrap_or(0); + + if q == 0 || q > 128 || p > 512 { + // Fall back to decimal + let turns = angle.to_radians() / std::f64::consts::TAU; + let turns = turns.rem_euclid(1.0); + return format!("{turns:.6}") + .trim_end_matches('0') + .trim_end_matches('.') + .to_string(); + } + + match (p, q) { + (1, 1) => "1".to_string(), + (p, 1) => format!("{p}"), + (1, q) => format!("1/{q}"), + (p, q) => format!("{p}/{q}"), + } +} + +/// Build the full symbol string for a gate, including angles if parameterized. +fn full_gate_symbol(gate: &Gate, unit: AngleUnit) -> String { + let base = gate_symbol(gate.gate_type); + if gate.angles.is_empty() { + return base.to_string(); + } + let angle_strs: Vec = gate + .angles + .iter() + .copied() + .map(|a| format_angle(a, unit)) + .collect(); + format!("{base}({})", angle_strs.join(",")) +} + +// ==================== Color mapping ==================== + +/// Map a `GateType` to its diagram color using the PECOS axis color algebra. +fn gate_color(gate_type: GateType) -> CellColor { + match gate_type { + GateType::X | GateType::RX | GateType::RXX => CellColor::XAxis, + GateType::Y | GateType::RY | GateType::RYY => CellColor::YAxis, + GateType::Z + | GateType::RZ + | GateType::T + | GateType::Tdg + | GateType::RZZ + | GateType::Measure + | GateType::Prep + | GateType::SZZ + | GateType::SZZdg + | GateType::CRZ => CellColor::ZAxis, + GateType::SX | GateType::SXdg | GateType::SXX | GateType::SXXdg => CellColor::YZMix, + GateType::SY + | GateType::SYdg + | GateType::SYY + | GateType::SYYdg + | GateType::H + | GateType::F + | GateType::Fdg + | GateType::CH => CellColor::XZMix, + GateType::SZ | GateType::SZdg => CellColor::XYMix, + // No clear single-axis color: idle, alloc/free, multi-qubit, custom + GateType::Idle + | GateType::I + | GateType::MeasureLeaked + | GateType::MeasureFree + | GateType::QAlloc + | GateType::QFree + | GateType::Custom + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::CCX + | GateType::SWAP + | GateType::U + | GateType::R1XY => CellColor::None, + } +} + +// ==================== Family mapping ==================== + +/// Map a `GateType` to its diagram family bracket/stroke style. +/// +/// Most gates use `Default` brackets (`[G]`). Only measurement and preparation +/// gates keep their asymmetric brackets (`|MZ)` and `(PZ|`). +fn gate_family(gate_type: GateType) -> GateFamily { + match gate_type { + GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { + GateFamily::Measurement + } + GateType::Prep | GateType::QAlloc | GateType::QFree => GateFamily::Preparation, + _ => GateFamily::Default, + } +} + +// ==================== Visual range and sublayer splitting ==================== + +/// Compute the set of rows a gate visually occupies. +/// +/// Single-qubit gates occupy only their target rows. Multi-qubit gates occupy +/// `min_row..=max_row` (all intermediate rows included) because the vertical +/// connector line passes through them. +fn compute_visual_range( + gate: &Gate, + qubit_to_row: &std::collections::BTreeMap, +) -> BTreeSet { + let rows: Vec = gate + .qubits + .iter() + .filter_map(|q| qubit_to_row.get(q).copied()) + .collect(); + if rows.is_empty() { + return BTreeSet::new(); + } + let arity = gate.gate_type.quantum_arity(); + if arity <= 1 { + rows.into_iter().collect() + } else { + let min = *rows.iter().min().unwrap(); + let max = *rows.iter().max().unwrap(); + (min..=max).collect() + } +} + +/// Split a layer of gates into sublayers such that no two gates in the same +/// sublayer have overlapping visual row ranges. +/// +/// Uses a first-fit algorithm with gates sorted by their minimum visual row. +/// For interval graphs (contiguous visual ranges, which all multi-qubit gates +/// produce), sorting by left endpoint guarantees an optimal split that uses the +/// minimum number of sublayers (equal to the maximum clique size). Without the +/// sort, insertion order can produce unnecessary extra sublayers. +fn split_layer_into_sublayers<'a>( + layer: &[&'a Gate], + qubit_to_row: &std::collections::BTreeMap, +) -> Vec> { + // Compute visual ranges and sort by minimum row for optimal coloring. + let mut gates_with_range: Vec<(&'a Gate, BTreeSet)> = layer + .iter() + .map(|&gate| (gate, compute_visual_range(gate, qubit_to_row))) + .collect(); + gates_with_range.sort_by_key(|(_, range)| range.iter().next().copied().unwrap_or(0)); + + let mut sublayers: Vec<(BTreeSet, Vec<&'a Gate>)> = Vec::new(); + + for (gate, visual_range) in gates_with_range { + let mut placed = false; + for (occupied, gates) in &mut sublayers { + if occupied.is_disjoint(&visual_range) { + occupied.extend(&visual_range); + gates.push(gate); + placed = true; + break; + } + } + if !placed { + sublayers.push((visual_range, vec![gate])); + } + } + + sublayers.into_iter().map(|(_, gates)| gates).collect() +} + +// ==================== Grid building ==================== + +/// Place a single gate's decomposed cells and connectors into a diagram. +fn place_gate( + gate: &Gate, + diagram: &mut CircuitDiagram, + qubit_to_row: &std::collections::BTreeMap, + num_rows: usize, + angle_unit: AngleUnit, +) { + let decomposed = decompose_gate(gate, qubit_to_row, num_rows, angle_unit); + for (row, cell, color) in decomposed.cells { + if row < num_rows { + diagram.set_cell(row, cell, color); + } + } + if let Some((top, bottom)) = decomposed.connector { + if let Some(label) = decomposed.connector_label { + diagram.add_labeled_connector(top, bottom, label); + } else { + diagram.add_connector(top, bottom); + } + } +} + +/// Result of decomposing a gate: cells and optional connector span. +struct DecomposedGate { + cells: Vec<(usize, DiagramCell, CellColor)>, + /// Vertical connector span `(top_row, bottom_row)` if this is a multi-qubit gate. + connector: Option<(usize, usize)>, + /// Optional label to display on the connector line (for symmetric two-qubit gates). + connector_label: Option, +} + +/// Decompose a single `Gate` into per-row cell assignments. +fn decompose_gate( + gate: &Gate, + qubit_to_row: &std::collections::BTreeMap, + num_rows: usize, + angle_unit: AngleUnit, +) -> DecomposedGate { + let arity = gate.gate_type.quantum_arity(); + let qubits = &gate.qubits; + let mut cells = Vec::new(); + + let color = gate_color(gate.gate_type); + let mut connector = None; + let mut connector_label = None; + + if arity == 1 { + let sym = full_gate_symbol(gate, angle_unit); + let family = gate_family(gate.gate_type); + for &q in qubits { + if let Some(&row) = qubit_to_row.get(&q) { + cells.push((row, DiagramCell::Gate(sym.clone(), family), color)); + } + } + } else if arity == 2 { + let sym = full_gate_symbol(gate, angle_unit); + for pair in qubits.chunks(2) { + if pair.len() < 2 { + continue; + } + let (q_a, q_b) = (pair[0], pair[1]); + let Some(&row_a) = qubit_to_row.get(&q_a) else { + continue; + }; + let Some(&row_b) = qubit_to_row.get(&q_b) else { + continue; + }; + + let (top, bottom) = if row_a < row_b { + (row_a, row_b) + } else { + (row_b, row_a) + }; + + match gate.gate_type { + GateType::CX => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + row_b, + DiagramCell::Gate("X".to_string(), GateFamily::Default), + CellColor::XAxis, + )); + } + GateType::CY => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + row_b, + DiagramCell::Gate("Y".to_string(), GateFamily::Default), + CellColor::YAxis, + )); + } + GateType::CZ => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push((row_b, DiagramCell::Control, CellColor::ControlDot)); + } + GateType::CH => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + row_b, + DiagramCell::Gate("H".to_string(), GateFamily::Default), + CellColor::XZMix, + )); + } + GateType::SWAP => { + cells.push(( + row_a, + DiagramCell::Gate("x".to_string(), GateFamily::Default), + CellColor::None, + )); + cells.push(( + row_b, + DiagramCell::Gate("x".to_string(), GateFamily::Default), + CellColor::None, + )); + } + // Symmetric two-qubit interactions: dots on both wires, + // label on the connector line between them. + GateType::RXX + | GateType::RYY + | GateType::RZZ + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SZZ + | GateType::SZZdg => { + cells.push((row_a, DiagramCell::Control, color)); + cells.push((row_b, DiagramCell::Control, color)); + connector_label = Some(sym.clone()); + } + _ => { + let family = gate_family(gate.gate_type); + cells.push((row_a, DiagramCell::Gate(sym.clone(), family), color)); + cells.push((row_b, DiagramCell::Gate(sym.clone(), family), color)); + } + } + + connector = Some((top, bottom)); + + // Intermediate rows: crossings on qubit wires. + for row in (top + 1)..bottom { + if row < num_rows { + cells.push((row, DiagramCell::Crossing, CellColor::None)); + } + } + } + } else if arity == 3 { + for triple in qubits.chunks(3) { + if triple.len() < 3 { + continue; + } + let (c0, c1, t) = (triple[0], triple[1], triple[2]); + let rows: Vec> = [c0, c1, t] + .iter() + .map(|q| qubit_to_row.get(q).copied()) + .collect(); + if rows.iter().any(Option::is_none) { + continue; + } + let rows: Vec = rows.into_iter().map(|r| r.unwrap()).collect(); + let top = *rows.iter().min().unwrap(); + let bottom = *rows.iter().max().unwrap(); + + cells.push((rows[0], DiagramCell::Control, CellColor::ControlDot)); + cells.push((rows[1], DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + rows[2], + DiagramCell::Gate("X".to_string(), GateFamily::Default), + CellColor::XAxis, + )); + + connector = Some((top, bottom)); + + let gate_rows: BTreeSet = rows.iter().copied().collect(); + for row in (top + 1)..bottom { + if !gate_rows.contains(&row) && row < num_rows { + cells.push((row, DiagramCell::Crossing, CellColor::None)); + } + } + } + } + + DecomposedGate { + cells, + connector, + connector_label, + } +} + +// ==================== Diagram building ==================== + +/// Build a `CircuitDiagram` from gate layers. +/// +/// Returns `None` when `layers` contain no qubits. +fn build_diagram(layers: &[Vec<&Gate>], angle_unit: AngleUnit) -> Option { + let mut qubit_set = BTreeSet::new(); + for layer in layers { + for gate in layer { + for &q in &gate.qubits { + qubit_set.insert(q); + } + } + } + let qubits: Vec = qubit_set.into_iter().collect(); + if qubits.is_empty() { + return None; + } + + let qubit_to_row: std::collections::BTreeMap = + qubits.iter().enumerate().map(|(i, &q)| (q, i)).collect(); + let num_rows = qubits.len(); + + let labels: Vec = qubits.iter().map(|q| format!("q{}", q.0)).collect(); + let mut diagram = CircuitDiagram::with_labels(labels); + + for (layer_idx, layer) in layers.iter().enumerate() { + if layer.is_empty() { + if layer_idx > 0 { + diagram.advance(); + } + continue; + } + + let sublayers = split_layer_into_sublayers(layer, &qubit_to_row); + let is_split = sublayers.len() > 1; + let mut start_col = 0; + + for (sub_idx, sublayer) in sublayers.iter().enumerate() { + if layer_idx > 0 || sub_idx > 0 { + diagram.advance(); + } + if sub_idx == 0 && is_split { + start_col = diagram.current_col(); + } + for gate in sublayer { + place_gate(gate, &mut diagram, &qubit_to_row, num_rows, angle_unit); + } + } + + if is_split { + let end_col = diagram.current_col(); + diagram.add_column_group(format!("t{layer_idx}"), start_col, end_col); + } + } + + Some(diagram) +} + +/// Build a `CircuitDiagram` from layers, returning an empty 0-qubit diagram if +/// there are no qubits. Used by `render_with` on `TickCircuit`/`DagCircuit`. +pub(crate) fn build_diagram_or_empty( + layers: &[Vec<&Gate>], + angle_unit: AngleUnit, +) -> CircuitDiagram { + build_diagram(layers, angle_unit).unwrap_or_else(|| CircuitDiagram::new(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_core::Angle64; + + fn render_tick(build: impl FnOnce(&mut crate::TickCircuit)) -> String { + let mut tc = crate::TickCircuit::new(); + build(&mut tc); + tc.to_ascii() + } + + fn render_tick_color(build: impl FnOnce(&mut crate::TickCircuit)) -> String { + let mut tc = crate::TickCircuit::new(); + build(&mut tc); + tc.to_color_ascii() + } + + #[test] + fn single_qubit_gates_on_correct_wires() { + let out = render_tick(|tc| { + tc.tick().h(&[0]); + tc.tick().x(&[1]); + }); + assert!(out.contains("q0:")); + assert!(out.contains("q1:")); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0_line.contains("[H]")); + assert!(!q0_line.contains("[X]")); + assert!(q1_line.contains("[X]")); + assert!(!q1_line.contains("[H]")); + } + + #[test] + fn cx_shows_control_target_connector() { + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2]); + tc.tick().cx(&[(0, 2)]); + }); + assert!(out.contains('.')); + assert!(out.contains("[X]")); // CX target uses Default brackets + assert!(out.contains('|')); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q1_line.contains('+')); + } + + #[test] + fn multi_tick_alignment() { + let out = render_tick(|tc| { + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + }); + let qubit_lines: Vec<&str> = out.lines().filter(|l| l.starts_with('q')).collect(); + assert!(qubit_lines.len() >= 2); + let len0 = qubit_lines[0].len(); + for line in &qubit_lines { + assert_eq!(line.len(), len0, "Lines should have equal length"); + } + } + + #[test] + fn parameterized_gate_includes_angle() { + // Use a non-special angle (pi/8) that won't be simplified to a named gate. + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(1, 16), &[0]); + }); + assert!(out.contains("RZ(")); + assert!(out.contains("\u{03C0}/8")); + } + + #[test] + fn angle_format_common_fractions() { + // pi (half turn) + let out = render_tick(|tc| { + tc.tick().rz(Angle64::HALF_TURN, &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("\u{03C0})"), + "half turn should show as pi: {q0}" + ); + assert!( + !q0.contains('/'), + "half turn should not have a denominator: {q0}" + ); + + // pi/4 (eighth turn) + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("\u{03C0}/4"), + "eighth turn should show as pi/4: {q0}" + ); + + // 3pi/4 + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(3, 8), &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("3\u{03C0}/4"), + "3/8 turn should show as 3pi/4: {q0}" + ); + + // zero + let out = render_tick(|tc| { + tc.tick().rz(Angle64::ZERO, &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!(q0.contains("(0)"), "zero should show as 0: {q0}"); + } + + #[test] + fn empty_circuit_shows_header_only() { + let tc = crate::TickCircuit::new(); + let out = tc.to_ascii(); + assert!(out.contains("TickCircuit:")); + assert!(!out.contains("q0:")); + } + + #[test] + fn color_version_contains_ansi_plain_does_not() { + let plain = render_tick(|tc| { + tc.tick().h(&[0]); + }); + let colored = render_tick_color(|tc| { + tc.tick().h(&[0]); + }); + assert!(!plain.contains("\x1b[")); + assert!(colored.contains("\x1b[")); + } + + #[test] + fn non_contiguous_qubit_ids() { + let out = render_tick(|tc| { + tc.tick().h(&[5]); + tc.tick().h(&[10]); + }); + assert!(out.contains("q5:")); + assert!(out.contains("q10:")); + } + + #[test] + fn dag_and_tick_produce_identical_output() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + dag.h(1); + + let tick_out = tc.to_ascii(); + let dag_out = dag.to_ascii(); + + let tick_lines: Vec<&str> = tick_out.lines().filter(|l| l.starts_with('q')).collect(); + let dag_lines: Vec<&str> = dag_out.lines().filter(|l| l.starts_with('q')).collect(); + assert_eq!(tick_lines, dag_lines); + } + + #[test] + fn cz_shows_two_controls() { + let out = render_tick(|tc| { + tc.tick().cz(&[(0, 1)]); + }); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0_line.contains('.')); + assert!(q1_line.contains('.')); + } + + #[test] + fn swap_shows_x_on_both() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]).h(&[1]); + tc.tick(); + let swap_gate = Gate::simple( + GateType::SWAP, + smallvec::smallvec![QubitId::from(0usize), QubitId::from(1usize)], + ); + tc.get_tick_mut(1).unwrap().add_gate(swap_gate); + let out = tc.to_ascii(); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0_line.contains("[x]")); + assert!(q1_line.contains("[x]")); + } + + #[test] + fn measurement_and_prep() { + let out = render_tick(|tc| { + tc.tick().pz(&[0]); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + }); + assert!(out.contains("(PZ|")); + assert!(out.contains("[H]")); + assert!(out.contains("|MZ)")); + } + + #[test] + fn batched_single_qubit_gates() { + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2]); + }); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + let q2_line = out.lines().find(|l| l.starts_with("q2:")).unwrap(); + assert!(q0_line.contains("[H]")); + assert!(q1_line.contains("[H]")); + assert!(q2_line.contains("[H]")); + } + + #[test] + fn unicode_uses_box_drawing() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let out = tc.to_unicode(); + assert!(out.contains('\u{2500}')); // ─ + assert!(!out.contains("---")); // no plain dashes as wire + } + + #[test] + fn unicode_control_dot() { + let mut tc = crate::TickCircuit::new(); + tc.tick().cx(&[(0, 1)]); + let out = tc.to_unicode(); + assert!(out.contains('\u{25CF}')); // ● + } + + // ====================== SVG integration ====================== + + #[test] + fn tick_svg_contains_gate_elements() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let svg = tc.to_svg(); + assert!(svg.contains("")); + assert!(svg.contains(">H")); + assert!(svg.contains("H")); + assert!(dag_svg.contains(">H")); + assert!(tick_svg.contains(">X")); + assert!(dag_svg.contains(">X")); + } + + // ====================== TikZ integration ====================== + + #[test] + fn tick_tikz_contains_commands() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let tikz = tc.to_tikz(); + assert!(tikz.contains("\\begin{tikzpicture}")); + assert!(tikz.contains("\\end{tikzpicture}")); + assert!(tikz.contains("{H}")); + assert!(tikz.contains("\\node[ctrl")); + } + + #[test] + fn dag_tikz_contains_commands() { + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + let tikz = dag.to_tikz(); + assert!(tikz.contains("\\begin{tikzpicture}")); + assert!(tikz.contains("{H}")); + } + + // ====================== DOT integration ====================== + + #[test] + fn tick_dot_contains_graph() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let dot = tc.to_dot(); + assert!(dot.contains("digraph circuit")); + assert!(dot.contains("rankdir=LR")); + assert!(dot.contains("label=\"H\"")); + assert!(dot.contains("shape=point, width=0.12")); // control + } + + #[test] + fn dag_dot_contains_graph() { + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + let dot = dag.to_dot(); + assert!(dot.contains("digraph circuit")); + assert!(dot.contains("label=\"H\"")); + } + + // ====================== Gate family integration ====================== + + #[test] + fn family_brackets_in_tick_output() { + let out = render_tick(|tc| { + tc.tick().pz(&[0]); + tc.tick().h(&[0]); + tc.tick().sx(&[0]); + tc.tick().x(&[0]); + tc.tick().mz(&[0]); + }); + assert!(out.contains("(PZ|")); // Preparation + assert!(out.contains("[H]")); // Default + assert!(out.contains("[SX]")); // Default + assert!(out.contains("[X]")); // Default + assert!(out.contains("|MZ)")); // Measurement + } + + #[test] + fn family_brackets_in_dag_output() { + let mut dag = crate::DagCircuit::new(); + dag.pz(0); + dag.h(0); + dag.sx(0); + dag.x(0); + dag.mz(0); + let out = dag.to_ascii(); + assert!(out.contains("(PZ|")); + assert!(out.contains("[H]")); + assert!(out.contains("[SX]")); + assert!(out.contains("[X]")); + assert!(out.contains("|MZ)")); + } + + #[test] + fn svg_gates_have_solid_strokes() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().sz(&[0]); + let svg = tc.to_svg(); + // All gates now use Default family with solid strokes (no dasharray). + assert!(!svg.contains("stroke-dasharray")); + } + + // ==================== render_with tests ==================== + + #[test] + fn tick_render_with_default_matches_to_ascii() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let style = pecos_core::circuit_diagram::DiagramStyle::default(); + let via_render_with = tc.render_with(&style).text(); + let via_to_ascii = tc.to_ascii(); + assert_eq!(via_render_with, via_to_ascii); + } + + #[test] + fn tick_render_with_custom_palette_svg() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let style = pecos_core::circuit_diagram::DiagramStyle::builder() + .xz_mix("#AABBCC", "#112233", "#445566") + .build(); + let svg = tc.render_with(&style).svg(); + // H is XZMix, so the custom colors should appear. + assert!(svg.contains("#AABBCC")); + assert!(svg.contains("#112233")); + } + + #[test] + fn tick_render_with_monochrome() { + let mut tc = crate::TickCircuit::new(); + tc.tick().x(&[0]); + let style = pecos_core::circuit_diagram::DiagramStyle::builder() + .color(false) + .build(); + let svg = tc.render_with(&style).svg(); + // XAxis color should NOT appear. + assert!(!svg.contains("#FFB0B0")); + } + + #[test] + fn dag_render_with_default_matches_to_ascii() { + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + let style = pecos_core::circuit_diagram::DiagramStyle::default(); + let via_render_with = dag.render_with(&style).text(); + let via_to_ascii = dag.to_ascii(); + assert_eq!(via_render_with, via_to_ascii); + } + + #[test] + fn tick_render_with_ascii_and_unicode() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let style = pecos_core::circuit_diagram::DiagramStyle::default(); + let r = tc.render_with(&style); + let ascii = r.ascii(); + let unicode = r.unicode(); + assert!(ascii.contains('-')); + assert!(unicode.contains('\u{2500}')); + } + + // ==================== Rotation display tests ==================== + + #[test] + fn rotation_displays_angle_faithfully() { + // Visualizer should display the rotation gate as-is (no simplification). + let out = render_tick(|tc| { + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!(q0.contains("RZ("), "should show RZ label: {q0}"); + assert!(q0.contains("\u{03C0}/2"), "should show angle: {q0}"); + } + + #[test] + fn non_special_angle_displays_rotation() { + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(1, 6), &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("RZ("), + "non-special angle should keep RZ label: {q0}" + ); + } + + #[test] + fn rzz_displays_as_symmetric_gate() { + let out = render_tick(|tc| { + let eighth = Angle64::QUARTER_TURN / 2u64; + tc.tick().rzz(eighth, &[(0, 1)]); + }); + assert!( + out.contains("[RZZ("), + "RZZ should show bracketed label: {out}" + ); + } + + // ==================== Sub-column splitting tests ==================== + + #[test] + fn overlapping_cx_cz_splits_into_two_columns() { + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2, 3]); + let mut t = tc.tick(); + t.cx(&[(0, 2)]); + t.cz(&[(1, 3)]); + tc.tick().mz(&[0, 1, 2, 3]); + }); + // Both gates should be visible (no overwriting). + assert!(out.contains("[X]"), "CX target should be visible: {out}"); + let dot_count = out.matches('.').count(); + assert!( + dot_count >= 3, + "should have control dots for CX and CZ: {out}" + ); + // Bracket annotation should be present. + assert!( + out.contains("t1"), + "bracket label for tick 1 should appear: {out}" + ); + } + + #[test] + fn non_overlapping_gates_stay_in_one_column() { + let out = render_tick(|tc| { + let mut t = tc.tick(); + t.cx(&[(0, 1)]); + t.cz(&[(2, 3)]); + }); + // No bracket annotation since no splitting needed. + assert!( + !out.contains("|--"), + "should not have bracket dashes: {out}" + ); + } + + #[test] + fn single_qubit_gates_never_split() { + let out = render_tick(|tc| { + tc.tick().h(&[0]).x(&[1]).z(&[2]); + }); + // No bracket annotation for single-qubit gates in the same tick. + assert!( + !out.contains("|--"), + "should not have bracket dashes: {out}" + ); + } + + #[test] + fn chain_overlap_uses_optimal_two_sublayers() { + // Chain pattern: CZ(0,2)-CX(1,4)-CX(3,6)-CZ(5,7) + // Max clique = 2, so optimal split is 2 sub-columns. + // Without sorting by min row, naive first-fit with this insertion + // order would produce 3: CZ(0,2)+CZ(5,7) in bin 1, then CX(1,4) + // conflicts bin 1 -> bin 2, then CX(3,6) conflicts both -> bin 3. + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2, 3, 4, 5, 6, 7]); + let mut t = tc.tick(); + // Deliberately add in worst-case order for naive greedy. + t.cz(&[(0, 2)]); + t.cz(&[(5, 7)]); + t.cx(&[(1, 4)]); + t.cx(&[(3, 6)]); + }); + // Count the bracket dashes in the annotation line to determine the + // number of sub-columns. With 2 sub-columns we get one bracket group + // spanning 2 columns. With 3 we would get a wider span. + // Verify only one bracket group (one "t1" label). + let bracket_lines: Vec<&str> = out.lines().filter(|l| l.contains("t1")).collect(); + assert_eq!( + bracket_lines.len(), + 1, + "should have exactly one bracket: {out}" + ); + + // Count diagram columns: with optimal splitting, the tick should use + // exactly 2 sub-columns. Count distinct column positions by looking + // at how many gate symbols appear on the first qubit wire. + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1 = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + // q0 has H in tick 0, then control dot (.) in the overlap tick. + // q1 has H in tick 0, then either crossing or control in the overlap tick. + // All 4 gates should be visible. + assert!(out.contains("[X]"), "CX target should be visible: {out}"); + // Count control dots: CZ(0,2) has 2 dots, CZ(5,7) has 2 dots, + // CX(1,4) has 1 dot, CX(3,6) has 1 dot = 6 total. + let dot_count = out.matches('.').count(); + assert!( + dot_count >= 6, + "all 6 control dots should be visible (got {dot_count}): {out}" + ); + + // Verify 2 sub-columns, not 3: count the column separators in the + // bracket line. A 2-sub-column bracket has the pattern |---t1---| + // spanning 2 column widths. A 3-sub-column bracket would be wider. + // More direct check: count how many [H] appear on q0. + let h_on_q0 = q0.matches("[H]").count(); + let dots_on_q0 = q0.matches('.').count(); + assert_eq!(h_on_q0, 1, "q0 should have one H: {q0}"); + assert_eq!(dots_on_q0, 1, "q0 should have one control dot: {q0}"); + // q1 should have H and either a crossing or a control + let h_on_q1 = q1.matches("[H]").count(); + assert_eq!(h_on_q1, 1, "q1 should have one H: {q1}"); + } + + #[test] + fn overlapping_rzz_szz_splits_correctly() { + let out = render_tick(|tc| { + let quarter = Angle64::QUARTER_TURN; + let mut t = tc.tick(); + t.rzz(quarter, &[(0, 2)]); + t.szz(&[(1, 3)]); + }); + // Both gates should be visible after splitting. + assert!(out.contains("t0"), "bracket should appear: {out}"); + // RZZ label should appear in a connector row. + assert!( + out.contains("[RZZ(") || out.contains("RZZ("), + "RZZ label should be visible: {out}" + ); + // SZZ label should appear. + assert!( + out.contains("[SZZ]") || out.contains("SZZ"), + "SZZ label should be visible: {out}" + ); + } + + #[test] + fn non_adjacent_rzz_renders_label() { + // RZZ spanning 3 rows (1 intermediate qubit). + let out = render_tick(|tc| { + let quarter = Angle64::QUARTER_TURN; + tc.tick().h(&[0, 1, 2]); + tc.tick().rzz(quarter, &[(0, 2)]); + }); + assert!(out.contains("RZZ("), "RZZ label should be visible: {out}"); + + // RZZ spanning 5 rows (3 intermediate qubits). + let out = render_tick(|tc| { + let quarter = Angle64::QUARTER_TURN; + tc.tick().h(&[0, 1, 2, 3, 4]); + tc.tick().rzz(quarter, &[(0, 4)]); + }); + assert!( + out.contains("RZZ("), + "RZZ label should be visible on wide span: {out}" + ); + } + + #[test] + fn three_mutually_overlapping_gates_use_three_sublayers() { + // Three gates that all pairwise overlap: need 3 sub-columns. + let out = render_tick(|tc| { + let mut t = tc.tick(); + t.cx(&[(0, 3)]); + t.cz(&[(1, 4)]); + t.cx(&[(2, 5)]); + }); + // All three gates pairwise overlap (ranges {0..3}, {1..4}, {2..5}), + // so max clique = 3, requiring 3 sub-columns. + assert!(out.contains("t0"), "bracket should appear: {out}"); + // All gates should be visible. + let dot_count = out.matches('.').count(); + assert!( + dot_count >= 4, + "should have control dots for all gates (got {dot_count}): {out}" + ); + } +} diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index 22a2eb848..c817420d8 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -795,6 +795,104 @@ impl DagCircuit { self.dag.layers(roots) } + /// Export as a plain ASCII circuit diagram. + /// + /// Uses [`layers`](Self::layers) to determine column layout. + /// Horizontal qubit wires with gate symbols placed at each layer column. + #[must_use] + pub fn to_ascii(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .ascii() + } + + /// ASCII circuit diagram with ANSI color codes. + /// + /// Same layout as [`to_ascii`](Self::to_ascii) with color-coded gate + /// categories: blue for single-qubit, green for two-qubit, yellow for + /// measurements, cyan for preparations. + #[must_use] + pub fn to_color_ascii(&self) -> String { + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .ansi_color(true) + .build(), + ) + .ascii() + } + + /// Unicode circuit diagram with box-drawing characters. + #[must_use] + pub fn to_unicode(&self) -> String { + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .build(), + ) + .unicode() + } + + /// Unicode circuit diagram with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .ansi_color(true) + .build(), + ) + .unicode() + } + + /// Export as an SVG circuit diagram. + #[must_use] + pub fn to_svg(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .svg() + } + + /// Export as a `TikZ` `tikzpicture`. + #[must_use] + pub fn to_tikz(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .tikz() + } + + /// Export as a Graphviz DOT digraph. + #[must_use] + pub fn to_dot(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .dot() + } + + /// Create a [`DiagramRenderer`](pecos_core::circuit_diagram::DiagramRenderer) + /// bound to a custom [`DiagramStyle`](pecos_core::circuit_diagram::DiagramStyle). + #[must_use] + pub fn render_with<'a>( + &self, + style: &'a pecos_core::circuit_diagram::DiagramStyle, + ) -> pecos_core::circuit_diagram::DiagramRenderer<'a> { + let (header, layers) = self.diagram_parts(); + let diagram = crate::circuit_display::build_diagram_or_empty(&layers, style.angle_unit); + pecos_core::circuit_diagram::DiagramRenderer::new(diagram, header, style) + } + + fn diagram_parts(&self) -> (String, Vec>) { + let layers: Vec> = self + .layers() + .map(|node_ids| node_ids.iter().filter_map(|&id| self.gate(id)).collect()) + .collect(); + let num_qubits = self.qubits().len(); + let num_layers = layers.len(); + let header = format!( + "DagCircuit: {} qubit{}, {} layer{}", + num_qubits, + if num_qubits == 1 { "" } else { "s" }, + num_layers, + if num_layers == 1 { "" } else { "s" }, + ); + (header, layers) + } + /// Returns the root gates (gates with no incoming wires). #[must_use] pub fn roots(&self) -> Vec { diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 2f1058619..92228067f 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -64,8 +64,10 @@ //! ``` mod circuit; +mod circuit_display; mod dag_circuit; pub mod operator_matrix; +pub mod pass; mod tick_circuit; pub mod tick_circuit_soa; diff --git a/crates/pecos-quantum/src/operator_matrix.rs b/crates/pecos-quantum/src/operator_matrix.rs index d8eaa358c..62b1c52cf 100644 --- a/crates/pecos-quantum/src/operator_matrix.rs +++ b/crates/pecos-quantum/src/operator_matrix.rs @@ -241,7 +241,14 @@ pub fn operators_equiv_with_tolerance(a: &Operator, b: &Operator, tol: f64) -> b } /// Checks if two matrices are equal up to a global phase factor. -fn matrices_equiv_up_to_phase(a: &DMatrix, b: &DMatrix, tol: f64) -> bool { +/// +/// Returns `true` if A = e^{i*phi} * B for some real phi, within the given tolerance. +#[must_use] +pub fn matrices_equiv_up_to_phase( + a: &DMatrix, + b: &DMatrix, + tol: f64, +) -> bool { if a.nrows() != b.nrows() || a.ncols() != b.ncols() { return false; } diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs new file mode 100644 index 000000000..8e0cbe9c8 --- /dev/null +++ b/crates/pecos-quantum/src/pass.rs @@ -0,0 +1,3116 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Circuit transformation passes. +//! +//! Passes are explicit transformations applied to circuits before display or +//! simulation. Each pass implements [`CircuitPass`] and can modify both +//! [`TickCircuit`] and [`DagCircuit`] in place. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use pecos_core::gate_type::GateType; +use pecos_core::{Angle64, Gate, GateQubits, QubitId}; + +use crate::{Attribute, DagCircuit, TickCircuit}; + +/// A transformation pass that can be applied to circuits. +pub trait CircuitPass { + /// Apply this pass to a [`TickCircuit`]. + fn apply_tick(&self, circuit: &mut TickCircuit); + /// Apply this pass to a [`DagCircuit`]. + fn apply_dag(&self, circuit: &mut DagCircuit); +} + +/// An ordered collection of passes applied sequentially. +/// +/// `PassPipeline` itself implements [`CircuitPass`], so pipelines can be +/// nested inside other pipelines. +/// +/// # Examples +/// +/// ``` +/// use pecos_quantum::pass::*; +/// +/// let pipeline = PassPipeline::new() +/// .then(AbsorbBasisGates) +/// .then(MergeAdjacentRotations) +/// .then(RemoveIdentity) +/// .then(SimplifyRotations) +/// .then(CancelInverses) +/// .then(PeepholeOptimize); +/// ``` +pub struct PassPipeline { + passes: Vec>, +} + +impl PassPipeline { + /// Create an empty pipeline. + #[must_use] + pub fn new() -> Self { + Self { passes: Vec::new() } + } + + /// Append a pass to the pipeline and return `self` for chaining. + #[must_use] + pub fn then(mut self, pass: impl CircuitPass + 'static) -> Self { + self.passes.push(Box::new(pass)); + self + } +} + +impl Default for PassPipeline { + fn default() -> Self { + Self::new() + } +} + +impl CircuitPass for PassPipeline { + fn apply_tick(&self, circuit: &mut TickCircuit) { + for pass in &self.passes { + pass.apply_tick(circuit); + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + for pass in &self.passes { + pass.apply_dag(circuit); + } + } +} + +/// Replace rotation gates at special angles with their named equivalents. +/// +/// For example, `RZ(pi/2)` becomes `SZ`, `RX(pi)` becomes `X`, and +/// `RZZ(pi)` decomposes into two independent `Z` gates. +/// +/// # Single-qubit simplifications (in-place) +/// +/// | Rotation | Angle | Result | +/// |----------|-------|--------| +/// | RZ | pi | Z | +/// | RZ | pi/2 | SZ | +/// | RZ | 3pi/2 | `SZdg` | +/// | RZ | pi/4 | T | +/// | RZ | 7pi/4 | `Tdg` | +/// | RX | pi | X | +/// | RX | pi/2 | SX | +/// | RX | 3pi/2 | `SXdg` | +/// | RY | pi | Y | +/// | RY | pi/2 | SY | +/// | RY | 3pi/2 | `SYdg` | +/// | RZZ | pi/2 | SZZ | +/// | RZZ | 3pi/2 | `SZZdg` | +/// +/// # Two-qubit decompositions +/// +/// | Rotation | Angle | Result | +/// |----------|-------|--------| +/// | RZZ | pi | Z + Z | +/// | RXX | pi | X + X | +/// | RYY | pi | Y + Y | +pub struct SimplifyRotations; + +/// Eighth-turn constant for T gate: fraction = 1 << 61. +const EIGHTH: u64 = 1 << 61; +/// Seven-eighths-turn constant for Tdg gate: fraction = 7 << 61. +const SEVEN_EIGHTHS: u64 = 7 << 61; + +/// Map a rotation gate at a special angle to the equivalent named gate. +/// +/// Returns `None` when the rotation/angle pair has no named equivalent. +fn simplify_rotation(gate_type: GateType, angle: Angle64) -> Option { + let f = angle.fraction(); + + match gate_type { + GateType::RZ => match f { + f if f == Angle64::HALF_TURN.fraction() => Some(GateType::Z), + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SZ), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SZdg), + EIGHTH => Some(GateType::T), + SEVEN_EIGHTHS => Some(GateType::Tdg), + _ => None, + }, + GateType::RX => match f { + f if f == Angle64::HALF_TURN.fraction() => Some(GateType::X), + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SX), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SXdg), + _ => None, + }, + GateType::RY => match f { + f if f == Angle64::HALF_TURN.fraction() => Some(GateType::Y), + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SY), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SYdg), + _ => None, + }, + GateType::RZZ => match f { + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SZZ), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SZZdg), + _ => None, + }, + _ => None, + } +} + +/// Check whether a two-qubit rotation at half turn should decompose into two +/// single-qubit Pauli gates. Returns the Pauli gate type if so. +fn half_turn_decomposition(gate_type: GateType, angle: Angle64) -> Option { + if angle.fraction() != Angle64::HALF_TURN.fraction() { + return None; + } + match gate_type { + GateType::RZZ => Some(GateType::Z), + GateType::RXX => Some(GateType::X), + GateType::RYY => Some(GateType::Y), + _ => None, + } +} + +/// Apply an in-place simplification to a gate. Returns `true` if the gate was +/// simplified (either renamed in place or needs decomposition handling). +fn simplify_gate_in_place(gate: &mut Gate) -> bool { + if gate.angles.len() != 1 { + return false; + } + if let Some(named) = simplify_rotation(gate.gate_type, gate.angles[0]) { + gate.gate_type = named; + gate.angles.clear(); + return true; + } + false +} + +// === Helper functions for circuit transformation passes === + +/// Returns `true` if the gate is an identity operation (I, Idle, or zero-angle rotation). +fn is_identity_gate(gate: &Gate) -> bool { + match gate.gate_type { + GateType::I | GateType::Idle => true, + gt if is_rotation(gt) => gate.angles.len() == 1 && gate.angles[0].is_zero(), + _ => false, + } +} + +/// Returns `true` if the gate type is a rotation (parameterized by a single angle). +fn is_rotation(gt: GateType) -> bool { + matches!( + gt, + GateType::RX + | GateType::RY + | GateType::RZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ + | GateType::CRZ + ) +} + +/// Returns `true` if the gate type is its own inverse. +fn is_self_inverse(gt: GateType) -> bool { + matches!( + gt, + GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::I + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SWAP + | GateType::CCX + ) +} + +/// Returns the named inverse of a gate type, if one exists. +fn named_inverse(gt: GateType) -> Option { + match gt { + GateType::SX => Some(GateType::SXdg), + GateType::SXdg => Some(GateType::SX), + GateType::SY => Some(GateType::SYdg), + GateType::SYdg => Some(GateType::SY), + GateType::SZ => Some(GateType::SZdg), + GateType::SZdg => Some(GateType::SZ), + GateType::T => Some(GateType::Tdg), + GateType::Tdg => Some(GateType::T), + GateType::SZZ => Some(GateType::SZZdg), + GateType::SZZdg => Some(GateType::SZZ), + _ => None, + } +} + +/// Returns `true` if gates `a` and `b` are inverses of each other. +/// +/// Checks (in order): +/// 1. Qubits must match exactly. +/// 2. Self-inverse identical gates (e.g., H*H, CX*CX). +/// 3. Named inverse pairs (e.g., `SX*SXdg`, `T*Tdg`). +/// 4. Same rotation type with angles summing to zero (e.g., `RZ(t)*RZ(-t)`). +fn are_inverses(a: &Gate, b: &Gate) -> bool { + if a.qubits != b.qubits { + return false; + } + // Self-inverse identical gates + if a.gate_type == b.gate_type && is_self_inverse(a.gate_type) && a.angles == b.angles { + return true; + } + // Named inverse pairs + if let Some(inv) = named_inverse(a.gate_type) + && inv == b.gate_type + && a.angles == b.angles + { + return true; + } + // Rotation angles summing to zero + if a.gate_type == b.gate_type + && is_rotation(a.gate_type) + && a.angles.len() == 1 + && b.angles.len() == 1 + && (a.angles[0] + b.angles[0]).is_zero() + { + return true; + } + false +} + +/// Check if all qubit stacks agree on the same top-of-stack position. +/// +/// Returns `Some((tick_idx, gate_idx))` if every qubit in `qubits` has +/// a non-empty stack whose top entry is the same position, `None` otherwise. +fn check_all_stacks_agree( + stacks: &HashMap>, + qubits: &[QubitId], +) -> Option<(usize, usize)> { + let mut agreed: Option<(usize, usize)> = None; + for &q in qubits { + let top = *stacks.get(&q)?.last()?; + match agreed { + None => agreed = Some(top), + Some(prev) => { + if prev != top { + return None; + } + } + } + } + agreed +} + +/// Check if the successor of `node` on every qubit in `qubits` is the same DAG node. +fn dag_common_successor(circuit: &DagCircuit, node: usize, qubits: &[QubitId]) -> Option { + let mut result: Option = None; + for &q in qubits { + let s = circuit.successor_on_qubit(node, q)?; + match result { + None => result = Some(s), + Some(prev) if prev == s => {} + _ => return None, + } + } + result +} + +/// Check if a gate conjugated by H on a specific qubit simplifies. +/// +/// Returns `Some((new_gate_type, new_qubits))` if: +/// - H on target of CX -> CZ (same qubits) +/// - H on either qubit of CZ -> CX (other qubit becomes control, H qubit becomes target) +fn peephole_conjugation(middle: &Gate, h_qubit: QubitId) -> Option<(GateType, GateQubits)> { + match middle.gate_type { + GateType::CX if middle.qubits.len() == 2 && middle.qubits[1] == h_qubit => { + // H(target) CX(c,t) H(target) = CZ(c,t) + Some((GateType::CZ, middle.qubits.clone())) + } + GateType::CZ if middle.qubits.len() == 2 && middle.qubits.contains(&h_qubit) => { + // H(q) CZ(a,b) H(q) = CX(other, q) + let other = if middle.qubits[0] == h_qubit { + middle.qubits[1] + } else { + middle.qubits[0] + }; + Some((GateType::CX, smallvec::smallvec![other, h_qubit])) + } + _ => None, + } +} + +impl CircuitPass for SimplifyRotations { + fn apply_tick(&self, circuit: &mut TickCircuit) { + for tick in circuit.ticks_mut() { + // First pass: collect two-qubit decompositions. + // We need to know which gate indices to remove and what to add. + let mut decompositions: Vec<(usize, GateType)> = Vec::new(); + + for (i, gate) in tick.gates().iter().enumerate() { + if gate.angles.len() == 1 + && let Some(pauli) = half_turn_decomposition(gate.gate_type, gate.angles[0]) + { + decompositions.push((i, pauli)); + } + } + + // Process decompositions in reverse order to keep indices valid. + for &(idx, pauli) in decompositions.iter().rev() { + let qubits = tick.gates()[idx].qubits.clone(); + // Remove the two-qubit gate, add two single-qubit gates. + tick.remove_gate(idx); + for pair in qubits.chunks(2) { + if pair.len() == 2 { + tick.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[0]])); + tick.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[1]])); + } + } + } + + // Second pass: in-place simplification of remaining gates. + for gate in tick.gates_mut() { + simplify_gate_in_place(gate); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let nodes = circuit.nodes(); + + for node in nodes { + let Some(gate) = circuit.gate(node) else { + continue; + }; + + // Check for two-qubit half-turn decomposition first. + if gate.angles.len() == 1 + && let Some(pauli) = half_turn_decomposition(gate.gate_type, gate.angles[0]) + { + let qubits = gate.qubits.clone(); + + // Collect predecessor/successor nodes *before* removal + // (remove_gate deletes edges too). + let mut pred_map = Vec::new(); + let mut succ_map = Vec::new(); + for &q in &qubits { + pred_map.push((q, circuit.predecessor_on_qubit(node, q))); + succ_map.push((q, circuit.successor_on_qubit(node, q))); + } + + // Remove the two-qubit gate (and its edges). + circuit.remove_gate(node); + + // Add two single-qubit gates and rewire. + for pair in qubits.chunks(2) { + if pair.len() < 2 { + continue; + } + let node_a = + circuit.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[0]])); + let node_b = + circuit.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[1]])); + + // Rewire predecessors -> new nodes. + for &(q, pred) in &pred_map { + if let Some(pred) = pred { + if q == pair[0] { + let _ = circuit.connect(pred, node_a, q); + } else if q == pair[1] { + let _ = circuit.connect(pred, node_b, q); + } + } + } + + // Rewire new nodes -> successors. + for &(q, succ) in &succ_map { + if let Some(succ) = succ { + if q == pair[0] { + let _ = circuit.connect(node_a, succ, q); + } else if q == pair[1] { + let _ = circuit.connect(node_b, succ, q); + } + } + } + } + continue; + } + + // In-place simplification for single-qubit and two-qubit named replacements. + if let Some(gate) = circuit.gate_mut(node) { + simplify_gate_in_place(gate); + } + } + } +} + +/// Remove identity gates (I, Idle, zero-angle rotations) from circuits. +pub struct RemoveIdentity; + +impl CircuitPass for RemoveIdentity { + fn apply_tick(&self, circuit: &mut TickCircuit) { + for tick in circuit.ticks_mut() { + let to_remove: Vec = tick + .gates() + .iter() + .enumerate() + .filter(|(_, g)| is_identity_gate(g)) + .map(|(i, _)| i) + .collect(); + for &idx in to_remove.iter().rev() { + tick.remove_gate(idx); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let nodes = circuit.nodes(); + for node in nodes { + let Some(gate) = circuit.gate(node) else { + continue; + }; + if !is_identity_gate(gate) { + continue; + } + let qubits: Vec = gate.qubits.iter().copied().collect(); + let mut rewire = Vec::new(); + for &q in &qubits { + let pred = circuit.predecessor_on_qubit(node, q); + let succ = circuit.successor_on_qubit(node, q); + rewire.push((q, pred, succ)); + } + circuit.remove_gate(node); + for (q, pred, succ) in rewire { + if let (Some(p), Some(s)) = (pred, succ) { + let _ = circuit.connect(p, s, q); + } + } + } + } +} + +/// Cancel adjacent inverse gate pairs (e.g., `H*H`, `SX*SXdg`, `RZ(t)*RZ(-t)`). +/// +/// Uses a per-qubit stack to handle nested cancellations (A B B^-1 A^-1) +/// in a single pass over tick circuits. +pub struct CancelInverses; + +impl CircuitPass for CancelInverses { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut stacks: HashMap> = HashMap::new(); + let mut to_remove: Vec<(usize, usize)> = Vec::new(); + + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + let qubits: Vec = gate.qubits.iter().copied().collect(); + + if let Some((pred_ti, pred_gi)) = check_all_stacks_agree(&stacks, &qubits) { + let pred_gate = &circuit.ticks()[pred_ti].gates()[pred_gi]; + if are_inverses(pred_gate, gate) { + for &q in &qubits { + if let Some(stack) = stacks.get_mut(&q) { + stack.pop(); + } + } + to_remove.push((pred_ti, pred_gi)); + to_remove.push((ti, gi)); + continue; + } + } + + for &q in &qubits { + stacks.entry(q).or_default().push((ti, gi)); + } + } + } + + to_remove.sort_unstable(); + to_remove.dedup(); + for &(ti, gi) in to_remove.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + for node in topo { + let Some(gate) = circuit.gate(node) else { + continue; + }; + let qubits: Vec = gate.qubits.iter().copied().collect(); + + let Some(succ) = dag_common_successor(circuit, node, &qubits) else { + continue; + }; + let Some(succ_gate) = circuit.gate(succ) else { + continue; + }; + + if !are_inverses(gate, succ_gate) { + continue; + } + + let mut rewire = Vec::new(); + for &q in &qubits { + let pred = circuit.predecessor_on_qubit(node, q); + let succ_succ = circuit.successor_on_qubit(succ, q); + rewire.push((q, pred, succ_succ)); + } + + circuit.remove_gate(node); + circuit.remove_gate(succ); + + for (q, pred, succ_succ) in rewire { + if let (Some(p), Some(s)) = (pred, succ_succ) { + let _ = circuit.connect(p, s, q); + } + } + } + } +} + +/// Merge consecutive same-axis rotations (e.g., RZ(a)*RZ(b) -> RZ(a+b)). +/// +/// Uses a per-qubit stack to handle chains of rotations. After merging, +/// the surviving gate's angle is the sum of all merged angles. +pub struct MergeAdjacentRotations; + +impl CircuitPass for MergeAdjacentRotations { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut stacks: HashMap> = HashMap::new(); + let mut angle_adjustments: HashMap<(usize, usize), Angle64> = HashMap::new(); + let mut to_remove: Vec<(usize, usize)> = Vec::new(); + + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + let qubits: Vec = gate.qubits.iter().copied().collect(); + + if is_rotation(gate.gate_type) + && gate.angles.len() == 1 + && let Some((pred_ti, pred_gi)) = check_all_stacks_agree(&stacks, &qubits) + { + let pred_gate = &circuit.ticks()[pred_ti].gates()[pred_gi]; + if pred_gate.gate_type == gate.gate_type && pred_gate.qubits == gate.qubits { + *angle_adjustments + .entry((pred_ti, pred_gi)) + .or_insert(Angle64::ZERO) += gate.angles[0]; + to_remove.push((ti, gi)); + // Don't push; predecessor stays on stack for chain merging. + continue; + } + } + + // Push to stacks (for rotation or non-rotation gates). + for &q in &qubits { + stacks.entry(q).or_default().push((ti, gi)); + } + } + } + + // Apply angle adjustments to surviving gates. + for (&(ti, gi), &delta) in &angle_adjustments { + if let Some(tick) = circuit.get_tick_mut(ti) + && let Some(gate) = tick.gates_mut().get_mut(gi) + { + gate.angles[0] += delta; + } + } + + // Remove merged gates in reverse order. + to_remove.sort_unstable(); + for &(ti, gi) in to_remove.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + for node in topo { + loop { + let Some(gate) = circuit.gate(node) else { + break; + }; + if !is_rotation(gate.gate_type) || gate.angles.len() != 1 { + break; + } + let gate_type = gate.gate_type; + let qubits: Vec = gate.qubits.iter().copied().collect(); + + let Some(succ) = dag_common_successor(circuit, node, &qubits) else { + break; + }; + let Some(succ_gate) = circuit.gate(succ) else { + break; + }; + + if succ_gate.gate_type != gate_type + || succ_gate.qubits[..] != qubits[..] + || succ_gate.angles.len() != 1 + { + break; + } + + let succ_angle = succ_gate.angles[0]; + + // Save succ-of-successor for rewiring. + let mut rewire = Vec::new(); + for &q in &qubits { + let succ_succ = circuit.successor_on_qubit(succ, q); + rewire.push((q, succ_succ)); + } + + // Merge angle and remove successor. + circuit.gate_mut(node).unwrap().angles[0] += succ_angle; + circuit.remove_gate(succ); + + for (q, succ_succ) in rewire { + if let Some(ss) = succ_succ { + let _ = circuit.connect(node, ss, q); + } + } + } + } + } +} + +/// Recognize and simplify multi-gate patterns. +/// +/// Current rules: +/// - `H(q) CX(c,q) H(q)` -> `CZ(c,q)` +/// - `H(q) CZ(a,b) H(q)` -> `CX(other, q)` +pub struct PeepholeOptimize; + +impl CircuitPass for PeepholeOptimize { + fn apply_tick(&self, circuit: &mut TickCircuit) { + // Build per-qubit timeline: Vec of (tick_idx, gate_idx) in order. + let mut timelines: HashMap> = HashMap::new(); + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + for &q in &gate.qubits { + timelines.entry(q).or_default().push((ti, gi)); + } + } + } + + let mut replacements: Vec<((usize, usize), GateType, GateQubits)> = Vec::new(); + let mut to_remove: HashSet<(usize, usize)> = HashSet::new(); + + // Scan each qubit's timeline for H - middle - H pattern. + for (q, timeline) in &timelines { + if timeline.len() < 3 { + continue; + } + let mut i = 0; + while i + 2 < timeline.len() { + let (h1_ti, h1_gi) = timeline[i]; + let (mid_ti, mid_gi) = timeline[i + 1]; + let (h2_ti, h2_gi) = timeline[i + 2]; + + // Skip if any of these gates are already consumed. + if to_remove.contains(&(h1_ti, h1_gi)) + || to_remove.contains(&(mid_ti, mid_gi)) + || to_remove.contains(&(h2_ti, h2_gi)) + { + i += 1; + continue; + } + + let h1 = &circuit.ticks()[h1_ti].gates()[h1_gi]; + let mid = &circuit.ticks()[mid_ti].gates()[mid_gi]; + let h2 = &circuit.ticks()[h2_ti].gates()[h2_gi]; + + // Both must be single-qubit H on this qubit. + if h1.gate_type != GateType::H + || h1.qubits.len() != 1 + || h2.gate_type != GateType::H + || h2.qubits.len() != 1 + { + i += 1; + continue; + } + + if let Some((new_gt, new_qubits)) = peephole_conjugation(mid, *q) { + to_remove.insert((h1_ti, h1_gi)); + to_remove.insert((h2_ti, h2_gi)); + replacements.push(((mid_ti, mid_gi), new_gt, new_qubits)); + i += 3; // skip past the consumed triple + } else { + i += 1; + } + } + } + + // Apply replacements. + for ((ti, gi), new_gt, new_qubits) in &replacements { + if let Some(tick) = circuit.get_tick_mut(*ti) + && let Some(gate) = tick.gates_mut().get_mut(*gi) + { + gate.gate_type = *new_gt; + gate.qubits.clone_from(new_qubits); + } + } + + // Remove H gates in reverse order to preserve indices. + let mut remove_list: Vec<(usize, usize)> = to_remove + .iter() + .filter(|pos| !replacements.iter().any(|(p, _, _)| p == *pos)) + .copied() + .collect(); + remove_list.sort_unstable(); + for &(ti, gi) in remove_list.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + for node in topo { + let Some(gate) = circuit.gate(node) else { + continue; + }; + // Look for two-qubit gates (CX, CZ) where one qubit has H before and after. + if !matches!(gate.gate_type, GateType::CX | GateType::CZ) || gate.qubits.len() != 2 { + continue; + } + let qubits: Vec = gate.qubits.iter().copied().collect(); + + // Check each qubit for H-conjugation. + for &q in &qubits { + let Some(pred) = circuit.predecessor_on_qubit(node, q) else { + continue; + }; + let Some(succ) = circuit.successor_on_qubit(node, q) else { + continue; + }; + let Some(pred_gate) = circuit.gate(pred) else { + continue; + }; + let Some(succ_gate) = circuit.gate(succ) else { + continue; + }; + + // Both must be single-qubit H on this qubit. + if pred_gate.gate_type != GateType::H + || pred_gate.qubits.len() != 1 + || succ_gate.gate_type != GateType::H + || succ_gate.qubits.len() != 1 + { + continue; + } + + let gate = circuit.gate(node).unwrap(); + if let Some((new_gt, new_qubits)) = peephole_conjugation(gate, q) { + // Rewire around the two H gates. + let h_pred = circuit.predecessor_on_qubit(pred, q); + let h_succ = circuit.successor_on_qubit(succ, q); + circuit.remove_gate(pred); + circuit.remove_gate(succ); + // Update the middle gate in place. + let g = circuit.gate_mut(node).unwrap(); + g.gate_type = new_gt; + g.qubits = new_qubits; + // Rewire: h_pred -> node, node -> h_succ + if let Some(hp) = h_pred { + let _ = circuit.connect(hp, node, q); + } + if let Some(hs) = h_succ { + let _ = circuit.connect(node, hs, q); + } + break; // gate changed, move to next node + } + } + } + } +} + +// === Helper functions for AbsorbBasisGates === + +/// Returns `true` if the gate is a Z-basis preparation (produces |0>). +fn is_z_prep(gt: GateType) -> bool { + matches!(gt, GateType::Prep | GateType::QAlloc) +} + +/// Returns `true` if the gate is a Z-basis measurement. +fn is_z_measure(gt: GateType) -> bool { + matches!(gt, GateType::Measure | GateType::MeasureFree) +} + +/// Returns `true` if the gate is Z-diagonal (single- or multi-qubit). +/// +/// Z-diagonal gates are diagonal in the computational basis: they map each +/// basis state to itself times a phase. Applying one when every qubit is in +/// a Z eigenstate only adds a global phase (no-op), and it does not change +/// Z-measurement statistics. +fn is_z_diagonal(gate: &Gate) -> bool { + matches!( + gate.gate_type, + GateType::Z + | GateType::SZ + | GateType::SZdg + | GateType::T + | GateType::Tdg + | GateType::RZ + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::RZZ + | GateType::CRZ + ) +} + +/// Remove Z-diagonal gates that are redundant due to adjacent Z-basis +/// preparations or measurements. +/// +/// Z-basis preparations (PZ / `QAlloc`) produce |0>, an eigenstate of every +/// Z-diagonal operator. Applying any Z-diagonal gate (Z, SZ, `SZdg`, T, +/// `Tdg`, RZ, CZ, SZZ, `SZZdg`, RZZ, CRZ) when all its qubits are still +/// in a Z eigenstate only adds a global phase -- a physical no-op. +/// Similarly, Z-diagonal gates immediately before Z-basis measurements +/// (MZ / `MeasureFree`) do not change measurement statistics and can be +/// removed. +pub struct AbsorbBasisGates; + +impl CircuitPass for AbsorbBasisGates { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut to_remove: Vec<(usize, usize)> = Vec::new(); + + // Forward scan: absorb Z-diagonal gates after Z-preps. + let mut z_eigenstate: HashSet = HashSet::new(); + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + if is_z_prep(gate.gate_type) { + for &q in &gate.qubits { + z_eigenstate.insert(q); + } + } else if is_z_diagonal(gate) + && gate.qubits.iter().all(|q| z_eigenstate.contains(q)) + { + to_remove.push((ti, gi)); + } else { + for &q in &gate.qubits { + z_eigenstate.remove(&q); + } + } + } + } + + // Backward scan: absorb Z-diagonal gates before Z-measures. + let mut before_z_measure: HashSet = HashSet::new(); + for (ti, tick) in circuit.ticks().iter().enumerate().rev() { + for (gi, gate) in tick.gates().iter().enumerate().rev() { + if is_z_measure(gate.gate_type) { + for &q in &gate.qubits { + before_z_measure.insert(q); + } + } else if is_z_diagonal(gate) + && gate.qubits.iter().all(|q| before_z_measure.contains(q)) + { + to_remove.push((ti, gi)); + } else { + for &q in &gate.qubits { + before_z_measure.remove(&q); + } + } + } + } + + // Deduplicate and remove in reverse order to preserve indices. + to_remove.sort_unstable(); + to_remove.dedup(); + for &(ti, gi) in to_remove.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + let mut to_remove: Vec = Vec::new(); + + // Forward: track qubits in Z eigenstates, absorb Z-diagonal gates. + let mut z_eigenstate: HashSet = HashSet::new(); + for &node in &topo { + let Some(gate) = circuit.gate(node) else { + continue; + }; + if is_z_prep(gate.gate_type) { + for &q in &gate.qubits { + z_eigenstate.insert(q); + } + } else if is_z_diagonal(gate) && gate.qubits.iter().all(|q| z_eigenstate.contains(q)) { + to_remove.push(node); + } else { + for &q in &gate.qubits { + z_eigenstate.remove(&q); + } + } + } + + // Backward: track qubits whose next operation is a Z-measure. + let mut before_z_measure: HashSet = HashSet::new(); + for &node in topo.iter().rev() { + let Some(gate) = circuit.gate(node) else { + continue; + }; + if is_z_measure(gate.gate_type) { + for &q in &gate.qubits { + before_z_measure.insert(q); + } + } else if is_z_diagonal(gate) + && gate.qubits.iter().all(|q| before_z_measure.contains(q)) + { + to_remove.push(node); + } else { + for &q in &gate.qubits { + before_z_measure.remove(&q); + } + } + } + + // Deduplicate and remove, rewiring around each removed node. + to_remove.sort_unstable(); + to_remove.dedup(); + for &node in &to_remove { + let Some(gate) = circuit.gate(node) else { + continue; + }; + let qubits: Vec = gate.qubits.iter().copied().collect(); + let mut rewire = Vec::new(); + for &q in &qubits { + let pred = circuit.predecessor_on_qubit(node, q); + let succ = circuit.successor_on_qubit(node, q); + rewire.push((q, pred, succ)); + } + circuit.remove_gate(node); + for (q, pred, succ) in rewire { + if let (Some(p), Some(s)) = (pred, succ) { + let _ = circuit.connect(p, s, q); + } + } + } + } +} + +/// ASAP-schedule gates to minimise tick count, then drop empty ticks. +/// +/// For each gate (processed in original tick order), the pass assigns it to +/// the earliest tick where none of its qubits are still occupied. The +/// resulting circuit has the same gate order per qubit but fewer ticks. +/// +/// This is a `TickCircuit`-only optimisation; `apply_dag` is a no-op because +/// a DAG already represents the dependency graph without fixed time slots. +pub struct CompactTicks; + +impl CircuitPass for CompactTicks { + fn apply_tick(&self, circuit: &mut TickCircuit) { + // Collect every gate together with its per-gate attributes. + let mut entries: Vec<(Gate, BTreeMap)> = Vec::new(); + for tick in circuit.ticks() { + for (gi, gate) in tick.gates().iter().enumerate() { + let attrs: BTreeMap = tick + .gate_attrs(gi) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + entries.push((gate.clone(), attrs)); + } + } + + if entries.is_empty() { + circuit.clear(); + return; + } + + // ASAP scheduling: for each gate, find the earliest tick where none + // of its qubits are busy. + // `qubit_ready[q]` = the next tick index at which qubit q is free. + let mut qubit_ready: HashMap = HashMap::new(); + let mut assignments: Vec = Vec::with_capacity(entries.len()); + let mut num_ticks: usize = 0; + + for (gate, _) in &entries { + let earliest = gate + .qubits + .iter() + .map(|q| qubit_ready.get(q).copied().unwrap_or(0)) + .max() + .unwrap_or(0); + assignments.push(earliest); + for &q in &gate.qubits { + qubit_ready.insert(q, earliest + 1); + } + if earliest + 1 > num_ticks { + num_ticks = earliest + 1; + } + } + + // Save and restore circuit-level metadata across the rebuild. + let saved_attrs: BTreeMap = circuit + .circuit_attrs() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + circuit.clear(); + circuit.reserve_ticks(num_ticks); + + for (i, (gate, attrs)) in entries.into_iter().enumerate() { + let ti = assignments[i]; + let tick = circuit.get_tick_mut(ti).unwrap(); + let gi = tick.add_gate(gate); + if !attrs.is_empty() { + tick.set_gate_attrs(gi, attrs); + } + } + + if !saved_attrs.is_empty() { + circuit.set_metas(saved_attrs); + } + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // No-op: a DAG has no fixed time slots to compact. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== simplify_rotation unit tests ==================== + + #[test] + fn simplify_rz_quarter_turn_to_sz() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::QUARTER_TURN), + Some(GateType::SZ) + ); + } + + #[test] + fn simplify_rz_half_turn_to_z() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::HALF_TURN), + Some(GateType::Z) + ); + } + + #[test] + fn simplify_rz_three_quarters_to_szdg() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SZdg) + ); + } + + #[test] + fn simplify_rz_eighth_turn_to_t() { + let eighth = Angle64::from_turn_ratio(1, 8); + assert_eq!(simplify_rotation(GateType::RZ, eighth), Some(GateType::T)); + } + + #[test] + fn simplify_rz_seven_eighths_to_tdg() { + let seven_eighths = Angle64::from_turn_ratio(7, 8); + assert_eq!( + simplify_rotation(GateType::RZ, seven_eighths), + Some(GateType::Tdg) + ); + } + + #[test] + fn simplify_rx_quarter_turn_to_sx() { + assert_eq!( + simplify_rotation(GateType::RX, Angle64::QUARTER_TURN), + Some(GateType::SX) + ); + } + + #[test] + fn simplify_rx_half_turn_to_x() { + assert_eq!( + simplify_rotation(GateType::RX, Angle64::HALF_TURN), + Some(GateType::X) + ); + } + + #[test] + fn simplify_rx_three_quarters_to_sxdg() { + assert_eq!( + simplify_rotation(GateType::RX, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SXdg) + ); + } + + #[test] + fn simplify_ry_quarter_turn_to_sy() { + assert_eq!( + simplify_rotation(GateType::RY, Angle64::QUARTER_TURN), + Some(GateType::SY) + ); + } + + #[test] + fn simplify_ry_half_turn_to_y() { + assert_eq!( + simplify_rotation(GateType::RY, Angle64::HALF_TURN), + Some(GateType::Y) + ); + } + + #[test] + fn simplify_ry_three_quarters_to_sydg() { + assert_eq!( + simplify_rotation(GateType::RY, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SYdg) + ); + } + + #[test] + fn simplify_rzz_quarter_turn_to_szz() { + assert_eq!( + simplify_rotation(GateType::RZZ, Angle64::QUARTER_TURN), + Some(GateType::SZZ) + ); + } + + #[test] + fn simplify_rzz_three_quarters_to_szzdg() { + assert_eq!( + simplify_rotation(GateType::RZZ, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SZZdg) + ); + } + + #[test] + fn simplify_non_special_angle_unchanged() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::from_turn_ratio(1, 6)), + None + ); + } + + #[test] + fn simplify_non_rotation_unchanged() { + assert_eq!(simplify_rotation(GateType::H, Angle64::QUARTER_TURN), None); + } + + // ==================== half_turn_decomposition tests ==================== + + #[test] + fn rzz_half_turn_decomposes_to_z() { + assert_eq!( + half_turn_decomposition(GateType::RZZ, Angle64::HALF_TURN), + Some(GateType::Z) + ); + } + + #[test] + fn rxx_half_turn_decomposes_to_x() { + assert_eq!( + half_turn_decomposition(GateType::RXX, Angle64::HALF_TURN), + Some(GateType::X) + ); + } + + #[test] + fn ryy_half_turn_decomposes_to_y() { + assert_eq!( + half_turn_decomposition(GateType::RYY, Angle64::HALF_TURN), + Some(GateType::Y) + ); + } + + #[test] + fn rzz_non_half_turn_no_decomposition() { + assert_eq!( + half_turn_decomposition(GateType::RZZ, Angle64::QUARTER_TURN), + None + ); + } + + // ==================== TickCircuit pass tests ==================== + + #[test] + fn tick_simplify_rz_quarter_to_sz() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::SZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rz_half_to_z() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::HALF_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::Z); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rx_quarter_to_sx() { + let mut tc = TickCircuit::new(); + tc.tick().rx(Angle64::QUARTER_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::SX); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_ry_half_to_y() { + let mut tc = TickCircuit::new(); + tc.tick().ry(Angle64::HALF_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::Y); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rzz_quarter_to_szz() { + let mut tc = TickCircuit::new(); + tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::SZZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rzz_half_to_zz() { + let mut tc = TickCircuit::new(); + tc.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); + SimplifyRotations.apply_tick(&mut tc); + let gates = tc.ticks()[0].gates(); + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::Z); + assert_eq!(gates[1].gate_type, GateType::Z); + } + + #[test] + fn tick_simplify_rxx_half_to_xx() { + let mut tc = TickCircuit::new(); + tc.tick().rxx(Angle64::HALF_TURN, &[(0, 1)]); + SimplifyRotations.apply_tick(&mut tc); + let gates = tc.ticks()[0].gates(); + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::X); + assert_eq!(gates[1].gate_type, GateType::X); + } + + #[test] + fn tick_non_special_angle_unchanged() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::from_turn_ratio(1, 6), &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles.len(), 1); + } + + #[test] + fn tick_non_rotation_unchanged() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::H); + } + + #[test] + fn tick_simplify_eighth_turn_to_t() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::T); + assert!(gate.angles.is_empty()); + } + + // ==================== DagCircuit pass tests ==================== + + #[test] + fn dag_simplify_rz_quarter_to_sz() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::SZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn dag_simplify_rz_half_to_z() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::HALF_TURN, 0); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::Z); + assert!(gate.angles.is_empty()); + } + + #[test] + fn dag_simplify_rzz_quarter_to_szz() { + let mut dag = DagCircuit::new(); + dag.rzz(Angle64::QUARTER_TURN, 0, 1); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::SZZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn dag_simplify_rzz_half_to_zz() { + let mut dag = DagCircuit::new(); + dag.rzz(Angle64::HALF_TURN, 0, 1); + SimplifyRotations.apply_dag(&mut dag); + // The old node is removed, two new Z gates are added. + let nodes = dag.nodes(); + assert_eq!(nodes.len(), 2); + for &n in &nodes { + let g = dag.gate(n).unwrap(); + assert_eq!(g.gate_type, GateType::Z); + assert!(g.angles.is_empty()); + assert_eq!(g.qubits.len(), 1); + } + } + + #[test] + fn dag_non_special_angle_unchanged() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::from_turn_ratio(1, 6), 0); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles.len(), 1); + } + + // ==================== Matrix equivalence tests (Operator level) ==================== + // + // These verify that each simplification mapping preserves the unitary + // (up to global phase) by comparing the rotation Operator against the + // named-gate Operator using dense matrix comparison. + + use crate::operator_matrix::{ + matrices_equiv_up_to_phase, operators_equiv, to_matrix_with_size, + }; + + use pecos_core::operator::{self, Operator}; + + #[test] + fn matrix_rz_half_equiv_z() { + assert!(operators_equiv( + &operator::RZ(Angle64::HALF_TURN, 0), + &operator::Z(0), + )); + } + + #[test] + fn matrix_rz_quarter_equiv_sz() { + assert!(operators_equiv( + &operator::RZ(Angle64::QUARTER_TURN, 0), + &operator::SZ(0), + )); + } + + #[test] + fn matrix_rz_three_quarters_equiv_szdg() { + assert!(operators_equiv( + &operator::RZ(Angle64::THREE_QUARTERS_TURN, 0), + &operator::SZ(0).dg(), + )); + } + + #[test] + fn matrix_rz_eighth_equiv_t() { + assert!(operators_equiv( + &operator::RZ(Angle64::from_turn_ratio(1, 8), 0), + &operator::T(0), + )); + } + + #[test] + fn matrix_rz_seven_eighths_equiv_tdg() { + assert!(operators_equiv( + &operator::RZ(Angle64::from_turn_ratio(7, 8), 0), + &operator::T(0).dg(), + )); + } + + #[test] + fn matrix_rx_half_equiv_x() { + assert!(operators_equiv( + &operator::RX(Angle64::HALF_TURN, 0), + &operator::X(0), + )); + } + + #[test] + fn matrix_rx_quarter_equiv_sx() { + assert!(operators_equiv( + &operator::RX(Angle64::QUARTER_TURN, 0), + &operator::SX(0), + )); + } + + #[test] + fn matrix_rx_three_quarters_equiv_sxdg() { + assert!(operators_equiv( + &operator::RX(Angle64::THREE_QUARTERS_TURN, 0), + &operator::SX(0).dg(), + )); + } + + #[test] + fn matrix_ry_half_equiv_y() { + assert!(operators_equiv( + &operator::RY(Angle64::HALF_TURN, 0), + &operator::Y(0), + )); + } + + #[test] + fn matrix_ry_quarter_equiv_sy() { + assert!(operators_equiv( + &operator::RY(Angle64::QUARTER_TURN, 0), + &operator::SY(0), + )); + } + + #[test] + fn matrix_ry_three_quarters_equiv_sydg() { + assert!(operators_equiv( + &operator::RY(Angle64::THREE_QUARTERS_TURN, 0), + &operator::SY(0).dg(), + )); + } + + #[test] + fn matrix_rzz_quarter_equiv_szz() { + assert!(operators_equiv( + &operator::RZZ(Angle64::QUARTER_TURN, 0, 1), + &operator::SZZ(0, 1), + )); + } + + #[test] + fn matrix_rzz_three_quarters_equiv_szzdg() { + assert!(operators_equiv( + &operator::RZZ(Angle64::THREE_QUARTERS_TURN, 0, 1), + &operator::SZZ(0, 1).dg(), + )); + } + + #[test] + fn matrix_rzz_half_equiv_z_tensor_z() { + let rzz_pi = operator::RZZ(Angle64::HALF_TURN, 0, 1); + let z_z = operator::Z(0) & operator::Z(1); + assert!(operators_equiv(&rzz_pi, &z_z)); + } + + #[test] + fn matrix_rxx_half_equiv_x_tensor_x() { + let rxx_pi = operator::RXX(Angle64::HALF_TURN, 0, 1); + let x_x = operator::X(0) & operator::X(1); + assert!(operators_equiv(&rxx_pi, &x_x)); + } + + #[test] + fn matrix_ryy_half_equiv_y_tensor_y() { + let ryy_pi = operator::RYY(Angle64::HALF_TURN, 0, 1); + let y_y = operator::Y(0) & operator::Y(1); + assert!(operators_equiv(&ryy_pi, &y_y)); + } + + // ==================== Full-circuit matrix equivalence tests ==================== + // + // Convert a TickCircuit to an Operator chain, compute its unitary, + // apply SimplifyRotations, compute the new unitary, and compare. + + /// Convert a `TickCircuit` to an `Operator` by composing gates in order. + /// + /// Each tick's gates are tensored (parallel), then ticks are composed + /// (sequential). Returns `None` for an empty circuit. + fn tick_circuit_to_operator(tc: &TickCircuit) -> Option { + let mut tick_ops: Vec = Vec::new(); + + for tick in tc.ticks() { + let gates = tick.gates(); + if gates.is_empty() { + continue; + } + let mut gate_ops: Vec = Vec::new(); + for gate in gates { + let op = gate_to_operator(gate)?; + gate_ops.push(op); + } + // Tensor all gates in this tick (they act on disjoint qubits). + let tick_op = gate_ops.into_iter().reduce(|a, b| a & b).unwrap(); + tick_ops.push(tick_op); + } + + if tick_ops.is_empty() { + return None; + } + + // Compose ticks: last tick is outermost in matrix multiplication. + // Operator::Compose applies in reverse (like matrix multiplication), + // so we reverse to get time-ordering right. + tick_ops.reverse(); + Some(tick_ops.into_iter().reduce(|a, b| a * b).unwrap()) + } + + /// Convert a single `Gate` to an `Operator`. + fn gate_to_operator(gate: &pecos_core::Gate) -> Option { + let q0 = gate.qubits.first().copied()?; + match gate.gate_type { + GateType::H => Some(operator::H(q0)), + GateType::X => Some(operator::X(q0)), + GateType::Y => Some(operator::Y(q0)), + GateType::Z => Some(operator::Z(q0)), + GateType::SX => Some(operator::SX(q0)), + GateType::SXdg => Some(operator::SX(q0).dg()), + GateType::SY => Some(operator::SY(q0)), + GateType::SYdg => Some(operator::SY(q0).dg()), + GateType::SZ => Some(operator::SZ(q0)), + GateType::SZdg => Some(operator::SZ(q0).dg()), + GateType::T => Some(operator::T(q0)), + GateType::Tdg => Some(operator::T(q0).dg()), + GateType::RX => { + let angle = *gate.angles.first()?; + Some(operator::RX(angle, q0)) + } + GateType::RY => { + let angle = *gate.angles.first()?; + Some(operator::RY(angle, q0)) + } + GateType::RZ => { + let angle = *gate.angles.first()?; + Some(operator::RZ(angle, q0)) + } + GateType::CX => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::CX(q0, q1)) + } + GateType::CY => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::CY(q0, q1)) + } + GateType::CZ => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::CZ(q0, q1)) + } + GateType::RXX => { + let q1 = gate.qubits.get(1).copied()?; + let angle = *gate.angles.first()?; + Some(operator::RXX(angle, q0, q1)) + } + GateType::RYY => { + let q1 = gate.qubits.get(1).copied()?; + let angle = *gate.angles.first()?; + Some(operator::RYY(angle, q0, q1)) + } + GateType::RZZ => { + let q1 = gate.qubits.get(1).copied()?; + let angle = *gate.angles.first()?; + Some(operator::RZZ(angle, q0, q1)) + } + GateType::SZZ => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::SZZ(q0, q1)) + } + GateType::SZZdg => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::SZZ(q0, q1).dg()) + } + GateType::I | GateType::Idle => Some(operator::I(q0)), + _ => None, + } + } + + /// Assert that two `TickCircuit`s produce the same unitary (up to global phase). + fn assert_circuits_equiv(a: &TickCircuit, b: &TickCircuit) { + let op_a = tick_circuit_to_operator(a).expect("circuit A should be non-empty"); + let op_b = tick_circuit_to_operator(b).expect("circuit B should be non-empty"); + + // Determine qubit count from both operators. + let nq_a = op_a.qubits().into_iter().max().map_or(1, |q| q + 1); + let nq_b = op_b.qubits().into_iter().max().map_or(1, |q| q + 1); + let num_qubits = nq_a.max(nq_b); + + let mat_a = to_matrix_with_size(&op_a, num_qubits); + let mat_b = to_matrix_with_size(&op_b, num_qubits); + + assert!( + matrices_equiv_up_to_phase(&mat_a, &mat_b, 1e-10), + "circuits are not unitarily equivalent (up to global phase)", + ); + } + + #[test] + fn circuit_equiv_single_rz_quarter() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_single_rz_half() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::HALF_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_single_rx_quarter() { + let mut original = TickCircuit::new(); + original.tick().rx(Angle64::QUARTER_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_single_ry_half() { + let mut original = TickCircuit::new(); + original.tick().ry(Angle64::HALF_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rzz_quarter() { + let mut original = TickCircuit::new(); + original.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rzz_half_decomposition() { + let mut original = TickCircuit::new(); + original.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rxx_half_decomposition() { + let mut original = TickCircuit::new(); + original.tick().rxx(Angle64::HALF_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_ryy_half_decomposition() { + let mut original = TickCircuit::new(); + original.tick().ryy(Angle64::HALF_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_multi_gate_mixed() { + // A circuit with multiple rotation gates, some simplifiable, some not. + let mut original = TickCircuit::new(); + original + .tick() + .rz(Angle64::HALF_TURN, &[0]) + .rx(Angle64::QUARTER_TURN, &[1]); + original.tick().cx(&[(0, 1)]); + original + .tick() + .rz(Angle64::from_turn_ratio(1, 8), &[0]) + .ry(Angle64::THREE_QUARTERS_TURN, &[1]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_mixed_with_non_special_angles() { + // Mix of simplifiable and non-simplifiable rotations. + let mut original = TickCircuit::new(); + original + .tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(1, 6), &[1]); + original.tick().h(&[0, 1]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rzz_half_in_larger_circuit() { + // RZZ decomposition embedded in a multi-tick circuit. + let mut original = TickCircuit::new(); + original.tick().h(&[0, 1]); + original.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); + original.tick().h(&[0, 1]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_all_single_qubit_simplifications() { + // One gate for every single-qubit entry in the mapping table. + let seventh_eighth = Angle64::from_turn_ratio(7, 8); + let eighth = Angle64::from_turn_ratio(1, 8); + let mut original = TickCircuit::new(); + original + .tick() + .rz(Angle64::HALF_TURN, &[0]) // -> Z + .rz(Angle64::QUARTER_TURN, &[1]) // -> SZ + .rz(Angle64::THREE_QUARTERS_TURN, &[2]) // -> SZdg + .rz(eighth, &[3]); // -> T + original + .tick() + .rz(seventh_eighth, &[0]) // -> Tdg + .rx(Angle64::HALF_TURN, &[1]) // -> X + .rx(Angle64::QUARTER_TURN, &[2]) // -> SX + .rx(Angle64::THREE_QUARTERS_TURN, &[3]); // -> SXdg + original + .tick() + .ry(Angle64::HALF_TURN, &[0]) // -> Y + .ry(Angle64::QUARTER_TURN, &[1]) // -> SY + .ry(Angle64::THREE_QUARTERS_TURN, &[2]); // -> SYdg + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + // ==================== is_identity_gate tests ==================== + + #[test] + fn identity_gate_i() { + let gate = Gate::i(&[0]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn identity_gate_idle() { + let gate = Gate::idle(1.0, vec![QubitId::from(0)]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn identity_gate_rz_zero() { + let gate = Gate::rz(Angle64::ZERO, &[0]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn identity_gate_rxx_zero() { + let gate = Gate::rxx(Angle64::ZERO, &[(0, 1)]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn not_identity_gate_rz_nonzero() { + let gate = Gate::rz(Angle64::QUARTER_TURN, &[0]); + assert!(!is_identity_gate(&gate)); + } + + #[test] + fn not_identity_gate_h() { + let gate = Gate::h(&[0]); + assert!(!is_identity_gate(&gate)); + } + + // ==================== is_self_inverse tests ==================== + + #[test] + fn self_inverse_x() { + assert!(is_self_inverse(GateType::X)); + } + + #[test] + fn self_inverse_cx() { + assert!(is_self_inverse(GateType::CX)); + } + + #[test] + fn not_self_inverse_sx() { + assert!(!is_self_inverse(GateType::SX)); + } + + // ==================== named_inverse tests ==================== + + #[test] + fn named_inverse_sx_sxdg() { + assert_eq!(named_inverse(GateType::SX), Some(GateType::SXdg)); + assert_eq!(named_inverse(GateType::SXdg), Some(GateType::SX)); + } + + #[test] + fn named_inverse_t_tdg() { + assert_eq!(named_inverse(GateType::T), Some(GateType::Tdg)); + assert_eq!(named_inverse(GateType::Tdg), Some(GateType::T)); + } + + #[test] + fn named_inverse_szz_szzdg() { + assert_eq!(named_inverse(GateType::SZZ), Some(GateType::SZZdg)); + assert_eq!(named_inverse(GateType::SZZdg), Some(GateType::SZZ)); + } + + #[test] + fn named_inverse_h_none() { + assert_eq!(named_inverse(GateType::H), None); + } + + // ==================== are_inverses tests ==================== + + #[test] + fn inverses_x_x() { + let a = Gate::x(&[0]); + let b = Gate::x(&[0]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn inverses_cx_cx() { + let a = Gate::cx(&[(0, 1)]); + let b = Gate::cx(&[(0, 1)]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn inverses_sx_sxdg() { + let a = Gate::sx(&[0]); + let b = Gate::sxdg(&[0]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn inverses_rz_neg() { + let angle = Angle64::QUARTER_TURN; + let a = Gate::rz(angle, &[0]); + let b = Gate::rz(-angle, &[0]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn not_inverses_different_qubits() { + let a = Gate::x(&[0]); + let b = Gate::x(&[1]); + assert!(!are_inverses(&a, &b)); + } + + // ==================== RemoveIdentity tick tests ==================== + + #[test] + fn tick_remove_identity_i() { + let mut tc = TickCircuit::new(); + tc.tick(); + tc.ticks_mut()[0].add_gate(Gate::i(&[0])); + RemoveIdentity.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + } + + #[test] + fn tick_remove_identity_rz_zero() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::ZERO, &[0]); + RemoveIdentity.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + } + + #[test] + fn tick_remove_identity_preserves_nonzero() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + RemoveIdentity.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::RZ); + } + + #[test] + fn tick_remove_identity_mixed() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.ticks_mut()[0].add_gate(Gate::i(&[1])); + RemoveIdentity.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + } + + // ==================== RemoveIdentity DAG tests ==================== + + #[test] + fn dag_remove_identity_i() { + let mut dag = DagCircuit::new(); + dag.add_gate(Gate::i(&[0])); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_remove_identity_rz_zero() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::ZERO, 0); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_remove_identity_preserves_h() { + let mut dag = DagCircuit::new(); + dag.h(0); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + } + + #[test] + fn dag_remove_identity_rewires() { + let mut dag = DagCircuit::new(); + dag.h(0); + dag.add_gate(Gate::i(&[0])); + dag.z(0); + let nodes_before = dag.nodes(); + assert_eq!(nodes_before.len(), 3); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); + } + + // ==================== CancelInverses tick tests ==================== + + #[test] + fn tick_cancel_h_h() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_x_x() { + let mut tc = TickCircuit::new(); + tc.tick().x(&[0]); + tc.tick().x(&[0]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_sx_sxdg() { + let mut tc = TickCircuit::new(); + tc.tick().sx(&[0]); + tc.tick(); + tc.ticks_mut()[1].add_gate(Gate::sxdg(&[0])); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_t_tdg() { + let mut tc = TickCircuit::new(); + tc.tick().t(&[0]); + tc.tick(); + tc.ticks_mut()[1].add_gate(Gate::tdg(&[0])); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_cx_cx() { + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(0, 1)]); + tc.tick().cx(&[(0, 1)]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_rz_neg() { + let angle = Angle64::QUARTER_TURN; + let mut tc = TickCircuit::new(); + tc.tick().rz(angle, &[0]); + tc.tick().rz(-angle, &[0]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_nested() { + // H T Tdg H -> all cancel + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().t(&[0]); + tc.tick(); + tc.ticks_mut()[2].add_gate(Gate::tdg(&[0])); + tc.tick().h(&[0]); + CancelInverses.apply_tick(&mut tc); + for tick in tc.ticks() { + assert!(tick.gates().is_empty()); + } + } + + #[test] + fn tick_no_cancel_with_intervening_gate() { + // H X H -> no cancellation (X on same qubit between the H gates) + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[0]); + tc.tick().h(&[0]); + CancelInverses.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[1].gates().len(), 1); + assert_eq!(tc.ticks()[2].gates().len(), 1); + } + + #[test] + fn tick_no_cancel_different_qubits() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[1]); + CancelInverses.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[1].gates().len(), 1); + } + + // ==================== CancelInverses DAG tests ==================== + + #[test] + fn dag_cancel_h_h() { + let mut dag = DagCircuit::new(); + dag.h(0).h(0); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_cancel_cx_cx() { + let mut dag = DagCircuit::new(); + dag.cx(0, 1).cx(0, 1); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_cancel_rz_neg() { + let angle = Angle64::QUARTER_TURN; + let mut dag = DagCircuit::new(); + dag.rz(angle, 0).rz(-angle, 0); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_no_cancel_with_intervening_gate() { + let mut dag = DagCircuit::new(); + dag.h(0).x(0).h(0); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 3); + } + + // ==================== MergeAdjacentRotations tick tests ==================== + + #[test] + fn tick_merge_rz_rz() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles[0], Angle64::HALF_TURN); + } + + #[test] + fn tick_merge_chain_of_three() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles[0], Angle64::THREE_QUARTERS_TURN); + } + + #[test] + fn tick_merge_to_zero() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::THREE_QUARTERS_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert!(gate.angles[0].is_zero()); + } + + #[test] + fn tick_merge_rzz() { + let mut tc = TickCircuit::new(); + tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZZ); + assert_eq!(gate.angles[0], Angle64::HALF_TURN); + } + + #[test] + fn tick_no_merge_different_types() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rx(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + assert_eq!(tc.gate_count(), 2); + } + + #[test] + fn tick_no_merge_with_intervening_gate() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().h(&[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + assert_eq!(tc.gate_count(), 3); + } + + // ==================== MergeAdjacentRotations DAG tests ==================== + + #[test] + fn dag_merge_rz_rz() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0) + .rz(Angle64::QUARTER_TURN, 0); + MergeAdjacentRotations.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles[0], Angle64::HALF_TURN); + } + + #[test] + fn dag_merge_chain_of_three() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0) + .rz(Angle64::QUARTER_TURN, 0) + .rz(Angle64::QUARTER_TURN, 0); + MergeAdjacentRotations.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.angles[0], Angle64::THREE_QUARTERS_TURN); + } + + #[test] + fn dag_no_merge_with_intervening_gate() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0) + .h(0) + .rz(Angle64::QUARTER_TURN, 0); + MergeAdjacentRotations.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 3); + } + + // ==================== New pass matrix equivalence tests ==================== + + #[test] + fn circuit_equiv_remove_identity() { + let mut original = TickCircuit::new(); + original.tick().h(&[0]); + original.ticks_mut()[0].add_gate(Gate::i(&[1])); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + RemoveIdentity.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_cancel_inverses() { + let mut original = TickCircuit::new(); + original.tick().h(&[0, 1]); + original.tick().sx(&[0]).t(&[1]); + original.tick(); + original.ticks_mut()[2].add_gate(Gate::sxdg(&[0])); + original.ticks_mut()[2].add_gate(Gate::tdg(&[1])); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + CancelInverses.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_merge_adjacent() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_merge_then_simplify() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_merge_then_remove_identity() { + let mut original = TickCircuit::new(); + original.tick().h(&[0]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().rz(Angle64::THREE_QUARTERS_TURN, &[0]); + original.tick().h(&[0]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + RemoveIdentity.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_full_pipeline() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + RemoveIdentity.apply_tick(&mut simplified); + SimplifyRotations.apply_tick(&mut simplified); + CancelInverses.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + // ==================== Pass effectiveness analysis ==================== + + /// Count total gates across all ticks. + fn count_gates(tc: &TickCircuit) -> usize { + tc.ticks().iter().map(|t| t.gates().len()).sum() + } + + /// Apply the full pipeline and return (before, after) gate counts. + fn pipeline_stats(tc: &mut TickCircuit) -> (usize, usize) { + let before = count_gates(tc); + MergeAdjacentRotations.apply_tick(tc); + RemoveIdentity.apply_tick(tc); + SimplifyRotations.apply_tick(tc); + CancelInverses.apply_tick(tc); + PeepholeOptimize.apply_tick(tc); + let after = count_gates(tc); + (before, after) + } + + #[test] + fn analysis_pass_effectiveness() { + // -- Circuit 1: Redundant basis changes (common in compiled circuits) -- + // Pattern: H-CX-H on target qubit is equivalent to CZ + let mut c1 = TickCircuit::new(); + c1.tick().h(&[1]); + c1.tick().cx(&[(0, 1)]); + c1.tick().h(&[1]); + // PeepholeOptimize: H(target) CX(c,t) H(target) -> CZ(c,t) + let (b1, a1) = pipeline_stats(&mut c1); + + // -- Circuit 2: Rotation accumulation (variational / compiled) -- + let mut c2 = TickCircuit::new(); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(1, 8), &[1]); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(1, 8), &[1]); + c2.tick().cx(&[(0, 1)]); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(3, 8), &[1]); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(3, 8), &[1]); + // Merge: RZ(pi/2)+RZ(pi/2)->RZ(pi) on q0, RZ(1/8)+RZ(1/8)->RZ(1/4) on q1 + // Simplify: RZ(pi)->Z, RZ(pi/4)->T, etc. + // After CX: same pattern again + let (b2, a2) = pipeline_stats(&mut c2); + + // -- Circuit 3: Inverse cancellation (from circuit composition) -- + let mut c3 = TickCircuit::new(); + // Subcircuit A applies some basis change + c3.tick().h(&[0, 1]); + c3.tick().sx(&[0]).t(&[1]); + c3.tick().cx(&[(0, 1)]); + // Subcircuit B undoes the basis change then does something else + c3.tick().cx(&[(0, 1)]); + c3.ticks_mut()[3].add_gate(Gate::sxdg(&[0])); + c3.ticks_mut()[3].add_gate(Gate::tdg(&[1])); + // Wait, this won't cancel because CX is between SX and SXdg on different ticks. + // Let me restructure: undo in reverse order + let mut c3 = TickCircuit::new(); + c3.tick().h(&[0, 1]); + c3.tick().t(&[0]).sx(&[1]); + c3.tick().cx(&[(0, 1)]); + c3.tick().cx(&[(0, 1)]); // CX*CX = I + c3.tick(); + c3.ticks_mut()[4].add_gate(Gate::tdg(&[0])); + c3.ticks_mut()[4].add_gate(Gate::sxdg(&[1])); + c3.tick().h(&[0, 1]); // H*H = I (but intervening gates block) + c3.tick().z(&[0]); // actual operation + let (b3, a3) = pipeline_stats(&mut c3); + + // -- Circuit 4: Zero-angle rotations (from parameterized circuits at theta=0) -- + let mut c4 = TickCircuit::new(); + c4.tick().h(&[0, 1, 2]); + c4.tick() + .rz(Angle64::ZERO, &[0]) + .rx(Angle64::ZERO, &[1]) + .ry(Angle64::ZERO, &[2]); + c4.tick().cx(&[(0, 1)]); + c4.tick().cz(&[(1, 2)]); + c4.tick().rz(Angle64::ZERO, &[0]).rz(Angle64::ZERO, &[1]); + c4.tick().h(&[0, 1, 2]); + let (b4, a4) = pipeline_stats(&mut c4); + + // -- Circuit 5: Mixed redundancies (realistic compiled output) -- + let mut c5 = TickCircuit::new(); + c5.tick().h(&[0, 1, 2, 3]); + // Rotation chain on q0 + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + // Identity rotations on q2, q3 + c5.tick().rz(Angle64::ZERO, &[2]).rx(Angle64::ZERO, &[3]); + // Two-qubit rotation merge + c5.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + c5.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + // Self-inverse pair + c5.tick().h(&[2, 3]); + c5.tick().h(&[2, 3]); + c5.tick().cx(&[(0, 1)]).cz(&[(2, 3)]); + let (b5, a5) = pipeline_stats(&mut c5); + + // -- Circuit 6: Steane-style syndrome extraction fragment -- + let mut c6 = TickCircuit::new(); + // Ancilla prep + c6.tick().h(&[4, 5, 6]); + // CNOT fan-out + c6.tick().cx(&[(4, 0)]); + c6.tick().cx(&[(4, 1)]); + c6.tick().cx(&[(5, 1)]); + c6.tick().cx(&[(5, 2)]); + c6.tick().cx(&[(6, 2)]); + c6.tick().cx(&[(6, 3)]); + // Ancilla readout + c6.tick().h(&[4, 5, 6]); + // No redundancy here -- well-optimized QEC circuit + let (b6, a6) = pipeline_stats(&mut c6); + + println!(); + println!("=== Pass Pipeline Effectiveness ==="); + println!( + "Pipeline: MergeAdjacentRotations -> RemoveIdentity -> SimplifyRotations -> CancelInverses -> PeepholeOptimize" + ); + println!(); + println!( + "{:<45} {:>6} {:>6} {:>7}", + "Circuit", "Before", "After", "Saved" + ); + println!("{:-<45} {:->6} {:->6} {:->7}", "", "", "", ""); + for (name, b, a) in [ + ("1. Basis change (H-CX-H)", b1, a1), + ("2. Rotation accumulation", b2, a2), + ("3. Inverse cancellation (composed)", b3, a3), + ("4. Zero-angle rotations (theta=0)", b4, a4), + ("5. Mixed redundancies (compiled)", b5, a5), + ("6. QEC syndrome extraction", b6, a6), + ] { + let saved = b.saturating_sub(a); + let pct = if b > 0 { + saved as f64 / b as f64 * 100.0 + } else { + 0.0 + }; + println!("{name:<45} {b:>6} {a:>6} {saved:>4} ({pct:.0}%)"); + } + println!(); + } + + // ==================== peephole_conjugation helper tests ==================== + + #[test] + fn peephole_h_cx_target_to_cz() { + // H on CX target -> CZ + let gate = Gate::cx(&[(0, 1)]); + let result = peephole_conjugation(&gate, QubitId::from(1)); + assert!(result.is_some()); + let (gt, qubits) = result.unwrap(); + assert_eq!(gt, GateType::CZ); + assert_eq!(qubits[0], QubitId::from(0)); + assert_eq!(qubits[1], QubitId::from(1)); + } + + #[test] + fn peephole_h_cz_to_cx() { + // H on CZ qubit -> CX + let gate = Gate::cz(&[(0, 1)]); + let result = peephole_conjugation(&gate, QubitId::from(0)); + assert!(result.is_some()); + let (gt, qubits) = result.unwrap(); + assert_eq!(gt, GateType::CX); + assert_eq!(qubits[0], QubitId::from(1)); // other qubit becomes control + assert_eq!(qubits[1], QubitId::from(0)); // H qubit becomes target + } + + #[test] + fn peephole_h_cx_control_none() { + // H on CX control -> None (not a valid simplification) + let gate = Gate::cx(&[(0, 1)]); + assert!(peephole_conjugation(&gate, QubitId::from(0)).is_none()); + } + + #[test] + fn peephole_non_matching_none() { + // H with non-CX/CZ gate -> None + let gate = Gate::h(&[0]); + assert!(peephole_conjugation(&gate, QubitId::from(0)).is_none()); + } + + // ==================== PeepholeOptimize TickCircuit tests ==================== + + #[test] + fn peephole_tick_h_cx_h_to_cz() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + PeepholeOptimize.apply_tick(&mut tc); + // Should have 1 gate total: CZ(0,1) + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::CZ); + assert_eq!(gates[0].qubits[0], QubitId::from(0)); + assert_eq!(gates[0].qubits[1], QubitId::from(1)); + } + + #[test] + fn peephole_tick_h_cz_h_to_cx() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cz(&[(0, 1)]); + tc.tick().h(&[0]); + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::CX); + assert_eq!(gates[0].qubits[0], QubitId::from(1)); // other is control + assert_eq!(gates[0].qubits[1], QubitId::from(0)); // H qubit is target + } + + #[test] + fn peephole_tick_no_match_wrong_qubit() { + // H on CX control qubit does not trigger + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[0]); + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 3); // unchanged + } + + #[test] + fn peephole_tick_preserves_other_gates() { + // Surrounding gates are untouched + let mut tc = TickCircuit::new(); + tc.tick().x(&[2]); // unrelated gate + tc.tick().h(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + tc.tick().z(&[2]); // unrelated gate + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 3); // X, CZ, Z + assert_eq!(gates[0].gate_type, GateType::X); + assert_eq!(gates[1].gate_type, GateType::CZ); + assert_eq!(gates[2].gate_type, GateType::Z); + } + + #[test] + fn peephole_tick_multiple_patterns() { + // Two independent H-CX-H patterns + let mut tc = TickCircuit::new(); + tc.tick().h(&[1]).h(&[3]); + tc.tick().cx(&[(0, 1)]).cx(&[(2, 3)]); + tc.tick().h(&[1]).h(&[3]); + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|g| g.gate_type == GateType::CZ)); + } + + // ==================== PeepholeOptimize DagCircuit tests ==================== + + #[test] + fn peephole_dag_h_cx_h_to_cz() { + let mut dag = DagCircuit::new(); + dag.h(1).cx(0, 1).h(1); + PeepholeOptimize.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.gate_type, GateType::CZ); + } + + #[test] + fn peephole_dag_h_cz_h_to_cx() { + let mut dag = DagCircuit::new(); + dag.h(0).cz(0, 1).h(0); + PeepholeOptimize.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.gate_type, GateType::CX); + assert_eq!(gate.qubits[0], QubitId::from(1)); // other is control + assert_eq!(gate.qubits[1], QubitId::from(0)); // H qubit is target + } + + #[test] + fn peephole_dag_no_match() { + // H on CX control qubit does not trigger + let mut dag = DagCircuit::new(); + dag.h(0).cx(0, 1).h(0); + PeepholeOptimize.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 3); // unchanged + } + + // ==================== Peephole matrix equivalence tests ==================== + + #[test] + fn peephole_preserves_unitary_h_cx_h() { + // H(1) CX(0,1) H(1) should equal CZ(0,1) + let mut original = TickCircuit::new(); + original.tick().h(&[1]); + original.tick().cx(&[(0, 1)]); + original.tick().h(&[1]); + let mut optimized = original.clone(); + PeepholeOptimize.apply_tick(&mut optimized); + assert_circuits_equiv(&original, &optimized); + } + + #[test] + fn peephole_preserves_unitary_h_cz_h() { + // H(0) CZ(0,1) H(0) should equal CX(1,0) + let mut original = TickCircuit::new(); + original.tick().h(&[0]); + original.tick().cz(&[(0, 1)]); + original.tick().h(&[0]); + let mut optimized = original.clone(); + PeepholeOptimize.apply_tick(&mut optimized); + assert_circuits_equiv(&original, &optimized); + } + + #[test] + fn peephole_pipeline_with_peephole() { + // Full pipeline on a circuit combining rotation merging and peephole. + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().cx(&[(0, 1)]); + original.tick().h(&[1]); + let mut optimized = original.clone(); + MergeAdjacentRotations.apply_tick(&mut optimized); + RemoveIdentity.apply_tick(&mut optimized); + SimplifyRotations.apply_tick(&mut optimized); + CancelInverses.apply_tick(&mut optimized); + PeepholeOptimize.apply_tick(&mut optimized); + assert_circuits_equiv(&original, &optimized); + } + + // ==================== AbsorbBasisGates tick tests ==================== + + #[test] + fn tick_absorb_z_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().z(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ stays + assert_eq!(tc.ticks()[1].len(), 0); // Z removed + } + + #[test] + fn tick_absorb_rz_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().rz(Angle64::from_turn_ratio(3, 7), &[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 0); + } + + #[test] + fn tick_absorb_chain_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().t(&[0]); + tc.tick().sz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 0); + assert_eq!(tc.ticks()[2].len(), 0); + } + + #[test] + fn tick_no_absorb_x_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().x(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 1); // X stays + } + + #[test] + fn tick_absorb_before_measure() { + let mut tc = TickCircuit::new(); + tc.tick().sz(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 0); // SZ removed + assert_eq!(tc.ticks()[1].len(), 1); // MZ stays + } + + #[test] + fn tick_absorb_chain_before_measure() { + let mut tc = TickCircuit::new(); + tc.tick().t(&[0]); + tc.tick().sz(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 0); + assert_eq!(tc.ticks()[1].len(), 0); + assert_eq!(tc.ticks()[2].len(), 1); + } + + #[test] + fn tick_no_absorb_h_before_measure() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // H stays + assert_eq!(tc.ticks()[1].len(), 1); + } + + #[test] + fn tick_absorb_both_ends() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().t(&[0]); // absorbed by prep + tc.tick().x(&[0]); // breaks eigenstate + tc.tick().sz(&[0]); // absorbed by measure + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ + assert_eq!(tc.ticks()[1].len(), 0); // T removed + assert_eq!(tc.ticks()[2].len(), 1); // X stays + assert_eq!(tc.ticks()[3].len(), 0); // SZ removed + assert_eq!(tc.ticks()[4].len(), 1); // MZ + } + + #[test] + fn tick_z_diagonal_between_non_z_preserved() { + // PZ -> X -> T -> MZ + // Forward: T not absorbed (X breaks eigenstate) + // Backward: T absorbed (before MZ) + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().x(&[0]); + tc.tick().t(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ + assert_eq!(tc.ticks()[1].len(), 1); // X stays + assert_eq!(tc.ticks()[2].len(), 0); // T removed (before MZ) + assert_eq!(tc.ticks()[3].len(), 1); // MZ + } + + // ==================== AbsorbBasisGates DAG tests ==================== + + #[test] + fn dag_absorb_z_after_prep() { + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.z(0); + dag.h(0); // non-Z-diagonal anchor + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); // PZ + H remain + let topo = dag.topological_order(); + assert_eq!(dag.gate(topo[0]).unwrap().gate_type, GateType::Prep); + assert_eq!(dag.gate(topo[1]).unwrap().gate_type, GateType::H); + } + + #[test] + fn dag_absorb_before_measure() { + let mut dag = DagCircuit::new(); + dag.h(0); // non-Z-diagonal anchor + dag.sz(0); + dag.mz(0); + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); // H + MZ remain + let topo = dag.topological_order(); + assert_eq!(dag.gate(topo[0]).unwrap().gate_type, GateType::H); + assert_eq!(dag.gate(topo[1]).unwrap().gate_type, GateType::Measure); + } + + // ==================== AbsorbBasisGates multi-qubit tests ==================== + + #[test] + fn tick_absorb_cz_after_two_preps() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().cz(&[(0, 1)]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ(0,1) stays + assert_eq!(tc.ticks()[1].len(), 0); // CZ removed + } + + #[test] + fn tick_no_absorb_cz_after_one_prep() { + // Only qubit 0 is prepped; qubit 1 is not in Z eigenstate. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().cz(&[(0, 1)]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ stays + assert_eq!(tc.ticks()[1].len(), 1); // CZ stays + } + + #[test] + fn tick_absorb_cz_before_two_measures() { + let mut tc = TickCircuit::new(); + tc.tick().cz(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 0); // CZ removed + assert_eq!(tc.ticks()[1].len(), 1); // MZ(0,1) stays + } + + #[test] + fn tick_no_absorb_cz_before_one_measure() { + // Only qubit 0 is measured; qubit 1 continues. + let mut tc = TickCircuit::new(); + tc.tick().cz(&[(0, 1)]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // CZ stays + assert_eq!(tc.ticks()[1].len(), 1); + } + + #[test] + fn tick_absorb_szz_after_two_preps() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().szz(&[(0, 1)]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[1].len(), 0); // SZZ removed + } + + #[test] + fn dag_absorb_cz_after_two_preps() { + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.pz(1); + dag.cz(0, 1); + dag.h(0); // anchor + dag.h(1); // anchor + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 4); // 2 PZ + 2 H, CZ removed + } + + #[test] + fn dag_absorb_cz_before_two_measures() { + let mut dag = DagCircuit::new(); + dag.h(0); // anchor + dag.h(1); // anchor + dag.cz(0, 1); + dag.mz(0); + dag.mz(1); + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 4); // 2 H + 2 MZ, CZ removed + } + + // ==================== PassPipeline tests ==================== + + #[test] + fn pipeline_empty_is_noop() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[0]); + let pipeline = PassPipeline::new(); + pipeline.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 1); + } + + #[test] + fn pipeline_applies_passes_in_order() { + // RZ(pi/4) RZ(pi/4) -> merge to RZ(pi/2) -> simplify to SZ + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + let pipeline = PassPipeline::new() + .then(MergeAdjacentRotations) + .then(SimplifyRotations); + pipeline.apply_tick(&mut tc); + assert_eq!(count_gates(&tc), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::SZ); + } + + #[test] + fn pipeline_full_tick() { + // PZ -> T -> RZ(q) -> RZ(q) -> H -> H -> MZ + // AbsorbBasisGates removes T (after prep), merging combines RZs, + // CancelInverses removes H-H pair. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().t(&[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(MergeAdjacentRotations) + .then(RemoveIdentity) + .then(SimplifyRotations) + .then(CancelInverses); + pipeline.apply_tick(&mut tc); + // PZ stays, T and both RZs absorbed (after PZ), H+H cancelled, MZ stays + assert_eq!(count_gates(&tc), 2); // PZ + MZ + } + + #[test] + fn pipeline_full_dag() { + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.z(0); // absorbed after prep + dag.h(0); + dag.h(0); // cancel with previous H + dag.mz(0); + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(CancelInverses); + pipeline.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); // PZ + MZ + } + + #[test] + fn pipeline_default_is_empty() { + let pipeline = PassPipeline::default(); + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + pipeline.apply_tick(&mut tc); + assert_eq!(count_gates(&tc), 1); + } + + // ==================== CompactTicks tests ==================== + + #[test] + fn compact_independent_gates_merge_into_one_tick() { + // H(0) and X(1) are on different qubits -- can be parallel. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[1]); + assert_eq!(tc.num_ticks(), 2); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 1); + assert_eq!(tc.ticks()[0].len(), 2); + } + + #[test] + fn compact_dependent_gates_stay_sequential() { + // H(0) then X(0) -- same qubit, must stay in order. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[0]); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 2); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[1].gates()[0].gate_type, GateType::X); + } + + #[test] + fn compact_removes_empty_ticks() { + // After CancelInverses there may be empty ticks. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); // will be cancelled + tc.tick().x(&[0]); + CancelInverses.apply_tick(&mut tc); + // Now ticks 0 and 1 are empty, tick 2 has X. + assert_eq!(tc.num_ticks(), 3); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::X); + } + + #[test] + fn compact_empty_circuit() { + let mut tc = TickCircuit::new(); + tc.tick(); // empty tick + tc.tick(); // another empty tick + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 0); + } + + #[test] + fn compact_already_optimal() { + // All gates on different qubits in one tick -- already optimal. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).x(&[1]).z(&[2]); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 1); + assert_eq!(tc.ticks()[0].len(), 3); + } + + #[test] + fn compact_diamond_pattern() { + // PZ(0,1) -> H(0), X(1) -> CX(0,1) -> MZ(0,1) + // Spread across 4 ticks but H and X can share a tick. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().h(&[0]); + tc.tick().x(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + assert_eq!(tc.num_ticks(), 5); + CompactTicks.apply_tick(&mut tc); + // PZ(0,1) | H(0)+X(1) | CX(0,1) | MZ(0,1) = 4 ticks + assert_eq!(tc.num_ticks(), 4); + assert_eq!(tc.ticks()[1].len(), 2); // H and X merged + } + + #[test] + fn compact_preserves_gate_order_per_qubit() { + // Ensure per-qubit ordering is maintained. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().t(&[0]); + tc.tick().sz(&[0]); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 3); // all same qubit, no compaction + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[1].gates()[0].gate_type, GateType::T); + assert_eq!(tc.ticks()[2].gates()[0].gate_type, GateType::SZ); + } + + #[test] + fn compact_in_pipeline() { + // Full pipeline: absorb + cancel + compact. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().t(&[0]); // absorbed after prep + tc.tick().h(&[1]); + tc.tick().x(&[0]); + tc.tick().h(&[1]); // H-H cancel + tc.tick().mz(&[0, 1]); + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(CancelInverses) + .then(CompactTicks); + pipeline.apply_tick(&mut tc); + // After absorb+cancel: PZ(0,1), X(0), MZ(0,1) + // X(0) can't merge with PZ (qubit 0 busy) or MZ (qubit 0 busy). + assert_eq!(tc.num_ticks(), 3); + assert_eq!(count_gates(&tc), 3); + } +} diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 0e655ace7..b409a4ba5 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -195,6 +195,11 @@ impl Tick { &self.gates } + /// Get mutable access to the gates in this tick. + pub fn gates_mut(&mut self) -> &mut [Gate] { + &mut self.gates + } + /// Add a gate to this tick. pub fn add_gate(&mut self, gate: Gate) -> usize { let idx = self.gates.len(); @@ -543,6 +548,109 @@ impl TickCircuit { &self.ticks } + /// Get mutable access to all ticks. + pub fn ticks_mut(&mut self) -> &mut [Tick] { + &mut self.ticks + } + + /// Export as a plain ASCII circuit diagram. + /// + /// Produces horizontal qubit-wire lines with gate symbols placed at each + /// tick column. Two-qubit gates show `.`/`[X]` with `|` connectors. + #[must_use] + pub fn to_ascii(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .ascii() + } + + /// ASCII circuit diagram with ANSI color codes. + /// + /// Same layout as [`to_ascii`](Self::to_ascii) with color-coded gate + /// categories: blue for single-qubit, green for two-qubit, yellow for + /// measurements, cyan for preparations. + #[must_use] + pub fn to_color_ascii(&self) -> String { + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .ansi_color(true) + .build(), + ) + .ascii() + } + + /// Unicode circuit diagram with box-drawing characters. + #[must_use] + pub fn to_unicode(&self) -> String { + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .build(), + ) + .unicode() + } + + /// Unicode circuit diagram with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .ansi_color(true) + .build(), + ) + .unicode() + } + + /// Export as an SVG circuit diagram. + #[must_use] + pub fn to_svg(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .svg() + } + + /// Export as a `TikZ` `tikzpicture`. + #[must_use] + pub fn to_tikz(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .tikz() + } + + /// Export as a Graphviz DOT digraph. + #[must_use] + pub fn to_dot(&self) -> String { + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .dot() + } + + /// Create a [`DiagramRenderer`](pecos_core::circuit_diagram::DiagramRenderer) + /// bound to a custom [`DiagramStyle`](pecos_core::circuit_diagram::DiagramStyle). + #[must_use] + pub fn render_with<'a>( + &self, + style: &'a pecos_core::circuit_diagram::DiagramStyle, + ) -> pecos_core::circuit_diagram::DiagramRenderer<'a> { + let (header, layers) = self.diagram_parts(); + let diagram = crate::circuit_display::build_diagram_or_empty(&layers, style.angle_unit); + pecos_core::circuit_diagram::DiagramRenderer::new(diagram, header, style) + } + + fn diagram_parts(&self) -> (String, Vec>) { + let layers: Vec> = self + .ticks + .iter() + .map(|t| t.gates().iter().collect()) + .collect(); + let num_qubits = self.all_qubits().len(); + let header = format!( + "TickCircuit: {} qubit{}, {} tick{}", + num_qubits, + if num_qubits == 1 { "" } else { "s" }, + self.ticks.len(), + if self.ticks.len() == 1 { "" } else { "s" }, + ); + (header, layers) + } + /// Get the next tick index that will be allocated. #[must_use] pub fn next_tick_index(&self) -> usize { @@ -1148,6 +1256,16 @@ impl<'a> TickHandle<'a> { self.add_gate(Gate::szdg(qubits)) } + /// Apply F gate(s) to one or more qubits. + pub fn f(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { + self.add_gate(Gate::f(qubits)) + } + + /// Apply F-dagger gate(s) to one or more qubits. + pub fn fdg(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { + self.add_gate(Gate::fdg(qubits)) + } + /// Apply T gate(s) to one or more qubits. pub fn t(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { self.add_gate(Gate::t(qubits)) @@ -1319,6 +1437,54 @@ impl<'a> TickHandle<'a> { self.add_gate(Gate::szzdg(pairs)) } + /// Apply SXX gate(s) (sqrt-XX) to one or more qubit pairs. + pub fn sxx( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::sxx(pairs)) + } + + /// Apply SXX-dagger gate(s) to one or more qubit pairs. + pub fn sxxdg( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::sxxdg(pairs)) + } + + /// Apply SYY gate(s) (sqrt-YY) to one or more qubit pairs. + pub fn syy( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::syy(pairs)) + } + + /// Apply SYY-dagger gate(s) to one or more qubit pairs. + pub fn syydg( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::syydg(pairs)) + } + + /// Apply SWAP gate(s) to one or more qubit pairs. + pub fn swap( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::swap(pairs)) + } + + /// Apply CH (controlled-Hadamard) gate(s) to one or more qubit pairs. + pub fn ch( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::ch(pairs)) + } + /// Apply RXX rotation(s) to one or more qubit pairs. /// /// # Examples diff --git a/crates/pecos-quest/src/quantum_engine.rs b/crates/pecos-quest/src/quantum_engine.rs index a1e4d9938..ccdcd5ffc 100644 --- a/crates/pecos-quest/src/quantum_engine.rs +++ b/crates/pecos-quest/src/quantum_engine.rs @@ -112,6 +112,54 @@ impl Engine for QuestStateVecEngine { GateType::SZZdg => { self.simulator.szzdg(&cmd.qubits); } + GateType::F => { + self.simulator.f(&cmd.qubits); + } + GateType::Fdg => { + self.simulator.fdg(&cmd.qubits); + } + GateType::SY => { + self.simulator.sy(&cmd.qubits); + } + GateType::SYdg => { + self.simulator.sydg(&cmd.qubits); + } + GateType::SXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.sxx(&cmd.qubits); + } + GateType::SXXdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXXdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.sxxdg(&cmd.qubits); + } + GateType::SYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.syy(&cmd.qubits); + } + GateType::SYYdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYYdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.syydg(&cmd.qubits); + } GateType::SWAP => { self.simulator.swap(&cmd.qubits); } @@ -202,11 +250,21 @@ impl Engine for QuestStateVecEngine { .u(cmd.angles[0], cmd.angles[1], cmd.angles[2], &cmd.qubits); } } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { - return Err(PecosError::Processing(format!( - "Gate type {:?} is not yet supported by QuestStateVecEngine", - cmd.gate_type - ))); + GateType::RXX => { + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RXX gate requires at least one angle".to_string(), + )); + } + self.simulator.rxx(cmd.angles[0], &cmd.qubits); + } + GateType::RYY => { + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RYY gate requires at least one angle".to_string(), + )); + } + self.simulator.ryy(cmd.angles[0], &cmd.qubits); } } } @@ -333,6 +391,54 @@ impl Engine for QuestDensityMatrixEngine { GateType::SZZdg => { self.simulator.szzdg(&cmd.qubits); } + GateType::F => { + self.simulator.f(&cmd.qubits); + } + GateType::Fdg => { + self.simulator.fdg(&cmd.qubits); + } + GateType::SY => { + self.simulator.sy(&cmd.qubits); + } + GateType::SYdg => { + self.simulator.sydg(&cmd.qubits); + } + GateType::SXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.sxx(&cmd.qubits); + } + GateType::SXXdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXXdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.sxxdg(&cmd.qubits); + } + GateType::SYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.syy(&cmd.qubits); + } + GateType::SYYdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYYdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + self.simulator.syydg(&cmd.qubits); + } GateType::SWAP => { self.simulator.swap(&cmd.qubits); } @@ -423,11 +529,21 @@ impl Engine for QuestDensityMatrixEngine { .u(cmd.angles[0], cmd.angles[1], cmd.angles[2], &cmd.qubits); } } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { - return Err(PecosError::Processing(format!( - "Gate type {:?} is not yet supported by QuestDensityMatrixEngine", - cmd.gate_type - ))); + GateType::RXX => { + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RXX gate requires at least one angle".to_string(), + )); + } + self.simulator.rxx(cmd.angles[0], &cmd.qubits); + } + GateType::RYY => { + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RYY gate requires at least one angle".to_string(), + )); + } + self.simulator.ryy(cmd.angles[0], &cmd.qubits); } } } @@ -1157,6 +1273,129 @@ impl Engine for QuestCudaStateVecEngine { } } } + GateType::F => { + // F = SX · SZ = RX(pi/2) · RZ(pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_z)( + self.qureg_handle, + qubit, + std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_rotation_x)( + self.qureg_handle, + qubit, + std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::Fdg => { + // Fdg = F† = SZ† · SX† = RZ(-pi/2) · RX(-pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_x)( + self.qureg_handle, + qubit, + -std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_rotation_z)( + self.qureg_handle, + qubit, + -std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::SY => { + // SY = RY(pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_y)( + self.qureg_handle, + qubit, + std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::SYdg => { + // SYdg = RY(-pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_y)( + self.qureg_handle, + qubit, + -std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::SXX => { + // SXX = RXX(pi/2): decompose as H⊗H · SZZ · H⊗H + // Or equivalently: CNOT(a,b) · RX(pi/2, b) · CNOT(a,b) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)( + self.qureg_handle, + b, + std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::SXXdg => { + // SXXdg = RXX(-pi/2) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)( + self.qureg_handle, + b, + -std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::SYY => { + // SYY = RYY(pi/2): decompose as CNOT(a,b) · RY(pi/2, b) · CNOT(a,b) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)( + self.qureg_handle, + b, + std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::SYYdg => { + // SYYdg = RYY(-pi/2) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)( + self.qureg_handle, + b, + -std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } GateType::I | GateType::Idle | GateType::Custom @@ -1165,11 +1404,39 @@ impl Engine for QuestCudaStateVecEngine { | GateType::QFree => { // No operation needed (Custom is a placeholder whose actual gate name is in metadata) } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { - return Err(PecosError::Processing(format!( - "Gate type {:?} is not yet supported by QuestCudaStateVecEngine", - cmd.gate_type - ))); + GateType::RXX => { + // RXX(theta) = CNOT(a,b) · RX(theta, b) · CNOT(a,b) + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RXX gate requires at least one angle".to_string(), + )); + } + let theta = cmd.angles[0].to_radians(); + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)(self.qureg_handle, b, theta); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::RYY => { + // RYY(theta) = CNOT(a,b) · RY(theta, b) · CNOT(a,b) + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RYY gate requires at least one angle".to_string(), + )); + } + let theta = cmd.angles[0].to_radians(); + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)(self.qureg_handle, b, theta); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } } } } diff --git a/examples/svg_demo.rs b/examples/svg_demo.rs new file mode 100644 index 000000000..42824d724 --- /dev/null +++ b/examples/svg_demo.rs @@ -0,0 +1,60 @@ +// Standalone binary to generate demo SVGs. +// Run from the PECOS workspace root: +// cargo run --example svg_demo + +use pecos_quantum::{DagCircuit, TickCircuit}; +use pecos_core::Angle64; +use std::fs; + +fn main() { + let dir = "/tmp/pecos_svg_demo"; + + // --- Circuit 1: All gate families in a TickCircuit --- + let mut tc = TickCircuit::new(); + // tick 0: prep + tc.tick().pz(&[0, 1, 2, 3]); + // tick 1: Pauli family + tc.tick().x(&[0]).y(&[1]).z(&[2]).i(&[3]); + // tick 2: S-like family + tc.tick().sx(&[0]).sy(&[1]).sz(&[2]); + // tick 3: H-like family + tc.tick().h(&[0, 1, 2, 3]); + // tick 4: Default (T gate) + tc.tick().t(&[0]).tdg(&[1]).rz(Angle64::QUARTER_TURN, &[2]); + // tick 5: multi-qubit + tc.tick().cx(&[(0, 1)]).cz(&[(2, 3)]); + // tick 6: measure + tc.tick().mz(&[0, 1, 2, 3]); + + let svg1 = tc.to_svg(); + fs::write(format!("{dir}/families.svg"), &svg1).unwrap(); + + // --- Circuit 2: Teleportation-style circuit --- + let mut tc2 = TickCircuit::new(); + tc2.tick().pz(&[0, 1, 2]); + tc2.tick().h(&[1]); + tc2.tick().cx(&[(1, 2)]); + tc2.tick().cx(&[(0, 1)]); + tc2.tick().h(&[0]); + tc2.tick().mz(&[0, 1]); + + let svg2 = tc2.to_svg(); + fs::write(format!("{dir}/teleport.svg"), &svg2).unwrap(); + + // --- Circuit 3: DagCircuit with mixed gates --- + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.pz(1); + dag.h(0); + dag.sx(1); + dag.cx(0, 1); + dag.sz(0); + dag.h(1); + dag.mz(0); + dag.mz(1); + + let svg3 = dag.to_svg(); + fs::write(format!("{dir}/dag_mixed.svg"), &svg3).unwrap(); + + println!("SVGs written to {dir}/"); +} diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 6e92e6ec9..320216cfd 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -370,6 +370,100 @@ impl PyGateType { Self { inner: GateType::U } } + #[classattr] + #[pyo3(name = "F")] + fn f() -> Self { + Self { inner: GateType::F } + } + + #[classattr] + #[pyo3(name = "Fdg")] + fn fdg() -> Self { + Self { + inner: GateType::Fdg, + } + } + + #[classattr] + #[pyo3(name = "SXX")] + fn sxx() -> Self { + Self { + inner: GateType::SXX, + } + } + + #[classattr] + #[pyo3(name = "SXXdg")] + fn sxxdg() -> Self { + Self { + inner: GateType::SXXdg, + } + } + + #[classattr] + #[pyo3(name = "SYY")] + fn syy() -> Self { + Self { + inner: GateType::SYY, + } + } + + #[classattr] + #[pyo3(name = "SYYdg")] + fn syydg() -> Self { + Self { + inner: GateType::SYYdg, + } + } + + #[classattr] + #[pyo3(name = "SZZ")] + fn szz() -> Self { + Self { + inner: GateType::SZZ, + } + } + + #[classattr] + #[pyo3(name = "SZZdg")] + fn szzdg() -> Self { + Self { + inner: GateType::SZZdg, + } + } + + #[classattr] + #[pyo3(name = "SWAP")] + fn swap() -> Self { + Self { + inner: GateType::SWAP, + } + } + + #[classattr] + #[pyo3(name = "CH")] + fn ch() -> Self { + Self { + inner: GateType::CH, + } + } + + #[classattr] + #[pyo3(name = "CRZ")] + fn crz() -> Self { + Self { + inner: GateType::CRZ, + } + } + + #[classattr] + #[pyo3(name = "CCX")] + fn ccx() -> Self { + Self { + inner: GateType::CCX, + } + } + #[classattr] #[pyo3(name = "Measure")] fn measure() -> Self { @@ -1765,11 +1859,11 @@ impl PyTick { /// Get the set of qubits used in this tick. /// /// Returns a sorted list of qubit IDs that are acted upon by gates in this tick. - fn active_qubits(&self) -> Vec { + fn active_qubits(&self) -> Vec { self.inner .active_qubits() .into_iter() - .map(|q| PyQubitId { inner: q }) + .map(|q| q.0) .collect() } @@ -2619,6 +2713,80 @@ impl PyTickHandle { Ok(slf) } + /// Apply an F gate. + fn f(slf: Py, py: Python<'_>, q: usize) -> PyResult> { + slf.borrow_mut(py).add_gate_internal(py, Gate::f(&[q]))?; + Ok(slf) + } + + /// Apply an F-dagger gate. + fn fdg(slf: Py, py: Python<'_>, q: usize) -> PyResult> { + slf.borrow_mut(py).add_gate_internal(py, Gate::fdg(&[q]))?; + Ok(slf) + } + + /// Apply an SXX gate (sqrt-XX). + fn sxx(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::sxx(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply an SXX-dagger gate. + fn sxxdg(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::sxxdg(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply an SYY gate (sqrt-YY). + fn syy(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::syy(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply an SYY-dagger gate. + fn syydg(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::syydg(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply a SWAP gate. + fn swap(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::swap(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply a CH gate (controlled-Hadamard). + fn ch(slf: Py, py: Python<'_>, ctrl: usize, tgt: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::ch(&[(ctrl, tgt)]))?; + Ok(slf) + } + + /// Apply a CRZ gate (controlled-RZ). + fn crz( + slf: Py, + py: Python<'_>, + theta: AngleParam, + ctrl: usize, + tgt: usize, + ) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::crz(theta.0, &[(ctrl, tgt)]))?; + Ok(slf) + } + + /// Apply a CCX gate (Toffoli). + fn ccx(slf: Py, py: Python<'_>, q1: usize, q2: usize, q3: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::ccx(&[(q1, q2, q3)]))?; + Ok(slf) + } + /// Apply an RXX rotation. fn rxx( slf: Py, @@ -2658,6 +2826,136 @@ impl PyTickHandle { Ok(slf) } + // ========================================================================= + // Generic gate dispatch (name-based) + // ========================================================================= + + /// Add a gate by name, resolving to a native `GateType` if possible. + /// + /// If the name matches a known gate type (e.g., "H", "CX", "SZZ"), it is + /// added as that native type. Otherwise, it falls through to `custom_gate`. + /// + /// Args: + /// name: The gate name (case-insensitive for standard gates). + /// qubits: List of qubit IDs. + /// angles: Optional list of angle values (radians). + #[pyo3(signature = (name, qubits, angles=None))] + fn add_gate( + slf: Py, + py: Python<'_>, + name: &str, + qubits: Vec, + angles: Option>, + ) -> PyResult> { + use std::str::FromStr; + + match GateType::from_str(name) { + Ok(gate_type) => { + let arity = gate_type.quantum_arity(); + let angle_arity = gate_type.angle_arity(); + + // Validate angle count for parameterized gates + let angle_vals: Vec = angles + .unwrap_or_default() + .into_iter() + .map(Angle64::from_radians) + .collect(); + if angle_arity > 0 && angle_vals.len() != angle_arity { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Gate '{name}' requires {angle_arity} angle(s), got {}", + angle_vals.len() + ))); + } + + // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits) + let needs_broadcast = + arity > 0 && qubits.len() > arity && qubits.len().is_multiple_of(arity); + + if arity > 0 && qubits.len() != arity && !needs_broadcast { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Gate '{name}' requires {} qubit(s), got {} (not a valid multiple)", + arity, + qubits.len() + ))); + } + + let handle = slf.borrow_mut(py); + let tick_idx = handle.tick_idx; + let circuit_py = handle.circuit.clone_ref(py); + + let mut circuit = circuit_py.borrow_mut(py); + let tick = circuit.inner.get_tick_mut(tick_idx).ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "Tick {tick_idx} does not exist" + )) + })?; + + if needs_broadcast { + // Broadcast: create one gate per arity-chunk of qubits + let mut last_idx = None; + for chunk in qubits.chunks(arity) { + let qubit_ids: GateQubits = + chunk.iter().copied().map(QubitId::from).collect(); + let gate = Gate::new(gate_type, angle_vals.clone(), vec![], qubit_ids); + match tick.try_add_gate(gate) { + Ok(idx) => { + tick.set_gate_attr( + idx, + "_symbol", + Attribute::String(name.to_string()), + ); + last_idx = Some(idx); + } + Err(err) => { + let msg = format!( + "Qubit(s) {:?} already in use in tick {}", + err.conflicting_qubits + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + tick_idx + ); + return Err(PyErr::new::(msg)); + } + } + } + drop(circuit); + drop(handle); + slf.borrow_mut(py).last_gate_idx = last_idx; + Ok(slf) + } else { + // Normal: create single gate + let qubit_ids: GateQubits = qubits.into_iter().map(QubitId::from).collect(); + let gate = Gate::new(gate_type, angle_vals, vec![], qubit_ids); + match tick.try_add_gate(gate) { + Ok(idx) => { + tick.set_gate_attr(idx, "_symbol", Attribute::String(name.to_string())); + drop(circuit); + drop(handle); + slf.borrow_mut(py).last_gate_idx = Some(idx); + Ok(slf) + } + Err(err) => { + let msg = format!( + "Qubit(s) {:?} already in use in tick {}", + err.conflicting_qubits + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + tick_idx + ); + Err(PyErr::new::(msg)) + } + } + } + } + Err(_) => { + // Unknown gate name - fall through to custom_gate + PyTickHandle::custom_gate(slf, py, name, qubits, angles) + } + } + } + // ========================================================================= // Custom (unrecognized) gates // ========================================================================= diff --git a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py index 84d44ae51..6c3ba44bb 100644 --- a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py +++ b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py @@ -34,7 +34,7 @@ QubitConflictError = None # type: ignore[misc, assignment] if TYPE_CHECKING: - from collections.abc import Callable, Iterator + from collections.abc import Iterator from pecos.typing import JSONDict, JSONValue @@ -44,95 +44,8 @@ GateDict = dict[str, LocationSet] CircuitSetup = int | list[GateDict] | None -# Symbol to TickHandle method mapping for single-qubit gates -_SINGLE_QUBIT_GATES = { - "I": "i", - "H": "h", - "F": "f", - "FDG": "fdg", - "X": "x", - "Y": "y", - "Z": "z", - # sqrt gates - "SX": "sx", - "SXDG": "sxdg", - "SY": "sy", - "SYDG": "sydg", - "SZ": "sz", - "SZDG": "szdg", - # Aliases - "Q": "sx", - "QD": "sxdg", - "R": "sy", - "RD": "sydg", - "S": "sz", - "SD": "szdg", - "SDG": "szdg", # Also accept SDG as alias - "T": "t", - "TDG": "tdg", -} - -# Symbol to TickHandle method mapping for rotation gates (take angle parameter) -_ROTATION_GATES = { - "RX": "rx", - "RY": "ry", - "RZ": "rz", -} - -# Symbol to TickHandle method mapping for two-qubit gates -_TWO_QUBIT_GATES = { - "CX": "cx", - "CNOT": "cx", - "CY": "cy", - "CZ": "cz", - "SXX": "sxx", - "SXXDG": "sxxdg", - "SYY": "syy", - "SYYDG": "syydg", - "SZZ": "szz", - "SZZDG": "szzdg", -} - -# Symbol to TickHandle method mapping for two-qubit rotation gates -_TWO_QUBIT_ROTATION_GATES = { - "RXX": "rxx", - "RYY": "ryy", - "RZZ": "rzz", -} - -# Symbol to TickHandle method mapping for R1XY gate (takes theta, phi angles) -_R1XY_GATES = { - "R1XY": "r1xy", -} - -# Symbol to TickHandle method mapping for U gate (takes theta, phi, lambda angles) -_U_GATES = { - "U": "u", -} - -# Symbol to TickHandle method mapping for R2XXYYZZ gate (takes 3 angles: zz, yy, xx) -# This gate is decomposed into RZZ + RYY + RXX -_R2XXYYZZ_GATES = { - "R2XXYYZZ": "r2xxyyzz", - "RZZRYYRXX": "r2xxyyzz", # Alternative name - "RXXYYZZ": "r2xxyyzz", # Alternative name -} - -# SWAP gate - decomposed into CX gates: SWAP(a,b) = CX(a,b) CX(b,a) CX(a,b) -_SWAP_GATES = {"SWAP"} - -# Prep/measure gates -_PREP_GATES = { - "PREP", - "init", - "Init", - "init |0>", - "Init |0>", - "RESET", - "Reset", - "reset", -} -_MEASURE_GATES = {"MEASURE", "MZ", "measure", "Measure", "measure Z"} +# R2XXYYZZ gate names (composite gate, not a single native GateType) +_R2XXYYZZ_GATES = {"R2XXYYZZ", "RZZRYYRXX", "RXXYYZZ"} # GateType string to symbol mapping (for iteration) _GATETYPE_TO_SYMBOL = { @@ -168,6 +81,10 @@ "RXX": "RXX", "RYY": "RYY", "RZZ": "RZZ", + "CRZ": "CRZ", + "CH": "CH", + "CCX": "CCX", + "SWAP": "SWAP", "R2XXYYZZ": "R2XXYYZZ", "Prep": "init |0>", "Measure": "measure", @@ -256,19 +173,15 @@ def _add_gate_to_tick( locations: LocationSet, **params: JSONValue, ) -> None: - """Add a gate to a tick handle based on symbol.""" + """Add a gate to a tick handle based on symbol. + + Uses the Rust-side ``add_gate`` method which resolves gate names via + ``GateType::from_str``. Special handling is only needed for composite + gates (R2XXYYZZ) that don't map to a single GateType. + """ # Handle logical gate objects that have a .symbol attribute if not isinstance(symbol, str): symbol = symbol.symbol if hasattr(symbol, "symbol") else str(symbol) - symbol_upper = symbol.upper() - - # Convert locations to list, filtering out None values (placeholders for logical gates) - loc_list = [loc for loc in locations if loc is not None] - if not loc_list: - # No qubit operands -- store symbol as tick-level metadata - # (e.g., global barriers or marker gates) - tick_handle.meta("_symbol", symbol) - return # Serialize params for storage (handle tuples -> lists) def make_serializable(obj: object) -> object: @@ -282,141 +195,21 @@ def make_serializable(obj: object) -> object: params_json = json.dumps({k: make_serializable(v) for k, v in params.items()}) if params else "" - # Helper to store original symbol and params in metadata (idempotent - skips if qubit already used) - def add_with_symbol( - method: Callable[..., object], - *args: float, - ) -> object | None: - try: - result = method(*args) - except QubitConflictError: - # Qubit already in use in this tick - skip (idempotent behavior) - return None - else: - # Store original symbol and params for round-trip preservation - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - return result - - # Handle single-qubit gates - if symbol_upper in _SINGLE_QUBIT_GATES: - method_name = _SINGLE_QUBIT_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, q) - else: - add_with_symbol(method, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle rotation gates - if symbol_upper in _ROTATION_GATES: - method_name = _ROTATION_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - angles_val = params.get("angles") - if angles_val is not None and len(angles_val) >= 1: - angle = angles_val[0] - else: - angle = params.get("angle", params.get("theta", 0.0)) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, angle, q) - else: - add_with_symbol(method, angle, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle two-qubit gates - if symbol_upper in _TWO_QUBIT_GATES: - method_name = _TWO_QUBIT_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - for loc in loc_list: - if isinstance(loc, tuple) and len(loc) == 2: - add_with_symbol(method, loc[0], loc[1]) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle two-qubit rotation gates - if symbol_upper in _TWO_QUBIT_ROTATION_GATES: - method_name = _TWO_QUBIT_ROTATION_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - angles_val = params.get("angles") - if angles_val is not None and len(angles_val) >= 1: - angle = angles_val[0] - else: - angle = params.get("angle", params.get("theta", 0.0)) - for loc in loc_list: - if isinstance(loc, tuple) and len(loc) == 2: - add_with_symbol(method, angle, loc[0], loc[1]) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle R1XY gate (takes theta, phi angles) - if symbol_upper in _R1XY_GATES: - method_name = _R1XY_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - # Handle angles tuple or individual theta/phi params - angles = params.get("angles") - if angles is not None and len(angles) >= 2: - theta = angles[0] - phi = angles[1] - else: - theta = params.get("theta", params.get("angle", 0.0)) - phi = params.get("phi", 0.0) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, theta, phi, q) - else: - add_with_symbol(method, theta, phi, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle U gate (takes theta, phi, lambda angles) - if symbol_upper in _U_GATES: - method_name = _U_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - # Handle angles tuple or individual theta/phi/lambda params - angles = params.get("angles") - if angles is not None and len(angles) >= 3: - theta = angles[0] - phi = angles[1] - lambda_ = angles[2] - else: - theta = params.get("theta", 0.0) - phi = params.get("phi", 0.0) - lambda_ = params.get("lambda", params.get("lambda_", 0.0)) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, theta, phi, lambda_, q) - else: - add_with_symbol(method, theta, phi, lambda_, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle R2XXYYZZ gate (takes 3 angles: zz, yy, xx) - # R2XXYYZZ is not a native GateType. We store it as RZZ with metadata - # containing all three angles and the original symbol. When iterating, - # _iter_tick reconstructs the R2XXYYZZ gate from this metadata. - if symbol_upper in _R2XXYYZZ_GATES: - # Handle angles tuple or individual parameters + # Convert locations to list, filtering out None values (placeholders for logical gates) + loc_list = [loc for loc in locations if loc is not None] + if not loc_list: + # No qubit operands -- store symbol and params as tick-level metadata + # (e.g., global barriers or marker gates) + tick_handle.meta("_symbol", symbol) + if params_json: + tick_handle.meta("_params", params_json) + return + + # Handle R2XXYYZZ gate (composite, not a single native GateType) + if symbol.upper() in _R2XXYYZZ_GATES: angles = params.get("angles") if angles is not None and len(angles) >= 3: - zz_angle = angles[0] - yy_angle = angles[1] - xx_angle = angles[2] + zz_angle, yy_angle, xx_angle = angles[0], angles[1], angles[2] else: zz_angle = params.get("zz", 0.0) yy_angle = params.get("yy", 0.0) @@ -424,102 +217,27 @@ def add_with_symbol( for loc in loc_list: if isinstance(loc, tuple) and len(loc) == 2: - # Store as RZZ with R2XXYYZZ metadata result = tick_handle.rzz(zz_angle, loc[0], loc[1]) if hasattr(result, "meta"): result.meta("_symbol", symbol) - # Store all three angles as comma-separated string - result.meta( - "_r2xxyyzz_angles", - f"{zz_angle},{yy_angle},{xx_angle}", - ) + result.meta("_r2xxyyzz_angles", f"{zz_angle},{yy_angle},{xx_angle}") if params_json: result.meta("_params", params_json) return - # Handle SWAP gate - stored as CX with metadata - # SWAP is not a native GateType. We store it as CX with metadata - # indicating it's a SWAP. The simulator bindings handle SWAP directly. - if symbol_upper in _SWAP_GATES: - for loc in loc_list: - if isinstance(loc, tuple) and len(loc) == 2: - # Store as CX with SWAP metadata - result = tick_handle.cx(loc[0], loc[1]) - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - return - - # Handle prep gates - idempotent (skip if qubit already used in tick) - if symbol in _PREP_GATES or symbol_upper == "PREP": - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - try: - result = tick_handle.pz(q) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already initialized in this tick - else: - try: - result = tick_handle.pz(loc) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already initialized in this tick - return - - # Handle measure gates - idempotent (skip if qubit already used in tick) - if symbol in _MEASURE_GATES or symbol_upper == "MEASURE": - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - try: - result = tick_handle.mz(q) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already measured in this tick - else: - try: - result = tick_handle.mz(loc) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already measured in this tick - return + # Extract angles from params + angles = self._extract_angles_full(params) - # Fallback: try to use the symbol directly as a method name - method_name = symbol.lower() - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - for loc in loc_list: - if isinstance(loc, tuple): - if len(loc) == 2: - add_with_symbol(method, loc[0], loc[1]) - else: - for q in loc: - add_with_symbol(method, q) - else: - add_with_symbol(method, loc) - else: - # Store unrecognized gates using validated custom_gate method. - # First use of a name establishes its signature; subsequent uses are validated. - angles = self._extract_angles(params) - for loc in loc_list: - qubits = list(loc) if isinstance(loc, tuple) else [loc] - try: - result = tick_handle.custom_gate(symbol, qubits, angles if angles else None) - except QubitConflictError: - continue - if hasattr(result, "meta") and params_json: - result.meta("_params", params_json) + # Dispatch each location through Rust's add_gate (which resolves + # the name via GateType::from_str and falls back to custom_gate) + for loc in loc_list: + qubits = list(loc) if isinstance(loc, tuple) else [loc] + try: + result = tick_handle.add_gate(symbol, qubits, angles if angles else None) + except QubitConflictError: + continue + if hasattr(result, "meta") and params_json: + result.meta("_params", params_json) def append( self, @@ -757,7 +475,15 @@ def _iter_tick( if not grouped: tick_symbol = tick_obj.get_attr("_symbol") if tick_symbol is not None: - yield tick_symbol, set(), {} + tick_params: JSONDict = {} + tick_params_json = tick_obj.get_attr("_params") + if tick_params_json is not None: + try: + tick_params = json.loads(tick_params_json) + tick_params = self._fix_json_meta(tick_params) + except json.JSONDecodeError: + pass + yield tick_symbol, set(), tick_params return # Yield grouped results @@ -845,6 +571,31 @@ def _extract_angles(params: dict) -> list[float]: return [params["angle"]] return [] + @staticmethod + def _extract_angles_full(params: dict) -> list[float]: + """Extract angle values from gate parameters, supporting all param formats. + + Handles: angles (list), angle (single), theta, phi, lambda/lambda_. + """ + if not params: + return [] + # If explicit angles list is provided, use it directly + if "angles" in params: + return list(params["angles"]) + # Build angle list from named parameters + angles = [] + if "angle" in params: + angles.append(params["angle"]) + elif "theta" in params: + angles.append(params["theta"]) + if "phi" in params: + angles.append(params["phi"]) + if "lambda" in params: + angles.append(params["lambda"]) + elif "lambda_" in params: + angles.append(params["lambda_"]) + return angles + @staticmethod def _fix_json_meta(meta: JSONDict) -> JSONDict: """Fix some of the type issues for converting json rep back to a QuantumCircuit.""" @@ -898,7 +649,7 @@ def __setitem__(self, tick: int, item: tuple[GateDict, JSONDict]) -> None: # Get qubits to discard first tick_obj = self._inner.get_tick(actual_tick) if tick_obj is not None: - qubits_to_discard = list(tick_obj.active_qubits()) + qubits_to_discard = tick_obj.active_qubits() if qubits_to_discard: self._inner.discard(qubits_to_discard, actual_tick) @@ -926,7 +677,7 @@ def __delitem__(self, tick: int) -> None: actual_tick = tick if tick >= 0 else len(self) + tick tick_obj = self._inner.get_tick(actual_tick) if tick_obj is not None: - qubits_to_discard = list(tick_obj.active_qubits()) + qubits_to_discard = tick_obj.active_qubits() if qubits_to_discard: self._inner.discard(qubits_to_discard, actual_tick) diff --git a/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py b/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py index c87d4b1f2..0220bf998 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py +++ b/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py @@ -13,6 +13,9 @@ """Integration tests for quantum circuit operations.""" from __future__ import annotations +import copy +import json + from pecos.circuits import QuantumCircuit @@ -167,3 +170,854 @@ def test_tick_view_symbols_via_iter_ticks() -> None: symbols_per_tick.append(list(tick_view.symbols.keys())) assert symbols_per_tick == [["H"], ["CX"]] + + +def test_append_empty_locations_with_params() -> None: + """Test that params are preserved when appending a gate with empty locations.""" + qc = QuantumCircuit() + qc.append("cop", set(), a=1, b=2) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "cop" + assert locations == set() + assert params["a"] == 1 + assert params["b"] == 2 + + +def test_append_empty_locations_no_params() -> None: + """Test that a gate with empty locations and no params still round-trips.""" + qc = QuantumCircuit() + qc.append("barrier", set()) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "barrier" + assert locations == set() + assert params == {} + + +def test_custom_gate_with_arbitrary_params() -> None: + """Test that arbitrary keyword params are preserved on custom gates with qubits.""" + qc = QuantumCircuit() + qc.append("my_gate", {0}, foo="bar", count=42) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "my_gate" + assert locations == {0} + assert params["foo"] == "bar" + assert params["count"] == 42 + + +def test_known_gate_with_extra_params() -> None: + """Test that extra keyword params beyond angle are preserved on known gates.""" + qc = QuantumCircuit() + qc.append("RZ", {0}, angle=0.5, var_output={0: (1, 2)}) + + results = list(qc.items()) + assert len(results) == 1 + symbol, _locations, params = results[0] + assert symbol == "RZ" + assert params["angle"] == 0.5 + assert params["var_output"] == {0: (1, 2)} + + +# --------------------------------------------------------------------------- +# Constructor variants +# --------------------------------------------------------------------------- + + +def test_empty_constructor() -> None: + """Test default empty constructor.""" + qc = QuantumCircuit() + assert len(qc) == 0 + assert qc.metadata == {} + assert qc.qudits == set() + assert qc.active_qudits == [] + + +def test_constructor_with_num_ticks() -> None: + """Test constructor with integer creates reserved empty ticks.""" + qc = QuantumCircuit(3) + assert len(qc) == 3 + # All ticks should be empty + results = list(qc.items()) + assert results == [] + + +def test_constructor_with_gate_dicts() -> None: + """Test constructor with list of gate dictionaries.""" + qc = QuantumCircuit([{"H": {0}}, {"CX": {(0, 1)}}, {"measure Z": {0, 1}}]) + assert len(qc) == 3 + results = list(qc.items()) + assert len(results) == 3 + assert results[0][0] == "H" + assert results[1][0] == "CX" + + +def test_constructor_with_metadata() -> None: + """Test constructor with keyword metadata.""" + qc = QuantumCircuit(num_qubits=5, error_free=True) + assert qc.metadata["num_qubits"] == 5 + assert qc.metadata["error_free"] is True + + +# --------------------------------------------------------------------------- +# append() -- gate symbol variants +# --------------------------------------------------------------------------- + + +def test_append_gate_dict() -> None: + """Test append with gate dictionary (no locations arg).""" + qc = QuantumCircuit() + qc.append({"H": {0, 1}, "X": {2}}) + + results = list(qc.items()) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_append_string_symbol() -> None: + """Test append with string symbol and locations.""" + qc = QuantumCircuit() + qc.append("H", {0, 1, 2}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "H" + assert results[0][1] == {0, 1, 2} + + +def test_append_two_qubit_gate() -> None: + """Test append with two-qubit gate locations as tuples.""" + qc = QuantumCircuit() + qc.append("CX", {(0, 1), (2, 3)}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "CX" + assert results[0][1] == {(0, 1), (2, 3)} + + +def test_append_prep_and_measure() -> None: + """Test append with prep and measure gates.""" + qc = QuantumCircuit() + qc.append("init |0>", {0, 1}) + qc.append("measure Z", {0, 1}) + + results = list(qc.items()) + assert len(results) == 2 + assert results[0][0] == "init |0>" + assert results[1][0] == "measure Z" + + +def test_append_rotation_gate_with_angle() -> None: + """Test append with rotation gate using angle param.""" + qc = QuantumCircuit() + qc.append("RX", {0}, angle=1.57) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "RX" + assert locations == {0} + assert params["angle"] == 1.57 + + +def test_append_rotation_gate_with_angles_tuple() -> None: + """Test append with rotation gate using angles tuple param.""" + qc = QuantumCircuit() + qc.append("RZ", {0, 1}, angles=(0.5,)) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][2]["angles"] == (0.5,) + + +def test_append_r1xy_gate() -> None: + """Test R1XY gate with theta and phi angles.""" + qc = QuantumCircuit() + qc.append("R1XY", {0}, angles=(0.3, 0.7)) + + results = list(qc.items()) + assert len(results) == 1 + symbol, _, params = results[0] + assert symbol == "R1XY" + assert params["angles"] == (0.3, 0.7) + + +def test_append_two_qubit_rotation() -> None: + """Test two-qubit rotation gate with angle.""" + qc = QuantumCircuit() + qc.append("RZZ", {(0, 1)}, angle=0.25) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "RZZ" + assert (0, 1) in locations + assert params["angle"] == 0.25 + + +# --------------------------------------------------------------------------- +# append() -- params round-trip +# --------------------------------------------------------------------------- + + +def test_params_with_qec_metadata() -> None: + """Test params round-trip with QEC-style metadata (ancilla_ticks, datas, etc.).""" + qc = QuantumCircuit() + qc.append( + "X check", + set(), + ancilla_ticks=0, + data_ticks=[2, 4, 3, 5], + meas_ticks=7, + datas=[1, 2, 3, 4], + ancillas=0, + ) + + results = list(qc.items()) + assert len(results) == 1 + _, _, params = results[0] + assert params["ancilla_ticks"] == 0 + assert params["data_ticks"] == [2, 4, 3, 5] + assert params["meas_ticks"] == 7 + assert params["datas"] == [1, 2, 3, 4] + assert params["ancillas"] == 0 + + +def test_params_with_boolean_values() -> None: + """Test params with boolean values.""" + qc = QuantumCircuit() + qc.append("H", {0}, error_free=True, noiseless=False) + + results = list(qc.items()) + _, _, params = results[0] + assert params["error_free"] is True + assert params["noiseless"] is False + + +def test_params_with_string_values() -> None: + """Test params with string values.""" + qc = QuantumCircuit() + qc.append("my_gate", {0}, label="ancilla_prep", kind="stabilizer") + + results = list(qc.items()) + _, _, params = results[0] + assert params["label"] == "ancilla_prep" + assert params["kind"] == "stabilizer" + + +def test_params_with_nested_dict() -> None: + """Test params with nested dictionary values.""" + qc = QuantumCircuit() + qc.append("H", {0}, config={"depth": 3, "rounds": 10}) + + results = list(qc.items()) + _, _, params = results[0] + assert params["config"] == {"depth": 3, "rounds": 10} + + +# --------------------------------------------------------------------------- +# update() +# --------------------------------------------------------------------------- + + +def test_update_at_specific_tick() -> None: + """Test update adds gates to a specific existing tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("H", {2}) + qc.update("X", {1}, tick=0) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_update_last_tick_default() -> None: + """Test update defaults to last tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.update("X", {1}) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_update_with_gate_dict() -> None: + """Test update with gate dictionary form.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.update({"X": {1}, "Z": {2}}) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X", "Z"} + + +def test_update_with_params() -> None: + """Test update passes params through to the gate on a free qubit.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.update("measure Z", {1}, tick=0, forced_outcome=0) + + results = list(qc.items(tick=0)) + measure_results = [r for r in results if r[0] == "measure Z"] + assert len(measure_results) == 1 + assert measure_results[0][2].get("forced_outcome") == 0 + + +def test_update_emptyappend() -> None: + """Test update with emptyappend on empty circuit.""" + qc = QuantumCircuit() + assert len(qc) == 0 + qc.update("H", {0}, emptyappend=True) + assert len(qc) == 1 + results = list(qc.items()) + assert results[0][0] == "H" + + +def test_update_negative_tick() -> None: + """Test update with negative tick index.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + qc.update("Z", {2}, tick=-2) # Should target tick 0 + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert "Z" in symbols + + +# --------------------------------------------------------------------------- +# discard() +# --------------------------------------------------------------------------- + + +def test_discard_single_qubit() -> None: + """Test discard removes a single qubit from a tick.""" + qc = QuantumCircuit() + qc.append("H", {0, 1, 2}) + qc.discard({1}) + + results = list(qc.items(tick=-1)) + assert len(results) == 1 + assert 1 not in results[0][1] + + +def test_discard_at_specific_tick() -> None: + """Test discard at a specific tick index.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("X", {0, 1}) + qc.discard({0}, tick=0) + + results = list(qc.items(tick=0)) + for _, locations, _ in results: + assert 0 not in locations + + # Tick 1 should be unaffected + results = list(qc.items(tick=1)) + all_locs = set() + for _, locations, _ in results: + all_locs.update(locations) + assert 0 in all_locs + + +# --------------------------------------------------------------------------- +# items() iteration +# --------------------------------------------------------------------------- + + +def test_items_all_ticks() -> None: + """Test items() iterates across all ticks.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + qc.append("Z", {2}) + + results = list(qc.items()) + assert len(results) == 3 + symbols = [r[0] for r in results] + assert symbols == ["H", "X", "Z"] + + +def test_items_specific_tick() -> None: + """Test items(tick=N) iterates only that tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + results = list(qc.items(tick=1)) + assert len(results) == 1 + assert results[0][0] == "X" + + +def test_items_negative_tick() -> None: + """Test items(tick=-1) iterates last tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + results = list(qc.items(tick=-1)) + assert len(results) == 1 + assert results[0][0] == "X" + + +def test_items_yields_symbol_locations_params() -> None: + """Test that items() yields (symbol, locations, params) tuples.""" + qc = QuantumCircuit() + qc.append("RZ", {0}, angle=0.5) + + for symbol, locations, params in qc.items(): + assert isinstance(symbol, str) + assert isinstance(locations, set) + assert isinstance(params, dict) + + +def test_items_same_symbol_same_params_merged() -> None: + """Test that gates with same symbol and params in the same tick have locations merged.""" + qc = QuantumCircuit() + qc.append({"H": {0, 1, 2}}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][1] == {0, 1, 2} + + +def test_items_multiple_gate_types_in_tick() -> None: + """Test items with multiple gate types in same tick via gate dict.""" + qc = QuantumCircuit() + qc.append({"H": {0}, "X": {1}, "Z": {2}}) + + results = list(qc.items()) + assert len(results) == 3 + symbols = {r[0] for r in results} + assert symbols == {"H", "X", "Z"} + + +# --------------------------------------------------------------------------- +# iter_ticks() +# --------------------------------------------------------------------------- + + +def test_iter_ticks_yields_tick_view() -> None: + """Test iter_ticks yields (TickView, tick_index, metadata).""" + qc = QuantumCircuit(num_qubits=2) + qc.append("H", {0}) + qc.append("CX", {(0, 1)}) + + ticks = list(qc.iter_ticks()) + assert len(ticks) == 2 + for tick_view, tick_idx, meta in ticks: + assert isinstance(tick_idx, int) + assert meta == qc.metadata + # TickView should support items() + results = list(tick_view.items()) + assert len(results) >= 1 + + +def test_iter_ticks_metadata_is_circuit_metadata() -> None: + """Test that iter_ticks yields the circuit-level metadata for every tick.""" + qc = QuantumCircuit(label="test_circuit") + qc.append("H", {0}) + qc.append("X", {1}) + + for _, _, meta in qc.iter_ticks(): + assert meta["label"] == "test_circuit" + + +# --------------------------------------------------------------------------- +# Indexing: __getitem__, __setitem__, __delitem__ +# --------------------------------------------------------------------------- + + +def test_getitem_returns_tick_view() -> None: + """Test qc[i] returns a TickView for that tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + tick0 = qc[0] + results = list(tick0.items()) + assert results[0][0] == "H" + + tick1 = qc[1] + results = list(tick1.items()) + assert results[0][0] == "X" + + +def test_getitem_negative_index() -> None: + """Test qc[-1] returns last tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + tick = qc[-1] + results = list(tick.items()) + assert results[0][0] == "X" + + +def test_delitem_clears_tick() -> None: + """Test del qc[i] clears the tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + del qc[0] + + results = list(qc.items(tick=0)) + assert results == [] + # Length should remain the same (tick is cleared, not removed) + assert len(qc) == 2 + + +# --------------------------------------------------------------------------- +# __len__, __iter__, __str__ +# --------------------------------------------------------------------------- + + +def test_len() -> None: + """Test len(qc) returns number of ticks.""" + qc = QuantumCircuit() + assert len(qc) == 0 + qc.append("H", {0}) + assert len(qc) == 1 + qc.append("X", {1}) + assert len(qc) == 2 + + +def test_iter() -> None: + """Test iterating over qc yields same as items().""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + from_iter = list(qc) + from_items = list(qc.items()) + assert len(from_iter) == len(from_items) + for a, b in zip(from_iter, from_items, strict=False): + assert a[0] == b[0] + + +def test_str_representation() -> None: + """Test string representation includes gate info.""" + qc = QuantumCircuit() + qc.append("H", {0}) + s = str(qc) + assert "H" in s + assert "QuantumCircuit" in s + + +def test_str_with_metadata() -> None: + """Test string representation includes metadata when present.""" + qc = QuantumCircuit(label="test") + qc.append("H", {0}) + s = str(qc) + assert "label" in s + + +# --------------------------------------------------------------------------- +# add_ticks() +# --------------------------------------------------------------------------- + + +def test_add_ticks() -> None: + """Test add_ticks creates empty ticks.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.add_ticks(2) + + assert len(qc) == 3 + # Empty ticks should yield nothing + results = list(qc.items(tick=1)) + assert results == [] + results = list(qc.items(tick=2)) + assert results == [] + + +# --------------------------------------------------------------------------- +# qudits and active_qudits +# --------------------------------------------------------------------------- + + +def test_qudits_tracks_all_used() -> None: + """Test qudits property returns all qubits ever used.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("CX", {(2, 3)}) + + assert qc.qudits == {0, 1, 2, 3} + + +def test_active_qudits_per_tick() -> None: + """Test active_qudits returns list of sets, one per tick.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("CX", {(2, 3)}) + + active = qc.active_qudits + assert len(active) == 2 + assert active[0] == {0, 1} + assert active[1] == {2, 3} + + +# --------------------------------------------------------------------------- +# copy() +# --------------------------------------------------------------------------- + + +def test_copy_preserves_gates() -> None: + """Test copy preserves all gates and params.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("RZ", {0}, angle=0.5) + qc.append("CX", {(0, 1)}) + + qc2 = qc.copy() + assert len(qc2) == len(qc) + + orig = list(qc.items()) + copied = list(qc2.items()) + assert len(orig) == len(copied) + for o, c in zip(orig, copied, strict=False): + assert o[0] == c[0] # symbol + assert o[1] == c[1] # locations + assert o[2] == c[2] # params + + +def test_copy_is_independent() -> None: + """Test that modifying a copy does not affect the original.""" + qc = QuantumCircuit() + qc.append("H", {0}) + + qc2 = qc.copy() + qc2.append("X", {1}) + + assert len(qc) == 1 + assert len(qc2) == 2 + + +def test_copy_preserves_metadata() -> None: + """Test copy preserves circuit metadata.""" + qc = QuantumCircuit(label="original") + qc.append("H", {0}) + + qc2 = qc.copy() + assert qc2.metadata["label"] == "original" + + +def test_copy_module() -> None: + """Test copy.copy works on QuantumCircuit.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("RZ", {0}, angle=0.5) + + qc2 = copy.copy(qc) + assert len(qc2) == len(qc) + assert next(iter(qc.items()))[0] == next(iter(qc2.items()))[0] + + +# --------------------------------------------------------------------------- +# JSON round-trip +# --------------------------------------------------------------------------- + + +def test_json_roundtrip_basic() -> None: + """Test to_json_str / from_json_str round-trip.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("CX", {(0, 1)}) + qc.append("measure Z", {0, 1}) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + assert len(qc2) == len(qc) + orig = list(qc.items()) + restored = list(qc2.items()) + for o, r in zip(orig, restored, strict=False): + assert o[0] == r[0] + + +def test_json_roundtrip_with_params() -> None: + """Test JSON round-trip preserves gate params.""" + qc = QuantumCircuit() + qc.append("RZ", {0}, angle=0.5) + qc.append("my_gate", {1}, custom_param="hello", count=42) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + results = list(qc2.items()) + rz_params = results[0][2] + assert rz_params["angle"] == 0.5 + + custom_params = results[1][2] + assert custom_params["custom_param"] == "hello" + assert custom_params["count"] == 42 + + +def test_json_roundtrip_with_metadata() -> None: + """Test JSON round-trip preserves circuit metadata.""" + qc = QuantumCircuit(label="test", num_qubits=5) + qc.append("H", {0}) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + assert qc2.metadata["label"] == "test" + assert qc2.metadata["num_qubits"] == 5 + + +def test_json_roundtrip_var_output() -> None: + """Test JSON round-trip preserves var_output with int keys and tuple values.""" + qc = QuantumCircuit() + qc.append("measure Z", {0}, var_output={0: (1, 2)}) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + results = list(qc2.items()) + assert results[0][2]["var_output"] == {0: (1, 2)} + + +def test_json_str_is_valid_json() -> None: + """Test to_json_str produces valid JSON.""" + qc = QuantumCircuit(label="test") + qc.append("H", {0}) + + json_str = qc.to_json_str() + parsed = json.loads(json_str) + assert parsed["prog_type"] == "PECOS.QuantumCircuit" + assert "gates" in parsed + + +# --------------------------------------------------------------------------- +# TickView API +# --------------------------------------------------------------------------- + + +def test_tick_view_add() -> None: + """Test TickView.add() method adds gates.""" + qc = QuantumCircuit() + qc.append("H", {0}) + + tick = qc[0] + tick.add("X", {1}) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_tick_view_discard() -> None: + """Test TickView.discard() method removes locations.""" + qc = QuantumCircuit() + qc.append("H", {0, 1, 2}) + + tick = qc[0] + tick.discard({1}) + + results = list(qc.items(tick=0)) + assert 1 not in results[0][1] + + +def test_tick_view_active_qudits() -> None: + """Test TickView.active_qudits property.""" + qc = QuantumCircuit() + qc.append({"H": {0}, "CX": {(1, 2)}}) + + tick = qc[0] + assert tick.active_qudits == {0, 1, 2} or tick.active_qudits == {0, (1, 2)} + + +def test_tick_view_metadata() -> None: + """Test TickView.metadata returns circuit metadata.""" + qc = QuantumCircuit(label="test") + qc.append("H", {0}) + + tick = qc[0] + assert tick.metadata["label"] == "test" + + +def test_tick_view_str() -> None: + """Test TickView string representation.""" + qc = QuantumCircuit() + qc.append("H", {0}) + + tick = qc[0] + s = str(tick) + assert "H" in s + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +def test_multiple_appends_increment_ticks() -> None: + """Test each append creates a new tick.""" + qc = QuantumCircuit() + for i in range(5): + qc.append("H", {i}) + assert len(qc) == 5 + + +def test_empty_gate_dict_append() -> None: + """Test appending an empty gate dict.""" + qc = QuantumCircuit() + qc.append({}) + # Empty tick should exist but yield nothing + assert len(qc) == 0 or list(qc.items()) == [] + + +def test_gate_symbol_case_preserved() -> None: + """Test that the original gate symbol case is preserved in round-trip.""" + qc = QuantumCircuit() + qc.append("init |0>", {0}) + qc.append("measure Z", {0}) + + results = list(qc.items()) + assert results[0][0] == "init |0>" + assert results[1][0] == "measure Z" + + +def test_swap_gate() -> None: + """Test SWAP gate round-trips correctly.""" + qc = QuantumCircuit() + qc.append("SWAP", {(0, 1)}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "SWAP" + assert (0, 1) in results[0][1] + + +def test_r2xxyyzz_gate() -> None: + """Test R2XXYYZZ gate preserves all three angles.""" + qc = QuantumCircuit() + qc.append("R2XXYYZZ", {(0, 1)}, angles=(0.1, 0.2, 0.3)) + + results = list(qc.items()) + assert len(results) == 1 + symbol, _, params = results[0] + assert symbol == "R2XXYYZZ" + assert len(params["angles"]) == 3 + + +def test_u_gate() -> None: + """Test U gate with three angle parameters.""" + qc = QuantumCircuit() + qc.append("U", {0}, angles=(0.1, 0.2, 0.3)) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "U" + assert results[0][2]["angles"] == (0.1, 0.2, 0.3)