From fdc194288cde413ac16d326edffc2089ea05c1db Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 13 Nov 2025 13:54:21 +0100 Subject: [PATCH 01/11] Fix #193: add `Pattern.check_runnability` This commit adds the method `Pattern.check_runnability` that ensures a pattern is runnable. This covers #193, given that determinism is already checkable with Pauli flow finding, and pattern concatenation is already implemented by `Pattern.compose`. --- graphix/pattern.py | 111 ++++++++++++++++++++++++++++++++++++------ tests/test_pattern.py | 62 ++++++++++++++++++++++- 2 files changed, 157 insertions(+), 16 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index 2c96d77b..c3732eca 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -7,15 +7,17 @@ import copy import dataclasses +import enum import warnings from collections.abc import Iterable, Iterator from copy import deepcopy from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, SupportsFloat, TypeVar import networkx as nx -from typing_extensions import assert_never, override +from typing_extensions import assert_never from graphix import command, optimization, parameter from graphix.clifford import Clifford @@ -43,18 +45,6 @@ _StateT_co = TypeVar("_StateT_co", bound="BackendState", covariant=True) -@dataclass(frozen=True) -class NodeAlreadyPreparedError(Exception): - """Exception raised if a node is already prepared.""" - - node: int - - @override - def __str__(self) -> str: - """Return the message of the error.""" - return f"Node already prepared: {self.node}" - - class Pattern: """ MBQC pattern class. @@ -135,10 +125,12 @@ def add(self, cmd: Command) -> None: """ if cmd.kind == CommandKind.N: if cmd.node in self.__output_nodes: - raise NodeAlreadyPreparedError(cmd.node) + raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.AlreadyActive) self.__n_node += 1 self.__output_nodes.append(cmd.node) elif cmd.kind == CommandKind.M: + if cmd.node not in self.__output_nodes: + raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.AlreadyMeasured) self.__output_nodes.remove(cmd.node) self.__seq.append(cmd) @@ -537,7 +529,7 @@ def expand_domain(domain: set[command.Node]) -> None: elif plane == Plane.XZ: # M^{XZ,α} X^s Z^t = M^{XZ,(-1)^t((-1)^s·α+sπ)} # = M^{XZ,(-1)^{s+t}·α+(-1)^t·sπ} - # = M^{XZ,(-1)^{s+t}·α+sπ (since (-1)^t·π ≡ π (mod 2π)) + # = M^{XZ,(-1)^{s+t}·α+sπ} (since (-1)^t·π ≡ π (mod 2π)) # = S^s M^{XZ,(-1)^{s+t}·α} # = S^s M^{XZ,α} Z^{s+t} if s_domain: @@ -900,6 +892,7 @@ def get_layers(self) -> tuple[int, dict[int, set[int]]]: layers : dict of set nodes grouped by layer index(k) """ + self.check_runnability() # prevent infinite loop: e.g., [N(0), M(0, s_domain={0})] dependency = self._get_dependency() measured = self.results.keys() self.update_dependency(measured, dependency) @@ -1592,6 +1585,94 @@ def expand_domain(domain: set[int]) -> None: new_seq.extend(pauli_nodes.values()) self.__seq = new_seq + def check_runnability(self) -> None: + """Check whether the pattern is runnable. + + Raises `RunnabilityError` exception if it is not. + """ + active = set(self.input_nodes) + measured = set(self.results) + + def check_active(cmd: Command, node: int) -> None: + if node in measured: + raise RunnabilityError(cmd, node, RunnabilityErrorReason.AlreadyMeasured) + if node not in active: + raise RunnabilityError(cmd, node, RunnabilityErrorReason.NotYetActive) + + def check_measured(cmd: Command, node: int) -> None: + if node not in measured: + raise RunnabilityError(cmd, node, RunnabilityErrorReason.NotYetMeasured) + + for cmd in self: + if cmd.kind == CommandKind.N: + if cmd.node in active: + raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.AlreadyActive) + if cmd.node in measured: + raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.AlreadyMeasured) + active.add(cmd.node) + elif cmd.kind == CommandKind.E: + n0, n1 = cmd.nodes + check_active(cmd, n0) + check_active(cmd, n1) + elif cmd.kind == CommandKind.M: + check_active(cmd, cmd.node) + for domain in cmd.s_domain, cmd.t_domain: + if cmd.node in domain: + raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.DomainSelfLoop) + for node in domain: + check_measured(cmd, node) + active.remove(cmd.node) + measured.add(cmd.node) + # Use of `==` here for mypy + elif cmd.kind == CommandKind.X or cmd.kind == CommandKind.Z: # noqa: PLR1714 + check_active(cmd, cmd.node) + for node in cmd.domain: + check_measured(cmd, node) + elif cmd.kind == CommandKind.C: + check_active(cmd, cmd.node) + + +class RunnabilityErrorReason(Enum): + """Describe the reason for a pattern not being runnable.""" + + AlreadyActive = enum.auto() + """A node is prepared whereas it has already been prepared or it is an input node.""" + + AlreadyMeasured = enum.auto() + """A node is measured for a second time.""" + + NotYetActive = enum.auto() + """A node is entangled, measured or corrected whereas it has not been prepared yet and it is not an input node.""" + + NotYetMeasured = enum.auto() + """A node appears in the domain of a measurement of a correction whereas it has not been measured yet.""" + + DomainSelfLoop = enum.auto() + """A node appears in the domain of its own measurement. This is a particular case of `NotYetMeasured`, introduced to make the error message clearer.""" + + +@dataclass +class RunnabilityError(Exception): + """Error raised by :method:`Pattern.check_runnability`.""" + + cmd: Command + node: int + reason: RunnabilityErrorReason + + def __str__(self) -> str: + """Explain the error.""" + if self.reason == RunnabilityErrorReason.AlreadyActive: + return f"{self.cmd}: node {self.node} is already active." + if self.reason == RunnabilityErrorReason.AlreadyMeasured: + return f"{self.cmd}: node {self.node} is already measured." + if self.reason == RunnabilityErrorReason.NotYetActive: + return f"{self.cmd}: node {self.node} is not yet active." + if self.reason == RunnabilityErrorReason.NotYetMeasured: + return f"{self.cmd}: node {self.node} is not yet measured." + if self.reason == RunnabilityErrorReason.DomainSelfLoop: + return f"{self.cmd}: node {self.node} appears in the domain of its own measurement command." + assert_never(self.reason) + def measure_pauli(pattern: Pattern, leave_input: bool, copy: bool = False) -> Pattern: """Perform Pauli measurement of a pattern by fast graph state simulator. diff --git a/tests/test_pattern.py b/tests/test_pattern.py index eef7cfb6..2dddd851 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -15,7 +15,7 @@ from graphix.command import C, Command, CommandKind, E, M, N, X, Z from graphix.fundamentals import Plane from graphix.measurements import Outcome, PauliMeasurement -from graphix.pattern import Pattern, shift_outcomes +from graphix.pattern import Pattern, RunnabilityError, RunnabilityErrorReason, shift_outcomes from graphix.random_objects import rand_circuit, rand_gate from graphix.sim.density_matrix import DensityMatrix from graphix.sim.statevec import Statevec @@ -684,6 +684,66 @@ def test_compose_7(self, fx_rng: Generator) -> None: ): p1.compose(p2, mapping={0: 3}) + def test_check_runnability_success(self, fx_rng: Generator) -> None: + nqubits = 5 + depth = 5 + circuit = rand_circuit(nqubits, depth, fx_rng) + pattern = circuit.transpile().pattern + pattern.check_runnability() + + def test_check_runnability_failures(self) -> None: + with pytest.raises(RunnabilityError) as exc_info: + pattern = Pattern(input_nodes=[0], cmds=[N(0)]) + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.AlreadyActive + + with pytest.raises(RunnabilityError) as exc_info: + pattern = Pattern(cmds=[N(1), N(1)]) + assert exc_info.value.node == 1 + assert exc_info.value.reason == RunnabilityErrorReason.AlreadyActive + + pattern = Pattern(cmds=[E((2, 3))]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.check_runnability() + assert exc_info.value.node == 2 + assert exc_info.value.reason == RunnabilityErrorReason.NotYetActive + + pattern = Pattern(cmds=[N(2), E((2, 3))]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.check_runnability() + assert exc_info.value.node == 3 + assert exc_info.value.reason == RunnabilityErrorReason.NotYetActive + + pattern = Pattern(cmds=[N(1), M(1, s_domain={0})]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.check_runnability() + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.NotYetMeasured + + with pytest.raises(RunnabilityError) as exc_info: + pattern = Pattern(cmds=[N(1), M(1), M(1)]) + assert exc_info.value.node == 1 + assert exc_info.value.reason == RunnabilityErrorReason.AlreadyMeasured + + pattern = Pattern(cmds=[N(0), M(0)]) + pattern.results = {0: 0} + with pytest.raises(RunnabilityError) as exc_info: + pattern.check_runnability() + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.AlreadyMeasured + + pattern = Pattern(cmds=[N(0), M(0, s_domain={0})]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.check_runnability() + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.DomainSelfLoop + + pattern = Pattern(cmds=[N(0), M(0, s_domain={0})]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.get_layers() + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.DomainSelfLoop + def cp(circuit: Circuit, theta: float, control: int, target: int) -> None: """Controlled rotation gate, decomposed.""" # noqa: D401 From 49616d8108e42f4af2755378f4648856994095ca Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 20 Nov 2025 10:51:46 +0100 Subject: [PATCH 02/11] Remove partial runnability checks in `Pattern.add` --- graphix/pattern.py | 7 ++----- tests/test_pattern.py | 9 ++++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index fa81d09a..bf1bd3a0 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -124,14 +124,11 @@ def add(self, cmd: Command) -> None: MBQC command. """ if cmd.kind == CommandKind.N: - if cmd.node in self.__output_nodes: - raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.AlreadyActive) self.__n_node += 1 self.__output_nodes.append(cmd.node) elif cmd.kind == CommandKind.M: - if cmd.node not in self.__output_nodes: - raise RunnabilityError(cmd, cmd.node, RunnabilityErrorReason.AlreadyMeasured) - self.__output_nodes.remove(cmd.node) + if cmd.node in self.__output_nodes: + self.__output_nodes.remove(cmd.node) self.__seq.append(cmd) def extend(self, *cmds: Command | Iterable[Command]) -> None: diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 2dddd851..9fbca50d 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -692,13 +692,15 @@ def test_check_runnability_success(self, fx_rng: Generator) -> None: pattern.check_runnability() def test_check_runnability_failures(self) -> None: + pattern = Pattern(input_nodes=[0], cmds=[N(0)]) with pytest.raises(RunnabilityError) as exc_info: - pattern = Pattern(input_nodes=[0], cmds=[N(0)]) + pattern.check_runnability() assert exc_info.value.node == 0 assert exc_info.value.reason == RunnabilityErrorReason.AlreadyActive + pattern = Pattern(cmds=[N(1), N(1)]) with pytest.raises(RunnabilityError) as exc_info: - pattern = Pattern(cmds=[N(1), N(1)]) + pattern.check_runnability() assert exc_info.value.node == 1 assert exc_info.value.reason == RunnabilityErrorReason.AlreadyActive @@ -720,8 +722,9 @@ def test_check_runnability_failures(self) -> None: assert exc_info.value.node == 0 assert exc_info.value.reason == RunnabilityErrorReason.NotYetMeasured + pattern = Pattern(cmds=[N(1), M(1), M(1)]) with pytest.raises(RunnabilityError) as exc_info: - pattern = Pattern(cmds=[N(1), M(1), M(1)]) + pattern.check_runnability() assert exc_info.value.node == 1 assert exc_info.value.reason == RunnabilityErrorReason.AlreadyMeasured From dca5d805d2458b69522a955cfb2199d26b638175 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 20 Nov 2025 11:14:59 +0100 Subject: [PATCH 03/11] Fix compute_max_degree for empty patterns --- graphix/pattern.py | 5 ++++- tests/test_pattern.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index bf1bd3a0..4a716944 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1096,7 +1096,10 @@ def compute_max_degree(self) -> int: graph = self.extract_graph() degree = graph.degree() assert isinstance(degree, nx.classes.reportviews.DiDegreeView) - return int(max(dict(degree).values())) + degrees = dict(degree).values() + if len(degrees) == 0: + return 0 + return int(max(degrees)) def extract_graph(self) -> nx.Graph[int]: """Return the graph state from the command sequence, extracted from 'N' and 'E' commands. diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 9fbca50d..b61c11a7 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -747,6 +747,9 @@ def test_check_runnability_failures(self) -> None: assert exc_info.value.node == 0 assert exc_info.value.reason == RunnabilityErrorReason.DomainSelfLoop + def test_compute_max_degree_empty_pattern(self) -> None: + assert Pattern().compute_max_degree() == 0 + def cp(circuit: Circuit, theta: float, control: int, target: int) -> None: """Controlled rotation gate, decomposed.""" # noqa: D401 From 44c46e0fca581a5903aacd645a6d467390ca27be Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 20 Nov 2025 11:57:36 +0100 Subject: [PATCH 04/11] Fix visualization for empty pattern --- graphix/visualization.py | 17 ++++++++++------- tests/test_visualization.py | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/graphix/visualization.py b/graphix/visualization.py index 3736ec86..9c68bce1 100644 --- a/graphix/visualization.py +++ b/graphix/visualization.py @@ -277,7 +277,7 @@ def _shorten_path(path: Sequence[_Point]) -> list[_Point]: def _draw_labels(self, pos: Mapping[int, _Point]) -> None: fontsize = 12 - if max(self.graph.nodes()) >= 100: + if max(self.graph.nodes(), default=0) >= 100: fontsize = int(fontsize * 2 / len(str(max(self.graph.nodes())))) nx.draw_networkx_labels(self.graph, pos, font_size=fontsize) @@ -441,12 +441,12 @@ def visualize_graph( plt.plot([], [], color="tab:brown", label="xflow and zflow") plt.legend(loc="upper right", fontsize=10) - x_min = min(pos[node][0] for node in self.graph.nodes()) # Get the minimum x coordinate - x_max = max(pos[node][0] for node in self.graph.nodes()) # Get the maximum x coordinate - y_min = min(pos[node][1] for node in self.graph.nodes()) # Get the minimum y coordinate - y_max = max(pos[node][1] for node in self.graph.nodes()) # Get the maximum y coordinate + x_min = min((pos[node][0] for node in self.graph.nodes()), default=0) # Get the minimum x coordinate + x_max = max((pos[node][0] for node in self.graph.nodes()), default=0) # Get the maximum x coordinate + y_min = min((pos[node][1] for node in self.graph.nodes()), default=0) # Get the minimum y coordinate + y_max = max((pos[node][1] for node in self.graph.nodes()), default=0) # Get the maximum y coordinate - if l_k is not None: + if l_k is not None and l_k: # Draw the vertical lines to separate different layers for layer in range(min(l_k.values()), max(l_k.values())): plt.axvline( @@ -506,7 +506,7 @@ def get_figsize( raise ValueError("Figure size can only be computed given a layer mapping (l_k) or node positions (pos)") width = len({pos[node][0] for node in self.graph.nodes()}) * 0.8 else: - width = (max(l_k.values()) + 1) * 0.8 + width = (max(l_k.values(), default=0) + 1) * 0.8 height = len({pos[node][1] for node in self.graph.nodes()}) if pos is not None else len(self.v_out) return (width * node_distance[0], height * node_distance[1]) @@ -658,6 +658,9 @@ def get_pos_from_flow(self, f: Mapping[int, set[int]], l_k: Mapping[int, int]) - node = next(iter(f[node])) pos[node][1] = i + if not l_k: + return {} + lmax = max(l_k.values()) # Change the x coordinates of the nodes based on their layer, sort in descending order for node, layer in l_k.items(): diff --git a/tests/test_visualization.py b/tests/test_visualization.py index a5604565..a89cf45b 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -149,6 +149,12 @@ def test_custom_corrections() -> None: vis.visualize_from_pattern(pattern) +@pytest.mark.usefixtures("mock_plot") +def test_empty_pattern() -> None: + pattern = Pattern() + pattern.draw_graph() + + # Compare with baseline/test_draw_graph_reference.png # Update baseline by running: pytest --mpl-generate-path=tests/baseline @pytest.mark.usefixtures("mock_plot") From 6b2a55a0e98cd4c5ef773f443a9faf4603cc9efb Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 20 Nov 2025 17:05:44 +0100 Subject: [PATCH 05/11] Check runnability before standardizing or simulating --- graphix/optimization.py | 2 ++ graphix/simulator.py | 2 ++ tests/test_pattern.py | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/graphix/optimization.py b/graphix/optimization.py index 3172f1fe..0217e31a 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -95,6 +95,8 @@ def __init__(self, pattern: Pattern) -> None: self.z_dict = {} self.x_dict = {} + pattern.check_runnability() + for cmd in pattern: if cmd.kind == CommandKind.N: self.n_list.append(cmd) diff --git a/graphix/simulator.py b/graphix/simulator.py index dc1ce6db..2f1425b9 100644 --- a/graphix/simulator.py +++ b/graphix/simulator.py @@ -302,6 +302,8 @@ def run(self, input_state: Data = BasicStates.PLUS, rng: Generator | None = None pattern = self.noise_model.input_nodes(self.pattern.input_nodes, rng=rng) if input_state is not None else [] pattern.extend(self.noise_model.transpile(self.pattern, rng=rng)) + pattern.check_runnability() + for cmd in pattern: if cmd.kind == CommandKind.N: self.backend.add_nodes(nodes=[cmd.node], data=cmd.state) diff --git a/tests/test_pattern.py b/tests/test_pattern.py index b61c11a7..ec940f55 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -728,6 +728,12 @@ def test_check_runnability_failures(self) -> None: assert exc_info.value.node == 1 assert exc_info.value.reason == RunnabilityErrorReason.AlreadyMeasured + pattern = Pattern(cmds=[M(0)]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.check_runnability() + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.NotYetActive + pattern = Pattern(cmds=[N(0), M(0)]) pattern.results = {0: 0} with pytest.raises(RunnabilityError) as exc_info: @@ -747,6 +753,12 @@ def test_check_runnability_failures(self) -> None: assert exc_info.value.node == 0 assert exc_info.value.reason == RunnabilityErrorReason.DomainSelfLoop + pattern = Pattern(cmds=[N(0), M(0, s_domain={0})]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.simulate_pattern() + assert exc_info.value.node == 0 + assert exc_info.value.reason == RunnabilityErrorReason.DomainSelfLoop + def test_compute_max_degree_empty_pattern(self) -> None: assert Pattern().compute_max_degree() == 0 From 4e8bb3eefa8e95512e3f10e5397a0edb34dd534c Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 20 Nov 2025 17:42:23 +0100 Subject: [PATCH 06/11] Checking for runnability for `shift_signals` and comments --- graphix/optimization.py | 5 +++++ graphix/pattern.py | 7 +++++++ graphix/simulator.py | 4 +++- tests/test_pattern.py | 6 ++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 0217e31a..31da9226 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -95,6 +95,11 @@ def __init__(self, pattern: Pattern) -> None: self.z_dict = {} self.x_dict = {} + # Standardization could turn non-runnable patterns into + # runnable ones, so we check runnability first to avoid hiding + # code-logic errors. + # For example, the non-runnable pattern E(0,1) M(0) N(1) N(0) would + # become M(0) E(0,1) N(1) N(0), which is runnable. pattern.check_runnability() for cmd in pattern: diff --git a/graphix/pattern.py b/graphix/pattern.py index 4a716944..76669c2d 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -467,6 +467,13 @@ def shift_signals(self, method: str = "direct") -> dict[int, set[int]]: signal_dict : dict[int, set[int]] For each node, the signal that have been shifted. """ + # Shifting signals could turn non-runnable patterns into + # runnable ones, so we check runnability first to avoid hiding + # code-logic errors. + # For example, the non-runnable pattern {1}[M(0)] N(0) would + # become M(0) N(0), which is runnable. + self.check_runnability() + if method == "direct": return self.shift_signals_direct() if method == "mc": diff --git a/graphix/simulator.py b/graphix/simulator.py index 2f1425b9..f4490ac4 100644 --- a/graphix/simulator.py +++ b/graphix/simulator.py @@ -302,7 +302,9 @@ def run(self, input_state: Data = BasicStates.PLUS, rng: Generator | None = None pattern = self.noise_model.input_nodes(self.pattern.input_nodes, rng=rng) if input_state is not None else [] pattern.extend(self.noise_model.transpile(self.pattern, rng=rng)) - pattern.check_runnability() + # We check runnability first to provide clearer error messages and + # to catch these errors before starting the simulation. + self.pattern.check_runnability() for cmd in pattern: if cmd.kind == CommandKind.N: diff --git a/tests/test_pattern.py b/tests/test_pattern.py index ec940f55..a084ef58 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -759,6 +759,12 @@ def test_check_runnability_failures(self) -> None: assert exc_info.value.node == 0 assert exc_info.value.reason == RunnabilityErrorReason.DomainSelfLoop + pattern = Pattern(cmds=[N(0), M(0, s_domain={1})]) + with pytest.raises(RunnabilityError) as exc_info: + pattern.shift_signals() + assert exc_info.value.node == 1 + assert exc_info.value.reason == RunnabilityErrorReason.NotYetMeasured + def test_compute_max_degree_empty_pattern(self) -> None: assert Pattern().compute_max_degree() == 0 From d9c81f363558e970504b212f7d0abd09dcd48641 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 20 Nov 2025 18:18:32 +0100 Subject: [PATCH 07/11] Ensure move_pauli_measurements_to_the_front preserves runnability --- graphix/pattern.py | 14 ++++++++------ tests/test_pattern.py | 7 +++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index 76669c2d..7ae8ead5 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1576,12 +1576,14 @@ def expand_domain(domain: set[int]) -> None: new_seq: list[Command] = [] pauli_nodes_inserted = False for cmd in self: - if cmd.kind == CommandKind.M: - if cmd.node not in pauli_nodes: - if not pauli_nodes_inserted: - new_seq.extend(pauli_nodes.values()) - pauli_nodes_inserted = True - new_seq.append(cmd) + if (cmd.kind == CommandKind.M and cmd.node not in pauli_nodes) or cmd.kind in { + CommandKind.X, + CommandKind.Z, + }: + if not pauli_nodes_inserted: + new_seq.extend(pauli_nodes.values()) + pauli_nodes_inserted = True + new_seq.append(cmd) else: new_seq.append(cmd) if not pauli_nodes_inserted: diff --git a/tests/test_pattern.py b/tests/test_pattern.py index a084ef58..28c035d4 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -768,6 +768,13 @@ def test_check_runnability_failures(self) -> None: def test_compute_max_degree_empty_pattern(self) -> None: assert Pattern().compute_max_degree() == 0 + def test_move_pauli_measurements_to_the_front_preserves_runnability(self) -> None: + circuit = Circuit(width=1) + circuit.h(0) + pattern = circuit.transpile().pattern + pattern.move_pauli_measurements_to_the_front() + pattern.check_runnability() + def cp(circuit: Circuit, theta: float, control: int, target: int) -> None: """Controlled rotation gate, decomposed.""" # noqa: D401 From 82acc0b585ec00225f00312849c7fbf724b2dfc9 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 21 Nov 2025 15:20:03 +0100 Subject: [PATCH 08/11] Move perform_pauli_pushing to StandardizedPattern --- graphix/optimization.py | 73 ++++++++++++++++++++++++- graphix/pattern.py | 107 ++++++------------------------------- tests/test_optimization.py | 1 + tests/test_pattern.py | 14 ----- 4 files changed, 88 insertions(+), 107 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 31da9226..40e84753 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import copy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, assert_never import networkx as nx @@ -11,7 +11,8 @@ from graphix import command from graphix.clifford import Clifford from graphix.command import CommandKind, Node -from graphix.measurements import Domains +from graphix.fundamentals import Axis +from graphix.measurements import Domains, PauliMeasurement if TYPE_CHECKING: from collections.abc import Mapping @@ -166,6 +167,74 @@ def extract_graph(self) -> nx.Graph[int]: graph.add_edge(u, v) return graph + def perform_pauli_pushing(self, leave_nodes: set[Node] | None = None) -> None: + """Move all Pauli measurements before the other measurements (except nodes in `leave_nodes`).""" + if leave_nodes is None: + leave_nodes = set() + shift_domains: dict[int, set[int]] = {} + + def expand_domain(domain: set[int]) -> set[int]: + """Merge previously shifted domains into ``domain``. + + Parameters + ---------- + domain : set[int] + Domain to update with any accumulated shift information. + """ + new_domain = set(domain) + for node in domain & shift_domains.keys(): + new_domain ^= shift_domains[node] + return new_domain + + pauli_list = [] + non_pauli_list = [] + for cmd in self.m_list: + s_domain = expand_domain(cmd.s_domain) + t_domain = expand_domain(cmd.t_domain) + pm = PauliMeasurement.try_from( + cmd.plane, cmd.angle + ) # None returned if the measurement is not in Pauli basis + if pm is None or cmd.node in leave_nodes: + non_pauli_list.append( + command.M(node=cmd.node, angle=cmd.angle, plane=cmd.plane, s_domain=s_domain, t_domain=t_domain) + ) + else: + if pm.axis == Axis.X: + # M^X X^s Z^t = M^{XY,0} X^s Z^t + # = M^{XY,(-1)^s·0+tπ} + # = S^t M^X + # M^{-X} X^s Z^t = M^{XY,π} X^s Z^t + # = M^{XY,(-1)^s·π+tπ} + # = S^t M^{-X} + shift_domains[cmd.node] = t_domain + elif pm.axis == Axis.Y: + # M^Y X^s Z^t = M^{XY,π/2} X^s Z^t + # = M^{XY,(-1)^s·π/2+tπ} + # = M^{XY,π/2+(s+t)π} (since -π/2 = π/2 - π ≡ π/2 + π (mod 2π)) + # = S^{s+t} M^Y + # M^{-Y} X^s Z^t = M^{XY,-π/2} X^s Z^t + # = M^{XY,(-1)^s·(-π/2)+tπ} + # = M^{XY,-π/2+(s+t)π} (since π/2 = -π/2 + π) + # = S^{s+t} M^{-Y} + shift_domains[cmd.node] = s_domain ^ t_domain + elif pm.axis == Axis.Z: + # M^Z X^s Z^t = M^{XZ,0} X^s Z^t + # = M^{XZ,(-1)^t((-1)^s·0+sπ)} + # = M^{XZ,(-1)^t·sπ} + # = M^{XZ,sπ} (since (-1)^t·π ≡ π (mod 2π)) + # = S^s M^Z + # M^{-Z} X^s Z^t = M^{XZ,π} X^s Z^t + # = M^{XZ,(-1)^t((-1)^s·π+sπ)} + # = M^{XZ,(s+1)π} + # = S^s M^{-Z} + shift_domains[cmd.node] = s_domain + else: + assert_never(pm.axis) + pauli_list.append(command.M(node=cmd.node, angle=cmd.angle, plane=cmd.plane)) + self.m_list = pauli_list + non_pauli_list + self.z_dict = {node: expand_domain(domain) for node, domain in self.z_dict.items()} + self.x_dict = {node: expand_domain(domain) for node, domain in self.x_dict.items()} + def to_pattern(self) -> Pattern: """Return the standardized pattern.""" pattern = graphix.pattern.Pattern(input_nodes=self.pattern.input_nodes) diff --git a/graphix/pattern.py b/graphix/pattern.py index 7ae8ead5..84e26fdb 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1378,9 +1378,9 @@ def perform_pauli_measurements(self, leave_input: bool = False, ignore_pauli_wit .. seealso:: :func:`measure_pauli` """ - if not ignore_pauli_with_deps: - self.move_pauli_measurements_to_the_front() - measure_pauli(self, leave_input, copy=False) + # if not ignore_pauli_with_deps: + # self.move_pauli_measurements_to_the_front() + measure_pauli(self, leave_input, copy=False, ignore_pauli_with_deps=ignore_pauli_with_deps) def draw_graph( self, @@ -1507,89 +1507,6 @@ def copy(self) -> Pattern: result.results = self.results.copy() return result - def move_pauli_measurements_to_the_front(self, leave_nodes: set[int] | None = None) -> None: - """Move all the Pauli measurements to the front of the sequence (except nodes in `leave_nodes`).""" - if leave_nodes is None: - leave_nodes = set() - self.standardize() - pauli_nodes = {} - shift_domains: dict[int, set[int]] = {} - - def expand_domain(domain: set[int]) -> None: - """Merge previously shifted domains into ``domain``. - - Parameters - ---------- - domain : set[int] - Domain to update with any accumulated shift information. - """ - for node in domain & shift_domains.keys(): - domain ^= shift_domains[node] - - for cmd in self: - # Use of == for mypy - if cmd.kind == CommandKind.X or cmd.kind == CommandKind.Z: # noqa: PLR1714 - expand_domain(cmd.domain) - if cmd.kind == CommandKind.M: - expand_domain(cmd.s_domain) - expand_domain(cmd.t_domain) - pm = PauliMeasurement.try_from( - cmd.plane, cmd.angle - ) # None returned if the measurement is not in Pauli basis - if pm is not None and cmd.node not in leave_nodes: - if pm.axis == Axis.X: - # M^X X^s Z^t = M^{XY,0} X^s Z^t - # = M^{XY,(-1)^s·0+tπ} - # = S^t M^X - # M^{-X} X^s Z^t = M^{XY,π} X^s Z^t - # = M^{XY,(-1)^s·π+tπ} - # = S^t M^{-X} - shift_domains[cmd.node] = cmd.t_domain - elif pm.axis == Axis.Y: - # M^Y X^s Z^t = M^{XY,π/2} X^s Z^t - # = M^{XY,(-1)^s·π/2+tπ} - # = M^{XY,π/2+(s+t)π} (since -π/2 = π/2 - π ≡ π/2 + π (mod 2π)) - # = S^{s+t} M^Y - # M^{-Y} X^s Z^t = M^{XY,-π/2} X^s Z^t - # = M^{XY,(-1)^s·(-π/2)+tπ} - # = M^{XY,-π/2+(s+t)π} (since π/2 = -π/2 + π) - # = S^{s+t} M^{-Y} - shift_domains[cmd.node] = cmd.s_domain ^ cmd.t_domain - elif pm.axis == Axis.Z: - # M^Z X^s Z^t = M^{XZ,0} X^s Z^t - # = M^{XZ,(-1)^t((-1)^s·0+sπ)} - # = M^{XZ,(-1)^t·sπ} - # = M^{XZ,sπ} (since (-1)^t·π ≡ π (mod 2π)) - # = S^s M^Z - # M^{-Z} X^s Z^t = M^{XZ,π} X^s Z^t - # = M^{XZ,(-1)^t((-1)^s·π+sπ)} - # = M^{XZ,(s+1)π} - # = S^s M^{-Z} - shift_domains[cmd.node] = cmd.s_domain - else: - assert_never(pm.axis) - cmd.s_domain = set() - cmd.t_domain = set() - pauli_nodes[cmd.node] = cmd - - # Create a new sequence with all Pauli nodes to the front - new_seq: list[Command] = [] - pauli_nodes_inserted = False - for cmd in self: - if (cmd.kind == CommandKind.M and cmd.node not in pauli_nodes) or cmd.kind in { - CommandKind.X, - CommandKind.Z, - }: - if not pauli_nodes_inserted: - new_seq.extend(pauli_nodes.values()) - pauli_nodes_inserted = True - new_seq.append(cmd) - else: - new_seq.append(cmd) - if not pauli_nodes_inserted: - new_seq.extend(pauli_nodes.values()) - self.__seq = new_seq - def check_runnability(self) -> None: """Check whether the pattern is runnable. @@ -1679,7 +1596,9 @@ def __str__(self) -> str: assert_never(self.reason) -def measure_pauli(pattern: Pattern, leave_input: bool, copy: bool = False) -> Pattern: +def measure_pauli( + pattern: Pattern, leave_input: bool, *, copy: bool = False, ignore_pauli_with_deps: bool = False +) -> Pattern: """Perform Pauli measurement of a pattern by fast graph state simulator. Uses the decorated-graph method implemented in graphix.graphsim to perform @@ -1697,6 +1616,10 @@ def measure_pauli(pattern: Pattern, leave_input: bool, copy: bool = False) -> Pa copy : bool True: changes will be applied to new copied object and will be returned False: changes will be applied to the supplied Pattern object + ignore_pauli_with_deps : bool + Optional (*False* by default). + If *True*, Pauli measurements with domains depending on other measures are preserved as-is in the pattern. + If *False*, all Pauli measurements are preprocessed. Formally, measurements are swapped so that all Pauli measurements are applied first, and domains are updated accordingly. Returns ------- @@ -1708,6 +1631,9 @@ def measure_pauli(pattern: Pattern, leave_input: bool, copy: bool = False) -> Pa .. seealso:: :class:`graphix.graphsim.GraphState` """ standardized_pattern = optimization.StandardizedPattern(pattern) + if not ignore_pauli_with_deps: + standardized_pattern.perform_pauli_pushing() + output_nodes = pattern.output_nodes graph = standardized_pattern.extract_graph() graph_state = GraphState(nodes=graph.nodes, edges=graph.edges, vops=standardized_pattern.c_dict) results: dict[int, Outcome] = {} @@ -1767,16 +1693,15 @@ def measure_pauli(pattern: Pattern, leave_input: bool, copy: bool = False) -> Pa new_seq.extend(command.N(node=index) for index in set(graph_state.nodes) - set(new_inputs)) new_seq.extend(command.E(nodes=edge) for edge in graph_state.edges) new_seq.extend( - cmd.clifford(Clifford(vops[cmd.node])) - for cmd in pattern - if cmd.kind == CommandKind.M and cmd.node in graph_state.nodes + cmd.clifford(Clifford(vops[cmd.node])) for cmd in standardized_pattern.m_list if cmd.node in graph_state.nodes ) new_seq.extend( command.C(node=index, clifford=Clifford(vops[index])) for index in pattern.output_nodes if vops[index] != Clifford.I ) - new_seq.extend(cmd for cmd in pattern if cmd.kind in {CommandKind.X, CommandKind.Z}) + new_seq.extend(command.Z(node=node, domain=domain) for node, domain in standardized_pattern.z_dict.items()) + new_seq.extend(command.X(node=node, domain=domain) for node, domain in standardized_pattern.x_dict.items()) pat = Pattern() if copy else pattern diff --git a/tests/test_optimization.py b/tests/test_optimization.py index dfc78549..44b8d7d4 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -78,6 +78,7 @@ def test_flow_after_pauli_preprocessing(fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() + # pattern.move_pauli_measurements_to_the_front() pattern.perform_pauli_measurements() pattern2 = incorporate_pauli_results(pattern) pattern2.standardize() diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 28c035d4..3d65755a 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -768,13 +768,6 @@ def test_check_runnability_failures(self) -> None: def test_compute_max_degree_empty_pattern(self) -> None: assert Pattern().compute_max_degree() == 0 - def test_move_pauli_measurements_to_the_front_preserves_runnability(self) -> None: - circuit = Circuit(width=1) - circuit.h(0) - pattern = circuit.transpile().pattern - pattern.move_pauli_measurements_to_the_front() - pattern.check_runnability() - def cp(circuit: Circuit, theta: float, control: int, target: int) -> None: """Controlled rotation gate, decomposed.""" # noqa: D401 @@ -972,13 +965,6 @@ def test_arbitrary_inputs_tn(self, fx_rng: Generator, nqb: int, rand_circ: Circu backend="tensornetwork", graph_prep="sequential", input_state=states, rng=fx_rng ) - def test_remove_qubit(self) -> None: - p = Pattern(input_nodes=[0, 1]) - p.add(M(node=0)) - p.add(C(node=0, clifford=Clifford.X)) - with pytest.raises(KeyError): - p.simulate_pattern() - def assert_equal_edge(edge: Sequence[int], ref: Sequence[int]) -> bool: return any(all(ei == ri for ei, ri in zip(edge, other)) for other in (ref, reversed(ref))) From 5d8ba0614f14547233fa686cbb17ca21d247c172 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 21 Nov 2025 15:31:56 +0100 Subject: [PATCH 09/11] Import assert_never from typing_extensions --- graphix/optimization.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 40e84753..27ba0db4 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -3,7 +3,10 @@ from __future__ import annotations from copy import copy -from typing import TYPE_CHECKING, assert_never +from typing import TYPE_CHECKING + +# assert_never added in Python 3.11 +from typing_extension import assert_never import networkx as nx From 36a3129a7bf9cd6f11ab8a6d75524c821c4507c9 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 21 Nov 2025 16:08:51 +0100 Subject: [PATCH 10/11] Fix typo --- graphix/optimization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 27ba0db4..fc939f12 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -5,11 +5,11 @@ from copy import copy from typing import TYPE_CHECKING -# assert_never added in Python 3.11 -from typing_extension import assert_never - import networkx as nx +# assert_never added in Python 3.11 +from typing_extensions import assert_never + import graphix.pattern from graphix import command from graphix.clifford import Clifford From 7962710285243ffa1433406ddb638671f5c8fdff Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 25 Nov 2025 07:22:34 +0100 Subject: [PATCH 11/11] Update CHANGELOG --- CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c0bb47..ddc5fda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- #364: `Pattern.simulate_pattern`, `Pattern.standardize`, - `Pattern.perform_pauli_measurements`, `Pattern.minimize_space`, - `Pattern.get_layers` check that the pattern is runnable beforehand. +- #364: `Pattern.simulate_pattern`, `Pattern.shift_signals`, + `Pattern.standardize`, `Pattern.perform_pauli_measurements`, + `Pattern.minimize_space`, `Pattern.get_layers` check that the + pattern is runnable beforehand. + +- #364: `Pattern.compute_max_degree` and `Pattern.draw_graph` no longer + fail on empty patterns. + ### Changed @@ -37,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #361: `StandardizedPattern` is now an immutable dataclass. The class method `StandardizedPattern.from_pattern` instantiates a `StandardizedPattern` from `Pattern`. +- #364: `StandardizedPattern.perform_pauli_pushing` replaces `Pattern.move_pauli_measurements_to_the_front`. + - #371: Drop support for Python 3.9 ## [0.3.3] - 2025-10-23