From 49ff9cd88594703394e17142d92e9af6bf69524a Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 13 May 2026 17:06:40 +0200 Subject: [PATCH 1/9] Update is_close, is_equal, compose, to_pattern, extract_opengraph --- graphix/flow/core.py | 5 +- graphix/opengraph.py | 81 ++++++++++++++++++++---- graphix/optimization.py | 6 +- graphix/pattern.py | 42 +++---------- tests/test_opengraph.py | 132 +++++++++++++++++++++++++++++++++++++++- tests/test_pattern.py | 45 ++++++++++++++ 6 files changed, 256 insertions(+), 55 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 10e02f347..f614116ee 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -16,7 +16,7 @@ from typing_extensions import assert_never, override from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponentialDAG, PauliString -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, @@ -192,6 +192,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 diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 7a3bab19c..bae8b1352 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. @@ -177,7 +184,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 +295,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 +321,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 +330,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,24 +677,64 @@ 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: + - If we attempt to merge a measured node with a Clifford-decorated node the measure label must implement the method ``clifford`` to absorb the Clifford command, that is, the measure label must be a subtype of ``Measurement``. + + 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}.") + + # To comply with mypy, we could define a runtime-checkable Protocol: + # + # from typing import Protocol, Self, runtime_checkable + # @runtime_checkable + # class HasClifford(Protocol): + # def clifford(self, clifford_gate: Clifford) -> Self: ... + # + # if not isinstance(um, HasClifford): + # raise OpenGraphError("...") + # measurements[u] = um.clifford(vc) + # + # This informs mypy that `um` has the `clifford` attribute + # without narrowing the type of `um` so we can still assign it to + # measurements: dict[int, _AM_co] + # However, `isinstance` with protocols is disadvised since it can decrease + # performance significantly. + # https://typing.python.org/en/latest/reference/protocols.html + + # Apply other's output Cliffords onto self's measurement + if um is not None and (vc := other.output_cliffords.get(v)) is not None: + if not hasattr(um, "clifford"): + raise OpenGraphError( + f"Cannot merge a measured node with a Clifford-decorated node because the measurement label cannot absorb the Clifford: {v, vc} -> {u, um}" + ) + measurements[u] = um.clifford(vc) # type: ignore[attr-defined] + + # Apply self's output Cliffords onto other's measurement + if vm is not None and (uc := self.output_cliffords.get(u)) is not None: + if not hasattr(vm, "clifford"): + raise OpenGraphError( + f"Cannot merge a measured node with a Clifford-decorated node because the measurement label cannot absorb the Clifford: {v, vm} -> {u, uc}" + ) + measurements_other[v] = vm.clifford(uc) # type: ignore[attr-defined] shift = max(*self.graph.nodes, *mapping.values()) + 1 @@ -709,10 +759,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 5b23d51c1..e5358ddcb 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -433,10 +433,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: @@ -444,7 +440,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 c28bf7555..f5f115c05 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -27,7 +27,6 @@ from graphix.fundamentals import Axis, Plane, Sign from graphix.graphsim import GraphState from graphix.measurements import BlochMeasurement, Measurement, Outcome, PauliMeasurement, 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 @@ -48,6 +47,7 @@ from graphix.command import CommandType from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections + from graphix.opengraph import OpenGraph from graphix.optimization import StandardizedPattern from graphix.parameter import ExpressionOrSupportsComplex, ExpressionOrSupportsFloat, Parameter from graphix.sim import Backend, Data, DensityMatrixBackend, StatevectorBackend @@ -1139,44 +1139,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 proceeds by standardizing 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. diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 1fd2cac83..d32687fe6 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,107 @@ def _compose_5() -> OpenGraphComposeTestCase: return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) +@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) + + +@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) + + +@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) + + 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 +1098,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 +1118,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 +1173,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 +1183,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 948c5d9ca..a01cb92b6 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -1168,6 +1168,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 From 13be79842facb04a41acc5c9ea92d1cb8a4c4091 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 13 May 2026 17:40:53 +0200 Subject: [PATCH 2/9] Up visualization module --- examples/visualization.py | 2 +- graphix/opengraph.py | 1 - graphix/pattern.py | 14 +++++------ graphix/visualization.py | 46 +++++++++++++++++++++++++++++-------- tests/test_visualization.py | 12 +++++----- 5 files changed, 50 insertions(+), 25 deletions(-) diff --git a/examples/visualization.py b/examples/visualization.py index 0aa642b2e..9971e6507 100644 --- a/examples/visualization.py +++ b/examples/visualization.py @@ -55,7 +55,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/opengraph.py b/graphix/opengraph.py index bae8b1352..41a2132dd 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -152,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]: diff --git a/graphix/pattern.py b/graphix/pattern.py index f5f115c05..21cfc9e04 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1437,7 +1437,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: @@ -1452,8 +1451,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 @@ -1469,12 +1466,11 @@ 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) + local_clifford_map = self.extract_clifford() if options.get("local_clifford") else None if annotations is None: og = self.extract_opengraph() - gv = GraphVisualizer.from_opengraph(og=og, **options) + gv = GraphVisualizer.from_opengraph(og=og, local_clifford_map=local_clifford_map, **options) else: match annotations: case DrawPatternAnnotations.Flow: @@ -1508,11 +1504,13 @@ def draw( "The pattern's open graph does not have Pauli flow. Consider setting the `annotations` parameter to `None` or `DrawPatternAnnotations.XZCorrections`." ) - gv = GraphVisualizer.from_flow(flow=flow, **options) + gv = GraphVisualizer.from_flow(flow=flow, local_clifford_map=local_clifford_map, **options) case DrawPatternAnnotations.XZCorrections: xzcorrections = self.extract_xzcorrections() - gv = GraphVisualizer.from_xzcorrections(xz_corr=xzcorrections, **options) + gv = GraphVisualizer.from_xzcorrections( + xz_corr=xzcorrections, local_clifford_map=local_clifford_map, **options + ) gv.visualize() diff --git a/graphix/visualization.py b/graphix/visualization.py index 5821fc39f..d3f81b816 100644 --- a/graphix/visualization.py +++ b/graphix/visualization.py @@ -115,8 +115,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 +133,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 +151,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 @@ -172,6 +171,8 @@ class GraphVisualizer: Mapping of node identifiers to (x, y) coordinates. edge_paths : Mapping[_Edge, _Path] Bezier curve paths for graph edges. + local_clifford_map: Mapping[int, Clifford] + Mapping of node identifiers to Clifford operations. arrow_paths : Mapping[_Edge, Colored[_Path]] Colored bezier curve paths for correction dependency arrows. n_layers : int @@ -195,6 +196,7 @@ class GraphVisualizer: og: OpenGraph[AbstractMeasurement] pos: Mapping[int, _Point] edge_paths: Mapping[_Edge, _Path] + local_clifford_map: Mapping[int, Clifford] arrow_paths: Mapping[_Edge, Colored[_Path]] | None = None n_layers: int | None = None options: VisualizationOptions = dataclasses.field(default_factory=VisualizationOptions) @@ -203,6 +205,7 @@ class GraphVisualizer: @staticmethod def from_opengraph( og: OpenGraph[AbstractMeasurement], + local_clifford_map: Mapping[int, Clifford] | None = None, **kwargs: Unpack[DrawKwargs], ) -> GraphVisualizer: """Create a ``GraphVisualizer`` from an ``OpenGraph`` instance. @@ -210,6 +213,10 @@ def from_opengraph( Parameters ---------- og : OpenGraph[AbstractMeasurement] + local_clifford_map: Mapping[int, Clifford] | None, default=None + Mapping of node identifiers to Clifford operations. If ``None``, + it is read out from the open graph's Clifford-outputs mapping. + Display is controlled by ``VisualizationOptions.local_clifford``. options: Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. @@ -222,10 +229,14 @@ def from_opengraph( pos = _scale_positions(pos, options.node_distance) edge_paths = _compute_edge_paths(og, pos) + if local_clifford_map is None: + local_clifford_map = og.output_cliffords + return GraphVisualizer( og=og, pos=pos, edge_paths=edge_paths, + local_clifford_map=local_clifford_map, options=options, _source=_Source.OG, ) @@ -233,6 +244,7 @@ def from_opengraph( @staticmethod def from_flow( flow: PauliFlow[AbstractMeasurement], + local_clifford_map: Mapping[int, Clifford] | None = None, **kwargs: Unpack[DrawKwargs], ) -> GraphVisualizer: """Create a ``GraphVisualizer`` from a ``PauliFlow`` instance. @@ -240,6 +252,10 @@ def from_flow( Parameters ---------- flow : PauliFlow[AbstractMeasurement] + local_clifford_map: Mapping[int, Clifford] | None, default=None + Mapping of node identifiers to Clifford operations. If ``None``, + it is read out from the open graph's Clifford-outputs mapping. + Display is controlled by ``VisualizationOptions.local_clifford``. options: Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. @@ -255,10 +271,14 @@ def from_flow( arrow_paths = _compute_arrow_paths(pos, flow.og.graph.edges(), corrections) n_layers = len(flow.partial_order_layers) + if local_clifford_map is None: + local_clifford_map = flow.og.output_cliffords + return GraphVisualizer( og=flow.og, pos=pos, edge_paths=edge_paths, + local_clifford_map=local_clifford_map, arrow_paths=arrow_paths, n_layers=n_layers, options=options, @@ -268,6 +288,7 @@ def from_flow( @staticmethod def from_xzcorrections( xz_corr: XZCorrections[AbstractMeasurement], + local_clifford_map: Mapping[int, Clifford] | None = None, **kwargs: Unpack[DrawKwargs], ) -> GraphVisualizer: """Create a ``GraphVisualizer`` from an ``XZCorrections`` instance. @@ -275,6 +296,10 @@ def from_xzcorrections( Parameters ---------- xz_corr : XZCorrections[AbstractMeasurement] + local_clifford_map: Mapping[int, Clifford] | None, default=None + Mapping of node identifiers to Clifford operations. If ``None``, + it is read out from the open graph's Clifford-outputs mapping. + Display is controlled by ``VisualizationOptions.local_clifford``. options: Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. @@ -290,10 +315,14 @@ def from_xzcorrections( arrow_paths = _compute_arrow_paths(pos, xz_corr.og.graph.edges(), corrections) n_layers = len(xz_corr.partial_order_layers) + if local_clifford_map is None: + local_clifford_map = xz_corr.og.output_cliffords + return GraphVisualizer( og=xz_corr.og, pos=pos, edge_paths=edge_paths, + local_clifford_map=local_clifford_map, arrow_paths=arrow_paths, n_layers=n_layers, options=options, @@ -322,7 +351,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 +582,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.local_clifford_map.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_visualization.py b/tests/test_visualization.py index e1ee898b7..87642ddfd 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() From dcea284d6a4cb33e4778eb39da22200d4b64c52c Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 18 May 2026 13:32:58 +0200 Subject: [PATCH 3/9] Fix old bug in og compose --- graphix/opengraph.py | 2 +- graphix/optimization.py | 2 +- graphix/pattern.py | 4 ++-- tests/test_opengraph.py | 33 +++++++++++++++++++++++++++++++++ tests/test_pattern.py | 7 +++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 41a2132dd..5374b02fb 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -735,7 +735,7 @@ def compose(self, other: OpenGraph[_AM_co], mapping: Mapping[int, int]) -> tuple ) measurements_other[v] = vm.clifford(uc) # type: ignore[attr-defined] - shift = max(*self.graph.nodes, *mapping.values()) + 1 + 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) diff --git a/graphix/optimization.py b/graphix/optimization.py index e5358ddcb..89480e82b 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -128,7 +128,7 @@ class StandardizedPattern(_StandardizedPattern): m_list: tuple[command.M] The M commands. c_dict: Mapping[Node, Clifford] - Mapping associating Clifford corrections to some nodes. + Mapping associating Clifford corrections to some output nodes. z_dict: Mapping[Node, frozenset[Node]] Mapping associating Z-domains to some nodes. x_dict: Mapping[Node, frozenset[Node]] diff --git a/graphix/pattern.py b/graphix/pattern.py index 21cfc9e04..c5d6d0d29 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -256,7 +256,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 @@ -1139,7 +1139,7 @@ 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 proceeds by standardizing the pattern first to guarantee that + 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``. diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index d32687fe6..3086c8eec 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -820,6 +820,7 @@ 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. @@ -854,6 +855,7 @@ def _compose_6() -> OpenGraphComposeTestCase: 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. @@ -887,6 +889,7 @@ def _compose_7() -> OpenGraphComposeTestCase: 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. @@ -921,6 +924,36 @@ def _compose_8() -> OpenGraphComposeTestCase: 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) + + 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}: diff --git a/tests/test_pattern.py b/tests/test_pattern.py index a01cb92b6..709cf857e 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -703,6 +703,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) From ea87d6b7b181d7cf0f385bea5e2e8144ef3b9575 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 18 May 2026 13:43:27 +0200 Subject: [PATCH 4/9] Add not implemented error in circuit extraction --- graphix/flow/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index f614116ee..8ce85f7e5 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -856,6 +856,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) From 05d540f1e1f45e7fae867648522d6b06da76dd89 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 21 May 2026 12:04:54 +0200 Subject: [PATCH 5/9] Fix docs --- graphix/optimization.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index c16f92309..99f33ea72 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -117,14 +117,12 @@ class StandardizedPattern(_StandardizedPattern): Set of edges. Each edge is a set with two elements. m_list: tuple[command.M] The M commands. - c_dict: Mapping[Node, Clifford] - Mapping associating Clifford corrections to some output nodes. z_dict: Mapping[Node, frozenset[Node]] Mapping associating Z-domains to some nodes. 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. """ From 40b928ff5a9e09995b31040386f429b5f602531f Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 21 May 2026 18:20:31 +0200 Subject: [PATCH 6/9] Implement method `clifford` for all `AbstractMeasurement` --- graphix/clifford.py | 19 +++++++++------ graphix/fundamentals.py | 53 ++++++++++++++++++++++++++++++++++++++++- graphix/measurements.py | 45 +--------------------------------- graphix/opengraph.py | 12 ++-------- 4 files changed, 67 insertions(+), 62 deletions(-) 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/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 5374b02fb..75f7fae8d 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -721,19 +721,11 @@ def compose(self, other: OpenGraph[_AM_co], mapping: Mapping[int, int]) -> tuple # Apply other's output Cliffords onto self's measurement if um is not None and (vc := other.output_cliffords.get(v)) is not None: - if not hasattr(um, "clifford"): - raise OpenGraphError( - f"Cannot merge a measured node with a Clifford-decorated node because the measurement label cannot absorb the Clifford: {v, vc} -> {u, um}" - ) - measurements[u] = um.clifford(vc) # type: ignore[attr-defined] + 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: - if not hasattr(vm, "clifford"): - raise OpenGraphError( - f"Cannot merge a measured node with a Clifford-decorated node because the measurement label cannot absorb the Clifford: {v, vm} -> {u, uc}" - ) - measurements_other[v] = vm.clifford(uc) # type: ignore[attr-defined] + measurements_other[v] = vm.clifford(uc) shift = max((*self.graph.nodes, *mapping.values())) + 1 From e3cae0d7fbe4e9b635b6558db1e260bd453d9190 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 22 May 2026 14:11:22 +0200 Subject: [PATCH 7/9] Add test composition with plane --- graphix/opengraph.py | 19 ------------------- tests/test_opengraph.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 75f7fae8d..8400c1a59 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -676,7 +676,6 @@ 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. - - If we attempt to merge a measured node with a Clifford-decorated node the measure label must implement the method ``clifford`` to absorb the Clifford command, that is, the measure label must be a subtype of ``Measurement``. The returned open graph follows this convention: - :math:`I = (I_1 \cup I_2) \setminus M \cup (I_1 \cap I_2 \cap M)`, @@ -701,24 +700,6 @@ def compose(self, other: OpenGraph[_AM_co], mapping: Mapping[int, int]) -> tuple 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}.") - # To comply with mypy, we could define a runtime-checkable Protocol: - # - # from typing import Protocol, Self, runtime_checkable - # @runtime_checkable - # class HasClifford(Protocol): - # def clifford(self, clifford_gate: Clifford) -> Self: ... - # - # if not isinstance(um, HasClifford): - # raise OpenGraphError("...") - # measurements[u] = um.clifford(vc) - # - # This informs mypy that `um` has the `clifford` attribute - # without narrowing the type of `um` so we can still assign it to - # measurements: dict[int, _AM_co] - # However, `isinstance` with protocols is disadvised since it can decrease - # performance significantly. - # https://typing.python.org/en/latest/reference/protocols.html - # 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) diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 3086c8eec..ab1e46637 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -954,6 +954,41 @@ def _compose_9() -> OpenGraphComposeTestCase: 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}: From 40841d37d8c5628435ee969ce386fd42edc06119 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 22 May 2026 14:17:19 +0200 Subject: [PATCH 8/9] Fix visualization clifford --- graphix/flow/core.py | 18 ++---------------- graphix/pattern.py | 10 +++------- graphix/visualization.py | 33 +-------------------------------- 3 files changed, 6 insertions(+), 55 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 9e57a342a..fa674c3fd 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -795,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 diff --git a/graphix/pattern.py b/graphix/pattern.py index 24b2d4bf5..bcc18035b 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1415,11 +1415,9 @@ 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. """ - local_clifford_map = self.extract_clifford() if options.get("local_clifford") else None - if annotations is None: og = self.extract_opengraph() - gv = GraphVisualizer.from_opengraph(og=og, local_clifford_map=local_clifford_map, **options) + gv = GraphVisualizer.from_opengraph(og=og, **options) else: match annotations: case DrawPatternAnnotations.Flow: @@ -1457,13 +1455,11 @@ def draw( "The pattern's open graph does not have Pauli flow. Consider setting the `annotations` parameter to `None` or `DrawPatternAnnotations.XZCorrections`." ) - gv = GraphVisualizer.from_flow(flow=flow, local_clifford_map=local_clifford_map, **options) + gv = GraphVisualizer.from_flow(flow=flow, **options) case DrawPatternAnnotations.XZCorrections: xzcorrections = self.extract_xzcorrections() - gv = GraphVisualizer.from_xzcorrections( - xz_corr=xzcorrections, local_clifford_map=local_clifford_map, **options - ) + gv = GraphVisualizer.from_xzcorrections(xz_corr=xzcorrections, **options) gv.visualize() diff --git a/graphix/visualization.py b/graphix/visualization.py index d3f81b816..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] @@ -171,8 +170,6 @@ class GraphVisualizer: Mapping of node identifiers to (x, y) coordinates. edge_paths : Mapping[_Edge, _Path] Bezier curve paths for graph edges. - local_clifford_map: Mapping[int, Clifford] - Mapping of node identifiers to Clifford operations. arrow_paths : Mapping[_Edge, Colored[_Path]] Colored bezier curve paths for correction dependency arrows. n_layers : int @@ -196,7 +193,6 @@ class GraphVisualizer: og: OpenGraph[AbstractMeasurement] pos: Mapping[int, _Point] edge_paths: Mapping[_Edge, _Path] - local_clifford_map: Mapping[int, Clifford] arrow_paths: Mapping[_Edge, Colored[_Path]] | None = None n_layers: int | None = None options: VisualizationOptions = dataclasses.field(default_factory=VisualizationOptions) @@ -205,7 +201,6 @@ class GraphVisualizer: @staticmethod def from_opengraph( og: OpenGraph[AbstractMeasurement], - local_clifford_map: Mapping[int, Clifford] | None = None, **kwargs: Unpack[DrawKwargs], ) -> GraphVisualizer: """Create a ``GraphVisualizer`` from an ``OpenGraph`` instance. @@ -213,10 +208,6 @@ def from_opengraph( Parameters ---------- og : OpenGraph[AbstractMeasurement] - local_clifford_map: Mapping[int, Clifford] | None, default=None - Mapping of node identifiers to Clifford operations. If ``None``, - it is read out from the open graph's Clifford-outputs mapping. - Display is controlled by ``VisualizationOptions.local_clifford``. options: Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. @@ -229,14 +220,10 @@ def from_opengraph( pos = _scale_positions(pos, options.node_distance) edge_paths = _compute_edge_paths(og, pos) - if local_clifford_map is None: - local_clifford_map = og.output_cliffords - return GraphVisualizer( og=og, pos=pos, edge_paths=edge_paths, - local_clifford_map=local_clifford_map, options=options, _source=_Source.OG, ) @@ -244,7 +231,6 @@ def from_opengraph( @staticmethod def from_flow( flow: PauliFlow[AbstractMeasurement], - local_clifford_map: Mapping[int, Clifford] | None = None, **kwargs: Unpack[DrawKwargs], ) -> GraphVisualizer: """Create a ``GraphVisualizer`` from a ``PauliFlow`` instance. @@ -252,10 +238,6 @@ def from_flow( Parameters ---------- flow : PauliFlow[AbstractMeasurement] - local_clifford_map: Mapping[int, Clifford] | None, default=None - Mapping of node identifiers to Clifford operations. If ``None``, - it is read out from the open graph's Clifford-outputs mapping. - Display is controlled by ``VisualizationOptions.local_clifford``. options: Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. @@ -271,14 +253,10 @@ def from_flow( arrow_paths = _compute_arrow_paths(pos, flow.og.graph.edges(), corrections) n_layers = len(flow.partial_order_layers) - if local_clifford_map is None: - local_clifford_map = flow.og.output_cliffords - return GraphVisualizer( og=flow.og, pos=pos, edge_paths=edge_paths, - local_clifford_map=local_clifford_map, arrow_paths=arrow_paths, n_layers=n_layers, options=options, @@ -288,7 +266,6 @@ def from_flow( @staticmethod def from_xzcorrections( xz_corr: XZCorrections[AbstractMeasurement], - local_clifford_map: Mapping[int, Clifford] | None = None, **kwargs: Unpack[DrawKwargs], ) -> GraphVisualizer: """Create a ``GraphVisualizer`` from an ``XZCorrections`` instance. @@ -296,10 +273,6 @@ def from_xzcorrections( Parameters ---------- xz_corr : XZCorrections[AbstractMeasurement] - local_clifford_map: Mapping[int, Clifford] | None, default=None - Mapping of node identifiers to Clifford operations. If ``None``, - it is read out from the open graph's Clifford-outputs mapping. - Display is controlled by ``VisualizationOptions.local_clifford``. options: Unpack[DrawKwargs] Options controlling graph visualization. See :class:`VisualizationOptions`. @@ -315,14 +288,10 @@ def from_xzcorrections( arrow_paths = _compute_arrow_paths(pos, xz_corr.og.graph.edges(), corrections) n_layers = len(xz_corr.partial_order_layers) - if local_clifford_map is None: - local_clifford_map = xz_corr.og.output_cliffords - return GraphVisualizer( og=xz_corr.og, pos=pos, edge_paths=edge_paths, - local_clifford_map=local_clifford_map, arrow_paths=arrow_paths, n_layers=n_layers, options=options, @@ -582,7 +551,7 @@ def _draw_measurements_labels(self) -> None: def _draw_local_clifford(self) -> None: """Add text labels indicating Clifford commands.""" - for node, clifford in self.local_clifford_map.items(): + 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"{clifford}", fontsize=LC_MEAS_FS, zorder=3) From fdd655ace8131e72ea01a05c7b316bdf464dde0d Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 22 May 2026 14:24:19 +0200 Subject: [PATCH 9/9] Up CHANGELOG --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 092bf1a67..15a0f114a 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,15 @@ 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. + + ## [0.3.5] - 2026-03-26 ### Added