diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe5c58e..ca9380e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #507: Introduced new methods `PauliString.__str__`, `PauliString.from_str`, `PauliString.to_tableau`, and `PauliString.from_tableau`. +- #510 + - Added new attribute `OpenGraph.output_cliffords` + - Added `clifford` abstract method to `AbstractMeasurement`. Implemented it for `Plane` and `Axis`. + ### Fixed - #454, #481: Ensure `Pattern.minimize_space` only reduces max-space and does not increase it. - #235, #489: Correct sign for `YZ` measurements in `from_pyzx_graph`. ZX diagrams are now correctly converted into open graphs, even if they are reduced. +- #510 + - `Pattern.extract_opengraph` returns the same open graph before and after standardization. + - The round trip `Pattern` -> `OpenGraph` -> `Flow` -> `XZCorrections` -> `Pattern` is guaranteed for deterministic patterns in the LC fragment. + ### Changed - #452: Use `uv` for dependency management @@ -51,6 +59,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #507: Static method `PauliString.from_measured_node` is subsumed by the function `extraction_ps_from_corrected_node`. +- #510: + - `Pattern.extract_opengraph` standardizes the pattern first. + - `XZCorrections.to_pattern` applies Cliffords in `OpenGraph.output_cliffords` at the end of the pattern. + - `OpenGraph.isclose` and `OpenGraph.is_equal_structurally` check equality of `OpenGraph.output_cliffords`. + - `OpenGraph.compose` merges Clifford decorations with measurements or other Clifford decorations on outputs if required. + - `.draw` methods allow to show Clifford commands in the outputs. + - `PauliFlow.extract_circuit` raises `NotImplementedError` if the open graph has Clifford decorations. + - #512: Method `Circuit.simulate_statevector` accepts a `backend: DenseStateBackend[_DenseStateT] | Literal["statevector", "densitymatrix"]` parameter. ## [0.3.5] - 2026-03-26 diff --git a/examples/visualization.py b/examples/visualization.py index 282cb6b91..8b4e8742e 100644 --- a/examples/visualization.py +++ b/examples/visualization.py @@ -54,7 +54,7 @@ # Instead of the measurement planes, we can show the local Clifford of the resource graph. # see *clifford.py* for the details of the indices of each single-qubit Clifford operators. # 6 is the Hadamard and 8 is the :math:`\sqrt{iY}` operator. -pattern.draw(flow_from_pattern=True, show_local_clifford=True) +pattern.draw(flow_from_pattern=True, local_clifford=True) # %% # Visualize based on the graph diff --git a/graphix/clifford.py b/graphix/clifford.py index 34d88530c..fc09eff3f 100644 --- a/graphix/clifford.py +++ b/graphix/clifford.py @@ -132,12 +132,10 @@ def __matmul__(self, other: Clifford) -> Clifford: return Clifford(CLIFFORD_MUL[self.value][other.value]) return NotImplemented - def measure(self, pauli: Pauli) -> Pauli: - """Compute C† P C.""" - if pauli.symbol == I: - return copy.deepcopy(pauli) + def measure_axis(self, axis: Axis) -> Pauli: + """Compute C† P C with P the Pauli +axis.""" table = CLIFFORD_MEASURE[self.value] - match pauli.symbol: + match axis: case Axis.X: symbol, sign = table.x case Axis.Y: @@ -145,8 +143,15 @@ def measure(self, pauli: Pauli) -> Pauli: case Axis.Z: symbol, sign = table.z case _: - typing_extensions.assert_never(pauli.symbol) - return pauli.unit * Pauli(symbol, ComplexUnit.from_properties(sign=sign)) + typing_extensions.assert_never(axis) + return Pauli(symbol, ComplexUnit.from_properties(sign=sign)) + + def measure(self, pauli: Pauli) -> Pauli: + """Compute C† P C.""" + if pauli.symbol == I: + return copy.deepcopy(pauli) + new_pauli = self.measure_axis(pauli.symbol) + return pauli.unit * new_pauli def commute_domains(self, domains: Domains) -> Domains: """ diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 6baee7269..fa674c3fd 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -21,7 +21,7 @@ PauliExponentialDAG, extraction_ps_from_corrected_node, ) -from graphix.command import E, M, N, X, Z +from graphix.command import C, E, M, N, X, Z from graphix.flow._find_gpflow import ( CorrectionMatrix, compute_partial_order_layers, @@ -200,6 +200,9 @@ def to_pattern( for corrected_node in self.x_corrections.get(measured_node, []): pattern.add(X(node=corrected_node, domain={measured_node})) + for output_node, clifford in self.og.output_cliffords.items(): + pattern.add(C(node=output_node, clifford=clifford)) + pattern.reorder_output_nodes(self.og.output_nodes) return pattern @@ -792,22 +795,8 @@ def draw(self, **options: Unpack[DrawKwargs]) -> None: Parameters ---------- - pauli_measurements : bool, default=True - If ``True``, Pauli-measured nodes are highlighted with distinct coloring. - measurement_labels : bool, default=False - If ``True``, measurement labels (planes and axis) are displayed in the visualization. - node_labels : bool | Mapping[int, str], default=True - If ``True``, display numeric node labels. If a mapping, use custom labels - for nodes specified in the mapping. - node_distance : tuple[float, float], default=(1, 1) - Scaling factors (x_scale, y_scale) applied to node positions. - legend : bool, default=True - If ``True``, legend is shown. - figsize : tuple[int, int] | None, default=None - Figure dimensions (width, height) in inches. If ``None``, dimensions are - determined automatically based on graph structure. - filename : Path | None, default=None - File path to save the visualization. If ``None``, figure is displayed but not saved. + options: Unpack[DrawKwargs] + Options controlling graph visualization. See :class:`VisualizationOptions`. """ from graphix.visualization import GraphVisualizer # noqa: PLC0415 Avoid circular imports @@ -937,6 +926,8 @@ def extract_circuit(self: PauliFlow[Measurement]) -> ExtractionResult: [1] Simmons, 2021 (arXiv:2109.05654). [2] Mitosek and Backens, 2024 (arXiv:2410.23439). """ + if self.og.output_cliffords: + raise NotImplementedError("Circuit extraction is not supported for open graphs with Clifford decorations.") pexp_dag = PauliExponentialDAG.from_focused_flow(self) clifford_map = CliffordMap.from_focused_flow(self) diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index 2c4adac1d..809482acf 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -18,8 +18,9 @@ from graphix.repr_mixins import EnumReprMixin if TYPE_CHECKING: - from typing import TypeAlias + from typing import Self, TypeAlias + from graphix.clifford import Clifford from graphix.parameter import ExpressionOrFloat Angle: TypeAlias = float @@ -293,6 +294,48 @@ def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: f """ return self == other + @abstractmethod + def clifford(self, clifford_gate: Clifford) -> Self: + r"""Return a new measurement command with a :class:`Clifford` applied. + + Parameters + ---------- + clifford_gate : Clifford + Clifford gate to apply before the measurement. + + Returns + ------- + Self + Equivalent measurement representing the pattern ``MC``. + + Notes + ----- + - The return type is ``Self``, meaning that a Clifford applied + to a Bloch measurement returns a Bloch measurement, and a + Clifford applied to a Pauli measurement returns a Pauli + measurement. + - The method :func:`Measurement.clifford` does not always + commute with the method :func:`Measurement.to_bloch`: the + underlying Pauli measurement will be the same but the Bloch + representation can be on different planes. + + Examples + -------- + >>> from graphix.clifford import Clifford + >>> from graphix.measurements import Measurement, PauliMeasurement + >>> Measurement.XY(0.25).clifford(Clifford.H) + Measurement.YZ(1.75) + >>> Measurement.X.clifford(Clifford.S) + -Measurement.Y + >>> for pauli in PauliMeasurement: + ... for clifford in Clifford: + ... assert pauli.to_bloch().clifford(clifford).try_to_pauli() == pauli.clifford(clifford) + >>> Measurement.Y.clifford(Clifford.H).to_bloch() + Measurement.XY(1.5) + >>> Measurement.Y.to_bloch().clifford(Clifford.H) + Measurement.YZ(1.5) + """ + class AbstractPlanarMeasurement(AbstractMeasurement): """Abstract base class for planar measurement objects. @@ -328,6 +371,10 @@ class Axis(AbstractMeasurement, EnumReprMixin, Enum, metaclass=CustomMeta): def to_plane_or_axis(self) -> Axis: return self + @override + def clifford(self, clifford_gate: Clifford) -> Axis: + return clifford_gate.measure_axis(self).axis + class SingletonI(Enum): """Singleton I.""" @@ -457,3 +504,7 @@ def to_plane(self) -> Plane: Plane """ return self + + @override + def clifford(self, clifford_gate: Clifford) -> Plane: + return Plane.from_axes(*(axis.clifford(clifford_gate) for axis in self.axes)) diff --git a/graphix/measurements.py b/graphix/measurements.py index f702d7b05..55307b9d4 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -88,49 +88,6 @@ def XZ(angle: ParameterizedAngle) -> BlochMeasurement: # noqa: N802 """Return a Bloch measurement on the XZ plane.""" return BlochMeasurement(angle, Plane.XZ) - @abstractmethod - def clifford(self, clifford_gate: Clifford) -> Self: - r"""Return a new measurement command with a :class:`Clifford` applied. - - Parameters - ---------- - clifford_gate : Clifford - Clifford gate to apply before the measurement. - - Returns - ------- - Self - Equivalent measurement representing the pattern ``MC``. - - Notes - ----- - - The return type is ``Self``, meaning that a Clifford applied - to a Bloch measurement returns a Bloch measurement, and a - Clifford applied to a Pauli measurement returns a Pauli - measurement. - - The method :func:`Measurement.clifford` does not always - commute with the method :func:`Measurement.to_bloch`: the - underlying Pauli measurement will be the same but the Bloch - representation can be on different planes. - - Examples - -------- - >>> from graphix.clifford import Clifford - >>> from graphix.measurements import Measurement, PauliMeasurement - >>> Measurement.XY(0.25).clifford(Clifford.H) - Measurement.YZ(1.75) - >>> Measurement.X.clifford(Clifford.S) - -Measurement.Y - >>> for pauli in PauliMeasurement: - ... for clifford in Clifford: - ... assert pauli.to_bloch().clifford(clifford).try_to_pauli() == pauli.clifford(clifford) - >>> Measurement.Y.clifford(Clifford.H).to_bloch() - Measurement.XY(1.5) - >>> Measurement.Y.to_bloch().clifford(Clifford.H) - Measurement.YZ(1.5) - - """ - @abstractmethod def to_bloch(self) -> BlochMeasurement: """Return the measurement description as an angle and a plane on the Bloch sphere. @@ -362,7 +319,7 @@ def to_plane(self) -> Plane: @override def clifford(self, clifford_gate: Clifford) -> BlochMeasurement: - new_plane = Plane.from_axes(*(PauliMeasurement(axis).clifford(clifford_gate).axis for axis in self.plane.axes)) + new_plane = self.plane.clifford(clifford_gate) cos_pauli = PauliMeasurement(self.plane.cos).clifford(clifford_gate) sin_pauli = PauliMeasurement(self.plane.sin).clifford(clifford_gate) exchange = cos_pauli.axis != new_plane.cos diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 7a3bab19c..8400c1a59 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -2,12 +2,14 @@ from __future__ import annotations +import dataclasses from dataclasses import dataclass from typing import TYPE_CHECKING, Generic, TypeVar from warnings import warn import networkx as nx +from graphix.clifford import Clifford from graphix.flow._find_cflow import find_cflow from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix from graphix.flow.core import GFlow, PauliFlow @@ -48,6 +50,8 @@ class OpenGraph(Generic[_AM_co]): An ordered sequence of node labels corresponding to the open graph outputs. measurements : Mapping[int, _AM_co] A mapping between the non-output nodes of the open graph (``key``) and their corresponding measurement label (``value``). Measurement labels can be specified as `Measurement`, `Plane` or `Axis` instances. + output_cliffords: Mapping[int, Clifford] + A mapping between output nodes of the open graph (``key``) and Clifford operators. This attribute allows to preserve the semantics of (deterministic) patterns with local-clifford commands when extracting the open graph. See :meth:`Pattern.extract_opengraph`. Notes ----- @@ -70,6 +74,7 @@ class OpenGraph(Generic[_AM_co]): input_nodes: Sequence[int] output_nodes: Sequence[int] measurements: Mapping[int, _AM_co] + output_cliffords: Mapping[int, Clifford] = dataclasses.field(default_factory=dict) def __post_init__(self) -> None: """Validate the correctness of the open graph.""" @@ -91,6 +96,8 @@ def __post_init__(self) -> None: raise OpenGraphError("Input nodes contain duplicates.") if len(outputs) != len(self.output_nodes): raise OpenGraphError("Output nodes contain duplicates.") + if not outputs.issuperset(self.output_cliffords.keys()): + raise OpenGraphError("Cliffords in `output_cliffords` mapping can only act on output nodes.") def to_pattern(self: OpenGraph[Measurement], *, stacklevel: int = 1) -> Pattern: """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. @@ -145,7 +152,6 @@ def draw(self, **options: Unpack[DrawKwargs]) -> None: from graphix.visualization import GraphVisualizer # noqa: PLC0415 Avoid circular imports gv = GraphVisualizer.from_opengraph(og=self, **options) - gv.visualize() def map(self: OpenGraph[_A], f: Callable[[_A], _B]) -> OpenGraph[_B]: @@ -177,7 +183,7 @@ def map(self: OpenGraph[_A], f: Callable[[_A], _B]) -> OpenGraph[_B]: {0: Plane.XY, 1: Plane.YZ} """ measurements = {node: f(meas) for node, meas in self.measurements.items()} - return OpenGraph(self.graph, self.input_nodes, self.output_nodes, measurements) + return OpenGraph(self.graph, self.input_nodes, self.output_nodes, measurements, self.output_cliffords) def to_bloch(self: OpenGraph[Measurement]) -> OpenGraph[BlochMeasurement]: """Return the open graph where all measurements are converted to Bloch. @@ -288,6 +294,7 @@ def isclose(self, other: OpenGraph[_AM_co], rel_tol: float = 1e-09, abs_tol: flo - Truly equal underlying graphs (not up to an isomorphism). - Equal input and output nodes. - Same measurement planes or axes and approximately equal measurement angles if the open graph is of parametric type `Measurement`. + - Equal ``output_cliffords`` mappings. The static typer does not allow an ``isclose`` comparison of two open graphs with different parametric type. For a structural comparison, see :func:`OpenGraph.is_equal_structurally`. """ @@ -313,6 +320,7 @@ def is_equal_structurally(self, other: OpenGraph[AbstractMeasurement]) -> bool: This method verifies the open graphs have: - Truly equal underlying graphs (not up to an isomorphism). - Equal input and output nodes. This assumes equal types as well, i.e., if ``self.input_nodes`` is a ``list`` and ``other.input_nodes`` is a ``tuple``, this method will return ``False``. + - Equal ``output_cliffords`` mappings. It assumes the open graphs are well formed. The static typer allows comparing the structure of two open graphs with different parametric type. @@ -321,6 +329,7 @@ def is_equal_structurally(self, other: OpenGraph[AbstractMeasurement]) -> bool: nx.utils.graphs_equal(self.graph, other.graph) and self.input_nodes == other.input_nodes and self.output_nodes == other.output_nodes + and self.output_cliffords == other.output_cliffords ) def neighbors(self, nodes: Collection[int]) -> set[int]: @@ -667,26 +676,39 @@ def compose(self, other: OpenGraph[_AM_co], mapping: Mapping[int, int]) -> tuple The open graph composition requires that - :math:`V \subseteq V_2`. - If both `v` and `u` are measured, the corresponding measurements must have the same plane and angle. - The returned open graph follows this convention: + + The returned open graph follows this convention: - :math:`I = (I_1 \cup I_2) \setminus M \cup (I_1 \cap I_2 \cap M)`, - :math:`O = (O_1 \cup O_2) \setminus M \cup (O_1 \cap O_2 \cap M)`, - If only one node of the pair `{v:u}` is measured, this measure is assigned to :math:`u \in V` in the resulting open graph. - Input (and, respectively, output) nodes in the returned open graph have the order of the open graph `self` followed by those of the open graph `other`. Merged nodes are removed, except when they are input (or output) nodes in both open graphs, in which case, they appear in the order they originally had in the graph `self`. + - Clifford operations on output nodes are propagated to the output nodes of the resulting open graph or incorporated into measurements if the Clifford-decorated nodes were merged with measured nodes. """ if not (mapping.keys() <= other.graph.nodes): raise ValueError("Keys of mapping must be correspond to nodes of other.") if len(mapping) != len(set(mapping.values())): raise ValueError("Values in mapping contain duplicates.") + measurements = {**self.measurements} + measurements_other = {**other.measurements} + for v, u in mapping.items(): - if ( - (vm := other.measurements.get(v)) is not None - and (um := self.measurements.get(u)) is not None - and not vm.isclose(um) - ): - raise OpenGraphError(f"Attempted to merge nodes with different measurements: {v, vm} -> {u, um}.") + vm = other.measurements.get(v) + um = self.measurements.get(u) + + # Validate that shared measurements are compatible + if vm is not None and um is not None and not vm.isclose(um): + raise OpenGraphError(f"Cannot merge nodes with different measurements: {v, vm} -> {u, um}.") - shift = max(*self.graph.nodes, *mapping.values()) + 1 + # Apply other's output Cliffords onto self's measurement + if um is not None and (vc := other.output_cliffords.get(v)) is not None: + measurements[u] = um.clifford(vc) + + # Apply self's output Cliffords onto other's measurement + if vm is not None and (uc := self.output_cliffords.get(u)) is not None: + measurements_other[v] = vm.clifford(uc) + + shift = max((*self.graph.nodes, *mapping.values())) + 1 mapping_sequential = { node: i for i, node in enumerate(sorted(other.graph.nodes - mapping.keys()), start=shift) @@ -709,10 +731,19 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: inputs = merge_ports(self.input_nodes, other.input_nodes) outputs = merge_ports(self.output_nodes, other.output_nodes) - measurements_shifted = {mapping_complete[i]: meas for i, meas in other.measurements.items()} - measurements = {**self.measurements, **measurements_shifted} + measurements_shifted = {mapping_complete[i]: meas for i, meas in measurements_other.items()} + measurements.update(measurements_shifted) + + oset = set(outputs) + output_cliffords = {node: clifford for node, clifford in self.output_cliffords.items() if node in oset} + for node, clifford in other.output_cliffords.items(): + if (mapped_node := mapping_complete[node]) in oset: + # if a clifford `C` has been already applied to a node, + # applying a clifford `C'` to the same node is equivalent + # to applying `C'C` to a fresh node. + output_cliffords[mapped_node] = clifford @ output_cliffords.get(mapped_node, Clifford.I) - return OpenGraph(g, inputs, outputs, measurements), mapping_complete + return OpenGraph(g, inputs, outputs, measurements, output_cliffords), mapping_complete def subs(self: OpenGraph[_M], variable: Parameter, substitute: ExpressionOrSupportsFloat) -> OpenGraph[_M]: """Substitute a parameter with a value or expression in all measurement angles. diff --git a/graphix/optimization.py b/graphix/optimization.py index 9569f6028..99f33ea72 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -122,7 +122,7 @@ class StandardizedPattern(_StandardizedPattern): x_dict: Mapping[Node, frozenset[Node]] Mapping associating X-domains to some nodes. c_dict: Mapping[Node, Clifford] - Mapping associating Clifford corrections to some nodes. + Mapping associating Clifford corrections to some output nodes. """ @@ -346,10 +346,6 @@ def extract_opengraph(self) -> OpenGraph[Measurement]: ------ ValueError If ``N`` commands in the pattern do not represent a :math:`\ket{+}` state. - - Notes - ----- - This operation loses all the information on the Clifford commands. """ for n in self.n_list: if n.state != BasicStates.PLUS: @@ -357,7 +353,7 @@ def extract_opengraph(self) -> OpenGraph[Measurement]: f"Open graph construction in flow extraction requires N commands to represent a |+⟩ state. Error found in {n}." ) measurements = {m.node: m.measurement for m in self.m_list} - return OpenGraph(self.extract_graph(), self.input_nodes, self.output_nodes, measurements) + return OpenGraph(self.extract_graph(), self.input_nodes, self.output_nodes, measurements, self.c_dict) def extract_partial_order_layers(self) -> tuple[frozenset[int], ...]: """Extract the measurement order of the pattern in the form of layers. diff --git a/graphix/pattern.py b/graphix/pattern.py index 198d0577d..bcc18035b 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -25,7 +25,6 @@ from graphix.flow.exceptions import FlowError from graphix.fundamentals import Plane from graphix.measurements import BlochMeasurement, Measurement, Outcome, toggle_outcome -from graphix.opengraph import OpenGraph from graphix.pretty_print import OutputFormat, pattern_to_str from graphix.qasm3_exporter import pattern_to_qasm3_lines from graphix.sim import DensityMatrix, MBQCTensorNet, Statevec @@ -47,6 +46,7 @@ from graphix.clifford import Clifford from graphix.command import CommandType from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections + from graphix.opengraph import OpenGraph from graphix.parameter import ExpressionOrSupportsComplex, ExpressionOrSupportsFloat, Parameter from graphix.sim import Backend, Data, DensityMatrixBackend, StatevectorBackend from graphix.sim.base_backend import _StateT_co @@ -234,7 +234,7 @@ def compose( stacklevel=2, ) - shift = max(*nodes_p1, *mapping.values()) + 1 + shift = max((*nodes_p1, *mapping.values())) + 1 mapping_sequential = { node: i for i, node in enumerate(sorted(nodes_p2 - mapping.keys()), start=shift) } # assigns new labels to nodes in other not specified in mapping @@ -1110,44 +1110,16 @@ def extract_isolated_nodes(self) -> set[int]: def extract_opengraph(self) -> OpenGraph[Measurement]: r"""Extract the underlying resource-state open graph from the pattern. + This method standardizes the pattern first to guarantee that + Clifford commands are properly encoded in the resulting open graph. + Specifically, Cliffords acting on measured nodes are absorbed into measurements, + while Cliffords acting on output nodes are stored in ``OpenGraph.output_cliffords``. + Returns ------- OpenGraph[Measurement] - - Raises - ------ - ValueError - If `N` commands in the pattern do not represent a :math:`|+\rangle` state. - - Notes - ----- - This operation loses all the information on the Clifford commands. """ - nodes = set(self.input_nodes) - edges: set[tuple[int, int]] = set() - measurements: dict[int, Measurement] = {} - - for cmd in self.__seq: - match cmd.kind: - case CommandKind.N: - if cmd.state != BasicStates.PLUS: - raise PatternError( - f"Open graph extraction requires N commands to represent a |+⟩ state. Error found in {cmd}." - ) - nodes.add(cmd.node) - case CommandKind.E: - u, v = cmd.nodes - if u > v: - u, v = v, u - edges.symmetric_difference_update({(u, v)}) - case CommandKind.M: - measurements[cmd.node] = cmd.measurement - - graph = nx.Graph(edges) - graph.add_nodes_from(nodes) - - # Inputs and outputs are casted to `tuple` to replicate the behavior of `:func: graphix.opitmization.StandardizedPattern.extract_opengraph`. - return OpenGraph(graph, tuple(self.__input_nodes), tuple(self.__output_nodes), measurements) + return optimization.StandardizedPattern.from_pattern(self).extract_opengraph() def extract_clifford(self) -> dict[int, Clifford]: """Extract Clifford commands. @@ -1414,7 +1386,6 @@ def draw( *, annotations: DrawPatternAnnotations | None = DrawPatternAnnotations.Flow, flow_from_pattern: bool = True, - show_local_clifford: bool = False, stacklevel: int = 1, **options: Unpack[DrawKwargs], ) -> None: @@ -1429,8 +1400,6 @@ def draw( - ``None``: show the underlying open graph only. flow_from_pattern : bool, default=True If ``True``, the command sequence of the pattern is used to derive flow or gflow structure. If ``False``, only the underlying opengraph is used. - show_local_clifford : bool, default=False - If ``True``, the local Clifford operators are printed. options : Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. stacklevel : int, optional @@ -1446,9 +1415,6 @@ def draw( ----- If ``flow_from_pattern==True`` but the pattern is not compatible with a gflow, an attempt to be extract the flow from the underlying open graph will be made while warning the user. """ - lc = self.extract_clifford() if show_local_clifford else None - options.setdefault("local_clifford", lc) - if annotations is None: og = self.extract_opengraph() gv = GraphVisualizer.from_opengraph(og=og, **options) diff --git a/graphix/visualization.py b/graphix/visualization.py index 5821fc39f..8bd39d68b 100644 --- a/graphix/visualization.py +++ b/graphix/visualization.py @@ -30,7 +30,6 @@ # Unpack introduced in Python 3.12 from typing_extensions import Unpack - from graphix.clifford import Clifford from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement _Point: TypeAlias = tuple[float, float] @@ -115,8 +114,8 @@ class DrawKwargs(TypedDict, total=False): pauli_measurements: bool measurement_labels: bool + local_clifford: bool node_labels: bool | Mapping[int, str] - local_clifford: Mapping[int, Clifford] | None node_distance: tuple[float, float] legend: bool figsize: tuple[int, int] | None @@ -133,12 +132,11 @@ class VisualizationOptions: If ``True``, Pauli-measured nodes are highlighted with distinct coloring. measurement_labels : bool, default=False If ``True``, measurement labels (planes and axis) are displayed in the visualization. + local_clifford : bool, default=False + If ``True``, Clifford operations are displayed in the visualization. node_labels : bool | Mapping[int, str], default=True If ``True``, display numeric node labels. If a mapping, use custom labels for nodes specified in the mapping. - local_clifford : Mapping[int, Clifford] | None, default=None - Mapping of node identifiers to local Clifford operators. If provided, - operators are displayed on their corresponding nodes. node_distance : tuple[float, float], default=(1, 1) Scaling factors (x_scale, y_scale) applied to node positions. legend : bool, default=True @@ -152,8 +150,8 @@ class VisualizationOptions: pauli_measurements: bool = True measurement_labels: bool = False + local_clifford: bool = False node_labels: bool | Mapping[int, str] = True - local_clifford: Mapping[int, Clifford] | None = None node_distance: tuple[float, float] = (1, 1) legend: bool = True figsize: tuple[int, int] | None = None @@ -322,7 +320,7 @@ def visualize(self) -> None: if self.options.measurement_labels: self._draw_measurements_labels() - if self.options.local_clifford is not None: + if self.options.local_clifford: self._draw_local_clifford() self._set_plot_lims(plot_lims) @@ -553,10 +551,9 @@ def _draw_measurements_labels(self) -> None: def _draw_local_clifford(self) -> None: """Add text labels indicating Clifford commands.""" - assert self.options.local_clifford is not None - for node in self.options.local_clifford: + for node, clifford in self.og.output_cliffords.items(): x, y = self.pos[node] + np.array([0.2, 0.2]) - plt.text(x, y, f"{self.options.local_clifford[node]}", fontsize=LC_MEAS_FS, zorder=3) + plt.text(x, y, f"{clifford}", fontsize=LC_MEAS_FS, zorder=3) def _draw_legend(self) -> None: """Add legend to plot. diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 1fd2cac83..ab1e46637 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -13,7 +13,8 @@ import networkx as nx import pytest -from graphix.command import E +from graphix.clifford import Clifford +from graphix.command import C, E from graphix.fundamentals import ANGLE_PI, Axis, Plane from graphix.measurements import Measurement from graphix.opengraph import OpenGraph, OpenGraphError @@ -819,6 +820,175 @@ def _compose_5() -> OpenGraphComposeTestCase: return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) +# Merge clifford with measurement +@register_open_graph_compose_test_case +def _compose_6() -> OpenGraphComposeTestCase: + """Generate composition test with Cliffords. + + Graph 1 + [1] -- (2) + + Graph 2 = Graph 1 + + Mapping: 1 -> 2 + + Expected graph + [1] -- 2 -- (3) + + """ + g: nx.Graph[int] = nx.Graph([(1, 2)]) + inputs = [1] + outputs = [2] + meas = dict.fromkeys(g.nodes - set(outputs), Measurement.XY(0)) + og1 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.H}) + og2 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.X}) + og_ref = OpenGraph( + nx.Graph([(1, 2), (2, 3)]), + input_nodes=[1], + output_nodes=[3], + measurements={1: Measurement.XY(0), 2: Measurement.YZ(0)}, + output_cliffords={3: Clifford.X}, + ) + + mapping = {1: 2} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Merge clifford with measurement +@register_open_graph_compose_test_case +def _compose_7() -> OpenGraphComposeTestCase: + """Generate composition test with Cliffords. + + Graph 1 + [1] -- (2) + + Graph 2 = Graph 1 + + Mapping: 2 -> 1 + + Expected graph + [3] -- [1] -- (2) + + """ + g: nx.Graph[int] = nx.Graph([(1, 2)]) + inputs = [1] + outputs = [2] + meas = dict.fromkeys(g.nodes - set(outputs), Measurement.XY(0)) + og1 = OpenGraph(g, inputs, outputs, meas) + og2 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.Z}) + og_ref = OpenGraph( + nx.Graph([(1, 2), (1, 3)]), + input_nodes=[3], + output_nodes=[2], + measurements={1: Measurement.XY(1), 3: Measurement.XY(0)}, + ) + + mapping = {2: 1} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Merge outputs with Cliffords +@register_open_graph_compose_test_case +def _compose_8() -> OpenGraphComposeTestCase: + """Generate composition test with Cliffords. + + Graph 1 + [1] -- (2) + + Graph 2 = Graph 1 + + Mapping: 2 -> 2 + + Expected graph + [1] -- (2) -- [3] + + """ + g: nx.Graph[int] = nx.Graph([(1, 2)]) + inputs = [1] + outputs = [2] + meas = dict.fromkeys(g.nodes - set(outputs), Measurement.XY(0)) + og1 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.H}) + og2 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.Z}) + og_ref = OpenGraph( + nx.Graph([(1, 2), (2, 3)]), + input_nodes=[1, 3], + output_nodes=[2], + measurements={1: Measurement.XY(0), 3: Measurement.XY(0)}, + output_cliffords={2: Clifford.Z @ Clifford.H}, + ) + + mapping = {2: 2} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Parallel composition for single-node graph with empty mapping +@register_open_graph_compose_test_case +def _compose_9() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + [(0)] + + Graph 2 = Graph 1 + + Mapping: {} + + Expected graph + [(0)] [(1)] + + """ + g: nx.Graph[int] = nx.Graph() + g = nx.Graph() + g.add_node(0) + og: OpenGraph[Measurement] = OpenGraph(graph=g, input_nodes=[0], output_nodes=[0], measurements={}) + + g_ref: nx.Graph[int] = nx.Graph() + g_ref.add_nodes_from((0, 1)) + og_ref: OpenGraph[Measurement] = OpenGraph(graph=g_ref, input_nodes=[0, 1], output_nodes=[0, 1], measurements={}) + + mapping: dict[int, int] = {} + + return OpenGraphComposeTestCase(og, og, og_ref, mapping) + + +# Merge clifford with measurement +@register_open_graph_compose_test_case +def _compose_10() -> OpenGraphComposeTestCase: + """Generate composition test with Cliffords. + + Graph 1 + [1] -- (2) + + Graph 2 = Graph 1 + + Mapping: 1 -> 2 + + Expected graph + [1] -- 2 -- (3) + + """ + g: nx.Graph[int] = nx.Graph([(1, 2)]) + inputs = [1] + outputs = [2] + meas = dict.fromkeys(g.nodes - set(outputs), Plane.XY) + og1 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.H}) + og2 = OpenGraph(g, inputs, outputs, meas, output_cliffords={2: Clifford.X}) + og_ref = OpenGraph( + nx.Graph([(1, 2), (2, 3)]), + input_nodes=[1], + output_nodes=[3], + measurements={1: Plane.XY, 2: Plane.YZ}, + output_cliffords={3: Clifford.X}, + ) + + mapping = {1: 2} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: """Verify if the input pattern is deterministic.""" for plane in {Plane.XY, Plane.XZ, Plane.YZ}: @@ -996,12 +1166,14 @@ def test_isclose_axis(self) -> None: input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Axis.X), + output_cliffords={3: Clifford.H}, ) og_2 = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Axis.Y), + output_cliffords={3: Clifford.H}, ) assert not og_1.isclose(og_2) @@ -1014,30 +1186,35 @@ def test_is_equal_structurally(self) -> None: input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Measurement.XY(0.15)), + output_cliffords={3: Clifford.X}, ) og_2 = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Measurement.XY(0.1)), + output_cliffords={3: Clifford.X}, ) og_3 = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Plane.XY), + output_cliffords={3: Clifford.H @ Clifford.Z @ Clifford.H}, ) og_4 = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Axis.X), + output_cliffords={3: Clifford.X @ Clifford.I}, ) og_5 = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (2, 3), (0, 3)]), input_nodes=[0], output_nodes=[3], measurements=dict.fromkeys(range(3), Axis.X), + output_cliffords={3: Clifford.H}, ) assert og_1.is_equal_structurally(og_2) assert og_1.is_equal_structurally(og_3) @@ -1064,7 +1241,7 @@ def test_compose_exception(self) -> None: with pytest.raises( OpenGraphError, match=re.escape( - "Attempted to merge nodes with different measurements: (0, Measurement.Y) -> (0, Measurement.X)." + "Cannot merge nodes with different measurements: (0, Measurement.Y) -> (0, Measurement.X)." ), ): og1.compose(og2, mapping) @@ -1074,10 +1251,27 @@ def test_compose_exception(self) -> None: with pytest.raises( OpenGraphError, - match=re.escape("Attempted to merge nodes with different measurements: (0, Plane.XZ) -> (0, Plane.XY)."), + match=re.escape("Cannot merge nodes with different measurements: (0, Plane.XZ) -> (0, Plane.XY)."), ): og3.compose(og4, mapping) + def test_compose_clifford(self, fx_rng: Generator) -> None: + """Tests if open graph composition with Cliffords preserves the semantics of pattern composition with Cliffords.""" + p1 = Pattern(input_nodes=[0], cmds=[C(0, Clifford.Z)]) + p2 = Pattern(input_nodes=[0], cmds=[C(0, Clifford.H)]) + mapping = {0: 0} + pc, _ = p1.compose(p2, mapping) + + og1 = p1.extract_opengraph() + og2 = p2.extract_opengraph() + og_c, _ = og1.compose(og2, mapping) + pc_test = og_c.to_pattern() + + sv = pc.simulate_pattern(rng=fx_rng) + sv_test = pc_test.simulate_pattern(rng=fx_rng) + + assert sv.isclose(sv_test) + def test_subs(self) -> None: alpha = Placeholder("alpha") value = 0.3 diff --git a/tests/test_pattern.py b/tests/test_pattern.py index d340ffaf2..6a87c4016 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -615,6 +615,13 @@ def test_compose_3(self) -> None: assert p_1 == pc_1 assert p_2 == pc_2 + # Pattern composition for single-node graph with empty mapping + def test_compose_4(self) -> None: + p = Pattern(input_nodes=[0], cmds=[C(0, Clifford.Z)]) + p_test, _ = p.compose(p, mapping={}) + p_ref = Pattern(input_nodes=[0, 1], cmds=[C(0, Clifford.Z), C(1, Clifford.Z)]) + assert p_test == p_ref + # Equivalence between pattern and circuit composition def test_compose_5(self, fx_rng: Generator) -> None: circuit_1 = Circuit(1) @@ -1070,6 +1077,51 @@ def test_perform_pauli_pushing(self) -> None: original_pattern.to_bloch().perform_pauli_pushing() assert original_pattern.perform_pauli_pushing(copy=True, standardize=True).is_standard() + def test_extract_opengraph_standardization(self) -> None: + p = Pattern(cmds=[N(0), C(0, Clifford.H), M(0, Measurement.XY(0.3))]) + og = p.extract_opengraph() + p.standardize() + og_std = p.extract_opengraph() + + assert og.isclose(og_std) + + @pytest.mark.parametrize( + "pattern", + [ + Pattern( + input_nodes=[0], + cmds=[ + N(1), + E((0, 1)), + C(0, Clifford.H), + M(0, -Measurement.Z), + X(1, {0}), + C(1, Clifford.H), + C(1, Clifford.X), + ], + ), + Pattern( + cmds=[N(0), C(0, Clifford.H), C(0, Clifford.X), C(0, Clifford.Z)], + ), + Pattern( + input_nodes=[0], + cmds=[ + N(1), + E((0, 1)), + C(0, Clifford.S), + C(1, Clifford.X), + ], + ), + ], + ) + def test_extract_opengraph_roundtrip(self, pattern: Pattern, fx_rng: Generator) -> None: + pattern_test = pattern.extract_opengraph().to_pattern() + + sv = pattern.simulate_pattern(rng=fx_rng) + sv_test = pattern_test.simulate_pattern(rng=fx_rng) + + assert sv.isclose(sv_test) + def cp(circuit: Circuit, theta: Angle, control: int, target: int) -> None: """Controlled rotation gate, decomposed.""" # noqa: D401 diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 9c6dae1f6..c2f6942b2 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -107,11 +107,11 @@ def example_pflow(rng: Generator) -> Pattern: @pytest.mark.parametrize("flow_from_pattern", [False, True]) @pytest.mark.parametrize("measurement_labels", [False, True]) @pytest.mark.parametrize("pauli_measurements", [False, True]) -@pytest.mark.parametrize("show_local_clifford", [False, True]) +@pytest.mark.parametrize("local_clifford", [False, True]) def test_draw_pattern_flow( example: Callable[[Generator], Pattern], flow_from_pattern: bool, - show_local_clifford: bool, + local_clifford: bool, pauli_measurements: bool, measurement_labels: bool, fx_rng: Generator, @@ -121,7 +121,7 @@ def test_draw_pattern_flow( flow_from_pattern=flow_from_pattern, pauli_measurements=pauli_measurements, measurement_labels=measurement_labels, - show_local_clifford=show_local_clifford, + local_clifford=local_clifford, node_distance=(0.7, 0.6), ) plt.close() @@ -131,10 +131,10 @@ def test_draw_pattern_flow( @pytest.mark.parametrize("example", [example_flow, example_gflow, example_pflow]) @pytest.mark.parametrize("measurement_labels", [False, True]) @pytest.mark.parametrize("pauli_measurements", [False, True]) -@pytest.mark.parametrize("show_local_clifford", [False, True]) +@pytest.mark.parametrize("local_clifford", [False, True]) def test_draw_pattern_xzcorrections( example: Callable[[Generator], Pattern], - show_local_clifford: bool, + local_clifford: bool, pauli_measurements: bool, measurement_labels: bool, fx_rng: Generator, @@ -144,7 +144,7 @@ def test_draw_pattern_xzcorrections( annotations=DrawPatternAnnotations.XZCorrections, pauli_measurements=pauli_measurements, measurement_labels=measurement_labels, - show_local_clifford=show_local_clifford, + local_clifford=local_clifford, node_distance=(0.7, 0.6), ) plt.close()