From ef7d956571b2d04f1a085c8f1f3a83a41c04d53a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 10:03:49 +0200 Subject: [PATCH 01/40] Fix #168: Remove Pauli measurements This commit implements the algorithm described in [BMBdF+21], Theorem 4.12 in Section 4.3: Removing Clifford vertices. The new method `Pattern.remove_pauli_measurements` removes almost all Pauli measurements on non-input nodes, and all of them if the pattern has flow, fixing #168. [BMBdF+21] Miriam Backens, Hector Miller-Bakewell, Giovanni de Felice, Leo Lobski, and John van de Wetering, There and back again: A circuit extraction tale, Quantum, 2021, https://doi.org/10.22331/q-2021-03-25-421 --- graphix/command.py | 19 ++++++ graphix/optimization.py | 133 ++++++++++++++-------------------------- graphix/pattern.py | 43 ++++++++++++- 3 files changed, 106 insertions(+), 89 deletions(-) diff --git a/graphix/command.py b/graphix/command.py index b1c5951d8..8dcad749e 100644 --- a/graphix/command.py +++ b/graphix/command.py @@ -14,6 +14,9 @@ from graphix.repr_mixins import DataclassReprMixin from graphix.states import BasicStates, State +if TYPE_CHECKING: + from collections.abc import Callable + Node: TypeAlias = int logger = logging.getLogger(__name__) @@ -141,6 +144,22 @@ def clifford(self, clifford_gate: Clifford) -> M: domains.t_domain, ) + def map(self, f: Callable[[Measurement], Measurement]) -> M: + """Return a measurement command where the function ``f`` has been applied to the measurement. + + Parameters + ---------- + f: Callable[[Measurement], Measurement] + Function applied to the measurement. + + Returns + ------- + M + The resulting command. + + """ + return M(self.node, f(self.measurement), self.s_domain, self.t_domain) + @dataclasses.dataclass(repr=False) class E(_KindChecker, BaseCommand): diff --git a/graphix/optimization.py b/graphix/optimization.py index 5b23d51c1..09d5d49cd 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -13,8 +13,6 @@ import networkx as nx # assert_never added in Python 3.11 -from typing_extensions import assert_never - from graphix import command from graphix.clifford import Clifford, Domains from graphix.command import CommandKind, Node @@ -238,9 +236,9 @@ def from_pattern(cls, pattern: Pattern) -> Self: def extract_graph(self) -> nx.Graph[int]: """Return the graph state from the command sequence, extracted from 'N' and 'E' commands. - Returns - ------- - graph_state: nx.Graph + > Returns + ------- + graph_state: nx.Graph """ graph: nx.Graph[int] = nx.Graph() graph.add_nodes_from(self.input_nodes) @@ -250,9 +248,15 @@ def extract_graph(self) -> nx.Graph[int]: graph.add_edge(u, v) return graph - def perform_pauli_pushing(self, leave_nodes: AbstractSet[Node] | None = None, *, stacklevel: int = 1) -> Self: + def perform_pauli_pushing( + self, leave_nodes: AbstractSet[Node] | None = None, *, stacklevel: int = 1 + ) -> StandardizedPattern: """Move Pauli measurements before the other measurements. + If you need to recover the cut between Pauli measurements and + non-Pauli measurements or the shifted signal, you can use + :meth:`~graphix.remove_pauli_measurements.PauliPushingCut.from_standardized_pattern` instead. + Parameters ---------- leave_nodes : AbstractSet[Node], optional @@ -267,90 +271,13 @@ def perform_pauli_pushing(self, leave_nodes: AbstractSet[Node] | None = None, *, Pattern The pattern in which Pauli measurements have been moved before the other measurements. + """ - self._warn_non_inferred_pauli_measurements(stacklevel=stacklevel + 1) - - if leave_nodes: - leave_non_pauli_nodes = [ - cmd.node - for cmd in self.m_list - if not isinstance(cmd.measurement, PauliMeasurement) and cmd.node in leave_nodes - ] - if leave_non_pauli_nodes: - warn( - f"`leave_nodes` contains nodes that are not Pauli: {leave_non_pauli_nodes}. The constraint has no effect on these nodes.", - stacklevel=stacklevel + 1, - ) + from graphix.remove_pauli_measurements import PauliPushingCut # noqa: PLC0415 - shift_domains: dict[int, set[int]] = {} - - def expand_domain(domain: AbstractSet[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) - if not isinstance(cmd.measurement, PauliMeasurement) or (leave_nodes and cmd.node in leave_nodes): - non_pauli_list.append( - command.M(node=cmd.node, measurement=cmd.measurement, s_domain=s_domain, t_domain=t_domain) - ) - else: - match cmd.measurement.axis: - case 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 - case 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 - case 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 - case _: - assert_never(cmd.measurement.axis) - pauli_list.append(command.M(node=cmd.node, measurement=cmd.measurement)) - return self.__class__( - self.input_nodes, - self.output_nodes, - self.results, - self.n_list, - self.e_set, - pauli_list + non_pauli_list, - self.c_dict, - {node: expand_domain(domain) for node, domain in self.z_dict.items()}, - {node: expand_domain(domain) for node, domain in self.x_dict.items()}, - ) + return PauliPushingCut.from_standardized_pattern( + self, leave_nodes, stacklevel=stacklevel + 1 + ).to_standardized_pattern() def max_space(self) -> int: """Compute the maximum number of nodes that must be present in the graph (graph space) during the execution of the space-optimal pattern for the given measurement order. @@ -643,6 +570,36 @@ def extract_xzcorrections(self) -> XZCorrections[Measurement]: og, x_corr, z_corr ) # Raises a `XZCorrectionsError` if the input dictionaries are not well formed. + def map(self, f: Callable[[Measurement], Measurement]) -> StandardizedPattern: + """Return a pattern where the function ``f`` has been applied to each measurement. + + Parameters + ---------- + f: Callable[[Measurement], Measurement] + Function applied to each measurement. + + Returns + ------- + StandardizedPattern + The resulting pattern. + """ + m_list = tuple(cmd_m.map(f) for cmd_m in self.m_list) + return StandardizedPattern( + self.input_nodes, + self.output_nodes, + self.results, + self.n_list, + self.e_set, + m_list, + self.c_dict, + self.z_dict, + self.x_dict, + ) + + def to_bloch(self) -> StandardizedPattern: + """Return an equivalent pattern in which all measurements are represented as Bloch measurements.""" + return self.map(lambda m: m.to_bloch()) + def _warn_non_inferred_pauli_measurements(self, stacklevel: int) -> None: for m in self.m_list: if isinstance(m.measurement, BlochMeasurement) and m.measurement.try_to_pauli() is not None: diff --git a/graphix/pattern.py b/graphix/pattern.py index c28bf7555..6534b6235 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1682,7 +1682,7 @@ def map(self, f: Callable[[Measurement], Measurement]) -> Pattern: for cmd in self: if cmd.kind == CommandKind.M: - new_pattern.add(command.M(cmd.node, f(cmd.measurement), cmd.s_domain, cmd.t_domain)) + new_pattern.add(cmd.map(f)) else: new_pattern.add(cmd) @@ -1776,6 +1776,47 @@ def perform_pauli_pushing( self.__seq = pattern.__seq return self + def remove_pauli_measurements( + self, *, copy: bool = False, standardize: bool = False, stacklevel: int = 1 + ) -> Pattern: + """Remove non-input Pauli measurements from the given pattern. + + See :func:`~remove_pauli_measurements.remove_pauli_measurements` for more information. + + Parameters + ---------- + pattern: StandardizedPattern + Standardized pattern to optimize. + copy : bool, optional + If ``True``, the current pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + standardize: bool, optional + If ``True``, the pattern is returned in standardized form. + The default is ``False``: the nodes are prepared on a + need-by-need basis, minimizing space usage. + stacklevel : int, optional + Stack level to use for warnings. Defaults to 1, meaning that warnings + are reported at this function's call site. + + Returns + ------- + StandardizedPattern + The pattern in which Pauli measurements have been moved + before the other measurements. If ``copy`` is ``False``, + the result is ``self``. + + """ + from graphix.remove_pauli_measurements import remove_pauli_measurements # noqa: PLC0415 + + standardized_pattern = optimization.StandardizedPattern.from_pattern(self) + standardized_pattern = remove_pauli_measurements(standardized_pattern, stacklevel=stacklevel + 1) + pattern = standardized_pattern.to_pattern() if standardize else standardized_pattern.to_space_optimal_pattern() + if copy: + return pattern + self.__seq = pattern.__seq + return self + class PatternError(Exception): """Exception subclass to handle pattern errors.""" From 00cdf0972e44f79cd2e1ae8341f37db99f6d46b9 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 10:11:07 +0200 Subject: [PATCH 02/40] Add missing files --- graphix/remove_pauli_measurements.py | 518 ++++++++++++++++++++++++ tests/test_remove_pauli_measurements.py | 202 +++++++++ 2 files changed, 720 insertions(+) create mode 100644 graphix/remove_pauli_measurements.py create mode 100644 tests/test_remove_pauli_measurements.py diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py new file mode 100644 index 000000000..65e019dd4 --- /dev/null +++ b/graphix/remove_pauli_measurements.py @@ -0,0 +1,518 @@ +"""Remove Pauli measurements. + +This module implements the algorithm described in [BMBdF+21], Theorem +4.12 in Section 4.3: Removing Clifford vertices. + +[BMBdF+21] Miriam Backens, Hector Miller-Bakewell, Giovanni de Felice, +Leo Lobski, and John van de Wetering, There and back again: A circuit +extraction tale, Quantum, 2021, +https://doi.org/10.22331/q-2021-03-25-421 + +""" + +from __future__ import annotations + +import dataclasses +import itertools +from dataclasses import dataclass +from typing import TYPE_CHECKING +from warnings import warn + +from typing_extensions import assert_never + +from graphix.clifford import Clifford, Domains +from graphix.command import Command +from graphix.fundamentals import Axis, Sign +from graphix.measurements import PauliMeasurement +from graphix.optimization import StandardizedPattern + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from collections.abc import Set as AbstractSet + from typing import TypeAlias, TypeVar + + import networkx as nx + + from graphix.command import Node + + Graph: TypeAlias = nx.Graph[int] +else: + Graph = "nx.Graph" + + +@dataclass(frozen=True, slots=True) +class PauliPushingCut: + """Cut of the pattern measurements into Pauli and non-Pauli measurements. + + This structure is returned by :meth:`StandardizedPattern.cut_by_pauli_pushing`. + """ + + original_pattern: StandardizedPattern + + pauli_measurements: list[Command.M] + """Pauli measurements: they are all applied before non-Pauli measurements and their domains are empty.""" + + non_pauli_measurements: list[Command.M] + + shifted_domains: dict[int, set[int]] + """The shifted domains. + + The output of the original pattern can be retrieved by using + :func:`~graphix.pattern.shift_outcomes` with these domains. + """ + + @classmethod + def from_standardized_pattern( + cls, pattern: StandardizedPattern, leave_nodes: AbstractSet[Node] | None = None, *, stacklevel: int = 1 + ) -> PauliPushingCut: + """Move Pauli measurements before the other measurements and return the cut between Pauli measurements and non-Pauli measurements. + + If you only need the resulting pattern, you can use + :meth:`perform_pauli_pushing` instead. + + Parameters + ---------- + leave_nodes : AbstractSet[Node], optional + Nodes that should not be moved. This constraint only + applies to Pauli nodes and has no effect on non-Pauli nodes. + stacklevel : int, optional + Stack level to use for warnings. Defaults to 1, meaning that warnings + are reported at this function's call site. + + Returns + ------- + PauliPushingCut + The cut between Pauli measurements and non-Pauli measurements. + + """ + pattern._warn_non_inferred_pauli_measurements(stacklevel=stacklevel + 1) + + if leave_nodes: + leave_non_pauli_nodes = [ + cmd.node + for cmd in pattern.m_list + if not isinstance(cmd.measurement, PauliMeasurement) and cmd.node in leave_nodes + ] + if leave_non_pauli_nodes: + warn( + f"`leave_nodes` contains nodes that are not Pauli: {leave_non_pauli_nodes}. The constraint has no effect on these nodes.", + stacklevel=stacklevel + 1, + ) + + shifted_domains: dict[int, set[int]] = {} + + pauli_measurements = [] + non_pauli_measurements = [] + for cmd in pattern.m_list: + s_domain = _expand_domain(shifted_domains, cmd.s_domain) + t_domain = _expand_domain(shifted_domains, cmd.t_domain) + if not isinstance(cmd.measurement, PauliMeasurement) or (leave_nodes and cmd.node in leave_nodes): + non_pauli_measurements.append( + Command.M(node=cmd.node, measurement=cmd.measurement, s_domain=s_domain, t_domain=t_domain) + ) + else: + match cmd.measurement.axis: + case 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} + shifted_domains[cmd.node] = t_domain + case 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} + shifted_domains[cmd.node] = s_domain ^ t_domain + case 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} + shifted_domains[cmd.node] = s_domain + case _: + assert_never(cmd.measurement.axis) + pauli_measurements.append(Command.M(node=cmd.node, measurement=cmd.measurement)) + return cls(pattern, pauli_measurements, non_pauli_measurements, shifted_domains) + + def measurements(self) -> list[Command.M]: + """Return the list of measurements, where Pauli measurements appear first and without signal.""" + return self.pauli_measurements + self.non_pauli_measurements + + def to_standardized_pattern(self) -> StandardizedPattern: + """Return the standardized pattern where all Pauli measurements have been pushed.""" + return StandardizedPattern( + self.original_pattern.input_nodes, + self.original_pattern.output_nodes, + self.original_pattern.results, + self.original_pattern.n_list, + self.original_pattern.e_set, + self.measurements(), + self.original_pattern.c_dict, + _expand_corrections(self.shifted_domains, self.original_pattern.z_dict), + _expand_corrections(self.shifted_domains, self.original_pattern.x_dict), + ) + + +def _expand_domain(shifted_domains: Mapping[Node, AbstractSet[Node]], domain: AbstractSet[Node]) -> set[Node]: + """Merge previously shifted domains into ``domain``. + + Parameters + ---------- + shifted_domains: Mapping[Node, AbstractSet[Node]] + Shifted domains + domain : AbstractSet[Node] + Domain to update with any accumulated shift information. + """ + new_domain = set(domain) + for node in domain & shifted_domains.keys(): + new_domain ^= shifted_domains[node] + return new_domain + + +def _expand_corrections( + shifted_domains: Mapping[Node, AbstractSet[Node]], corrections: Mapping[Node, AbstractSet[Node]] +) -> dict[Node, set[Node]]: + return {node: _expand_domain(shifted_domains, domain) for node, domain in corrections.items()} + + +@dataclass(slots=True) +class _NodeSpec: + """Annotations attached to every node of the graph state.""" + + src: Node + """The corresponding node in the original pattern.""" + + domains: Domains = dataclasses.field(default_factory=lambda: Domains(set(), set())) + """Correction domains (the nodes refer to the numbering of the original pattern).""" + + clifford: Clifford = Clifford.I + is_output: bool = False + + index: int = 0 + """Index of the node in the original pattern. + + For output node, this is the index in the output node list of the + original pattern. For measured nodes, this is the index of the + measurement in the measurement sequence (after Pauli pushing). + """ + + pauli_measurement: PauliMeasurement | None = None + """Pauli measurement if the node is not an input and is measured with a Pauli measurement. + + ``None`` if the node is an input, an output, or measured with a non-Pauli measurement. + """ + + +class _RemovePauliMeasurements: + """Processing structure for Pauli measurement removal. + + This class is instantiated from a Pauli-pushing cut and can be + converted back to a standardized pattern with the method + :meth:`to_standardized_pattern`. The public methods preserve the + pattern semantics as invariant, such that an equivalent + standardized pattern can be obtained at any stage of the process. + + """ + + cut: PauliPushingCut + """Cut of the pattern measurements obtained by Pauli pushing.""" + + graph: Graph + node_specs: dict[Node, _NodeSpec] + measurements: list[Command.M] + pauli_measurements: dict[Axis, set[Node]] + node_map: dict[Node, Node] + + def __init__(self, cut: PauliPushingCut) -> None: + self.cut = cut + self.graph = cut.original_pattern.extract_graph() + self.node_specs = {node: _NodeSpec(node) for node in self.graph.nodes()} + for node, domain in cut.original_pattern.x_dict.items(): + self.node_specs[node].domains.s_domain = _expand_domain(cut.shifted_domains, domain) + for node, domain in cut.original_pattern.z_dict.items(): + self.node_specs[node].domains.t_domain = _expand_domain(cut.shifted_domains, domain) + for node, clifford in cut.original_pattern.c_dict.items(): + self.node_specs[node].clifford = clifford + for i, node in enumerate(cut.original_pattern.output_nodes): + spec = self.node_specs[node] + spec.is_output = True + spec.index = i + self.measurements = cut.measurements() + for i, cmd_m in enumerate(self.measurements): + spec = self.node_specs[cmd_m.node] + spec.index = i + self.pauli_measurements = {axis: set() for axis in Axis} + input_node_set = set(cut.original_pattern.input_nodes) + for cmd_m in self.cut.pauli_measurements: + if not isinstance(cmd_m.measurement, PauliMeasurement): + msg = "Pauli measurement expected." + raise TypeError(msg) + if cmd_m.node not in input_node_set: + self.node_specs[cmd_m.node].pauli_measurement = cmd_m.measurement + self.pauli_measurements[cmd_m.measurement.axis].add(cmd_m.node) + self.node_map = {node: node for node in self.graph.nodes()} + + def _apply_clifford(self, node: Node, clifford: Clifford) -> None: + spec = self.node_specs[node] + spec.clifford @= clifford + spec.domains = clifford.commute_domains(spec.domains) + if spec.pauli_measurement is not None: + axis = spec.pauli_measurement.axis + spec.pauli_measurement = spec.pauli_measurement.clifford(clifford) + new_axis = spec.pauli_measurement.axis + if new_axis != axis: + self.pauli_measurements[axis].remove(spec.src) + self.pauli_measurements[new_axis].add(spec.src) + + def local_complement(self, u: Node) -> None: + """ + Local complement. + + Implements Lemma 2.31 and 4.3 [BMBdF+21]. + """ + n_u = set(self.graph.neighbors(u)) + _complement_subgraph(self.graph, n_u) + # |+⟩⟨+| + exp(-iπ/2) |-⟩⟨-| = H S† H + self._apply_clifford(u, Clifford.H @ Clifford.SDG @ Clifford.H) + for node in n_u: + # |0⟩⟨0| + exp(iπ/2) |1⟩⟨1| = S + self._apply_clifford(node, Clifford.S) + + def pivot_vertices(self, u: Node, v: Node) -> None: + """ + Pivot two vertices. + + Prerequisite (not checked): + - (u, v) is a graph edge; + - u and v are not input nodes. + + Implements Lemmate 2.32 and 4.5 [BMBdF+21]. + """ + n_u = set(self.graph.neighbors(u)) + n_v = set(self.graph.neighbors(v)) + + only_u = n_u - n_v - {v} + only_v = n_v - n_u - {u} + inter = n_u & n_v + + _complement_edges(self.graph, only_u, only_v) + _complement_edges(self.graph, only_u, inter) + _complement_edges(self.graph, only_v, inter) + + spec_u = self.node_specs[u] + spec_v = self.node_specs[v] + self.node_specs[v] = spec_u + self.node_specs[u] = spec_v + self.node_map[spec_u.src] = v + self.node_map[spec_v.src] = u + + self._apply_clifford(u, Clifford.H) + self._apply_clifford(v, Clifford.H) + + for node in inter: + self._apply_clifford(node, Clifford.Z) + + def remove_node(self, u: Node) -> None: + spec = self.node_specs[u] + if spec.pauli_measurement is None: + msg = "Pauli measurement expected" + raise RuntimeError(msg) + self.pauli_measurements[spec.pauli_measurement.axis].remove(spec.src) + del self.node_map[spec.src] + del self.node_specs[u] + self.graph.remove_node(u) + + def remove_z(self, u: Node, sign: Sign) -> None: + """ + Remove Z/-Z measurement. + + Prerequisite (not checked): + - u measured in Z (sign==PLUS) or -Z (sign=MINUS); + - u is not an input node. + + Implements Lemma 4.7 [BMBdF+21]. + """ + if sign == Sign.MINUS: + for node in self.graph.neighbors(u): + self._apply_clifford(node, Clifford.Z) + self.remove_node(u) + + def remove_y(self, u: Node, sign: Sign) -> None: + """ + Remove Y/-Y measurement. + + Prerequisite (not checked): + - u measured in Y (sign==PLUS) or -Y (sign=MINUS); + - u is not an input node. + + Implements Lemma 4.8 [BMBdF+21]. + """ + self.local_complement(u) + self.remove_z(u, sign) + + def remove_x_with_non_input_neighbor(self, u: Node, v: Node, sign: Sign) -> None: + """ + Remove X/-X measurement. + + Prerequisite (not checked): + - u measured in X (sign==PLUS) or -X (sign=MINUS); + - (u, v) is a graph edge; + - u and v are not input nodes. + + Implements Lemma 4.9 [BMBdF+21]. + """ + self.pivot_vertices(u, v) + self.remove_z(v, sign) + + def to_standardized_pattern(self) -> StandardizedPattern: + output_nodes: list[Node | None] = [None] * len(self.cut.original_pattern.output_nodes) + measurements: list[Command.M | None] = [None] * len(self.measurements) + z_dict: dict[Node, set[int]] = {} + x_dict: dict[Node, set[int]] = {} + c_dict: dict[Node, Clifford] = {} + for node, spec in self.node_specs.items(): + if spec.is_output: + output_nodes[spec.index] = node + if spec.domains.t_domain: + z_dict[node] = _map_domain(self.node_map, spec.domains.t_domain) + if spec.domains.s_domain: + x_dict[node] = _map_domain(self.node_map, spec.domains.s_domain) + if spec.clifford != Clifford.I: + c_dict[node] = spec.clifford + else: + cmd_m = self.measurements[spec.index].clifford(spec.clifford) + cmd_m.node = node + cmd_m.s_domain = _map_domain(self.node_map, cmd_m.s_domain) + cmd_m.t_domain = _map_domain(self.node_map, cmd_m.t_domain) + measurements[spec.index] = cmd_m + return StandardizedPattern( + self.cut.original_pattern.input_nodes, + _filter_none(output_nodes), + self.cut.original_pattern.results, + (cmd_n for cmd_n in self.cut.original_pattern.n_list if cmd_n.node in self.node_specs), + (frozenset(edge) for edge in self.graph.edges()), + _filter_none(measurements), + c_dict, + z_dict, + x_dict, + ) + + +def _complement_subgraph(graph: nx.Graph[Node], s: set[Node]) -> None: + """Complement edges in a given subgraph.""" + all_pairs = {(u, v) for u, v in itertools.combinations(s, 2)} + existing = {edge for edge in all_pairs if edge in graph.edges()} + graph.remove_edges_from(existing) + graph.add_edges_from(all_pairs - existing) + + +def _complement_edges(graph: nx.Graph[Node], s: set[Node], t: set[Node]) -> None: + """Complement edges between two set of nodes. + + s and t are supposed to be disjoint. + """ + all_pairs = {(u, v) for u in s for v in t} + existing = {(u, v) for u, v in graph.edges(s) if v in t} + graph.remove_edges_from(existing) + graph.add_edges_from(all_pairs - existing) + + +if TYPE_CHECKING: + T = TypeVar("T") + + +def _filter_none(it: Iterable[T | None]) -> list[T]: + return [elt for elt in it if elt is not None] + + +def _map_domain(node_map: Mapping[Node, Node], domain: set[Node]) -> set[Node]: + return {v for node in domain if (v := node_map.get(node)) is not None} + + +def remove_pauli_measurements(pattern: StandardizedPattern, *, stacklevel: int = 1) -> StandardizedPattern: + """Remove non-input Pauli measurements from the given pattern. + + This function implements the algorithm described in Theorem 4.12 + in Section 4.3: Removing Clifford vertices [BMBdF+21]. + + This function removes all non-input Y and Z measured nodes and all + non-input X measured nodes connected to any other internal vertex. + Furthermore, if any non-input X measured node is connected to an + output node, pivoting these nodes enables eliminating further + nodes. In particular, if the pattern has flow, all non-input + Pauli measurements are removed. + + Parameters + ---------- + pattern: StandardizedPattern + Standardized pattern to optimize. + stacklevel : int, optional + Stack level to use for warnings. Defaults to 1, meaning that warnings + are reported at this function's call site. + + Returns + ------- + StandardizedPattern + The pattern in which Pauli measurements have been moved + before the other measurements. If ``copy`` is ``False``, + the result is ``self``. + + """ + cut = PauliPushingCut.from_standardized_pattern(pattern, stacklevel=stacklevel + 1) + process = _RemovePauliMeasurements(cut) + input_node_set = set(pattern.input_nodes) + output_node_set = set(pattern.output_nodes) + while True: + for axis, remove in ( + (Axis.Y, process.remove_y), # Step 1: remove any non-input Y measured node + (Axis.Z, process.remove_z), # Step 2: remove any non-input Z measured node + ): + while True: + node = next(iter(process.pauli_measurements[axis]), None) + if node is None: + break + new_node = process.node_map[node] + spec = process.node_specs[new_node] + if spec.pauli_measurement is None: + msg = "Pauli measurement expected." + raise RuntimeError(msg) + remove(new_node, spec.pauli_measurement.sign) + + # Step 3: remove any non-input X measured node connected to any other internal vertex + for node in process.pauli_measurements[Axis.X]: + new_node = process.node_map[node] + non_input_neighbors = set(process.graph.neighbors(new_node)) - input_node_set + if not non_input_neighbors: + break + v, *_ = non_input_neighbors + spec = process.node_specs[new_node] + if spec.pauli_measurement is None: + msg = "Pauli measurement expected." + raise RuntimeError(msg) + process.remove_x_with_non_input_neighbor(new_node, v, spec.pauli_measurement.sign) + break + else: + # Step 4: pivot a non-input X measured node connected to an output node if any (Lemma 4.11) + for node in process.pauli_measurements[Axis.X]: + new_node = process.node_map[node] + non_input_output_nodes = set(process.graph.neighbors(new_node)) & output_node_set - input_node_set + if not non_input_output_nodes: + continue + v, *_ = non_input_output_nodes + process.pivot_vertices(node, v) + break # node removed: break for-loop, continue outer while-loop + else: + break # no node found: interrupt outer while-loop + return process.to_standardized_pattern() diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py new file mode 100644 index 000000000..1a070bd40 --- /dev/null +++ b/tests/test_remove_pauli_measurements.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import networkx as nx +import pytest +from numpy.random import Generator + +from graphix import Axis, BlochMeasurement, Circuit, Measurement, OpenGraph, PauliMeasurement, Sign, StandardizedPattern +from graphix.random_objects import rand_circuit, rand_state_vector +from graphix.remove_pauli_measurements import PauliPushingCut, _RemovePauliMeasurements, remove_pauli_measurements + +if TYPE_CHECKING: + from collections.abc import Mapping + from collections.abc import Set as AbstractSet + + from numpy.random import PCG64 + + from graphix.command import Node + from graphix.pattern import Pattern + + +def opengraph_lemma_2_31(measurements: Mapping[Node, Measurement]) -> OpenGraph[Measurement]: + graph = nx.Graph( + [ + (0, 1), + (0, 2), + (0, 3), + (1, 2), + (1, 3), + ] + ) + output_nodes = tuple(node for node in range(4) if node not in measurements) + return OpenGraph(graph, input_nodes=(1, 2, 3), output_nodes=output_nodes, measurements=measurements) + + +def opengraph_lemma_2_32(measurements: Mapping[Node, Measurement]) -> OpenGraph[Measurement]: + graph = nx.Graph( + [ + (0, 1), + (0, 2), + (1, 2), + (0, 3), + (1, 3), + (0, 4), + (3, 4), + (1, 5), + (3, 5), + (0, 6), + (2, 6), + (5, 6), + (1, 7), + (2, 7), + (4, 7), + ] + ) + output_nodes = tuple(node for node in range(8) if node not in measurements) + return OpenGraph(graph, input_nodes=(4, 5, 6, 7), output_nodes=output_nodes, measurements=measurements) + + +@pytest.mark.parametrize("measured_set", [set(), {1}, {2}]) +def test_local_complement(fx_rng: Generator, measured_set: AbstractSet[int]) -> None: + og = opengraph_lemma_2_31({node: Measurement.XY(0.25) for node in measured_set}) + pattern = og.to_pattern() + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + remove_pauli_measurements = _RemovePauliMeasurements(cut) + remove_pauli_measurements.local_complement(0) + standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() + og2 = standardized_pattern2.extract_opengraph() + expected_graph = nx.Graph( + [ + (0, 1), + (0, 2), + (0, 3), + (2, 3), + ] + ) + assert nx.utils.graphs_equal(og2.graph, expected_graph) + pattern2 = standardized_pattern2.to_pattern() + assert pattern2.extract_gflow() + check_pattern_equivalence(pattern, pattern2, rng=fx_rng) + + +@pytest.mark.parametrize("measured_set", [set(), {4}, {4, 5}, {4, 5, 6}, {4, 5, 7}, {0}, {1}, {2}, {3}, {0, 2}]) +def test_pivot_vertices(fx_rng: Generator, measured_set: AbstractSet[int]) -> None: + og = opengraph_lemma_2_32({node: Measurement.XY(0.25) for node in measured_set}) + pattern = og.to_pattern() + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + remove_pauli_measurements = _RemovePauliMeasurements(cut) + remove_pauli_measurements.pivot_vertices(0, 1) + standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() + og2 = standardized_pattern2.extract_opengraph() + expected_graph = nx.Graph( + [ + (0, 1), + (0, 2), + (1, 2), + (0, 3), + (1, 3), + (0, 4), + (2, 4), + (1, 5), + (2, 5), + (4, 5), + (0, 6), + (3, 6), + (1, 7), + (3, 7), + (6, 7), + ] + ) + assert nx.utils.graphs_equal(og2.graph, expected_graph) + assert og2.output_nodes == tuple(0 if node == 1 else 1 if node == 0 else node for node in og.output_nodes) + pattern2 = standardized_pattern2.to_pattern() + assert pattern2.extract_gflow() + check_pattern_equivalence(pattern, pattern2, rng=fx_rng) + + +@pytest.mark.parametrize("node", [0, 1, 2, 3]) +@pytest.mark.parametrize("sign", Sign) +def test_remove_z(fx_rng: Generator, node: Node, sign: Sign) -> None: + og = opengraph_lemma_2_32({node: PauliMeasurement(Axis.Z, sign)}) + pattern = og.to_pattern() + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + remove_pauli_measurements = _RemovePauliMeasurements(cut) + remove_pauli_measurements.remove_z(node, sign) + standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() + pattern2 = standardized_pattern2.to_pattern() + check_pattern_equivalence(pattern, pattern2, rng=fx_rng) + + +@pytest.mark.parametrize("node", [0, 1, 2, 3]) +@pytest.mark.parametrize("sign", Sign) +def test_remove_y(fx_rng: Generator, node: Node, sign: Sign) -> None: + og = opengraph_lemma_2_32({node: PauliMeasurement(Axis.Y, sign)}) + pattern = og.to_pattern() + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + remove_pauli_measurements = _RemovePauliMeasurements(cut) + remove_pauli_measurements.remove_y(node, sign) + standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() + pattern2 = standardized_pattern2.to_pattern() + check_pattern_equivalence(pattern, pattern2, rng=fx_rng) + + +@pytest.mark.parametrize("sign", Sign) +def test_remove_x_with_non_input_neighbor(fx_rng: Generator, sign: Sign) -> None: + og = opengraph_lemma_2_32({0: PauliMeasurement(Axis.X, sign)}) + pattern = og.to_pattern() + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + remove_pauli_measurements = _RemovePauliMeasurements(cut) + remove_pauli_measurements.remove_x_with_non_input_neighbor(0, 1, sign) + standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() + pattern2 = standardized_pattern2.to_pattern() + check_pattern_equivalence(pattern, pattern2, rng=fx_rng) + + +def check_circuit(circuit: Circuit, rng: Generator) -> None: + pattern = circuit.transpile().pattern + standardized_pattern = StandardizedPattern.from_pattern(pattern) + standardized_pattern2 = remove_pauli_measurements(standardized_pattern) + + input_node_set = set(standardized_pattern2.input_nodes) + assert all( + isinstance(cmd_m.measurement, BlochMeasurement) or cmd_m.node in input_node_set + for cmd_m in standardized_pattern2.m_list + ) + + # Check that the pattern has a gflow + standardized_pattern2.to_bloch().extract_gflow() + + pattern2 = standardized_pattern2.to_pattern() + check_pattern_equivalence(pattern, pattern2, rng=rng) + + +def test_ccx(fx_rng: Generator) -> None: + circuit = Circuit(3) + circuit.ccx(0, 1, 2) + check_circuit(circuit, fx_rng) + + +@pytest.mark.parametrize("jumps", range(1, 11)) +def test_random_circuit(fx_bg: PCG64, jumps: int) -> None: + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 4 + depth = 4 + circuit = rand_circuit(nqubits, depth, rng) + check_circuit(circuit, rng) + + +def check_pattern_equivalence(pattern: Pattern, pattern2: Pattern, rng: Generator) -> None: + pattern.minimize_space() + pattern2.minimize_space() + for _ in range(4): + input_state = rand_state_vector(len(pattern.input_nodes), rng=rng) + state = pattern.simulate_pattern(input_state=input_state, rng=rng) + state2 = pattern2.simulate_pattern(input_state=input_state, rng=rng) + assert state.isclose(state2) From 881eefba663bcda1620762086189398d7e8686a2 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 11:40:15 +0200 Subject: [PATCH 03/40] Rename `_remove_node` and more comments --- graphix/remove_pauli_measurements.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 65e019dd4..1ab44f1e2 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -231,9 +231,19 @@ class _RemovePauliMeasurements: graph: Graph node_specs: dict[Node, _NodeSpec] + measurements: list[Command.M] + """List of the original measurements after Pauli-pushing.""" + pauli_measurements: dict[Axis, set[Node]] + """For each axis, the set of non-input nodes that have a Pauli measurement on that axis. + + Nodes are given with the indexing of the original pattern: use ``node_map`` to retrieve the index in the graph.""" + node_map: dict[Node, Node] + """Mapping from the nodes of the original pattern to the nodes of the graph (that may have been pivoted). + + The following invariant is maintained for all node ``u``: ``node_specs[node_map[u]].src == u``.""" def __init__(self, cut: PauliPushingCut) -> None: self.cut = cut @@ -265,6 +275,11 @@ def __init__(self, cut: PauliPushingCut) -> None: self.node_map = {node: node for node in self.graph.nodes()} def _apply_clifford(self, node: Node, clifford: Clifford) -> None: + """Apply a single-qubit Clifford gate to a node. + + This internal method breaks the semantics invariant: the + semantics of the pattern is not preserved. + """ spec = self.node_specs[node] spec.clifford @= clifford spec.domains = clifford.commute_domains(spec.domains) @@ -324,7 +339,12 @@ def pivot_vertices(self, u: Node, v: Node) -> None: for node in inter: self._apply_clifford(node, Clifford.Z) - def remove_node(self, u: Node) -> None: + def _remove_node(self, u: Node) -> None: + """Remove a node from the graph. + + This internal method breaks the semantics invariant: the + semantics of the pattern is not preserved. + """ spec = self.node_specs[u] if spec.pauli_measurement is None: msg = "Pauli measurement expected" @@ -347,7 +367,7 @@ def remove_z(self, u: Node, sign: Sign) -> None: if sign == Sign.MINUS: for node in self.graph.neighbors(u): self._apply_clifford(node, Clifford.Z) - self.remove_node(u) + self._remove_node(u) def remove_y(self, u: Node, sign: Sign) -> None: """ From f0db759204dca3a61453af6dd4b6d07e89278402 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 17:14:38 +0200 Subject: [PATCH 04/40] Type annotations for pyright --- graphix/remove_pauli_measurements.py | 4 ++-- tests/test_remove_pauli_measurements.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 1ab44f1e2..d08009f63 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -101,8 +101,8 @@ def from_standardized_pattern( shifted_domains: dict[int, set[int]] = {} - pauli_measurements = [] - non_pauli_measurements = [] + pauli_measurements: list[Command.M] = [] + non_pauli_measurements: list[Command.M] = [] for cmd in pattern.m_list: s_domain = _expand_domain(shifted_domains, cmd.s_domain) t_domain = _expand_domain(shifted_domains, cmd.t_domain) diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index 1a070bd40..e1abae35e 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -18,10 +18,11 @@ from graphix.command import Node from graphix.pattern import Pattern + from graphix.remove_pauli_measurements import Graph def opengraph_lemma_2_31(measurements: Mapping[Node, Measurement]) -> OpenGraph[Measurement]: - graph = nx.Graph( + graph: Graph = nx.Graph( [ (0, 1), (0, 2), @@ -35,7 +36,7 @@ def opengraph_lemma_2_31(measurements: Mapping[Node, Measurement]) -> OpenGraph[ def opengraph_lemma_2_32(measurements: Mapping[Node, Measurement]) -> OpenGraph[Measurement]: - graph = nx.Graph( + graph: Graph = nx.Graph( [ (0, 1), (0, 2), @@ -68,7 +69,7 @@ def test_local_complement(fx_rng: Generator, measured_set: AbstractSet[int]) -> remove_pauli_measurements.local_complement(0) standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() og2 = standardized_pattern2.extract_opengraph() - expected_graph = nx.Graph( + expected_graph: Graph = nx.Graph( [ (0, 1), (0, 2), @@ -92,7 +93,7 @@ def test_pivot_vertices(fx_rng: Generator, measured_set: AbstractSet[int]) -> No remove_pauli_measurements.pivot_vertices(0, 1) standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() og2 = standardized_pattern2.extract_opengraph() - expected_graph = nx.Graph( + expected_graph: Graph = nx.Graph( [ (0, 1), (0, 2), From 78755a217499bd75780c38a5461fdad45f37c84a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 17:15:52 +0200 Subject: [PATCH 05/40] Revert `extract_graph` docstring --- graphix/optimization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 09d5d49cd..673e3bf39 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -236,9 +236,9 @@ def from_pattern(cls, pattern: Pattern) -> Self: def extract_graph(self) -> nx.Graph[int]: """Return the graph state from the command sequence, extracted from 'N' and 'E' commands. - > Returns - ------- - graph_state: nx.Graph + Returns + ------- + graph_state: nx.Graph """ graph: nx.Graph[int] = nx.Graph() graph.add_nodes_from(self.input_nodes) From 22f6322a7c2b15bcd73b89d0bceacaa969d15981 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 17:31:04 +0200 Subject: [PATCH 06/40] Add `remove_pauli_measurements` module to the documentation --- docs/source/optimization.rst | 10 ++++++++++ graphix/remove_pauli_measurements.py | 26 ++++++++++++++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/source/optimization.rst b/docs/source/optimization.rst index b2663c8ac..ee965a0d1 100644 --- a/docs/source/optimization.rst +++ b/docs/source/optimization.rst @@ -16,3 +16,13 @@ This module defines space minimization procedures for patterns. .. automodule:: graphix.space_minimization :members: + +:mod:`graphix.remove_pauli_measurements` module ++++++++++++++++++++++++++++++++++++++++++++++++ + +This module provides procedures for pushing Pauli measurements in +front of a pattern and for subsequently removing them from the +pattern. + +.. automodule:: graphix.remove_pauli_measurements + :members: diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index d08009f63..828055ceb 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -1,13 +1,23 @@ """Remove Pauli measurements. -This module implements the algorithm described in [BMBdF+21], Theorem -4.12 in Section 4.3: Removing Clifford vertices. +This module provides procedures for pushing Pauli measurements in +front of a pattern and for subsequently removing them from the +pattern. -[BMBdF+21] Miriam Backens, Hector Miller-Bakewell, Giovanni de Felice, -Leo Lobski, and John van de Wetering, There and back again: A circuit -extraction tale, Quantum, 2021, -https://doi.org/10.22331/q-2021-03-25-421 +Pauli pushing uses commutation rules of Pauli measurements to move +them before other measurements while appropriately shifting their +signals, so that all Pauli measurements end up with empty +domains. This step is required before the actual removal can be +performed. + +For the removal itself, this module implements the algorithm described +in [BMBdF+21], Theorem 4.12 (Section 4.3: Removing Clifford +vertices). +[BMBdF+21] Miriam Backens, Hector Miller-Bakewell, Giovanni de Felice, + Leo Lobski, and John van de Wetering, + There and back again: A circuit extraction tale, Quantum, 2021, + https://doi.org/10.22331/q-2021-03-25-421 """ from __future__ import annotations @@ -464,8 +474,8 @@ def _map_domain(node_map: Mapping[Node, Node], domain: set[Node]) -> set[Node]: def remove_pauli_measurements(pattern: StandardizedPattern, *, stacklevel: int = 1) -> StandardizedPattern: """Remove non-input Pauli measurements from the given pattern. - This function implements the algorithm described in Theorem 4.12 - in Section 4.3: Removing Clifford vertices [BMBdF+21]. + This function implements the algorithm described in [BMBdF+21], + Theorem 4.12 (Section 4.3: Removing Clifford vertices). This function removes all non-input Y and Z measured nodes and all non-input X measured nodes connected to any other internal vertex. From 98fa9bbf37b6dad580eb2396fa5053ffccd554c0 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 5 May 2026 23:43:24 +0200 Subject: [PATCH 07/40] Comment on quoting type --- graphix/remove_pauli_measurements.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 828055ceb..fba4dca67 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -47,6 +47,7 @@ Graph: TypeAlias = nx.Graph[int] else: + # The type is quoted because we don't need to import `nx`. Graph = "nx.Graph" From f6acaedebcb564b21c65d301a32589535583e3a5 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 6 May 2026 08:13:16 +0200 Subject: [PATCH 08/40] Improve test coverage --- graphix/pattern.py | 5 +- graphix/remove_pauli_measurements.py | 44 +++++++--------- tests/test_remove_pauli_measurements.py | 70 +++++++++++++++++++++---- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index 6534b6235..f8b188e5c 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1807,10 +1807,11 @@ def remove_pauli_measurements( the result is ``self``. """ - from graphix.remove_pauli_measurements import remove_pauli_measurements # noqa: PLC0415 + from graphix.remove_pauli_measurements import PauliPushingCut, remove_pauli_measurements # noqa: PLC0415 standardized_pattern = optimization.StandardizedPattern.from_pattern(self) - standardized_pattern = remove_pauli_measurements(standardized_pattern, stacklevel=stacklevel + 1) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern, stacklevel=stacklevel + 1) + standardized_pattern = remove_pauli_measurements(cut) pattern = standardized_pattern.to_pattern() if standardize else standardized_pattern.to_space_optimal_pattern() if copy: return pattern diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index fba4dca67..1ee1ae3c5 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -152,7 +152,7 @@ def from_standardized_pattern( # = M^{XZ,(s+1)π} # = S^s M^{-Z} shifted_domains[cmd.node] = s_domain - case _: + case _: # pragma: no cover assert_never(cmd.measurement.axis) pauli_measurements.append(Command.M(node=cmd.node, measurement=cmd.measurement)) return cls(pattern, pauli_measurements, non_pauli_measurements, shifted_domains) @@ -238,7 +238,7 @@ class _RemovePauliMeasurements: """ cut: PauliPushingCut - """Cut of the pattern measurements obtained by Pauli pushing.""" + """Cut of the pattern measurements obtained by Pauli-pushing.""" graph: Graph node_specs: dict[Node, _NodeSpec] @@ -254,7 +254,8 @@ class _RemovePauliMeasurements: node_map: dict[Node, Node] """Mapping from the nodes of the original pattern to the nodes of the graph (that may have been pivoted). - The following invariant is maintained for all node ``u``: ``node_specs[node_map[u]].src == u``.""" + The following invariant is maintained for all node ``u``: ``node_specs[node_map[u]].src == u``. + """ def __init__(self, cut: PauliPushingCut) -> None: self.cut = cut @@ -277,7 +278,7 @@ def __init__(self, cut: PauliPushingCut) -> None: self.pauli_measurements = {axis: set() for axis in Axis} input_node_set = set(cut.original_pattern.input_nodes) for cmd_m in self.cut.pauli_measurements: - if not isinstance(cmd_m.measurement, PauliMeasurement): + if not isinstance(cmd_m.measurement, PauliMeasurement): # pragma: no cover msg = "Pauli measurement expected." raise TypeError(msg) if cmd_m.node not in input_node_set: @@ -357,7 +358,7 @@ def _remove_node(self, u: Node) -> None: semantics of the pattern is not preserved. """ spec = self.node_specs[u] - if spec.pauli_measurement is None: + if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected" raise RuntimeError(msg) self.pauli_measurements[spec.pauli_measurement.axis].remove(spec.src) @@ -400,7 +401,7 @@ def remove_x_with_non_input_neighbor(self, u: Node, v: Node, sign: Sign) -> None Prerequisite (not checked): - u measured in X (sign==PLUS) or -X (sign=MINUS); - (u, v) is a graph edge; - - u and v are not input nodes. + - u and v are internal nodes. Implements Lemma 4.9 [BMBdF+21]. """ @@ -472,7 +473,7 @@ def _map_domain(node_map: Mapping[Node, Node], domain: set[Node]) -> set[Node]: return {v for node in domain if (v := node_map.get(node)) is not None} -def remove_pauli_measurements(pattern: StandardizedPattern, *, stacklevel: int = 1) -> StandardizedPattern: +def remove_pauli_measurements(cut: PauliPushingCut) -> StandardizedPattern: """Remove non-input Pauli measurements from the given pattern. This function implements the algorithm described in [BMBdF+21], @@ -487,24 +488,17 @@ def remove_pauli_measurements(pattern: StandardizedPattern, *, stacklevel: int = Parameters ---------- - pattern: StandardizedPattern - Standardized pattern to optimize. - stacklevel : int, optional - Stack level to use for warnings. Defaults to 1, meaning that warnings - are reported at this function's call site. + cut: PauliPushingCut + The Pauli-pushed pattern to optimize. Returns ------- StandardizedPattern - The pattern in which Pauli measurements have been moved - before the other measurements. If ``copy`` is ``False``, - the result is ``self``. - + The pattern in which Pauli measurements have been removed. """ - cut = PauliPushingCut.from_standardized_pattern(pattern, stacklevel=stacklevel + 1) process = _RemovePauliMeasurements(cut) - input_node_set = set(pattern.input_nodes) - output_node_set = set(pattern.output_nodes) + input_node_set = set(cut.original_pattern.input_nodes) + output_node_set = set(cut.original_pattern.output_nodes) while True: for axis, remove in ( (Axis.Y, process.remove_y), # Step 1: remove any non-input Y measured node @@ -516,7 +510,7 @@ def remove_pauli_measurements(pattern: StandardizedPattern, *, stacklevel: int = break new_node = process.node_map[node] spec = process.node_specs[new_node] - if spec.pauli_measurement is None: + if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected." raise RuntimeError(msg) remove(new_node, spec.pauli_measurement.sign) @@ -524,12 +518,12 @@ def remove_pauli_measurements(pattern: StandardizedPattern, *, stacklevel: int = # Step 3: remove any non-input X measured node connected to any other internal vertex for node in process.pauli_measurements[Axis.X]: new_node = process.node_map[node] - non_input_neighbors = set(process.graph.neighbors(new_node)) - input_node_set - if not non_input_neighbors: - break - v, *_ = non_input_neighbors + internal_neighbors = set(process.graph.neighbors(new_node)) - input_node_set - output_node_set + if not internal_neighbors: + continue + v, *_ = internal_neighbors spec = process.node_specs[new_node] - if spec.pauli_measurement is None: + if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected." raise RuntimeError(msg) process.remove_x_with_non_input_neighbor(new_node, v, spec.pauli_measurement.sign) diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index e1abae35e..9c880fb18 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -6,7 +6,7 @@ import pytest from numpy.random import Generator -from graphix import Axis, BlochMeasurement, Circuit, Measurement, OpenGraph, PauliMeasurement, Sign, StandardizedPattern +from graphix import Axis, BlochMeasurement, Clifford, Circuit, Command, Measurement, OpenGraph, Pattern, PauliMeasurement, Sign, StandardizedPattern from graphix.random_objects import rand_circuit, rand_state_vector from graphix.remove_pauli_measurements import PauliPushingCut, _RemovePauliMeasurements, remove_pauli_measurements @@ -159,17 +159,19 @@ def test_remove_x_with_non_input_neighbor(fx_rng: Generator, sign: Sign) -> None pattern2 = standardized_pattern2.to_pattern() check_pattern_equivalence(pattern, pattern2, rng=fx_rng) +def all_bloch_measurement_or_input_node(input_nodes: Iterator[Node], measurement_commands: Iterator[Command.M]) -> bool: + input_node_set = set(input_nodes) + return all( + isinstance(cmd_m.measurement, BlochMeasurement) or cmd_m.node in input_node_set + for cmd_m in measurement_commands + ) -def check_circuit(circuit: Circuit, rng: Generator) -> None: - pattern = circuit.transpile().pattern +def check_pattern(pattern: Pattern, rng: Generator) -> None: standardized_pattern = StandardizedPattern.from_pattern(pattern) - standardized_pattern2 = remove_pauli_measurements(standardized_pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + standardized_pattern2 = remove_pauli_measurements(cut) - input_node_set = set(standardized_pattern2.input_nodes) - assert all( - isinstance(cmd_m.measurement, BlochMeasurement) or cmd_m.node in input_node_set - for cmd_m in standardized_pattern2.m_list - ) + assert all_bloch_measurement_or_input_node(standardized_pattern2.input_nodes, standardized_pattern2.m_list) # Check that the pattern has a gflow standardized_pattern2.to_bloch().extract_gflow() @@ -181,7 +183,7 @@ def check_circuit(circuit: Circuit, rng: Generator) -> None: def test_ccx(fx_rng: Generator) -> None: circuit = Circuit(3) circuit.ccx(0, 1, 2) - check_circuit(circuit, fx_rng) + check_pattern(circuit.transpile().pattern, fx_rng) @pytest.mark.parametrize("jumps", range(1, 11)) @@ -190,7 +192,7 @@ def test_random_circuit(fx_bg: PCG64, jumps: int) -> None: nqubits = 4 depth = 4 circuit = rand_circuit(nqubits, depth, rng) - check_circuit(circuit, rng) + check_pattern(circuit.transpile().pattern, rng) def check_pattern_equivalence(pattern: Pattern, pattern2: Pattern, rng: Generator) -> None: @@ -201,3 +203,49 @@ def check_pattern_equivalence(pattern: Pattern, pattern2: Pattern, rng: Generato state = pattern.simulate_pattern(input_state=input_state, rng=rng) state2 = pattern2.simulate_pattern(input_state=input_state, rng=rng) assert state.isclose(state2) + +def test_step_4() -> None: + graph: Graph = nx.Graph( + [ + (0, 1), + (1, 2) + ] + ) + measurements = {0: Measurement.XY(0.25), 1: Measurement.X} + output_nodes = tuple(node for node in range(8) if node not in measurements) + og = OpenGraph(graph, input_nodes=(0,), output_nodes=(2,), measurements=measurements) + pattern = og.to_pattern() + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + standardized_pattern2 = remove_pauli_measurements(cut) + assert len(standardized_pattern2.m_list) == 1 + +def test_step_4_no_flow() -> None: + pattern = Pattern(input_nodes=(0,), output_nodes=(0,), cmds=[Command.N(1), Command.E((0, 1)), Command.M(1)]) + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + standardized_pattern2 = remove_pauli_measurements(cut) + assert len(standardized_pattern2.m_list) == 1 + +def test_cliffords_in_original_pattern(fx_rng: Generator) -> None: + circuit = Circuit(2) + circuit.cnot(0, 1) + pattern = circuit.transpile().pattern + u, v = pattern.output_nodes + pattern.add(Command.C(u, Clifford.S)) + pattern.add(Command.C(v, Clifford.SDG)) + check_pattern(pattern, fx_rng) + +def test_pattern_remove_pauli_measurements() -> None: + circuit = Circuit(2) + circuit.cnot(0, 1) + pattern = circuit.transpile().pattern + pattern2 = pattern.remove_pauli_measurements(copy=True) + assert all_bloch_measurement_or_input_node(pattern2.input_nodes, (cmd for cmd in pattern2 if isinstance(cmd, Command.M))) + assert not pattern2.is_standard() + pattern3 = pattern.remove_pauli_measurements(copy=True, standardize=True) + assert all_bloch_measurement_or_input_node(pattern3.input_nodes, (cmd for cmd in pattern3 if isinstance(cmd, Command.M))) + assert pattern3.is_standard() + assert not all_bloch_measurement_or_input_node(pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M))) + pattern.remove_pauli_measurements() + assert all_bloch_measurement_or_input_node(pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M))) From 4e030b642bae6ee8f201e984686e9776b989c49b Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 6 May 2026 08:31:07 +0200 Subject: [PATCH 09/40] Refactor `remove_pauli_measurements` function --- graphix/remove_pauli_measurements.py | 125 +++++++++++++++--------- tests/test_remove_pauli_measurements.py | 4 +- 2 files changed, 81 insertions(+), 48 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 1ee1ae3c5..b6353acf3 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -234,7 +234,6 @@ class _RemovePauliMeasurements: :meth:`to_standardized_pattern`. The public methods preserve the pattern semantics as invariant, such that an equivalent standardized pattern can be obtained at any stage of the process. - """ cut: PauliPushingCut @@ -251,6 +250,9 @@ class _RemovePauliMeasurements: Nodes are given with the indexing of the original pattern: use ``node_map`` to retrieve the index in the graph.""" + input_node_set: set[Node] + output_node_set: set[Node] + node_map: dict[Node, Node] """Mapping from the nodes of the original pattern to the nodes of the graph (that may have been pivoted). @@ -276,12 +278,13 @@ def __init__(self, cut: PauliPushingCut) -> None: spec = self.node_specs[cmd_m.node] spec.index = i self.pauli_measurements = {axis: set() for axis in Axis} - input_node_set = set(cut.original_pattern.input_nodes) + self.input_node_set = set(cut.original_pattern.input_nodes) + self.output_node_set = set(cut.original_pattern.output_nodes) for cmd_m in self.cut.pauli_measurements: if not isinstance(cmd_m.measurement, PauliMeasurement): # pragma: no cover msg = "Pauli measurement expected." raise TypeError(msg) - if cmd_m.node not in input_node_set: + if cmd_m.node not in self.input_node_set: self.node_specs[cmd_m.node].pauli_measurement = cmd_m.measurement self.pauli_measurements[cmd_m.measurement.axis].add(cmd_m.node) self.node_map = {node: node for node in self.graph.nodes()} @@ -394,7 +397,7 @@ def remove_y(self, u: Node, sign: Sign) -> None: self.local_complement(u) self.remove_z(u, sign) - def remove_x_with_non_input_neighbor(self, u: Node, v: Node, sign: Sign) -> None: + def remove_x_with_internal_neighbor(self, u: Node, v: Node, sign: Sign) -> None: """ Remove X/-X measurement. @@ -408,6 +411,74 @@ def remove_x_with_non_input_neighbor(self, u: Node, v: Node, sign: Sign) -> None self.pivot_vertices(u, v) self.remove_z(v, sign) + def remove_all_y_or_z(self) -> None: + """ + Remove all Y and Z measurements, repeatedly. + + Implements Theorem 4.12, Steps 1 and 2. + """ + for axis, remove in ( + (Axis.Y, self.remove_y), # Step 1: remove any non-input Y measured node + (Axis.Z, self.remove_z), # Step 2: remove any non-input Z measured node + ): + while True: + node = next(iter(self.pauli_measurements[axis]), None) + if node is None: + break + new_node = self.node_map[node] + spec = self.node_specs[new_node] + if spec.pauli_measurement is None: # pragma: no cover + msg = "Pauli measurement expected." + raise RuntimeError(msg) + remove(new_node, spec.pauli_measurement.sign) + + def try_remove_x_with_internal_neighbor(self) -> bool: + """ + Find an X measurement connected to internal neighbor and remove it if any. + + Implements Theorem 4.12, Step 3. + + Returns + ------- + bool + ``True`` if a node has been found and removed, ``False`` otherwise + """ + for node in self.pauli_measurements[Axis.X]: + new_node = self.node_map[node] + internal_neighbors = set(self.graph.neighbors(new_node)) - self.input_node_set - self.output_node_set + if not internal_neighbors: + continue + v, *_ = internal_neighbors + spec = self.node_specs[new_node] + if spec.pauli_measurement is None: # pragma: no cover + msg = "Pauli measurement expected." + raise RuntimeError(msg) + self.remove_x_with_internal_neighbor(new_node, v, spec.pauli_measurement.sign) + return True + return False + + def try_pivot_x_with_output_node(self) -> bool: + """ + Find an X measurement connected to an output node and pivot it if any. + + Implements Theorem 4.12, Step 4. + + Returns + ------- + bool + ``True`` if a node has been found and pivoted, ``False`` otherwise + """ + for node in self.pauli_measurements[Axis.X]: + new_node = self.node_map[node] + non_input_output_nodes = set(self.graph.neighbors(new_node)) & self.output_node_set - self.input_node_set + if not non_input_output_nodes: + continue + v, *_ = non_input_output_nodes + self.pivot_vertices(node, v) + return True + return False + + def to_standardized_pattern(self) -> StandardizedPattern: output_nodes: list[Node | None] = [None] * len(self.cut.original_pattern.output_nodes) measurements: list[Command.M | None] = [None] * len(self.measurements) @@ -497,47 +568,9 @@ def remove_pauli_measurements(cut: PauliPushingCut) -> StandardizedPattern: The pattern in which Pauli measurements have been removed. """ process = _RemovePauliMeasurements(cut) - input_node_set = set(cut.original_pattern.input_nodes) - output_node_set = set(cut.original_pattern.output_nodes) while True: - for axis, remove in ( - (Axis.Y, process.remove_y), # Step 1: remove any non-input Y measured node - (Axis.Z, process.remove_z), # Step 2: remove any non-input Z measured node - ): - while True: - node = next(iter(process.pauli_measurements[axis]), None) - if node is None: - break - new_node = process.node_map[node] - spec = process.node_specs[new_node] - if spec.pauli_measurement is None: # pragma: no cover - msg = "Pauli measurement expected." - raise RuntimeError(msg) - remove(new_node, spec.pauli_measurement.sign) - - # Step 3: remove any non-input X measured node connected to any other internal vertex - for node in process.pauli_measurements[Axis.X]: - new_node = process.node_map[node] - internal_neighbors = set(process.graph.neighbors(new_node)) - input_node_set - output_node_set - if not internal_neighbors: - continue - v, *_ = internal_neighbors - spec = process.node_specs[new_node] - if spec.pauli_measurement is None: # pragma: no cover - msg = "Pauli measurement expected." - raise RuntimeError(msg) - process.remove_x_with_non_input_neighbor(new_node, v, spec.pauli_measurement.sign) - break - else: - # Step 4: pivot a non-input X measured node connected to an output node if any (Lemma 4.11) - for node in process.pauli_measurements[Axis.X]: - new_node = process.node_map[node] - non_input_output_nodes = set(process.graph.neighbors(new_node)) & output_node_set - input_node_set - if not non_input_output_nodes: - continue - v, *_ = non_input_output_nodes - process.pivot_vertices(node, v) - break # node removed: break for-loop, continue outer while-loop - else: - break # no node found: interrupt outer while-loop + process.remove_all_y_or_z() # Steps 1 and 2 + if not process.try_remove_x_with_internal_neighbor(): # Step 3 + if not process.try_pivot_x_with_output_node(): # Step 4 + break return process.to_standardized_pattern() diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index 9c880fb18..c4cf0f25d 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -148,13 +148,13 @@ def test_remove_y(fx_rng: Generator, node: Node, sign: Sign) -> None: @pytest.mark.parametrize("sign", Sign) -def test_remove_x_with_non_input_neighbor(fx_rng: Generator, sign: Sign) -> None: +def test_remove_x_with_internal_neighbor(fx_rng: Generator, sign: Sign) -> None: og = opengraph_lemma_2_32({0: PauliMeasurement(Axis.X, sign)}) pattern = og.to_pattern() standardized_pattern = StandardizedPattern.from_pattern(pattern) cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) remove_pauli_measurements = _RemovePauliMeasurements(cut) - remove_pauli_measurements.remove_x_with_non_input_neighbor(0, 1, sign) + remove_pauli_measurements.remove_x_with_internal_neighbor(0, 1, sign) standardized_pattern2 = remove_pauli_measurements.to_standardized_pattern() pattern2 = standardized_pattern2.to_pattern() check_pattern_equivalence(pattern, pattern2, rng=fx_rng) From ecbea8ddab6d333f15db7708a260a7a3b9f05bf6 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 6 May 2026 08:40:58 +0200 Subject: [PATCH 10/40] Fix ruff and mypy --- graphix/remove_pauli_measurements.py | 21 ++++++----- tests/test_remove_pauli_measurements.py | 49 +++++++++++++++++-------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index b6353acf3..b5c418af1 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -152,7 +152,7 @@ def from_standardized_pattern( # = M^{XZ,(s+1)π} # = S^s M^{-Z} shifted_domains[cmd.node] = s_domain - case _: # pragma: no cover + case _: # pragma: no cover assert_never(cmd.measurement.axis) pauli_measurements.append(Command.M(node=cmd.node, measurement=cmd.measurement)) return cls(pattern, pauli_measurements, non_pauli_measurements, shifted_domains) @@ -281,7 +281,7 @@ def __init__(self, cut: PauliPushingCut) -> None: self.input_node_set = set(cut.original_pattern.input_nodes) self.output_node_set = set(cut.original_pattern.output_nodes) for cmd_m in self.cut.pauli_measurements: - if not isinstance(cmd_m.measurement, PauliMeasurement): # pragma: no cover + if not isinstance(cmd_m.measurement, PauliMeasurement): # pragma: no cover msg = "Pauli measurement expected." raise TypeError(msg) if cmd_m.node not in self.input_node_set: @@ -361,7 +361,7 @@ def _remove_node(self, u: Node) -> None: semantics of the pattern is not preserved. """ spec = self.node_specs[u] - if spec.pauli_measurement is None: # pragma: no cover + if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected" raise RuntimeError(msg) self.pauli_measurements[spec.pauli_measurement.axis].remove(spec.src) @@ -427,7 +427,7 @@ def remove_all_y_or_z(self) -> None: break new_node = self.node_map[node] spec = self.node_specs[new_node] - if spec.pauli_measurement is None: # pragma: no cover + if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected." raise RuntimeError(msg) remove(new_node, spec.pauli_measurement.sign) @@ -450,7 +450,7 @@ def try_remove_x_with_internal_neighbor(self) -> bool: continue v, *_ = internal_neighbors spec = self.node_specs[new_node] - if spec.pauli_measurement is None: # pragma: no cover + if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected." raise RuntimeError(msg) self.remove_x_with_internal_neighbor(new_node, v, spec.pauli_measurement.sign) @@ -478,7 +478,6 @@ def try_pivot_x_with_output_node(self) -> bool: return True return False - def to_standardized_pattern(self) -> StandardizedPattern: output_nodes: list[Node | None] = [None] * len(self.cut.original_pattern.output_nodes) measurements: list[Command.M | None] = [None] * len(self.measurements) @@ -569,8 +568,10 @@ def remove_pauli_measurements(cut: PauliPushingCut) -> StandardizedPattern: """ process = _RemovePauliMeasurements(cut) while True: - process.remove_all_y_or_z() # Steps 1 and 2 - if not process.try_remove_x_with_internal_neighbor(): # Step 3 - if not process.try_pivot_x_with_output_node(): # Step 4 - break + process.remove_all_y_or_z() # Steps 1 and 2 + if ( + not process.try_remove_x_with_internal_neighbor() # Step 3 + and not process.try_pivot_x_with_output_node() # Step 4 + ): + break return process.to_standardized_pattern() diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index c4cf0f25d..6c62150c3 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -6,18 +6,29 @@ import pytest from numpy.random import Generator -from graphix import Axis, BlochMeasurement, Clifford, Circuit, Command, Measurement, OpenGraph, Pattern, PauliMeasurement, Sign, StandardizedPattern +from graphix import ( + Axis, + BlochMeasurement, + Circuit, + Clifford, + Command, + Measurement, + OpenGraph, + Pattern, + PauliMeasurement, + Sign, + StandardizedPattern, +) from graphix.random_objects import rand_circuit, rand_state_vector from graphix.remove_pauli_measurements import PauliPushingCut, _RemovePauliMeasurements, remove_pauli_measurements if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping from collections.abc import Set as AbstractSet from numpy.random import PCG64 from graphix.command import Node - from graphix.pattern import Pattern from graphix.remove_pauli_measurements import Graph @@ -159,13 +170,15 @@ def test_remove_x_with_internal_neighbor(fx_rng: Generator, sign: Sign) -> None: pattern2 = standardized_pattern2.to_pattern() check_pattern_equivalence(pattern, pattern2, rng=fx_rng) -def all_bloch_measurement_or_input_node(input_nodes: Iterator[Node], measurement_commands: Iterator[Command.M]) -> bool: + +def all_bloch_measurement_or_input_node(input_nodes: Iterable[Node], measurement_commands: Iterable[Command.M]) -> bool: input_node_set = set(input_nodes) return all( isinstance(cmd_m.measurement, BlochMeasurement) or cmd_m.node in input_node_set for cmd_m in measurement_commands ) + def check_pattern(pattern: Pattern, rng: Generator) -> None: standardized_pattern = StandardizedPattern.from_pattern(pattern) cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) @@ -204,15 +217,10 @@ def check_pattern_equivalence(pattern: Pattern, pattern2: Pattern, rng: Generato state2 = pattern2.simulate_pattern(input_state=input_state, rng=rng) assert state.isclose(state2) + def test_step_4() -> None: - graph: Graph = nx.Graph( - [ - (0, 1), - (1, 2) - ] - ) + graph: Graph = nx.Graph([(0, 1), (1, 2)]) measurements = {0: Measurement.XY(0.25), 1: Measurement.X} - output_nodes = tuple(node for node in range(8) if node not in measurements) og = OpenGraph(graph, input_nodes=(0,), output_nodes=(2,), measurements=measurements) pattern = og.to_pattern() standardized_pattern = StandardizedPattern.from_pattern(pattern) @@ -220,6 +228,7 @@ def test_step_4() -> None: standardized_pattern2 = remove_pauli_measurements(cut) assert len(standardized_pattern2.m_list) == 1 + def test_step_4_no_flow() -> None: pattern = Pattern(input_nodes=(0,), output_nodes=(0,), cmds=[Command.N(1), Command.E((0, 1)), Command.M(1)]) standardized_pattern = StandardizedPattern.from_pattern(pattern) @@ -227,6 +236,7 @@ def test_step_4_no_flow() -> None: standardized_pattern2 = remove_pauli_measurements(cut) assert len(standardized_pattern2.m_list) == 1 + def test_cliffords_in_original_pattern(fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cnot(0, 1) @@ -236,16 +246,25 @@ def test_cliffords_in_original_pattern(fx_rng: Generator) -> None: pattern.add(Command.C(v, Clifford.SDG)) check_pattern(pattern, fx_rng) + def test_pattern_remove_pauli_measurements() -> None: circuit = Circuit(2) circuit.cnot(0, 1) pattern = circuit.transpile().pattern pattern2 = pattern.remove_pauli_measurements(copy=True) - assert all_bloch_measurement_or_input_node(pattern2.input_nodes, (cmd for cmd in pattern2 if isinstance(cmd, Command.M))) + assert all_bloch_measurement_or_input_node( + pattern2.input_nodes, (cmd for cmd in pattern2 if isinstance(cmd, Command.M)) + ) assert not pattern2.is_standard() pattern3 = pattern.remove_pauli_measurements(copy=True, standardize=True) - assert all_bloch_measurement_or_input_node(pattern3.input_nodes, (cmd for cmd in pattern3 if isinstance(cmd, Command.M))) + assert all_bloch_measurement_or_input_node( + pattern3.input_nodes, (cmd for cmd in pattern3 if isinstance(cmd, Command.M)) + ) assert pattern3.is_standard() - assert not all_bloch_measurement_or_input_node(pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M))) + assert not all_bloch_measurement_or_input_node( + pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M)) + ) pattern.remove_pauli_measurements() - assert all_bloch_measurement_or_input_node(pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M))) + assert all_bloch_measurement_or_input_node( + pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M)) + ) From fb95587433a41b4aa155e4744b14b16dc6546dca Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 7 May 2026 08:00:29 +0200 Subject: [PATCH 11/40] Fixes from Mateo's review --- graphix/optimization.py | 3 +- graphix/pattern.py | 6 +- graphix/remove_pauli_measurements.py | 107 ++++++++++-------------- tests/test_remove_pauli_measurements.py | 34 +++++--- 4 files changed, 71 insertions(+), 79 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 673e3bf39..3118d814d 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -268,10 +268,9 @@ def perform_pauli_pushing( Returns ------- - Pattern + StandardizedPattern The pattern in which Pauli measurements have been moved before the other measurements. - """ from graphix.remove_pauli_measurements import PauliPushingCut # noqa: PLC0415 diff --git a/graphix/pattern.py b/graphix/pattern.py index f8b188e5c..a657c4c96 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1785,8 +1785,6 @@ def remove_pauli_measurements( Parameters ---------- - pattern: StandardizedPattern - Standardized pattern to optimize. copy : bool, optional If ``True``, the current pattern remains unchanged and a new pattern is returned. The default is ``False``, meaning @@ -1801,11 +1799,10 @@ def remove_pauli_measurements( Returns ------- - StandardizedPattern + Pattern The pattern in which Pauli measurements have been moved before the other measurements. If ``copy`` is ``False``, the result is ``self``. - """ from graphix.remove_pauli_measurements import PauliPushingCut, remove_pauli_measurements # noqa: PLC0415 @@ -1816,6 +1813,7 @@ def remove_pauli_measurements( if copy: return pattern self.__seq = pattern.__seq + self.__output_nodes = pattern.__output_nodes return self diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index b5c418af1..37c8ad0bc 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -37,9 +37,9 @@ from graphix.optimization import StandardizedPattern if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Mapping from collections.abc import Set as AbstractSet - from typing import TypeAlias, TypeVar + from typing import TypeAlias import networkx as nx @@ -79,10 +79,13 @@ def from_standardized_pattern( """Move Pauli measurements before the other measurements and return the cut between Pauli measurements and non-Pauli measurements. If you only need the resulting pattern, you can use - :meth:`perform_pauli_pushing` instead. + :meth:`StandardizedPattern.perform_pauli_pushing` or + :meth:`~graphix.pattern.Pattern.perform_pauli_pushing` instead. Parameters ---------- + pattern: StandardizedPattern + The pattern to reorder. leave_nodes : AbstractSet[Node], optional Nodes that should not be moved. This constraint only applies to Pauli nodes and has no effect on non-Pauli nodes. @@ -209,15 +212,6 @@ class _NodeSpec: """Correction domains (the nodes refer to the numbering of the original pattern).""" clifford: Clifford = Clifford.I - is_output: bool = False - - index: int = 0 - """Index of the node in the original pattern. - - For output node, this is the index in the output node list of the - original pattern. For measured nodes, this is the index of the - measurement in the measurement sequence (after Pauli pushing). - """ pauli_measurement: PauliMeasurement | None = None """Pauli measurement if the node is not an input and is measured with a Pauli measurement. @@ -269,14 +263,7 @@ def __init__(self, cut: PauliPushingCut) -> None: self.node_specs[node].domains.t_domain = _expand_domain(cut.shifted_domains, domain) for node, clifford in cut.original_pattern.c_dict.items(): self.node_specs[node].clifford = clifford - for i, node in enumerate(cut.original_pattern.output_nodes): - spec = self.node_specs[node] - spec.is_output = True - spec.index = i self.measurements = cut.measurements() - for i, cmd_m in enumerate(self.measurements): - spec = self.node_specs[cmd_m.node] - spec.index = i self.pauli_measurements = {axis: set() for axis in Axis} self.input_node_set = set(cut.original_pattern.input_nodes) self.output_node_set = set(cut.original_pattern.output_nodes) @@ -328,7 +315,7 @@ def pivot_vertices(self, u: Node, v: Node) -> None: - (u, v) is a graph edge; - u and v are not input nodes. - Implements Lemmate 2.32 and 4.5 [BMBdF+21]. + Implements Lemmas 2.32 and 4.5 [BMBdF+21]. """ n_u = set(self.graph.neighbors(u)) n_v = set(self.graph.neighbors(v)) @@ -421,10 +408,7 @@ def remove_all_y_or_z(self) -> None: (Axis.Y, self.remove_y), # Step 1: remove any non-input Y measured node (Axis.Z, self.remove_z), # Step 2: remove any non-input Z measured node ): - while True: - node = next(iter(self.pauli_measurements[axis]), None) - if node is None: - break + while (node := next(iter(self.pauli_measurements[axis]), None)) is not None: new_node = self.node_map[node] spec = self.node_specs[new_node] if spec.pauli_measurement is None: # pragma: no cover @@ -446,9 +430,9 @@ def try_remove_x_with_internal_neighbor(self) -> bool: for node in self.pauli_measurements[Axis.X]: new_node = self.node_map[node] internal_neighbors = set(self.graph.neighbors(new_node)) - self.input_node_set - self.output_node_set - if not internal_neighbors: + v = next(iter(internal_neighbors), None) + if v is None: continue - v, *_ = internal_neighbors spec = self.node_specs[new_node] if spec.pauli_measurement is None: # pragma: no cover msg = "Pauli measurement expected." @@ -471,41 +455,46 @@ def try_pivot_x_with_output_node(self) -> bool: for node in self.pauli_measurements[Axis.X]: new_node = self.node_map[node] non_input_output_nodes = set(self.graph.neighbors(new_node)) & self.output_node_set - self.input_node_set - if not non_input_output_nodes: + v = next(iter(non_input_output_nodes), None) + if v is None: continue - v, *_ = non_input_output_nodes self.pivot_vertices(node, v) return True return False + def _create_new_m(self, original_m: Command.M) -> Command.M | None: + node = self.node_map.get(original_m.node) + if node is None: + return None + spec = self.node_specs[node] + new_m = original_m.clifford(spec.clifford) + new_m.node = node + new_m.s_domain = _map_domain(self.node_map, new_m.s_domain) + new_m.t_domain = _map_domain(self.node_map, new_m.t_domain) + return new_m + def to_standardized_pattern(self) -> StandardizedPattern: - output_nodes: list[Node | None] = [None] * len(self.cut.original_pattern.output_nodes) - measurements: list[Command.M | None] = [None] * len(self.measurements) - z_dict: dict[Node, set[int]] = {} - x_dict: dict[Node, set[int]] = {} - c_dict: dict[Node, Clifford] = {} - for node, spec in self.node_specs.items(): - if spec.is_output: - output_nodes[spec.index] = node - if spec.domains.t_domain: - z_dict[node] = _map_domain(self.node_map, spec.domains.t_domain) - if spec.domains.s_domain: - x_dict[node] = _map_domain(self.node_map, spec.domains.s_domain) - if spec.clifford != Clifford.I: - c_dict[node] = spec.clifford - else: - cmd_m = self.measurements[spec.index].clifford(spec.clifford) - cmd_m.node = node - cmd_m.s_domain = _map_domain(self.node_map, cmd_m.s_domain) - cmd_m.t_domain = _map_domain(self.node_map, cmd_m.t_domain) - measurements[spec.index] = cmd_m + n_list = tuple(cmd_n for cmd_n in self.cut.original_pattern.n_list if cmd_n.node in self.node_specs) + output_nodes = tuple(self.node_map[node] for node in self.cut.original_pattern.output_nodes) + measurements = tuple(new_m for original_m in self.measurements if (new_m := self._create_new_m(original_m))) + z_dict = { + node: t_domain + for node in output_nodes + if (t_domain := _map_domain(self.node_map, self.node_specs[node].domains.t_domain)) + } + x_dict = { + node: s_domain + for node in output_nodes + if (s_domain := _map_domain(self.node_map, self.node_specs[node].domains.s_domain)) + } + c_dict = {node: clifford for node in output_nodes if (clifford := self.node_specs[node].clifford) != Clifford.I} return StandardizedPattern( self.cut.original_pattern.input_nodes, - _filter_none(output_nodes), + output_nodes, self.cut.original_pattern.results, - (cmd_n for cmd_n in self.cut.original_pattern.n_list if cmd_n.node in self.node_specs), - (frozenset(edge) for edge in self.graph.edges()), - _filter_none(measurements), + n_list, + self.graph.edges(), + measurements, c_dict, z_dict, x_dict, @@ -514,8 +503,8 @@ def to_standardized_pattern(self) -> StandardizedPattern: def _complement_subgraph(graph: nx.Graph[Node], s: set[Node]) -> None: """Complement edges in a given subgraph.""" - all_pairs = {(u, v) for u, v in itertools.combinations(s, 2)} - existing = {edge for edge in all_pairs if edge in graph.edges()} + all_pairs = set(itertools.combinations(s, 2)) + existing = all_pairs & graph.edges() graph.remove_edges_from(existing) graph.add_edges_from(all_pairs - existing) @@ -523,7 +512,7 @@ def _complement_subgraph(graph: nx.Graph[Node], s: set[Node]) -> None: def _complement_edges(graph: nx.Graph[Node], s: set[Node], t: set[Node]) -> None: """Complement edges between two set of nodes. - s and t are supposed to be disjoint. + ``s`` and ``t`` are supposed to be disjoint. """ all_pairs = {(u, v) for u in s for v in t} existing = {(u, v) for u, v in graph.edges(s) if v in t} @@ -531,14 +520,6 @@ def _complement_edges(graph: nx.Graph[Node], s: set[Node], t: set[Node]) -> None graph.add_edges_from(all_pairs - existing) -if TYPE_CHECKING: - T = TypeVar("T") - - -def _filter_none(it: Iterable[T | None]) -> list[T]: - return [elt for elt in it if elt is not None] - - def _map_domain(node_map: Mapping[Node, Node], domain: set[Node]) -> set[Node]: return {v for node in domain if (v := node_map.get(node)) is not None} diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index 6c62150c3..de86bb2fd 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -193,6 +193,16 @@ def check_pattern(pattern: Pattern, rng: Generator) -> None: check_pattern_equivalence(pattern, pattern2, rng=rng) +def check_pattern_equivalence(pattern: Pattern, pattern2: Pattern, rng: Generator) -> None: + pattern.minimize_space() + pattern2.minimize_space() + for _ in range(4): + input_state = rand_state_vector(len(pattern.input_nodes), rng=rng) + state = pattern.simulate_pattern(input_state=input_state, rng=rng) + state2 = pattern2.simulate_pattern(input_state=input_state, rng=rng) + assert state.isclose(state2) + + def test_ccx(fx_rng: Generator) -> None: circuit = Circuit(3) circuit.ccx(0, 1, 2) @@ -208,16 +218,6 @@ def test_random_circuit(fx_bg: PCG64, jumps: int) -> None: check_pattern(circuit.transpile().pattern, rng) -def check_pattern_equivalence(pattern: Pattern, pattern2: Pattern, rng: Generator) -> None: - pattern.minimize_space() - pattern2.minimize_space() - for _ in range(4): - input_state = rand_state_vector(len(pattern.input_nodes), rng=rng) - state = pattern.simulate_pattern(input_state=input_state, rng=rng) - state2 = pattern2.simulate_pattern(input_state=input_state, rng=rng) - assert state.isclose(state2) - - def test_step_4() -> None: graph: Graph = nx.Graph([(0, 1), (1, 2)]) measurements = {0: Measurement.XY(0.25), 1: Measurement.X} @@ -268,3 +268,17 @@ def test_pattern_remove_pauli_measurements() -> None: assert all_bloch_measurement_or_input_node( pattern.input_nodes, (cmd for cmd in pattern if isinstance(cmd, Command.M)) ) + + +def test_pattern_remove_pauli_measurements_output_nodes() -> None: + og = OpenGraph( + graph=nx.Graph([(1, 2)]), + input_nodes=[], + output_nodes=[2], + measurements={ + 1: Measurement.X, + }, + ) + pattern = og.to_pattern() + pattern.remove_pauli_measurements() + pattern.simulate_pattern() From c38821d1e3cb7646a4778bb51542f9f21bdce736 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 7 May 2026 08:21:39 +0200 Subject: [PATCH 12/40] Make `measurements` a property` and use `tuple` instead of `list` in `PauliPushingCut` --- graphix/remove_pauli_measurements.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 37c8ad0bc..a5319d693 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -60,10 +60,10 @@ class PauliPushingCut: original_pattern: StandardizedPattern - pauli_measurements: list[Command.M] + pauli_measurements: tuple[Command.M, ...] """Pauli measurements: they are all applied before non-Pauli measurements and their domains are empty.""" - non_pauli_measurements: list[Command.M] + non_pauli_measurements: tuple[Command.M, ...] shifted_domains: dict[int, set[int]] """The shifted domains. @@ -72,6 +72,11 @@ class PauliPushingCut: :func:`~graphix.pattern.shift_outcomes` with these domains. """ + @property + def measurements(self) -> tuple[Command.M, ...]: + """Return the list of measurements, where Pauli measurements appear first and without signal.""" + return self.pauli_measurements + self.non_pauli_measurements + @classmethod def from_standardized_pattern( cls, pattern: StandardizedPattern, leave_nodes: AbstractSet[Node] | None = None, *, stacklevel: int = 1 @@ -158,11 +163,7 @@ def from_standardized_pattern( case _: # pragma: no cover assert_never(cmd.measurement.axis) pauli_measurements.append(Command.M(node=cmd.node, measurement=cmd.measurement)) - return cls(pattern, pauli_measurements, non_pauli_measurements, shifted_domains) - - def measurements(self) -> list[Command.M]: - """Return the list of measurements, where Pauli measurements appear first and without signal.""" - return self.pauli_measurements + self.non_pauli_measurements + return cls(pattern, tuple(pauli_measurements), tuple(non_pauli_measurements), shifted_domains) def to_standardized_pattern(self) -> StandardizedPattern: """Return the standardized pattern where all Pauli measurements have been pushed.""" @@ -172,7 +173,7 @@ def to_standardized_pattern(self) -> StandardizedPattern: self.original_pattern.results, self.original_pattern.n_list, self.original_pattern.e_set, - self.measurements(), + self.measurements, self.original_pattern.c_dict, _expand_corrections(self.shifted_domains, self.original_pattern.z_dict), _expand_corrections(self.shifted_domains, self.original_pattern.x_dict), @@ -236,7 +237,7 @@ class _RemovePauliMeasurements: graph: Graph node_specs: dict[Node, _NodeSpec] - measurements: list[Command.M] + measurements: tuple[Command.M, ...] """List of the original measurements after Pauli-pushing.""" pauli_measurements: dict[Axis, set[Node]] @@ -263,7 +264,7 @@ def __init__(self, cut: PauliPushingCut) -> None: self.node_specs[node].domains.t_domain = _expand_domain(cut.shifted_domains, domain) for node, clifford in cut.original_pattern.c_dict.items(): self.node_specs[node].clifford = clifford - self.measurements = cut.measurements() + self.measurements = cut.measurements self.pauli_measurements = {axis: set() for axis in Axis} self.input_node_set = set(cut.original_pattern.input_nodes) self.output_node_set = set(cut.original_pattern.output_nodes) From f8ec5bf97e5c991deacd2cffe8dbab03e0922ea5 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 8 May 2026 01:33:45 +0200 Subject: [PATCH 13/40] Replace by `remove_pauli_measurements` everywhere --- CHANGELOG.md | 4 +- README.md | 2 +- docs/source/tutorial.rst | 5 +- examples/deutsch_jozsa.py | 3 +- examples/mbqc_vqe.py | 3 +- examples/qaoa.py | 3 +- examples/qft_with_tn.py | 5 +- examples/tn_simulation.py | 8 +- examples/visualization.py | 3 +- graphix/qasm3_exporter.py | 2 +- graphix/remove_pauli_measurements.py | 31 +++-- tests/test_optimization.py | 15 +-- tests/test_parameter.py | 3 +- tests/test_pattern.py | 149 ++++++------------------- tests/test_pyzx.py | 3 +- tests/test_qasm3_exporter.py | 4 +- tests/test_qasm3_exporter_to_qiskit.py | 3 +- tests/test_tnsim.py | 9 +- tests/test_visualization.py | 6 +- 19 files changed, 90 insertions(+), 171 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cdab9f23..cbee3f227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #490: Introduced new `Instruction` and `Command` namespace classes for instruction and command instantiation. -- #476 Introduced new methods `OpenGraph.extract_circuit`, `CliffordMap.to_tableau` and new function `graphix.circ_ext.compilation.cm_berg_pass`. Circuit extraction can be done natively in Graphix. +- #476: Introduced new methods `OpenGraph.extract_circuit`, `CliffordMap.to_tableau` and new function `graphix.circ_ext.compilation.cm_berg_pass`. Circuit extraction can be done natively in Graphix. ### Fixed ### Changed +- #168, #498: `Pattern.remove_pauli_measurements` replaces `Pattern.perform_pauli_measurements`: the new algorithm removes all non-input Pauli nodes from patterns with flow and returns a pattern + - #490: Exposed more common classes and methods to top level `__init__.py`. - Renamed `Instruction`, `InstructionWithoutRZZ` and `Command` to `InstructionType`, `InstructionTypeWithoutRZZ` and `CommandType` respectively. - Moved `InstructionType`, `InstructionTypeWithoutRZZ`, `CommandType`, `Correction` and `CommandOrNoise` to `TYPE_CHECKING` blocks. diff --git a/README.md b/README.md index d40169b05..c44e006f2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ pattern.draw_graph(flow_from_pattern=False) ### preprocessing Pauli measurements (Clifford gates) ```python -pattern.perform_pauli_measurements() +pattern.remove_pauli_measurements() pattern.draw_graph() ``` diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index c44505bee..ca417afbe 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -175,11 +175,10 @@ It is known that quantum circuit consisting of Pauli basis states, Clifford gate `_; e.g. the graph state simulator runs in :math:`\mathcal{O}(n \log n)` time). The Pauli measurement part of the MBQC is exactly this, and they can be preprocessed by our graph state simulator :class:`~graphix.graphsim.GraphState` - see :doc:`lc-mbqc` for more detailed description. -We can call this in a line by calling :meth:`~graphix.pattern.Pattern.remove_input_nodes` followed by :meth:`~graphix.pattern.Pattern.perform_pauli_measurements()` (both methods of the :class:`~graphix.pattern.Pattern` object). The first method removes the input nodes, while the second method optimizes the measurement pattern. +We can call :meth:`~graphix.pattern.Pattern.remove_pauli_measurements()` (method of the :class:`~graphix.pattern.Pattern` object) to optimize the measurement pattern. We get an updated measurement pattern without Pauli measurements as follows: ->>> pattern.remove_input_nodes() ->>> pattern.perform_pauli_measurements() +>>> pattern.remove_pauli_measurements() >>> pattern Pattern(input_nodes=[], cmds=[N(0), N(1), N(3), N(7), E((0, 3)), E((1, 3)), E((1, 7)), M(0, Plane.YZ, 0.2907266109187514), M(1, Plane.YZ, 0.01258854060311348), C(3, Clifford.I), C(7, Clifford.I), Z(3, {0, 1, 5}), Z(7, {1, 5}), X(3, {2}), X(7, {2, 4, 6})], output_nodes=[3, 7]) diff --git a/examples/deutsch_jozsa.py b/examples/deutsch_jozsa.py index e5f82d056..68a032846 100644 --- a/examples/deutsch_jozsa.py +++ b/examples/deutsch_jozsa.py @@ -73,8 +73,7 @@ # %% # Now we preprocess all Pauli measurements, which requires that we move inputs to N commands -pattern.remove_input_nodes() -pattern.perform_pauli_measurements() +pattern.remove_pauli_measurements() print( pattern.to_ascii( left_to_right=True, diff --git a/examples/mbqc_vqe.py b/examples/mbqc_vqe.py index fce62723a..9b690df9f 100644 --- a/examples/mbqc_vqe.py +++ b/examples/mbqc_vqe.py @@ -92,8 +92,7 @@ def build_mbqc_pattern(self, params: Iterable[ParameterizedAngle]) -> Pattern: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() # Perform Pauli measurements + pattern.remove_pauli_measurements() # Perform Pauli measurements return pattern # %% diff --git a/examples/qaoa.py b/examples/qaoa.py index 0e57f3b66..f0f2c1a42 100644 --- a/examples/qaoa.py +++ b/examples/qaoa.py @@ -40,8 +40,7 @@ # %% # perform Pauli measurements and plot the new (minimal) graph to perform the same quantum computation -pattern.remove_input_nodes() -pattern.perform_pauli_measurements() +pattern.remove_pauli_measurements() pattern.draw(flow_from_pattern=False) # %% diff --git a/examples/qft_with_tn.py b/examples/qft_with_tn.py index eb92a6b3e..45994f48f 100644 --- a/examples/qft_with_tn.py +++ b/examples/qft_with_tn.py @@ -64,10 +64,9 @@ def qft(circuit: Circuit, n: int) -> None: print(f"Number of edges: {len(graph.edges)}") # %% -# Using efficient graph state simulator `graphix.graphsim`, we can classically preprocess Pauli measurements. +# Using graph rewriting rules, we can classically preprocess Pauli measurements. # We are currently improving the speed of this process by using rust-based graph manipulation backend. -pattern.remove_input_nodes() -pattern.perform_pauli_measurements() +pattern.remove_pauli_measurements() # %% diff --git a/examples/tn_simulation.py b/examples/tn_simulation.py index 8de62c998..a84676122 100644 --- a/examples/tn_simulation.py +++ b/examples/tn_simulation.py @@ -83,9 +83,8 @@ def ansatz( print(f"Number of edges: {len(graph.edges)}") # %% -# Optimizing by performing Pauli measurements in the pattern using efficient stabilizer simulator. -pattern.remove_input_nodes() -pattern.perform_pauli_measurements() +# Optimizing by performing Pauli measurements in the pattern. +pattern.remove_pauli_measurements() # %% # Simulate using the TN backend of graphix, which will return an MBQCTensorNet object. @@ -202,8 +201,7 @@ def cost( pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() mbqc_tn = pattern.simulate_pattern(backend="tensornetwork", graph_prep="parallel") exp_val: float = 0 for op in ham: diff --git a/examples/visualization.py b/examples/visualization.py index 0aa642b2e..282cb6b91 100644 --- a/examples/visualization.py +++ b/examples/visualization.py @@ -37,8 +37,7 @@ # %% # next, show the gflow: -pattern.remove_input_nodes() -pattern.perform_pauli_measurements() +pattern.remove_pauli_measurements() pattern.draw(flow_from_pattern=False, measurement_labels=True) diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 8b0066a4b..4291db211 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -66,7 +66,7 @@ def qasm3_gate_call(gate: str, operands: Iterable[str], args: Iterable[str] | No def angle_to_qasm3(angle: ParameterizedAngle) -> str: """Get the OpenQASM3 representation of an angle.""" - if not isinstance(angle, float): + if not isinstance(angle, (int, float)): raise TypeError("QASM export of symbolic pattern is not supported") return angle_to_str(angle, output=OutputFormat.ASCII, multiplication_sign=True) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index a5319d693..de1255874 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING from warnings import warn +import networkx as nx from typing_extensions import assert_never from graphix.clifford import Clifford, Domains @@ -41,14 +42,11 @@ from collections.abc import Set as AbstractSet from typing import TypeAlias - import networkx as nx - from graphix.command import Node Graph: TypeAlias = nx.Graph[int] else: - # The type is quoted because we don't need to import `nx`. - Graph = "nx.Graph" + Graph = nx.Graph @dataclass(frozen=True, slots=True) @@ -342,6 +340,16 @@ def pivot_vertices(self, u: Node, v: Node) -> None: for node in inter: self._apply_clifford(node, Clifford.Z) + u_output = u in self.output_node_set + v_output = v in self.output_node_set + if u_output != v_output: + if u_output: + old_output, new_output = u, v + else: + old_output, new_output = v, u + self.output_node_set.remove(old_output) + self.output_node_set.add(new_output) + def _remove_node(self, u: Node) -> None: """Remove a node from the graph. @@ -349,10 +357,8 @@ def _remove_node(self, u: Node) -> None: semantics of the pattern is not preserved. """ spec = self.node_specs[u] - if spec.pauli_measurement is None: # pragma: no cover - msg = "Pauli measurement expected" - raise RuntimeError(msg) - self.pauli_measurements[spec.pauli_measurement.axis].remove(spec.src) + if spec.pauli_measurement is not None: + self.pauli_measurements[spec.pauli_measurement.axis].remove(spec.src) del self.node_map[spec.src] del self.node_specs[u] self.graph.remove_node(u) @@ -463,6 +469,14 @@ def try_pivot_x_with_output_node(self) -> bool: return True return False + def remove_isolated_internal_nodes(self) -> None: + """Remove isolated internal nodes.""" + # Construct the list first since the graph should not be + # modified while enumerating isolated nodes. + for node in list(nx.isolates(self.graph)): + if node not in self.input_node_set and node not in self.output_node_set: + self._remove_node(node) + def _create_new_m(self, original_m: Command.M) -> Command.M | None: node = self.node_map.get(original_m.node) if node is None: @@ -556,4 +570,5 @@ def remove_pauli_measurements(cut: PauliPushingCut) -> StandardizedPattern: and not process.try_pivot_x_with_output_node() # Step 4 ): break + process.remove_isolated_internal_nodes() return process.to_standardized_pattern() diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 29bc663c9..aa308d47e 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -66,8 +66,7 @@ def test_incorporate_pauli_results(fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern2 = incorporate_pauli_results(pattern) state = pattern.simulate_pattern(rng=rng) state2 = pattern2.simulate_pattern(rng=rng) @@ -83,11 +82,10 @@ 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.remove_input_nodes() - pattern.perform_pauli_measurements() - pattern2 = incorporate_pauli_results(pattern) - gflow = pattern2.extract_gflow() + pattern.remove_pauli_measurements() + # We should convert to Bloch measurement the remaining Pauli + # measurements on input nodes. + gflow = pattern.to_bloch().extract_gflow() gflow.check_well_formed() @@ -100,8 +98,7 @@ def test_remove_useless_domains(fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern2 = remove_useless_domains(pattern) state = pattern.simulate_pattern(rng=rng) state2 = pattern2.simulate_pattern(rng=rng) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index f33040634..88f68d07e 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -170,8 +170,7 @@ def test_random_circuit_with_parameters(fx_bg: PCG64, jumps: int, use_xreplace: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.minimize_space() assignment: dict[Parameter, float] = {alpha: rng.uniform(high=2), beta: rng.uniform(high=2)} if use_xreplace: diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 948c5d9ca..eac96ea67 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -141,8 +141,7 @@ def test_pauli_non_contiguous(self) -> None: M(0, Measurement.X, s_domain=set(), t_domain=set()), ] ) - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() @pytest.mark.parametrize("jumps", range(1, 11)) def test_minimize_space_with_gflow(self, fx_bg: PCG64, jumps: int) -> None: @@ -154,8 +153,7 @@ def test_minimize_space_with_gflow(self, fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.minimize_space() state = circuit.simulate_statevector().statevec state_mbqc = pattern.simulate_pattern(rng=rng) @@ -233,18 +231,14 @@ def test_pauli_measurement_random_circuit( pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.minimize_space() state = circuit.simulate_statevector().statevec state_mbqc: Statevec | DensityMatrix = pattern.simulate_pattern(backend, rng=rng) assert compare_backend_result_with_statevec(state_mbqc, state) == pytest.approx(1) @pytest.mark.parametrize("jumps", range(1, 11)) - @pytest.mark.parametrize("ignore_pauli_with_deps", [False, True]) - def test_pauli_measurement_random_circuit_all_paulis( - self, fx_bg: PCG64, jumps: int, ignore_pauli_with_deps: bool - ) -> None: + def test_pauli_measurement_random_circuit_all_paulis(self, fx_bg: PCG64, jumps: int) -> None: rng = Generator(fx_bg.jumped(jumps)) nqubits = 3 depth = 3 @@ -252,10 +246,12 @@ def test_pauli_measurement_random_circuit_all_paulis( pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern.remove_input_nodes() - pattern.perform_pauli_measurements(ignore_pauli_with_deps=ignore_pauli_with_deps) - assert ignore_pauli_with_deps or not any( - cmd.measurement.try_to_pauli() is not None for cmd in pattern if cmd.kind == CommandKind.M + pattern.remove_pauli_measurements() + input_node_set = set(pattern.input_nodes) + assert not any( + cmd.measurement.try_to_pauli() is not None + for cmd in pattern + if cmd.kind == CommandKind.M and cmd.node not in input_node_set ) @pytest.mark.parametrize("pm", PauliMeasurement) @@ -264,10 +260,10 @@ def test_pauli_measurement_single(self, pm: PauliMeasurement) -> None: pattern.add(E(nodes=(0, 1))) pattern.add(M(0, pm)) pattern_ref = pattern.copy() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() - state = pattern.simulate_pattern() - state_ref = pattern_ref.simulate_pattern(branch_selector=ConstBranchSelector(0)) + pattern.remove_pauli_measurements() + branch_selector = ConstBranchSelector(0) + state = pattern.simulate_pattern(branch_selector=branch_selector) + state_ref = pattern_ref.simulate_pattern(branch_selector=branch_selector) assert state.isclose(state_ref) def test_pauli_measurement(self) -> None: @@ -288,46 +284,17 @@ def test_pauli_measurement(self) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() - isolated_nodes = pattern.extract_isolated_nodes() - # 42-node is the isolated and output node. - isolated_nodes_ref = {42} - assert isolated_nodes == isolated_nodes_ref - - def test_pauli_measurement_error(self, fx_rng: Generator) -> None: - nqubits = 2 - depth = 1 - circuit = rand_circuit(nqubits, depth, fx_rng) - pattern = circuit.transpile().pattern - pattern.standardize() - with pytest.raises(PatternError): - pattern.perform_pauli_measurements() - - def test_pauli_measurement_leave_input(self) -> None: - # test pattern is obtained from 3-qubit QFT with pauli measurement - circuit = Circuit(3) - for i in range(3): - circuit.h(i) - circuit.x(1) - circuit.x(2) - - # QFT - circuit.h(2) - cp(circuit, ANGLE_PI / 4, 0, 2) - cp(circuit, ANGLE_PI / 2, 1, 2) - circuit.h(1) - cp(circuit, ANGLE_PI / 2, 0, 1) - circuit.h(0) - swap(circuit, 0, 2) - pattern = circuit.transpile().pattern - pattern.standardize() - with pytest.raises(PatternError): - pattern.perform_pauli_measurements() + pattern_opt = pattern.remove_pauli_measurements(copy=True) + isolated_nodes = pattern_opt.extract_isolated_nodes() + assert isolated_nodes == set() + pattern.minimize_space() + pattern_opt.minimize_space() + state = pattern.simulate_pattern() + state_opt = pattern.simulate_pattern() + assert state.isclose(state_opt) @pytest.mark.parametrize("jumps", range(1, 6)) - @pytest.mark.parametrize("ignore_pauli_with_deps", [False, True]) - def test_pauli_measured_against_nonmeasured(self, fx_bg: PCG64, jumps: int, ignore_pauli_with_deps: bool) -> None: + def test_pauli_measured_against_nonmeasured(self, fx_bg: PCG64, jumps: int) -> None: rng = Generator(fx_bg.jumped(jumps)) nqubits = 2 depth = 2 @@ -335,48 +302,11 @@ def test_pauli_measured_against_nonmeasured(self, fx_bg: PCG64, jumps: int, igno pattern = circuit.transpile().pattern pattern.standardize() pattern1 = copy.deepcopy(pattern) - pattern1.remove_input_nodes() - pattern1.perform_pauli_measurements(ignore_pauli_with_deps=ignore_pauli_with_deps) + pattern1.remove_pauli_measurements() state = pattern.simulate_pattern(rng=rng) state1 = pattern1.simulate_pattern(rng=rng) assert state.isclose(state1) - @pytest.mark.parametrize("jumps", range(1, 4)) - def test_pauli_repeated_measurement(self, fx_bg: PCG64, jumps: int) -> None: - rng = Generator(fx_bg.jumped(jumps)) - nqubits = 2 - depth = 2 - circuit = rand_circuit(nqubits, depth, rng, use_ccx=False) - pattern = circuit.transpile().pattern - pattern.remove_input_nodes() - assert not pattern.results - pattern.perform_pauli_measurements() - assert pattern.results - pattern.perform_pauli_measurements() - assert pattern.results - - @pytest.mark.parametrize("jumps", range(1, 4)) - def test_pauli_repeated_measurement_compose(self, fx_bg: PCG64, jumps: int) -> None: - rng = Generator(fx_bg.jumped(jumps)) - nqubits = 2 - depth = 2 - circuit = rand_circuit(nqubits, depth, rng, use_ccx=False) - circuit1 = rand_circuit(nqubits, depth, rng, use_ccx=False) - pattern = circuit.transpile().pattern - pattern1 = circuit1.transpile().pattern - composed_pattern, _ = pattern.compose( - pattern1, mapping=dict(zip(pattern1.input_nodes, pattern.output_nodes, strict=True)), preserve_mapping=True - ) - pattern.remove_input_nodes() - pattern1.remove_input_nodes() - assert not pattern.results - assert not pattern1.results - pattern.perform_pauli_measurements() - pattern1.perform_pauli_measurements() - composed_pattern.remove_input_nodes() - composed_pattern.perform_pauli_measurements() - assert abs(len(composed_pattern.results) - len(pattern.results) - len(pattern1.results)) <= 2 - def test_extract_measurement_commands(self) -> None: preset_meas_plane = [ Plane.XY, @@ -477,8 +407,7 @@ def test_pauli_measurement_then_standardize(self, fx_bg: PCG64, jumps: int) -> N depth = 3 circuit = rand_circuit(nqubits, depth, rng) pattern = circuit.transpile().pattern - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.standardize() pattern.minimize_space() state = circuit.simulate_statevector().statevec @@ -753,8 +682,7 @@ def test_compose_7(self, fx_rng: Generator) -> None: circuit_1.h(0) circuit_1.rz(0, alpha) p1 = circuit_1.transpile().pattern - p1.remove_input_nodes() - p1.perform_pauli_measurements() + p1.remove_pauli_measurements() circuit_2 = Circuit(1) circuit_2.rz(0, alpha) @@ -880,12 +808,11 @@ def test_extract_partial_order_layers_results(self) -> None: c = Circuit(1) c.rz(0, 0.2) p = c.transpile().pattern - p.remove_input_nodes() - p.perform_pauli_measurements() - assert p.extract_partial_order_layers() == (frozenset({2}), frozenset({0})) + p.remove_pauli_measurements() + assert p.extract_partial_order_layers() == (frozenset({1}), frozenset({0})) p = Pattern(cmds=[N(0), N(1), N(2), M(0), E((1, 2)), X(1, {0}), M(2, Measurement.XY(0.3))]) - p.perform_pauli_measurements() + p.remove_pauli_measurements() assert p.extract_partial_order_layers() == (frozenset({1}), frozenset({2})) class PatternFlowTestCase(NamedTuple): @@ -1006,10 +933,8 @@ def test_extract_causal_flow_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None p_ref = circuit_1.transpile().pattern p_test = p_ref.to_bloch().extract_causal_flow().to_corrections().to_pattern().infer_pauli_measurements() - p_ref.remove_input_nodes() - p_test.remove_input_nodes() - p_ref.perform_pauli_measurements() - p_test.perform_pauli_measurements() + p_ref.remove_pauli_measurements() + p_test.remove_pauli_measurements() s_ref = p_ref.simulate_pattern(rng=rng) s_test = p_test.simulate_pattern(rng=rng) @@ -1025,10 +950,8 @@ def test_extract_gflow_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None: p_ref = circuit_1.transpile().pattern p_test = p_ref.to_bloch().extract_gflow().to_corrections().to_pattern().infer_pauli_measurements() - p_ref.remove_input_nodes() - p_test.remove_input_nodes() - p_ref.perform_pauli_measurements() - p_test.perform_pauli_measurements() + p_ref.remove_pauli_measurements() + p_test.remove_pauli_measurements() s_ref = p_ref.simulate_pattern(rng=rng) s_test = p_test.simulate_pattern(rng=rng) @@ -1122,8 +1045,7 @@ def test_extract_xzc_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None: p_test = xzc.to_pattern() for p in [p_ref, p_test]: - p.remove_input_nodes() - p.perform_pauli_measurements() + p.remove_pauli_measurements() s_ref = p_ref.simulate_pattern(rng=rng) s_test = p_test.simulate_pattern(rng=rng) @@ -1343,8 +1265,7 @@ def test_pauli_measurement_end_with_measure(self) -> None: p = Pattern(input_nodes=[0]) p.add(N(node=1)) p.add(M(1, Measurement.X)) - p.remove_input_nodes() - p.perform_pauli_measurements() + p.remove_pauli_measurements() @pytest.mark.parametrize("backend", ["statevector", "densitymatrix"]) @pytest.mark.filterwarnings("ignore:Simulating using densitymatrix backend with no noise.") diff --git a/tests/test_pyzx.py b/tests/test_pyzx.py index e4a0d868e..a45762e18 100644 --- a/tests/test_pyzx.py +++ b/tests/test_pyzx.py @@ -78,8 +78,7 @@ def test_random_clifford_t() -> None: def simulate_pattern(pattern: Pattern, rng: Generator) -> Statevec: - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.minimize_space() return pattern.simulate_pattern(rng=rng) diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index f9751d0de..ea643b7b6 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -55,7 +55,7 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: depth = 5 circuit = rand_circuit(nqubits, depth, rng=rng) pattern = circuit.transpile().pattern - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.minimize_space() + print(pattern) _qasm3 = pattern_to_qasm3(pattern) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index a857daa7d..9d281667f 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -119,8 +119,7 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: depth = 5 circuit = rand_circuit(nqubits, depth, rng=rng) pattern = circuit.transpile().pattern - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.minimize_space() # qiskit_qasm3_import.exceptions.ConversionError: initialisation of classical bits is not supported diff --git a/tests/test_tnsim.py b/tests/test_tnsim.py index 9827576af..4e34aa8f0 100644 --- a/tests/test_tnsim.py +++ b/tests/test_tnsim.py @@ -335,8 +335,7 @@ def test_with_graphtrans(self, fx_bg: PCG64, jumps: int, fx_rng: Generator) -> N pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() state = circuit.simulate_statevector().statevec tn_mbqc = pattern.simulate_pattern(backend="tensornetwork", rng=fx_rng) random_op3 = random_op(3, rng) @@ -354,8 +353,7 @@ def test_with_graphtrans_sequential(self, fx_bg: PCG64, jumps: int, fx_rng: Gene pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() state = circuit.simulate_statevector().statevec tn_mbqc = pattern.simulate_pattern(backend="tensornetwork", graph_prep="sequential", rng=fx_rng) random_op3 = random_op(3, rng) @@ -403,8 +401,7 @@ def test_evolve(self, fx_bg: PCG64, jumps: int, fx_rng: Generator) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() state = circuit.simulate_statevector().statevec tn_mbqc = pattern.simulate_pattern(backend="tensornetwork", rng=fx_rng) random_op3 = random_op(3, rng) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index e1ee898b7..9c6dae1f6 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -158,8 +158,7 @@ def example_hadamard() -> Pattern: def example_local_clifford() -> Pattern: pattern = example_hadamard() - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() return pattern @@ -258,8 +257,7 @@ def test_draw_graph_reference(flow_and_not_pauli_presimulate: bool) -> Figure: # to have causal flow. pattern = pattern.to_bloch() else: - pattern.remove_input_nodes() - pattern.perform_pauli_measurements() + pattern.remove_pauli_measurements() pattern.standardize() pattern.draw( flow_from_pattern=flow_and_not_pauli_presimulate, node_distance=(1, 1), measurement_labels=True, legend=False From a0822b81937e2b9ff72988b141a580427b28a664 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 8 May 2026 09:38:49 +0200 Subject: [PATCH 14/40] Comment on indexing of input nodes and output nodes --- graphix/remove_pauli_measurements.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index de1255874..eb627809d 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -244,7 +244,10 @@ class _RemovePauliMeasurements: Nodes are given with the indexing of the original pattern: use ``node_map`` to retrieve the index in the graph.""" input_node_set: set[Node] + """Set of input nodes: inputs nodes are never pivoted, therefore their indexing is preserved.""" + output_node_set: set[Node] + """Set of output nodes, using the new indexing.""" node_map: dict[Node, Node] """Mapping from the nodes of the original pattern to the nodes of the graph (that may have been pivoted). @@ -477,6 +480,9 @@ def remove_isolated_internal_nodes(self) -> None: if node not in self.input_node_set and node not in self.output_node_set: self._remove_node(node) + def transform_input_hadamard(self) -> None: + for node in list() + def _create_new_m(self, original_m: Command.M) -> Command.M | None: node = self.node_map.get(original_m.node) if node is None: From 0b68dcb984319d828e06871bedfc409d8ea1d278 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 8 May 2026 15:03:18 +0200 Subject: [PATCH 15/40] Include input nodes in `pauli_measurement` --- graphix/remove_pauli_measurements.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index eb627809d..6c5f04b39 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -213,9 +213,9 @@ class _NodeSpec: clifford: Clifford = Clifford.I pauli_measurement: PauliMeasurement | None = None - """Pauli measurement if the node is not an input and is measured with a Pauli measurement. + """Pauli measurement if the node is measured with a Pauli measurement. - ``None`` if the node is an input, an output, or measured with a non-Pauli measurement. + ``None`` if the node is an output or measured with a non-Pauli measurement. """ @@ -273,8 +273,8 @@ def __init__(self, cut: PauliPushingCut) -> None: if not isinstance(cmd_m.measurement, PauliMeasurement): # pragma: no cover msg = "Pauli measurement expected." raise TypeError(msg) + self.node_specs[cmd_m.node].pauli_measurement = cmd_m.measurement if cmd_m.node not in self.input_node_set: - self.node_specs[cmd_m.node].pauli_measurement = cmd_m.measurement self.pauli_measurements[cmd_m.measurement.axis].add(cmd_m.node) self.node_map = {node: node for node in self.graph.nodes()} @@ -290,6 +290,8 @@ def _apply_clifford(self, node: Node, clifford: Clifford) -> None: if spec.pauli_measurement is not None: axis = spec.pauli_measurement.axis spec.pauli_measurement = spec.pauli_measurement.clifford(clifford) + if node in self.input_node_set: + return new_axis = spec.pauli_measurement.axis if new_axis != axis: self.pauli_measurements[axis].remove(spec.src) From 840c41559af7166ff0a6033f6f182d3be69e670c Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 8 May 2026 15:13:42 +0200 Subject: [PATCH 16/40] Remove incomplete definition --- graphix/remove_pauli_measurements.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 6c5f04b39..9008b31c6 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -482,9 +482,6 @@ def remove_isolated_internal_nodes(self) -> None: if node not in self.input_node_set and node not in self.output_node_set: self._remove_node(node) - def transform_input_hadamard(self) -> None: - for node in list() - def _create_new_m(self, original_m: Command.M) -> Command.M | None: node = self.node_map.get(original_m.node) if node is None: From a70efe52af6d6afd9a643a80c4e095857fc8f827 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 19:02:39 +0200 Subject: [PATCH 17/40] Fix examples --- examples/qft_with_tn.py | 3 ++- examples/tn_simulation.py | 6 +++--- noxfile.py | 13 +++---------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/examples/qft_with_tn.py b/examples/qft_with_tn.py index 45994f48f..8abb6ded5 100644 --- a/examples/qft_with_tn.py +++ b/examples/qft_with_tn.py @@ -66,7 +66,8 @@ def qft(circuit: Circuit, n: int) -> None: # %% # Using graph rewriting rules, we can classically preprocess Pauli measurements. # We are currently improving the speed of this process by using rust-based graph manipulation backend. -pattern.remove_pauli_measurements() +pattern.remove_input_nodes() +pattern.remove_pauli_measurements(standardize=True) # %% diff --git a/examples/tn_simulation.py b/examples/tn_simulation.py index a84676122..f13a5f5fd 100644 --- a/examples/tn_simulation.py +++ b/examples/tn_simulation.py @@ -83,8 +83,8 @@ def ansatz( print(f"Number of edges: {len(graph.edges)}") # %% -# Optimizing by performing Pauli measurements in the pattern. -pattern.remove_pauli_measurements() +# Optimizing by removing Pauli measurements in the pattern. +pattern.remove_pauli_measurements(standardize=True) # %% # Simulate using the TN backend of graphix, which will return an MBQCTensorNet object. @@ -201,7 +201,7 @@ def cost( pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern.remove_pauli_measurements() + pattern.remove_pauli_measurements(standardize=True) mbqc_tn = pattern.simulate_pattern(backend="tensornetwork", graph_prep="parallel") exp_val: float = 0 for op in ham: diff --git a/noxfile.py b/noxfile.py index a2e5f253c..89e2ba018 100644 --- a/noxfile.py +++ b/noxfile.py @@ -109,16 +109,9 @@ class ReverseDependency: "package", [ ReverseDependency("https://github.com/thierry-martinez/graphix-stim-backend", branch="fix/graphix_namespace"), - ReverseDependency( - "https://github.com/TeamGraphix/graphix-symbolic", - ), - ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser", branch="fix_angles"), - ReverseDependency( - "https://github.com/thierry-martinez/veriphix", - doctest_modules=False, - install_target=".[dev]", - branch="fix/graphix_namespace", - ), + ReverseDependency("https://github.com/TeamGraphix/graphix-symbolic"), + ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser"), + ReverseDependency("https://github.com/qat-inria/veriphix", doctest_modules=False, install_target=".[dev]"), ReverseDependency("https://github.com/TeamGraphix/graphix-ibmq", doctest_modules=False), ReverseDependency("https://github.com/qat-inria/graphix-stim-compiler", branch="ps_dim"), ], From 26818e1fedb3ba7461a8cb4d7cb7768a3b81935a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 19:50:45 +0200 Subject: [PATCH 18/40] Pin pnpm to the latest version reported to work CI fails with version 11.0.9: https://github.com/TeamGraphix/graphix/actions/runs/25607609586/job/75172334745 CI succeeds with version 10.33.4: https://github.com/TeamGraphix/graphix/actions/runs/25476469782/job/74750924624 --- .github/workflows/doc.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index b7816f4fe..3c0cea037 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -59,7 +59,15 @@ jobs: - uses: actions/setup-node@v4 - run: | - corepack enable pnpm + # Pin pnpm to the latest version reported to work + # + # CI fails with version 11.0.9: + # https://github.com/TeamGraphix/graphix/actions/runs/25607609586/job/75172334745 + # + # CI succeeds with version 10.33.4: + # https://github.com/TeamGraphix/graphix/actions/runs/25476469782/job/74750924624 + corepack prepare pnpm@10.33.4 --activate + # corepack enable pnpm pnpm dlx prettier --write . - uses: reviewdog/action-suggester@v1.22.0 From e73e6b85cef09f104cf42b068770e14d5a190aaa Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 19:54:57 +0200 Subject: [PATCH 19/40] Restore `corepack enable` --- .github/workflows/doc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 3c0cea037..b03b60c1c 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -67,7 +67,7 @@ jobs: # CI succeeds with version 10.33.4: # https://github.com/TeamGraphix/graphix/actions/runs/25476469782/job/74750924624 corepack prepare pnpm@10.33.4 --activate - # corepack enable pnpm + corepack enable pnpm pnpm dlx prettier --write . - uses: reviewdog/action-suggester@v1.22.0 From 506e61738c7af6e025d2581345b8a0019eee915a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 19:57:28 +0200 Subject: [PATCH 20/40] Bump action-suggester v1.24.0 --- .github/workflows/doc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index b03b60c1c..133fee2e7 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -70,6 +70,6 @@ jobs: corepack enable pnpm pnpm dlx prettier --write . - - uses: reviewdog/action-suggester@v1.22.0 + - uses: reviewdog/action-suggester@v1.24.0 with: tool_name: ":art: text-formatter" From fc740296a47c776c7bca04f9dc9fde8c893dffbc Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 21:21:44 +0200 Subject: [PATCH 21/40] Fix `test_compose_7` --- tests/test_pattern.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_pattern.py b/tests/test_pattern.py index eac96ea67..a22b15529 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -682,6 +682,7 @@ def test_compose_7(self, fx_rng: Generator) -> None: circuit_1.h(0) circuit_1.rz(0, alpha) p1 = circuit_1.transpile().pattern + p1.remove_input_nodes() p1.remove_pauli_measurements() circuit_2 = Circuit(1) From 5a25eb86173dc35c6c70453ec71196e41e24cca3 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 22:56:09 +0200 Subject: [PATCH 22/40] Update visualization reference --- .../test_draw_graph_reference_False.png | Bin 3682 -> 14311 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/baseline/test_draw_graph_reference_False.png b/tests/baseline/test_draw_graph_reference_False.png index 6d0ee27c3cd375a3905f32746f5a912d2b78c9da..55b09a743f6764b2fa0cdcac160738ae2f40c5d7 100644 GIT binary patch literal 14311 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 From 296c93a96b90d847e05c75bab641a077cf139cd9 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 9 May 2026 22:57:32 +0200 Subject: [PATCH 23/40] Restore `remove_input_nodes` in `tn_simulation.py` --- examples/tn_simulation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/tn_simulation.py b/examples/tn_simulation.py index f13a5f5fd..63c061e1b 100644 --- a/examples/tn_simulation.py +++ b/examples/tn_simulation.py @@ -84,6 +84,7 @@ def ansatz( # %% # Optimizing by removing Pauli measurements in the pattern. +pattern.remove_input_nodes() pattern.remove_pauli_measurements(standardize=True) # %% @@ -201,6 +202,7 @@ def cost( pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() + pattern.remove_input_nodes() pattern.remove_pauli_measurements(standardize=True) mbqc_tn = pattern.simulate_pattern(backend="tensornetwork", graph_prep="parallel") exp_val: float = 0 From ef119c22b65245fd7f4ce3fbc90138e59ff66a4c Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sun, 10 May 2026 18:53:06 +0200 Subject: [PATCH 24/40] Remove `perform_pauli_measurements` --- docs/source/modifier.rst | 6 +- graphix/pattern.py | 194 ++------------------------- graphix/remove_pauli_measurements.py | 7 +- 3 files changed, 17 insertions(+), 190 deletions(-) diff --git a/docs/source/modifier.rst b/docs/source/modifier.rst index eee84b258..4fe4f5727 100644 --- a/docs/source/modifier.rst +++ b/docs/source/modifier.rst @@ -32,7 +32,9 @@ Pattern Manipulation .. automethod:: remove_input_nodes - .. automethod:: perform_pauli_measurements + .. automethod:: perform_pauli_pushing + + .. automethod:: remove_pauli_measurements .. automethod:: to_ascii @@ -83,4 +85,4 @@ Pattern Manipulation .. automethod:: to_bloch -.. autofunction:: measure_pauli +.. autofunction:: shift_outcomes diff --git a/graphix/pattern.py b/graphix/pattern.py index a657c4c96..d0696113a 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -21,12 +21,10 @@ from typing_extensions import assert_never from graphix import command, optimization -from graphix.clifford import Clifford from graphix.command import CommandKind, Node from graphix.flow.exceptions import FlowError -from graphix.fundamentals import Axis, Plane, Sign -from graphix.graphsim import GraphState -from graphix.measurements import BlochMeasurement, Measurement, Outcome, PauliMeasurement, toggle_outcome +from graphix.fundamentals import Plane +from graphix.measurements import BlochMeasurement, Measurement, Outcome, toggle_outcome from graphix.opengraph import OpenGraph from graphix.pretty_print import OutputFormat, pattern_to_str from graphix.qasm3_exporter import pattern_to_qasm3_lines @@ -46,9 +44,9 @@ # Unpack introduced in Python 3.12 from typing_extensions import Unpack + from graphix.clifford import Clifford from graphix.command import CommandType from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections - from graphix.optimization import StandardizedPattern from graphix.parameter import ExpressionOrSupportsComplex, ExpressionOrSupportsFloat, Parameter from graphix.sim import Backend, Data, DensityMatrixBackend, StatevectorBackend from graphix.sim.base_backend import _StateT_co @@ -1428,28 +1426,6 @@ def remove_input_nodes(self) -> None: empty_nodes: list[int] = [] self.__input_nodes = empty_nodes - def perform_pauli_measurements(self, ignore_pauli_with_deps: bool = False, *, stacklevel: int = 1) -> None: - """Perform Pauli measurements in the pattern using efficient stabilizer simulator. - - Parameters - ---------- - 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. - stacklevel : int, optional - Stack level to use for warnings. Defaults to 1, meaning that warnings - are reported at this function's call site. - - .. seealso:: :func:`measure_pauli` - - """ - if self.input_nodes: - raise PatternError("Remove inputs with `self.remove_input_nodes()` before performing Pauli presimulation.") - self.__dict__.update( - measure_pauli(self, ignore_pauli_with_deps=ignore_pauli_with_deps, stacklevel=stacklevel + 1).__dict__ - ) - def _warn_non_inferred_pauli_measurements(self, stacklevel: int) -> None: for cmd in self: if ( @@ -1739,6 +1715,10 @@ def perform_pauli_pushing( ) -> Pattern: """Move Pauli measurements before the other measurements. + If you need to recover the cut between Pauli measurements and + non-Pauli measurements or the shifted signal, you can use + :meth:`~graphix.remove_pauli_measurements.PauliPushingCut.from_standardized_pattern` instead. + Parameters ---------- leave_nodes : AbstractSet[Node], optional @@ -1865,158 +1845,6 @@ def __str__(self) -> str: assert_never(self.reason) -def measure_pauli(pattern: Pattern, *, ignore_pauli_with_deps: bool = False, stacklevel: int = 1) -> Pattern: - """Perform Pauli measurement of a pattern by fast graph state simulator. - - Uses the decorated-graph method implemented in graphix.graphsim to perform the measurements in Pauli bases, and then sort remaining nodes back into - pattern together with Clifford commands. Users are required to ensure there are no input nodes with :func:`graphix.pattern.Pattern.remove_input_nodes` before using this function. - - TODO: non-XY plane measurements in original pattern - - Parameters - ---------- - pattern : graphix.pattern.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. - stacklevel : int, optional - Stack level to use for warnings. Defaults to 1, meaning that warnings - are reported at this function's call site. - - Returns - ------- - new_pattern : graphix.Pattern object - pattern with Pauli measurement removed. - only returned if copy argument is True. - - - .. seealso:: :class:`graphix.pattern.Pattern.remove_input_nodes` - .. seealso:: :class:`graphix.graphsim.GraphState` - """ - pattern._warn_non_inferred_pauli_measurements(stacklevel=stacklevel + 1) - pat = Pattern() - standardized_pattern = optimization.StandardizedPattern.from_pattern(pattern) - if not ignore_pauli_with_deps: - standardized_pattern = standardized_pattern.perform_pauli_pushing(stacklevel=stacklevel + 1) - output_nodes = set(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] = pattern.results - to_measure, non_pauli_meas = pauli_nodes(standardized_pattern) - if not to_measure: - return pattern - for cmd in to_measure: - pattern_cmd = cmd[0] - measurement_basis = cmd[1] - # extract signals for adaptive angle. - s_signal = 0 - t_signal = 0 - match measurement_basis.axis: - case Axis.X: # X measurement is not affected by s_signal - t_signal = sum(results[j] for j in pattern_cmd.t_domain) - case Axis.Y: - s_signal = sum(results[j] for j in pattern_cmd.s_domain) - t_signal = sum(results[j] for j in pattern_cmd.t_domain) - case Axis.Z: # Z measurement is not affected by t_signal - s_signal = sum(results[j] for j in pattern_cmd.s_domain) - case _: - assert_never(measurement_basis.axis) - - if int(s_signal % 2) == 1: # equivalent to X byproduct - graph_state.h(pattern_cmd.node) - graph_state.z(pattern_cmd.node) - graph_state.h(pattern_cmd.node) - if int(t_signal % 2) == 1: # equivalent to Z byproduct - graph_state.z(pattern_cmd.node) - basis = measurement_basis - match basis.axis: - case Axis.X: - measure = graph_state.measure_x - case Axis.Y: - measure = graph_state.measure_y - case Axis.Z: - measure = graph_state.measure_z - case _: - assert_never(basis.axis) - if basis.sign == Sign.PLUS: - results[pattern_cmd.node] = measure(pattern_cmd.node, choice=0) - else: - results[pattern_cmd.node] = 0 if measure(pattern_cmd.node, choice=1) else 1 - - # measure (remove) isolated nodes. if they aren't Pauli measurements, - # measuring one of the results with probability of 1 should not occur as was possible above for Pauli measurements, - # which means we can just choose s=0. We should not remove output nodes even if isolated. - isolates = graph_state.isolated_nodes() - for node in non_pauli_meas: - if (node in isolates) and (node not in output_nodes): - graph_state.remove_node(node) - results[node] = 0 - - # update command sequence - vops = graph_state.extract_vops() - new_seq: list[CommandType] = [] - new_seq.extend(command.N(node=index) for index in set(graph_state.nodes)) - 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 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(command.Z(node=node, domain=set(domain)) for node, domain in standardized_pattern.z_dict.items()) - new_seq.extend(command.X(node=node, domain=set(domain)) for node, domain in standardized_pattern.x_dict.items()) - pat.replace(new_seq, input_nodes=[]) - pat.reorder_output_nodes(standardized_pattern.output_nodes) - assert pat.n_node == len(graph_state.nodes) - pat.results = results - return pat - - -def pauli_nodes(pattern: StandardizedPattern) -> tuple[list[tuple[command.M, PauliMeasurement]], set[int]]: - """Return the list of measurement commands that are in Pauli bases and that are not dependent on any non-Pauli measurements. - - Parameters - ---------- - pattern : optimization.StandardizedPattern - - Returns - ------- - pauli_node : list - list of measures - non_pauli_nodes : set[int] - """ - pauli_node: list[tuple[command.M, PauliMeasurement]] = [] - # Nodes that are non-Pauli measured, or pauli measured but depends on pauli measurement - non_pauli_node: set[int] = set() - for cmd in pattern.m_list: - if isinstance(cmd.measurement, PauliMeasurement): - # Pauli measurement to be removed - match cmd.measurement.axis: - case Axis.X: - if cmd.t_domain & non_pauli_node: # cmd depend on non-Pauli measurement - non_pauli_node.add(cmd.node) - else: - pauli_node.append((cmd, cmd.measurement)) - case Axis.Y: - if (cmd.s_domain | cmd.t_domain) & non_pauli_node: # cmd depend on non-Pauli measurement - non_pauli_node.add(cmd.node) - else: - pauli_node.append((cmd, cmd.measurement)) - case Axis.Z: - if cmd.s_domain & non_pauli_node: # cmd depend on non-Pauli measurement - non_pauli_node.add(cmd.node) - else: - pauli_node.append((cmd, cmd.measurement)) - case _: - raise PatternError("Unknown Pauli measurement basis") - else: - non_pauli_node.add(cmd.node) - return pauli_node, non_pauli_node - - def assert_permutation(original: list[int], user: list[int]) -> None: """Check that the provided `user` node list is a permutation from `original`.""" node_set = set(user) @@ -2056,23 +1884,23 @@ def extract_signal(plane: Plane, s_domain: set[int], t_domain: set[int]) -> Extr assert_never(plane) -def shift_outcomes(outcomes: dict[int, Outcome], signal_dict: dict[int, set[int]]) -> dict[int, Outcome]: +def shift_outcomes(outcomes: Mapping[int, Outcome], signal_dict: Mapping[int, AbstractSet[int]]) -> dict[int, Outcome]: """Update outcomes with shifted signals. Shifted signals (as returned by the method :func:`Pattern.shift_signals`) affect classical outputs (measurements) while leaving the quantum state invariant. - This method updates the given `outcomes` by swapping the + This method updates the given ``outcomes`` by swapping the measurements affected by signals. This can be used either to transform the value of :data:`Pattern.results` into measurements observed in the unshifted pattern, or vice versa. Parameters ---------- - outcomes : dict[int, int] + outcomes : Mapping[int, Outcome] Classical outputs. - signal_dict : dict[int, set[int]] + signal_dict : Mapping[int, AbstractSet[int]] For each node, the signal that has been shifted (as returned by :func:`Pattern.shift_signals`). diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 9008b31c6..0de60cd50 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -51,10 +51,7 @@ @dataclass(frozen=True, slots=True) class PauliPushingCut: - """Cut of the pattern measurements into Pauli and non-Pauli measurements. - - This structure is returned by :meth:`StandardizedPattern.cut_by_pauli_pushing`. - """ + """Cut of the pattern measurements into Pauli and non-Pauli measurements.""" original_pattern: StandardizedPattern @@ -82,7 +79,7 @@ def from_standardized_pattern( """Move Pauli measurements before the other measurements and return the cut between Pauli measurements and non-Pauli measurements. If you only need the resulting pattern, you can use - :meth:`StandardizedPattern.perform_pauli_pushing` or + :meth:`~graphix.optimization.StandardizedPattern.perform_pauli_pushing` or :meth:`~graphix.pattern.Pattern.perform_pauli_pushing` instead. Parameters From 3953e079029e5cd78d202f817b5830a6a1fa940a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sun, 10 May 2026 23:14:33 +0200 Subject: [PATCH 25/40] Remove `graphsim` --- CHANGELOG.md | 2 +- examples/fusion_extraction.py | 12 +- graphix/__init__.py | 3 +- graphix/extraction.py | 14 +- graphix/graphsim.py | 517 ---------------------------------- tests/test_extraction.py | 20 +- tests/test_graphsim.py | 159 ----------- 7 files changed, 30 insertions(+), 697 deletions(-) delete mode 100644 graphix/graphsim.py delete mode 100644 tests/test_graphsim.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cbee3f227..722623e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- #168, #498: `Pattern.remove_pauli_measurements` replaces `Pattern.perform_pauli_measurements`: the new algorithm removes all non-input Pauli nodes from patterns with flow and returns a pattern +- #168, #498: `Pattern.remove_pauli_measurements` replaces `Pattern.perform_pauli_measurements`. The new algorithm removes all non-input Pauli nodes from patterns that have a flow and returns a pattern that is equivalent for every input state. - #490: Exposed more common classes and methods to top level `__init__.py`. - Renamed `Instruction`, `InstructionWithoutRZZ` and `Command` to `InstructionType`, `InstructionTypeWithoutRZZ` and `CommandType` respectively. diff --git a/examples/fusion_extraction.py b/examples/fusion_extraction.py index 803edddf0..abc0a1746 100644 --- a/examples/fusion_extraction.py +++ b/examples/fusion_extraction.py @@ -18,19 +18,23 @@ import itertools -import graphix +import matplotlib.pyplot as plt +import networkx as nx + from graphix import extraction -from graphix.extraction import graph_to_fusion_network +from graphix.extraction import Graph, graph_to_fusion_network # %% # Here we say we want a graph state with 9 nodes and 12 edges. # We can obtain resource graph for a measurement pattern by using :code:`pattern.extract_graph()`. -gs = graphix.GraphState() +gs = Graph() nodes = [0, 1, 2, 3, 4, 5, 6, 7, 8] edges = [(0, 1), (1, 2), (2, 3), (3, 0), (3, 4), (0, 5), (4, 5), (5, 6), (6, 7), (7, 0), (7, 8), (8, 1)] gs.add_nodes_from(nodes) gs.add_edges_from(edges) -gs.draw() +labels = {i: i for i in iter(nodes)} +nx.draw(gs, labels=labels, node_color="C0", edgecolors="k") +plt.show() # %% # Decomposition with GHZ and linear cluster resource states with no limitation in their sizes. diff --git a/graphix/__init__.py b/graphix/__init__.py index 5b0e89eca..d588d2997 100644 --- a/graphix/__init__.py +++ b/graphix/__init__.py @@ -4,12 +4,12 @@ from graphix._version import __version__ from graphix.branch_selector import ConstBranchSelector, FixedBranchSelector, RandomBranchSelector +from graphix.channels import KrausChannel from graphix.circ_ext import CliffordMap, PauliExponential, PauliExponentialDAG, PauliString from graphix.clifford import Clifford from graphix.command import Command from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections from graphix.fundamentals import ANGLE_PI, Axis, Plane, Sign, angle_to_rad, rad_to_angle -from graphix.graphsim import GraphState from graphix.instruction import Instruction from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement from graphix.noise_models import DepolarisingNoiseModel, NoiseModel @@ -41,7 +41,6 @@ "DrawPatternAnnotations", "FixedBranchSelector", "GFlow", - "GraphState", "Instruction", "KrausChannel", "Measurement", diff --git a/graphix/extraction.py b/graphix/extraction.py index 5dd400541..27f96295b 100644 --- a/graphix/extraction.py +++ b/graphix/extraction.py @@ -6,11 +6,17 @@ import dataclasses import operator from enum import Enum +from typing import TYPE_CHECKING import networkx as nx import numpy as np -from graphix.graphsim import GraphState +if TYPE_CHECKING: + from typing import TypeAlias + + Graph: TypeAlias = nx.Graph[int] +else: + Graph = nx.Graph class ResourceType(Enum): @@ -38,7 +44,7 @@ class ResourceGraph: """ cltype: ResourceType - graph: GraphState + graph: Graph def __eq__(self, other: object) -> bool: """Return `True` if two resource graphs are equal, `False` otherwise.""" @@ -49,7 +55,7 @@ def __eq__(self, other: object) -> bool: def graph_to_fusion_network( - graph: GraphState, + graph: Graph, max_ghz: float = np.inf, max_lin: float = np.inf, ) -> list[ResourceGraph]: @@ -161,7 +167,7 @@ def create_resource_graph(node_ids: list[int], root: int | None = None) -> Resou else: edges = [(node_ids[i], node_ids[i + 1]) for i in range(len(node_ids)) if i + 1 < len(node_ids)] cluster_type = ResourceType.LINEAR - tmp_graph = GraphState() + tmp_graph = Graph() tmp_graph.add_nodes_from(node_ids) tmp_graph.add_edges_from(edges) return ResourceGraph(cltype=cluster_type, graph=tmp_graph) diff --git a/graphix/graphsim.py b/graphix/graphsim.py deleted file mode 100644 index 7aaf3cf2d..000000000 --- a/graphix/graphsim.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Graph simulator.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, TypedDict - -import networkx as nx -import typing_extensions - -from graphix import utils -from graphix.clifford import Clifford -from graphix.measurements import outcome -from graphix.ops import Ops -from graphix.sim.statevec import Statevec - -if TYPE_CHECKING: - import functools - from collections.abc import Iterable, Mapping - - from graphix.measurements import Outcome - - -if TYPE_CHECKING: - Graph = nx.Graph[int] -else: - Graph = nx.Graph - - -class MBQCGraphNode(TypedDict): - """MBQC graph node attributes.""" - - sign: bool - loop: bool - hollow: bool - - -class GraphState(Graph): - """Graph state simulator implemented with :mod:`networkx`. - - Performs Pauli measurements on graph states. - - ref: M. Elliot, B. Eastin & C. Caves, JPhysA 43, 025301 (2010) - and PRA 77, 042307 (2008) - - Each node has attributes: - :*hollow*: True if node is hollow (has local H operator) - :*sign*: True if node has negative sign (local Z operator) - :*loop*: True if node has loop (local S operator) - """ - - nodes: functools.cached_property[Mapping[int, MBQCGraphNode]] # type: ignore[assignment] - - def __init__( - self, - nodes: Iterable[int] | None = None, - edges: Iterable[tuple[int, int]] | None = None, - vops: Mapping[int, Clifford] | None = None, - ) -> None: - """Instantiate a graph simulator. - - Parameters - ---------- - nodes : Iterable[int] - A container of nodes - edges : Iterable[tuple[int, int]] - list of tuples (i,j) for pairs to be entangled. - vops : Mapping[int, Clifford] - dict of local Clifford gates with keys for node indices and Cliffords - """ - super().__init__() - if nodes is not None: - self.add_nodes_from(nodes) - if edges is not None: - self.add_edges_from(edges) - if vops is not None: - self.apply_vops(vops) - - @typing_extensions.override - def add_nodes_from( # pyright: ignore[reportIncompatibleMethodOverride] - self, - nodes_for_adding: Iterable[int | tuple[int, MBQCGraphNode]], # type: ignore[override] - **attr: Any, - ) -> None: - """Wrap `networkx.Graph.add_nodes_from` to initialize MBQCGraphNode attributes.""" - nodes_for_adding = list(nodes_for_adding) - super().add_nodes_from(nodes_for_adding, **attr) # type: ignore[arg-type] - for data in nodes_for_adding: - u, mp = data if isinstance(data, tuple) else (data, MBQCGraphNode(sign=False, hollow=False, loop=False)) - for k, v_ in mp.items(): - dst = self.nodes[u] - v = bool(v_) - # Need to use literal inside brackets - match k: - case "sign": - dst["sign"] = v - case "hollow": - dst["hollow"] = v - case "loop": - dst["loop"] = v - case _: - msg = "Invalid node attribute." - raise ValueError(msg) - - @typing_extensions.override - def add_node( - self, - node_for_adding: int, - **attr: Any, - ) -> None: - """Wrap `networkx.Graph.add_node` to initialize MBQCGraphNode attributes.""" - self.add_nodes_from((node_for_adding,), **attr) - - def local_complement(self, node: int) -> None: - """Perform local complementation of a graph.""" - g = self.subgraph(self.neighbors(node)) - g_new: nx.Graph[int] = nx.complement(g) - self.remove_edges_from(g.edges) - self.add_edges_from(g_new.edges) - - def apply_vops(self, vops: Mapping[int, Clifford]) -> None: - """Apply local Clifford operators to the graph state from a dictionary. - - Parameters - ---------- - vops : Mapping[int, Clifford] - dict containing node indices as keys and local Clifford - - Returns - ------- - None - """ - for node, vop in vops.items(): - for lc in reversed(vop.hsz): - match lc: - case Clifford.Z: - self.z(node) - case Clifford.H: - self.h(node) - case Clifford.S: - self.s(node) - case _: - raise RuntimeError - - def extract_vops(self) -> dict[int, Clifford]: - """Apply local Clifford operators to the graph state from a dictionary. - - Returns - ------- - vops : dict[int, Clifford] - dict containing node indices as keys and local Cliffords - """ - vops: dict[int, Clifford] = {} - for i in self.nodes: - vop = Clifford.I - if self.nodes[i]["sign"]: - vop = Clifford.Z @ vop - if self.nodes[i]["loop"]: - vop = Clifford.S @ vop - if self.nodes[i]["hollow"]: - vop = Clifford.H @ vop - vops[i] = vop - return vops - - def flip_fill(self, node: int) -> None: - """Flips the fill (local H) of a node. - - Parameters - ---------- - node : int - graph node to flip the fill - - Returns - ------- - None - """ - self.nodes[node]["hollow"] = not self.nodes[node]["hollow"] - - def flip_sign(self, node: int) -> None: - """Flip the sign (local Z) of a node. - - Note that application of Z gate is different from `flip_sign` - if there exist an edge from the node. - - Parameters - ---------- - node : int - graph node to flip the sign - - Returns - ------- - None - """ - self.nodes[node]["sign"] = not self.nodes[node]["sign"] - - def advance(self, node: int) -> None: - """Flip the loop (local S) of a node. - - If the loop already exist, sign is also flipped, - reflecting the relation SS=Z. - Note that application of S gate is different from `advance` - if there exist an edge from the node. - - Parameters - ---------- - node : int - graph node to advance the loop. - - Returns - ------- - None - """ - if self.nodes[node]["loop"]: - self.nodes[node]["loop"] = False - self.flip_sign(node) - else: - self.nodes[node]["loop"] = True - - def h(self, node: int) -> None: - """Apply H gate to a qubit (node). - - Parameters - ---------- - node : int - graph node to apply H gate - - Returns - ------- - None - """ - self.flip_fill(node) - - def s(self, node: int) -> None: - """Apply S gate to a qubit (node). - - Parameters - ---------- - node : int - graph node to apply S gate - - Returns - ------- - None - """ - if self.nodes[node]["hollow"]: - if self.nodes[node]["loop"]: - self.flip_fill(node) - self.nodes[node]["loop"] = False - self.local_complement(node) - for i in self.neighbors(node): - self.advance(i) - else: - self.local_complement(node) - for i in self.neighbors(node): - self.advance(i) - if self.nodes[node]["sign"]: - for i in self.neighbors(node): - self.flip_sign(i) - else: # solid - self.advance(node) - - def z(self, node: int) -> None: - """Apply Z gate to a qubit (node). - - Parameters - ---------- - node : int - graph node to apply Z gate - - Returns - ------- - None - """ - if self.nodes[node]["hollow"]: - for i in self.neighbors(node): - self.flip_sign(i) - if self.nodes[node]["loop"]: - self.flip_sign(node) - else: # solid - self.flip_sign(node) - - def equivalent_graph_e1(self, node: int) -> None: - """Tranform a graph state to a different graph state representing the same stabilizer state. - - This rule applies only to a node with loop. - - Parameters - ---------- - node1 : int - A graph node with a loop to apply rule E1 - - Returns - ------- - None - """ - if not self.nodes[node]["loop"]: - raise ValueError("node must have loop") - self.flip_fill(node) - self.local_complement(node) - for i in self.neighbors(node): - self.advance(i) - self.flip_sign(node) - if self.nodes[node]["sign"]: - for i in self.neighbors(node): - self.flip_sign(i) - - def equivalent_graph_e2(self, node1: int, node2: int) -> None: - """Tranform a graph state to a different graph state representing the same stabilizer state. - - This rule applies only to two connected nodes without loop. - - Parameters - ---------- - node1, node2 : int - connected graph nodes to apply rule E2 - - Returns - ------- - None - """ - if (node1, node2) not in self.edges and (node2, node1) not in self.edges: - raise ValueError("nodes must be connected by an edge") - if self.nodes[node1]["loop"] or self.nodes[node2]["loop"]: - raise ValueError("nodes must not have loop") - sg1 = self.nodes[node1]["sign"] - sg2 = self.nodes[node2]["sign"] - self.flip_fill(node1) - self.flip_fill(node2) - # local complement along edge between node1, node2 - self.local_complement(node1) - self.local_complement(node2) - self.local_complement(node1) - for i in iter(set(self.neighbors(node1)) & set(self.neighbors(node2))): - self.flip_sign(i) - if sg1: - self.flip_sign(node1) - for i in self.neighbors(node1): - self.flip_sign(i) - if sg2: - self.flip_sign(node2) - for i in self.neighbors(node2): - self.flip_sign(i) - - def equivalent_fill_node(self, node: int) -> int: - """Fill the chosen node by graph transformation rules E1 and E2. - - If the selected node is hollow and isolated, it cannot be filled - and warning is thrown. - - Parameters - ---------- - node : int - node to fill. - - Returns - ------- - result : int - if the selected node is hollow and isolated, *result* is 1. - if filled and isolated, 2. - otherwise it is 0. - """ - if self.nodes[node]["hollow"]: - if self.nodes[node]["loop"]: - self.equivalent_graph_e1(node) - return 0 - # node = hollow and loopless - if utils.iter_empty(self.neighbors(node)): - return 1 - for i in self.neighbors(node): - if not self.nodes[i]["loop"]: - self.equivalent_graph_e2(node, i) - return 0 - # if all neighbor has loop, pick one and apply E1, then E1 to the node. - i = next(self.neighbors(node)) - self.equivalent_graph_e1(i) # this gives loop to node. - self.equivalent_graph_e1(node) - return 0 - if utils.iter_empty(self.neighbors(node)): - return 2 - return 0 - - def measure_x(self, node: int, choice: Outcome = 0) -> Outcome: - """Perform measurement in X basis. - - According to original paper, we realise X measurement by - applying H gate to the measured node before Z measurement. - - Parameters - ---------- - node : int - qubit index to be measured - choice : int, 0 or 1 - choice of measurement outcome. observe (-1)^choice - - Returns - ------- - result : int - measurement outcome. 0 or 1. - """ - if choice not in {0, 1}: - raise ValueError("choice must be 0 or 1") - # check if isolated - if utils.iter_empty(self.neighbors(node)): - if self.nodes[node]["hollow"] or self.nodes[node]["loop"]: - choice_ = choice - elif self.nodes[node]["sign"]: # isolated and state is |-> - choice_ = 1 - else: # isolated and state is |+> - choice_ = 0 - self.remove_node(node) - return choice_ - self.h(node) - return self.measure_z(node, choice=choice) - - def measure_y(self, node: int, choice: Outcome = 0) -> Outcome: - """Perform measurement in Y basis. - - According to original paper, we realise Y measurement by - applying S,Z and H gate to the measured node before Z measurement. - - Parameters - ---------- - node : int - qubit index to be measured - choice : int, 0 or 1 - choice of measurement outcome. observe (-1)^choice - - Returns - ------- - result : int - measurement outcome. 0 or 1. - """ - if choice not in {0, 1}: - raise ValueError("choice must be 0 or 1") - self.s(node) - self.z(node) - self.h(node) - return self.measure_z(node, choice=choice) - - def measure_z(self, node: int, choice: Outcome = 0) -> Outcome: - """Perform measurement in Z basis. - - To realize the simple Z measurement on undecorated graph state, - we first fill the measured node (remove local H gate) - - Parameters - ---------- - node : int - qubit index to be measured - choice : int, 0 or 1 - choice of measurement outcome. observe (-1)^choice - - Returns - ------- - result : int - measurement outcome. 0 or 1. - """ - if choice not in {0, 1}: - raise ValueError("choice must be 0 or 1") - isolated = self.equivalent_fill_node(node) - if choice: - for i in self.neighbors(node): - self.flip_sign(i) - result = choice if not isolated else outcome(self.nodes[node]["sign"]) - self.remove_node(node) - return result - - def draw(self, fill_color: str = "C0") -> None: - """Draw decorated graph state. - - Negative nodes are indicated by negative sign of node labels. - - Parameters - ---------- - fill_color : str - optional, fill color of nodes - """ - nqubit = len(self.nodes) - nodes = list(self.nodes) - edges: list[tuple[int, int]] = list(self.edges) - labels = {i: i for i in iter(self.nodes)} - colors = [fill_color for _ in range(nqubit)] - for i in range(nqubit): - if self.nodes[nodes[i]]["loop"]: - edges.append((nodes[i], nodes[i])) - if self.nodes[nodes[i]]["hollow"]: - colors[i] = "white" - if self.nodes[nodes[i]]["sign"]: - labels[nodes[i]] = -1 * labels[nodes[i]] - g: nx.Graph[int] = nx.Graph() - g.add_nodes_from(nodes) - g.add_edges_from(edges) - nx.draw(g, labels=labels, node_color=colors, edgecolors="k") - - def to_statevector(self) -> Statevec: - """Convert the graph state into a state vector.""" - node_list = list(self.nodes) - nqubit = len(self.nodes) - gstate = Statevec(nqubit=nqubit) - # map graph node indices into 0 - (nqubit-1) for qubit indexing in statevec - imapping = {node_list[i]: i for i in range(nqubit)} - mapping = [node_list[i] for i in range(nqubit)] - for i, j in self.edges: - gstate.entangle((imapping[i], imapping[j])) - for i in range(nqubit): - if self.nodes[mapping[i]]["sign"]: - gstate.evolve_single(Ops.Z, i) - for i in range(nqubit): - if self.nodes[mapping[i]]["loop"]: - gstate.evolve_single(Ops.S, i) - for i in range(nqubit): - if self.nodes[mapping[i]]["hollow"]: - gstate.evolve_single(Ops.H, i) - return gstate - - def isolated_nodes(self) -> list[int]: - """Return a list of isolated nodes (nodes with no edges).""" - return list(nx.isolates(self)) diff --git a/tests/test_extraction.py b/tests/test_extraction.py index da5981cc1..c448509c4 100644 --- a/tests/test_extraction.py +++ b/tests/test_extraction.py @@ -1,12 +1,12 @@ from __future__ import annotations from graphix import extraction -from graphix.graphsim import GraphState +from graphix.extraction import Graph class TestExtraction: def test_cluster_extraction_one_ghz_cluster(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1, 2, 3, 4] edges = [(0, 1), (0, 2), (0, 3), (0, 4)] gs.add_nodes_from(nodes) @@ -18,7 +18,7 @@ def test_cluster_extraction_one_ghz_cluster(self) -> None: # we consider everything smaller than 4, a GHZ def test_cluster_extraction_small_ghz_cluster_1(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1, 2] edges = [(0, 1), (1, 2)] gs.add_nodes_from(nodes) @@ -30,7 +30,7 @@ def test_cluster_extraction_small_ghz_cluster_1(self) -> None: # we consider everything smaller than 4, a GHZ def test_cluster_extraction_small_ghz_cluster_2(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1] edges = [(0, 1)] gs.add_nodes_from(nodes) @@ -41,7 +41,7 @@ def test_cluster_extraction_small_ghz_cluster_2(self) -> None: assert clusters[0] == extraction.ResourceGraph(cltype=extraction.ResourceType.GHZ, graph=gs) def test_cluster_extraction_one_linear_cluster(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1, 2, 3, 4, 5, 6] edges = [(0, 1), (1, 2), (2, 3), (5, 4), (4, 6), (6, 0)] gs.add_nodes_from(nodes) @@ -52,7 +52,7 @@ def test_cluster_extraction_one_linear_cluster(self) -> None: assert clusters[0] == extraction.ResourceGraph(cltype=extraction.ResourceType.LINEAR, graph=gs) def test_cluster_extraction_one_ghz_one_linear(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] edges = [(0, 1), (0, 2), (0, 3), (0, 4), (4, 5), (5, 6), (6, 7), (7, 8), (8, 9)] gs.add_nodes_from(nodes) @@ -61,11 +61,11 @@ def test_cluster_extraction_one_ghz_one_linear(self) -> None: assert len(clusters) == 2 clusters_expected = [] - lin_cluster = GraphState() + lin_cluster = Graph() lin_cluster.add_nodes_from([4, 5, 6, 7, 8, 9]) lin_cluster.add_edges_from([(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)]) clusters_expected.append(extraction.ResourceGraph(extraction.ResourceType.LINEAR, lin_cluster)) - ghz_cluster = GraphState() + ghz_cluster = Graph() ghz_cluster.add_nodes_from([0, 1, 2, 3, 4]) ghz_cluster.add_edges_from([(0, 1), (0, 2), (0, 3), (0, 4)]) clusters_expected.append(extraction.ResourceGraph(extraction.ResourceType.GHZ, ghz_cluster)) @@ -75,7 +75,7 @@ def test_cluster_extraction_one_ghz_one_linear(self) -> None: ) def test_cluster_extraction_pentagonal_cluster(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1, 2, 3, 4] edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)] gs.add_nodes_from(nodes) @@ -92,7 +92,7 @@ def test_cluster_extraction_pentagonal_cluster(self) -> None: ) def test_cluster_extraction_one_plus_two(self) -> None: - gs = GraphState() + gs = Graph() nodes = [0, 1, 2] edges = [(0, 1)] gs.add_nodes_from(nodes) diff --git a/tests/test_graphsim.py b/tests/test_graphsim.py deleted file mode 100644 index 180c38929..000000000 --- a/tests/test_graphsim.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import networkx as nx -import numpy as np -import numpy.typing as npt - -from graphix.clifford import Clifford -from graphix.fundamentals import ANGLE_PI, Plane, angle_to_rad -from graphix.graphsim import GraphState -from graphix.ops import Ops -from graphix.sim.statevec import Statevec - -if TYPE_CHECKING: - from graphix.fundamentals import Angle - - -def graph_state_to_statevec(g: GraphState) -> Statevec: - node_list = list(g.nodes) - nqubit = len(g.nodes) - gstate = Statevec(nqubit=nqubit) - imapping = {node_list[i]: i for i in range(nqubit)} - mapping = [node_list[i] for i in range(nqubit)] - for i, j in g.edges: - gstate.entangle((imapping[i], imapping[j])) - for i in range(nqubit): - if g.nodes[mapping[i]]["sign"]: - gstate.evolve_single(Ops.Z, i) - for i in range(nqubit): - if g.nodes[mapping[i]]["loop"]: - gstate.evolve_single(Ops.S, i) - for i in range(nqubit): - if g.nodes[mapping[i]]["hollow"]: - gstate.evolve_single(Ops.H, i) - return gstate - - -def meas_op( - angle: Angle, vop: Clifford = Clifford.I, plane: Plane = Plane.XY, choice: int = 0 -) -> npt.NDArray[np.complex128]: - """Return the projection operator for given measurement angle and local Clifford op (VOP). - - .. seealso:: :mod:`graphix.clifford` - - Parameters - ---------- - angle : Angle - original measurement angle in units of π - vop : int - index of local Clifford (vop), see graphq.clifford.CLIFFORD - plane : 'XY', 'YZ' or 'ZX' - measurement plane on which angle shall be defined - choice : 0 or 1 - choice of measurement outcome. measured eigenvalue would be (-1)**choice. - - Returns - ------- - op : numpy array - projection operator - - """ - assert choice in {0, 1} - rad_angle = angle_to_rad(angle) - match plane: - case Plane.XY: - vec = (np.cos(rad_angle), np.sin(rad_angle), 0) - case Plane.YZ: - vec = (0, np.cos(rad_angle), np.sin(rad_angle)) - case Plane.XZ: - vec = (np.cos(rad_angle), 0, np.sin(rad_angle)) - op_mat = np.eye(2, dtype=np.complex128) / 2 - for i in range(3): - op_mat += (-1) ** (choice) * vec[i] * Clifford(i + 1).matrix / 2 - return (vop.conj.matrix @ op_mat @ vop.matrix).astype(np.complex128, copy=False) - - -class TestGraphSim: - def test_fig2(self) -> None: - """Three single-qubit measurements presented in Fig.2 of M. Elliot et al (2010).""" - nqubit = 6 - edges = [(0, 1), (1, 2), (3, 4), (4, 5), (0, 3), (1, 4), (2, 5)] - g = GraphState(nodes=np.arange(nqubit), edges=edges) - gstate = graph_state_to_statevec(g) - g.measure_x(0) - gstate.evolve_single(meas_op(0), 0) # x meas - gstate.normalize() - gstate.remove_qubit(0) - gstate2 = graph_state_to_statevec(g) - assert gstate.isclose(gstate2) - - g.measure_y(1, choice=0) - gstate.evolve_single(meas_op(0.5 * ANGLE_PI), 0) # y meas - gstate.normalize() - gstate.remove_qubit(0) - gstate2 = graph_state_to_statevec(g) - assert gstate.isclose(gstate2) - - g.measure_z(3) - gstate.evolve_single(meas_op(0.5 * ANGLE_PI, plane=Plane.YZ), 1) # z meas - gstate.normalize() - gstate.remove_qubit(1) - gstate2 = graph_state_to_statevec(g) - assert gstate.isclose(gstate2) - - def test_e2(self) -> None: - nqubit = 6 - edges = [(0, 1), (1, 2), (3, 4), (4, 5), (0, 3), (1, 4), (2, 5)] - g = GraphState(nodes=np.arange(nqubit), edges=edges) - g.h(3) - gstate = graph_state_to_statevec(g) - - g.equivalent_graph_e2(3, 4) - gstate2 = graph_state_to_statevec(g) - assert gstate.isclose(gstate2) - - g.equivalent_graph_e2(4, 0) - gstate3 = graph_state_to_statevec(g) - assert gstate.isclose(gstate3) - - g.equivalent_graph_e2(4, 5) - gstate4 = graph_state_to_statevec(g) - assert gstate.isclose(gstate4) - - g.equivalent_graph_e2(0, 3) - gstate5 = graph_state_to_statevec(g) - assert gstate.isclose(gstate5) - - g.equivalent_graph_e2(0, 3) - gstate6 = graph_state_to_statevec(g) - assert gstate.isclose(gstate6) - - def test_e1(self) -> None: - nqubit = 6 - edges = [(0, 1), (1, 2), (3, 4), (4, 5), (0, 3), (1, 4), (2, 5)] - g = GraphState(nodes=np.arange(nqubit), edges=edges) - g.nodes[3]["loop"] = True - gstate = graph_state_to_statevec(g) - g.equivalent_graph_e1(3) - - gstate2 = graph_state_to_statevec(g) - assert gstate.isclose(gstate2) - g.z(4) - gstate = graph_state_to_statevec(g) - g.equivalent_graph_e1(4) - gstate2 = graph_state_to_statevec(g) - assert gstate.isclose(gstate2) - g.equivalent_graph_e1(4) - gstate3 = graph_state_to_statevec(g) - assert gstate.isclose(gstate3) - - def test_local_complement(self) -> None: - nqubit = 6 - edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)] - exp_edges = [(0, 1), (1, 2), (0, 2), (2, 3), (3, 4), (4, 0)] - g = GraphState(nodes=np.arange(nqubit), edges=edges) - g.local_complement(1) - exp_g = GraphState(nodes=np.arange(nqubit), edges=exp_edges) - assert nx.utils.graphs_equal(g, exp_g) From bf8935950c12b4904d08b51806337494d09f10d6 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sun, 10 May 2026 23:26:27 +0200 Subject: [PATCH 26/40] Remove `Pattern.results` --- graphix/optimization.py | 95 +++----------------------- graphix/pattern.py | 36 ++-------- graphix/qasm3_exporter.py | 6 -- graphix/remove_pauli_measurements.py | 2 - graphix/sim/tensornet.py | 3 +- graphix/simulator.py | 2 +- graphix/space_minimization.py | 3 - tests/test_optimization.py | 18 +---- tests/test_pattern.py | 39 +++++------ tests/test_qasm3_exporter_to_qiskit.py | 5 +- tests/test_tnsim.py | 69 +++++++++---------- 11 files changed, 69 insertions(+), 209 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 3118d814d..6cd934203 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -23,7 +23,7 @@ FlowGenericErrorReason, ) from graphix.fundamentals import Axis, Plane, Sign -from graphix.measurements import BlochMeasurement, Measurement, Outcome, PauliMeasurement +from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement from graphix.opengraph import OpenGraph from graphix.space_minimization import ( minimize_space, @@ -84,7 +84,6 @@ class _StandardizedPattern: input_nodes: tuple[Node, ...] output_nodes: tuple[Node, ...] - results: Mapping[Node, Outcome] n_list: tuple[command.N, ...] e_set: frozenset[frozenset[Node]] m_list: tuple[command.M, ...] @@ -117,8 +116,6 @@ class StandardizedPattern(_StandardizedPattern): Input nodes. output_nodes: tuple[Node, ...] Output nodes. - results: Mapping[Node, Outcome] - Already measured nodes (by Pauli presimulation). n_list: tuple[command.N] The N commands. e_set: frozenset[frozenset[Node]] @@ -138,7 +135,6 @@ def __init__( self, input_nodes: Iterable[Node], output_nodes: Iterable[Node], - results: Mapping[Node, Outcome], n_list: Iterable[command.N], e_set: Iterable[Iterable[Node]], m_list: Iterable[command.M], @@ -150,7 +146,6 @@ def __init__( super().__init__( tuple(input_nodes), tuple(output_nodes), - MappingProxyType(dict(results)), tuple(n_list), frozenset(frozenset(edge) for edge in e_set), tuple(m_list), @@ -229,9 +224,7 @@ def from_pattern(cls, pattern: Pattern) -> Self: # has been already applied to a node, applying a clifford `C'` to the same # node is equivalent to apply `C'C` to a fresh node. c_dict[cmd.node] = cmd.clifford @ c_dict.get(cmd.node, Clifford.I) - return cls( - pattern.input_nodes, pattern.output_nodes, pattern.results, n_list, e_set, m_list, c_dict, z_dict, x_dict - ) + return cls(pattern.input_nodes, pattern.output_nodes, n_list, e_set, m_list, c_dict, z_dict, x_dict) def extract_graph(self) -> nx.Graph[int]: """Return the graph state from the command sequence, extracted from 'N' and 'E' commands. @@ -322,7 +315,6 @@ def to_pattern(self) -> Pattern: from graphix.pattern import Pattern # noqa: PLC0415 pattern = Pattern(input_nodes=self.input_nodes) - pattern.results = dict(self.results) pattern.extend( self.n_list, (command.E((u, v)) for u, v in self.e_set), @@ -396,8 +388,7 @@ def extract_partial_order_layers(self) -> tuple[frozenset[int], ...]: - There cannot be any empty layers. """ oset = frozenset(self.output_nodes) # First layer by convention. - pre_measured_nodes = self.results.keys() # Not included in the partial order layers. - excluded_nodes = oset | pre_measured_nodes + excluded_nodes = oset zero_indegree = set(self.input_nodes).union(n.node for n in self.n_list) - excluded_nodes dag: dict[int, set[int]] = { @@ -459,7 +450,6 @@ def extract_causal_flow(self) -> CausalFlow[BlochMeasurement]: In general, there may exist various layerings which represent the corrections of the pattern. To ensure that a given layering is compatible with the pattern's induced correction function, the partial order must be extracted from a standardized pattern. Commutation of entanglement commands with X and Z corrections in the standardization procedure may generate new corrections, which guarantees that all the topological information of the underlying graph is encoded in the extracted partial order. """ correction_function: dict[int, set[int]] = defaultdict(set) - pre_measured_nodes = self.results.keys() # Not included in the flow. for m in self.m_list: try: @@ -470,10 +460,10 @@ def extract_causal_flow(self) -> CausalFlow[BlochMeasurement]: valid = bloch.plane == Plane.XY if not valid: raise FlowGenericError(FlowGenericErrorReason.XYPlane) - _update_corrections(m.node, m.s_domain - pre_measured_nodes, correction_function) + _update_corrections(m.node, m.s_domain, correction_function) for node, domain in self.x_dict.items(): - _update_corrections(node, domain - pre_measured_nodes, correction_function) + _update_corrections(node, domain, correction_function) og = ( self.extract_opengraph() @@ -510,16 +500,15 @@ def extract_gflow(self) -> GFlow[BlochMeasurement]: The notes provided in :func:`self.extract_causal_flow` apply here as well. """ correction_function: dict[int, set[int]] = {} - pre_measured_nodes = self.results.keys() # Not included in the flow. for m in self.m_list: # Raises a `TypeError` if the measurement is not represented as a Bloch measurement if m.measurement.downcast_bloch().plane in {Plane.XZ, Plane.YZ}: correction_function.setdefault(m.node, set()).add(m.node) - _update_corrections(m.node, m.s_domain - pre_measured_nodes, correction_function) + _update_corrections(m.node, m.s_domain, correction_function) for node, domain in self.x_dict.items(): - _update_corrections(node, domain - pre_measured_nodes, correction_function) + _update_corrections(node, domain, correction_function) og = ( self.extract_opengraph() @@ -549,17 +538,15 @@ def extract_xzcorrections(self) -> XZCorrections[Measurement]: x_corr: dict[int, set[int]] = {} z_corr: dict[int, set[int]] = {} - pre_measured_nodes = self.results.keys() # Not included in the xz-corrections. - for m in self.m_list: - _update_corrections(m.node, m.s_domain - pre_measured_nodes, x_corr) - _update_corrections(m.node, m.t_domain - pre_measured_nodes, z_corr) + _update_corrections(m.node, m.s_domain, x_corr) + _update_corrections(m.node, m.t_domain, z_corr) for node, domain in self.x_dict.items(): - _update_corrections(node, domain - pre_measured_nodes, x_corr) + _update_corrections(node, domain, x_corr) for node, domain in self.z_dict.items(): - _update_corrections(node, domain - pre_measured_nodes, z_corr) + _update_corrections(node, domain, z_corr) og = ( self.extract_opengraph() @@ -586,7 +573,6 @@ def map(self, f: Callable[[Measurement], Measurement]) -> StandardizedPattern: return StandardizedPattern( self.input_nodes, self.output_nodes, - self.results, self.n_list, self.e_set, m_list, @@ -651,16 +637,6 @@ def _commute_clifford(clifford_gate: Clifford, c_dict: dict[int, Clifford], i: i ) -def _incorporate_pauli_results_in_domain( - results: Mapping[int, int], domain: AbstractSet[int] -) -> tuple[bool, set[int]] | None: - if not (results.keys() & domain): - return None - new_domain = set(domain - results.keys()) - odd_outcome = sum(outcome for node, outcome in results.items() if node in domain) % 2 - return odd_outcome == 1, new_domain - - def _update_corrections(node: Node, domain: AbstractSet[Node], correction: dict[Node, set[Node]]) -> None: """Update the correction mapping by adding a node to all entries in a domain. @@ -682,59 +658,11 @@ def _update_corrections(node: Node, domain: AbstractSet[Node], correction: dict[ correction.setdefault(measured_node, set()).add(node) -def incorporate_pauli_results(pattern: Pattern) -> Pattern: - """Return an equivalent pattern where results from Pauli presimulation are integrated in corrections.""" - from graphix.pattern import Pattern # noqa: PLC0415 - - result = Pattern(input_nodes=pattern.input_nodes) - for cmd in pattern: - match cmd.kind: - case CommandKind.M: - s = _incorporate_pauli_results_in_domain(pattern.results, cmd.s_domain) - t = _incorporate_pauli_results_in_domain(pattern.results, cmd.t_domain) - if s or t: - if s: - apply_x, new_s_domain = s - else: - apply_x = False - new_s_domain = cmd.s_domain - if t: - apply_z, new_t_domain = t - else: - apply_z = False - new_t_domain = cmd.t_domain - new_cmd = command.M(cmd.node, cmd.measurement, new_s_domain, new_t_domain) - if apply_x: - new_cmd = new_cmd.clifford(Clifford.X) - if apply_z: - new_cmd = new_cmd.clifford(Clifford.Z) - result.add(new_cmd) - else: - result.add(cmd) - case CommandKind.X | CommandKind.Z: - signal = _incorporate_pauli_results_in_domain(pattern.results, cmd.domain) - if signal: - apply_c, new_domain = signal - if new_domain: - cmd_cstr = command.X if cmd.kind == CommandKind.X else command.Z - result.add(cmd_cstr(cmd.node, new_domain)) - if apply_c: - c = Clifford.X if cmd.kind == CommandKind.X else Clifford.Z - result.add(command.C(cmd.node, c)) - else: - result.add(cmd) - case _: - result.add(cmd) - result.reorder_output_nodes(pattern.output_nodes) - return result - - def remove_useless_domains(pattern: Pattern) -> Pattern: """Return an equivalent pattern where measurement domains that are not used given the specific measurement angles and planes are removed.""" from graphix.pattern import Pattern # noqa: PLC0415 new_pattern = Pattern(input_nodes=pattern.input_nodes) - new_pattern.results = pattern.results for cmd in pattern: if cmd.kind == CommandKind.M: match cmd.measurement: @@ -756,7 +684,6 @@ def single_qubit_domains(pattern: Pattern) -> Pattern: from graphix.pattern import Pattern # noqa: PLC0415 new_pattern = Pattern(input_nodes=pattern.input_nodes) - new_pattern.results = pattern.results def decompose_domain( cmd: Callable[[int, set[int]], command.CommandType], node: int, domain: AbstractSet[int] diff --git a/graphix/pattern.py b/graphix/pattern.py index d0696113a..bb502bb3a 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -75,27 +75,8 @@ class Pattern: efficiency of the pattern accoring to measurement calculus. ref: V. Danos, E. Kashefi and P. Panangaden. J. ACM 54.2 8 (2007) - - Attributes - ---------- - list(self) : - list of commands. - - .. line-block:: - each command is a list [type, nodes, attr] which will be applied in the order of list indices. - type: one of {'N', 'M', 'E', 'X', 'Z', 'S', 'C'} - nodes: int for {'N', 'M', 'X', 'Z', 'S', 'C'} commands, tuple (i, j) for {'E'} command - attr for N: none - attr for M: meas_plane, angle, s_domain, t_domain - attr for X: signal_domain - attr for Z: signal_domain - attr for S: signal_domain - attr for C: clifford_index, as defined in :py:mod:`graphix.clifford` - n_node : int - total number of nodes in the resource state """ - results: dict[int, Outcome] __seq: list[CommandType] def __init__( @@ -116,7 +97,6 @@ def __init__( output_nodes : Iterable[int] | None Optional. List of output qubits. """ - self.results = {} # measurement results from the graph state simulator if input_nodes is None: self.__input_nodes = [] else: @@ -224,8 +204,8 @@ def compose( - Input (and, respectively, output) nodes in the returned pattern have the order of the pattern ``self`` followed by those of the pattern ``other``. Merged nodes are removed. - If ``preserve_mapping = True`` and :math:`|M_1| = |I_2| = |O_2|`, then the outputs of the returned pattern are the outputs of pattern ``self``, where the nth merged output is replaced by the output of pattern ``other`` corresponding to its nth input instead. """ - nodes_p1 = self.extract_nodes() | self.results.keys() # Results contain preprocessed Pauli nodes - nodes_p2 = other.extract_nodes() | other.results.keys() + nodes_p1 = self.extract_nodes() # Results contain preprocessed Pauli nodes + nodes_p2 = other.extract_nodes() if not mapping.keys() <= nodes_p2: raise PatternError("Keys of `mapping` must correspond to the nodes of `other`.") @@ -263,7 +243,6 @@ def compose( mapped_inputs = [mapping_complete[n] for n in other.input_nodes] mapped_outputs = [mapping_complete[n] for n in other.output_nodes] - mapped_results: dict[int, Outcome] = {mapping_complete[n]: m for n, m in other.results.items()} merged = mapping_values_set.intersection(self.__output_nodes) @@ -304,9 +283,7 @@ def update_command(cmd: CommandType) -> CommandType: seq = self.__seq + [update_command(c) for c in other] - results: dict[int, Outcome] = {**self.results, **mapped_results} p = Pattern(input_nodes=inputs, output_nodes=outputs, cmds=seq) - p.results = results return p, mapping_complete @@ -384,7 +361,6 @@ def __eq__(self, other: object) -> bool: self.__seq == other.__seq and self.__input_nodes == other.__input_nodes and self.__output_nodes == other.__output_nodes - and self.results == other.results ) def to_ascii( @@ -1567,7 +1543,6 @@ def copy(self) -> Pattern: result.__input_nodes = self.__input_nodes.copy() result.__output_nodes = self.__output_nodes.copy() result.__n_node = self.__n_node - result.results = self.results.copy() return result def check_runnability(self) -> None: @@ -1585,7 +1560,7 @@ def check_runnability(self) -> None: have hidden domains that cannot be checked. """ active = set(self.input_nodes) - measured = set(self.results) + measured = set() def check_active(cmd: CommandType, node: int) -> None: if node in measured: @@ -1654,7 +1629,6 @@ def map(self, f: Callable[[Measurement], Measurement]) -> Pattern: Pattern(input_nodes=[0], cmds=[M(0, Measurement.XZ(1.25))]) """ new_pattern = Pattern(input_nodes=self.input_nodes) - new_pattern.results = self.results for cmd in self: if cmd.kind == CommandKind.M: @@ -1893,8 +1867,8 @@ def shift_outcomes(outcomes: Mapping[int, Outcome], signal_dict: Mapping[int, Ab This method updates the given ``outcomes`` by swapping the measurements affected by signals. This can be used either to - transform the value of :data:`Pattern.results` into measurements - observed in the unshifted pattern, or vice versa. + transform the results into measurements observed in the unshifted + pattern, or vice versa. Parameters ---------- diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 4291db211..8ef80efbf 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -151,12 +151,6 @@ def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | Sta state = input_state if isinstance(input_state, State) else input_state[node] yield from state_to_qasm3_lines(node, state) yield "\n" - if pattern.results != {}: - for i in pattern.results: - res = pattern.results[i] - yield f"// measurement result of qubit q{i}\n" - yield f"bit c{i} = {res};\n" - yield "\n" for cmd in pattern: yield from command_to_qasm3_lines(cmd) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 0de60cd50..072686f51 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -165,7 +165,6 @@ def to_standardized_pattern(self) -> StandardizedPattern: return StandardizedPattern( self.original_pattern.input_nodes, self.original_pattern.output_nodes, - self.original_pattern.results, self.original_pattern.n_list, self.original_pattern.e_set, self.measurements, @@ -508,7 +507,6 @@ def to_standardized_pattern(self) -> StandardizedPattern: return StandardizedPattern( self.cut.original_pattern.input_nodes, output_nodes, - self.cut.original_pattern.results, n_list, self.graph.edges(), measurements, diff --git a/graphix/sim/tensornet.py b/graphix/sim/tensornet.py index 11f0bd072..f9e8c56f7 100644 --- a/graphix/sim/tensornet.py +++ b/graphix/sim/tensornet.py @@ -32,6 +32,7 @@ from graphix import Pattern from graphix.clifford import Clifford + from graphix.command import Node from graphix.measurements import Measurement, Outcome from graphix.sim import Data @@ -643,7 +644,7 @@ def __init__( graph_prep = "sequential" if max_degree > 5 or not pattern.is_standard() else "parallel" case _: raise ValueError(f"Invalid graph preparation strategy: {graph_prep}") - results = deepcopy(pattern.results) + results: dict[Node, Outcome] = {} if graph_prep == "parallel": if not pattern.is_standard(): raise ValueError("parallel preparation strategy does not support not-standardized pattern") diff --git a/graphix/simulator.py b/graphix/simulator.py index 29afa9858..e81439ebc 100644 --- a/graphix/simulator.py +++ b/graphix/simulator.py @@ -374,7 +374,7 @@ def __init__( prepare_method = DefaultPrepareMethod() self.__prepare_method = prepare_method if measure_method is None: - measure_method = DefaultMeasureMethod(pattern.results) + measure_method = DefaultMeasureMethod() self.__measure_method = measure_method @property diff --git a/graphix/space_minimization.py b/graphix/space_minimization.py index 602e42461..a20988803 100644 --- a/graphix/space_minimization.py +++ b/graphix/space_minimization.py @@ -110,7 +110,6 @@ def standardized_to_space_optimal_pattern(pattern: StandardizedPattern) -> Patte """ target = graphix.Pattern(input_nodes=pattern.input_nodes) - target.results = dict(pattern.results) initialized = set(pattern.input_nodes) done: set[Node] = set() n_dict = {n.node: n for n in pattern.n_list} @@ -229,8 +228,6 @@ def greedy_degree(pattern: StandardizedPattern) -> SpaceMinimizationHeuristicRes nodes = set(graph.nodes) not_measured = nodes - set(pattern.output_nodes) dependency = _extract_dependency(pattern) - # keys() should be converted into `set` because it is transient. - _update_dependency(set(pattern.results.keys()), dependency) meas_order = [] while not_measured: next_node = min((i for i in not_measured if not dependency[i]), key=graph.degree) diff --git a/tests/test_optimization.py b/tests/test_optimization.py index aa308d47e..42db33200 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -9,7 +9,7 @@ from graphix.command import C, CommandKind, E, M, N, X, Z from graphix.fundamentals import ANGLE_PI, Plane from graphix.measurements import Measurement -from graphix.optimization import StandardizedPattern, incorporate_pauli_results, remove_useless_domains +from graphix.optimization import StandardizedPattern, remove_useless_domains from graphix.pattern import Pattern from graphix.random_objects import rand_circuit from graphix.states import PlanarState @@ -57,22 +57,6 @@ def test_standardize_clifford_entanglement(fx_rng: Generator) -> None: assert state_p.isclose(state_ref) -@pytest.mark.parametrize("jumps", range(1, 11)) -def test_incorporate_pauli_results(fx_bg: PCG64, jumps: int) -> None: - rng = Generator(fx_bg.jumped(jumps)) - nqubits = 3 - depth = 3 - circuit = rand_circuit(nqubits, depth, rng) - pattern = circuit.transpile().pattern - pattern.standardize() - pattern.shift_signals() - pattern.remove_pauli_measurements() - pattern2 = incorporate_pauli_results(pattern) - state = pattern.simulate_pattern(rng=rng) - state2 = pattern2.simulate_pattern(rng=rng) - assert state.isclose(state2) - - @pytest.mark.parametrize("jumps", range(1, 11)) def test_flow_after_pauli_preprocessing(fx_bg: PCG64, jumps: int) -> None: rng = Generator(fx_bg.jumped(jumps)) diff --git a/tests/test_pattern.py b/tests/test_pattern.py index a22b15529..a471d863a 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -427,22 +427,22 @@ def test_standardize_two_cliffords(self, fx_bg: PCG64, jumps: int) -> None: state_p = pattern.simulate_pattern() assert state_p.isclose(state_ref) - @pytest.mark.parametrize("jumps", range(1, 48)) - def test_standardize_domains_and_clifford(self, fx_bg: PCG64, jumps: int) -> None: - rng = Generator(fx_bg.jumped(jumps)) - x, z = rng.integers(2, size=2) - c = rng.integers(len(Clifford)) - pattern = Pattern(input_nodes=[0]) - pattern.results[1] = x - pattern.add(X(node=0, domain={1})) - pattern.results[2] = z - pattern.add(Z(node=0, domain={2})) - pattern.add(C(node=0, clifford=Clifford(c))) - pattern_ref = pattern.copy() - pattern.standardize() - state_ref = pattern_ref.simulate_pattern() - state_p = pattern.simulate_pattern() - assert state_p.isclose(state_ref) + # @pytest.mark.parametrize("jumps", range(1, 48)) + # def test_standardize_domains_and_clifford(self, fx_bg: PCG64, jumps: int) -> None: + # rng = Generator(fx_bg.jumped(jumps)) + # x, z = rng.integers(2, size=2) + # c = rng.integers(len(Clifford)) + # pattern = Pattern(input_nodes=[0]) + # pattern.results[1] = x + # pattern.add(X(node=0, domain={1})) + # pattern.results[2] = z + # pattern.add(Z(node=0, domain={2})) + # pattern.add(C(node=0, clifford=Clifford(c))) + # pattern_ref = pattern.copy() + # pattern.standardize() + # state_ref = pattern_ref.simulate_pattern() + # state_p = pattern.simulate_pattern() + # assert state_p.isclose(state_ref) # Simple pattern composition def test_compose_1(self) -> None: @@ -745,13 +745,6 @@ def test_check_runnability_failures(self) -> None: 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: - 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() diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 9d281667f..c44eb9dda 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -14,7 +14,7 @@ from graphix.command import C, CommandKind, E, M, N from graphix.fundamentals import Plane from graphix.measurements import BlochMeasurement, Measurement, outcome -from graphix.optimization import incorporate_pauli_results, single_qubit_domains +from graphix.optimization import single_qubit_domains from graphix.qasm3_exporter import pattern_to_qasm3 from graphix.random_objects import rand_circuit from graphix.sim.statevec import StatevectorBackend @@ -122,9 +122,6 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: pattern.remove_pauli_measurements() pattern.minimize_space() - # qiskit_qasm3_import.exceptions.ConversionError: initialisation of classical bits is not supported - pattern = incorporate_pauli_results(pattern) - # qiskit_qasm3_import.exceptions.ConversionError: unhandled binary operator '^' pattern = single_qubit_domains(pattern) diff --git a/tests/test_tnsim.py b/tests/test_tnsim.py index 4e34aa8f0..c347e492b 100644 --- a/tests/test_tnsim.py +++ b/tests/test_tnsim.py @@ -1,7 +1,6 @@ from __future__ import annotations import itertools -from typing import TYPE_CHECKING import numpy as np import numpy.typing as npt @@ -10,8 +9,7 @@ from quimb.tensor import Tensor from graphix.branch_selector import RandomBranchSelector -from graphix.clifford import Clifford -from graphix.command import C, E, X, Z +from graphix.command import E from graphix.fundamentals import ANGLE_PI from graphix.ops import Ops from graphix.random_objects import rand_circuit @@ -20,9 +18,6 @@ from graphix.states import BasicStates from graphix.transpiler import Circuit -if TYPE_CHECKING: - from graphix.command import CommandType - def random_op(sites: int, rng: Generator) -> npt.NDArray[np.complex128]: size = 2**sites @@ -73,37 +68,37 @@ def test_entangle_nodes(self, fx_rng: Generator) -> None: contracted_ref = np.einsum("abcd, c, d, ab->", CZ.reshape(2, 2, 2, 2), plus, plus, random_vec) assert contracted == pytest.approx(contracted_ref) - def test_apply_one_site_operator(self, fx_rng: Generator) -> None: - clifford = Clifford(fx_rng.integers(len(Clifford))) - cmds: list[CommandType] = [ - X(node=0, domain={15}), - Z(node=0, domain={15}), - C(node=0, clifford=clifford), - ] - random_vec = fx_rng.normal(size=2) - - circuit = Circuit(1) - pattern = circuit.transpile().pattern - pattern.results[15] = 1 # X&Z operator will be applied. - for cmd in cmds: - pattern.add(cmd) - tn = pattern.simulate_pattern(backend="tensornetwork", rng=fx_rng) - dummy_index = gen_str() - ind = tn._dangling.pop("0") - tensor = tn.tensor_map[tn._get_tids_from_inds(ind).popleft()] - tensor.reindex({ind: dummy_index}, inplace=True) - random_vec_ts = Tensor(random_vec, [dummy_index], ["random_vector"]) - tn.add_tensor(random_vec_ts) - contracted = tn.contract() - - # reference - ops = [ - np.array([[0.0, 1.0], [1.0, 0.0]]), - np.array([[1.0, 0.0], [0.0, -1.0]]), - clifford.matrix, - ] - contracted_ref = np.einsum("i,ij,jk,kl,l", random_vec, ops[2], ops[1], ops[0], plus) - assert contracted == pytest.approx(contracted_ref) + # def test_apply_one_site_operator(self, fx_rng: Generator) -> None: + # clifford = Clifford(fx_rng.integers(len(Clifford))) + # cmds: list[CommandType] = [ + # X(node=0, domain={15}), + # Z(node=0, domain={15}), + # C(node=0, clifford=clifford), + # ] + # random_vec = fx_rng.normal(size=2) + # + # circuit = Circuit(1) + # pattern = circuit.transpile().pattern + # pattern.results[15] = 1 # X&Z operator will be applied. + # for cmd in cmds: + # pattern.add(cmd) + # tn = pattern.simulate_pattern(backend="tensornetwork", rng=fx_rng) + # dummy_index = gen_str() + # ind = tn._dangling.pop("0") + # tensor = tn.tensor_map[tn._get_tids_from_inds(ind).popleft()] + # tensor.reindex({ind: dummy_index}, inplace=True) + # random_vec_ts = Tensor(random_vec, [dummy_index], ["random_vector"]) + # tn.add_tensor(random_vec_ts) + # contracted = tn.contract() + # + # # reference + # ops = [ + # np.array([[0.0, 1.0], [1.0, 0.0]]), + # np.array([[1.0, 0.0], [0.0, -1.0]]), + # clifford.matrix, + # ] + # contracted_ref = np.einsum("i,ij,jk,kl,l", random_vec, ops[2], ops[1], ops[0], plus) + # assert contracted == pytest.approx(contracted_ref) def test_expectation_value1(self, fx_rng: Generator) -> None: circuit = Circuit(1) From 10bf9740cedefec42314a2b27d580858dde27aed Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sun, 10 May 2026 23:29:51 +0200 Subject: [PATCH 27/40] Move `c_dict` as last parameter of `StandardizedPattern` --- graphix/optimization.py | 16 ++++++++-------- graphix/remove_pauli_measurements.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/graphix/optimization.py b/graphix/optimization.py index 6cd934203..c04bea650 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -87,9 +87,9 @@ class _StandardizedPattern: n_list: tuple[command.N, ...] e_set: frozenset[frozenset[Node]] m_list: tuple[command.M, ...] - c_dict: Mapping[Node, Clifford] z_dict: Mapping[Node, frozenset[Node]] x_dict: Mapping[Node, frozenset[Node]] + c_dict: Mapping[Node, Clifford] class StandardizedPattern(_StandardizedPattern): @@ -122,12 +122,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 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. """ @@ -138,9 +138,9 @@ def __init__( n_list: Iterable[command.N], e_set: Iterable[Iterable[Node]], m_list: Iterable[command.M], - c_dict: Mapping[Node, Clifford], z_dict: Mapping[Node, Iterable[Node]], x_dict: Mapping[Node, Iterable[Node]], + c_dict: Mapping[Node, Clifford], ) -> None: """Return a new StandardizedPattern with immutable data structures.""" super().__init__( @@ -149,9 +149,9 @@ def __init__( tuple(n_list), frozenset(frozenset(edge) for edge in e_set), tuple(m_list), - MappingProxyType(dict(c_dict)), MappingProxyType({node: frozenset(nodes) for node, nodes in z_dict.items()}), MappingProxyType({node: frozenset(nodes) for node, nodes in x_dict.items()}), + MappingProxyType(dict(c_dict)), ) @classmethod @@ -165,9 +165,9 @@ def from_pattern(cls, pattern: Pattern) -> Self: n_list: list[command.N] = [] e_set: set[frozenset[Node]] = set() m_list: list[command.M] = [] - c_dict: dict[Node, Clifford] = {} z_dict: dict[Node, set[Node]] = {} x_dict: dict[Node, set[Node]] = {} + c_dict: dict[Node, Clifford] = {} # Standardization could turn non-runnable patterns into # runnable ones, so we check runnability first to avoid hiding @@ -224,7 +224,7 @@ def from_pattern(cls, pattern: Pattern) -> Self: # has been already applied to a node, applying a clifford `C'` to the same # node is equivalent to apply `C'C` to a fresh node. c_dict[cmd.node] = cmd.clifford @ c_dict.get(cmd.node, Clifford.I) - return cls(pattern.input_nodes, pattern.output_nodes, n_list, e_set, m_list, c_dict, z_dict, x_dict) + return cls(pattern.input_nodes, pattern.output_nodes, n_list, e_set, m_list, z_dict, x_dict, c_dict) def extract_graph(self) -> nx.Graph[int]: """Return the graph state from the command sequence, extracted from 'N' and 'E' commands. @@ -576,9 +576,9 @@ def map(self, f: Callable[[Measurement], Measurement]) -> StandardizedPattern: self.n_list, self.e_set, m_list, - self.c_dict, self.z_dict, self.x_dict, + self.c_dict, ) def to_bloch(self) -> StandardizedPattern: diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 072686f51..0697138ed 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -168,9 +168,9 @@ def to_standardized_pattern(self) -> StandardizedPattern: self.original_pattern.n_list, self.original_pattern.e_set, self.measurements, - self.original_pattern.c_dict, _expand_corrections(self.shifted_domains, self.original_pattern.z_dict), _expand_corrections(self.shifted_domains, self.original_pattern.x_dict), + self.original_pattern.c_dict, ) @@ -510,9 +510,9 @@ def to_standardized_pattern(self) -> StandardizedPattern: n_list, self.graph.edges(), measurements, - c_dict, z_dict, x_dict, + c_dict, ) From c87b2402f2a10da426e18790e4b787a6ed81e7a6 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Mon, 11 May 2026 07:37:44 +0200 Subject: [PATCH 28/40] Update reverse dependencies --- noxfile.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index 89e2ba018..860265838 100644 --- a/noxfile.py +++ b/noxfile.py @@ -108,10 +108,17 @@ class ReverseDependency: @nox.parametrize( "package", [ - ReverseDependency("https://github.com/thierry-martinez/graphix-stim-backend", branch="fix/graphix_namespace"), + ReverseDependency( + "https://github.com/thierry-martinez/graphix-stim-backend", branch="fix/graphix_498_remove_pauli" + ), ReverseDependency("https://github.com/TeamGraphix/graphix-symbolic"), ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser"), - ReverseDependency("https://github.com/qat-inria/veriphix", doctest_modules=False, install_target=".[dev]"), + ReverseDependency( + "https://github.com/thierry-martinez/veriphix", + doctest_modules=False, + install_target=".[dev]", + branch="fix/graphix_498_remove_pauli", + ), ReverseDependency("https://github.com/TeamGraphix/graphix-ibmq", doctest_modules=False), ReverseDependency("https://github.com/qat-inria/graphix-stim-compiler", branch="ps_dim"), ], From a046db7ab15dd2722f4bfbf9036841b17316e34c Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Mon, 11 May 2026 13:51:50 +0200 Subject: [PATCH 29/40] Restore GraphState --- docs/source/intro.rst | 13 +- docs/source/tutorial.rst | 3 +- graphix/__init__.py | 2 + graphix/extraction.py | 6 +- graphix/graphsim.py | 517 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 530 insertions(+), 11 deletions(-) create mode 100644 graphix/graphsim.py diff --git a/docs/source/intro.rst b/docs/source/intro.rst index c3570dd2e..3fa3686c8 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -124,16 +124,17 @@ which we can express by a long sequence, Note that the input state has *teleported* to qubits 6 and 7 after the computation. .. - We can inspect the graph state using :class:`~graphix.graphsim.GraphState` class: + We can inspect the graph state using :class:`~graphix.opengraph.OpenGraph` class: .. code-block:: python - from graphix import GraphState - g = GraphState(nodes=[0,1],edges=[(0,1)]) + from graphix import OpenGraph + import networkx as nx + og = OpenGraph(nx.Graph([0, 1]), output_nodes=[0, 1]) - >>> print(g.to_statevector()) - Statevec, data=[[ 0.5+0.j 0.5+0.j] - [ 0.5+0.j -0.5+0.j]], shape=(2, 2) + >>> print(og.to_pattern().simulate_pattern()) + Statevec object with statevector [[ 0.5+0.j 0.5+0.j] + [ 0.5+0.j -0.5+0.j]] and length (2, 2). diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index ca417afbe..14ec73e3e 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -172,8 +172,7 @@ Performing Pauli measurements +++++++++++++++++++++++++++++ It is known that quantum circuit consisting of Pauli basis states, Clifford gates and Pauli measurements can be simulated classically (see `Gottesman-Knill theorem -`_; e.g. the graph state simulator runs in :math:`\mathcal{O}(n \log n)` time). -The Pauli measurement part of the MBQC is exactly this, and they can be preprocessed by our graph state simulator :class:`~graphix.graphsim.GraphState` - see :doc:`lc-mbqc` for more detailed description. +`). We can call :meth:`~graphix.pattern.Pattern.remove_pauli_measurements()` (method of the :class:`~graphix.pattern.Pattern` object) to optimize the measurement pattern. We get an updated measurement pattern without Pauli measurements as follows: diff --git a/graphix/__init__.py b/graphix/__init__.py index d588d2997..12f8da89c 100644 --- a/graphix/__init__.py +++ b/graphix/__init__.py @@ -10,6 +10,7 @@ from graphix.command import Command from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections from graphix.fundamentals import ANGLE_PI, Axis, Plane, Sign, angle_to_rad, rad_to_angle +from graphix.graphsim import GraphState from graphix.instruction import Instruction from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement from graphix.noise_models import DepolarisingNoiseModel, NoiseModel @@ -41,6 +42,7 @@ "DrawPatternAnnotations", "FixedBranchSelector", "GFlow", + "GraphState", "Instruction", "KrausChannel", "Measurement", diff --git a/graphix/extraction.py b/graphix/extraction.py index 27f96295b..eea0dc32e 100644 --- a/graphix/extraction.py +++ b/graphix/extraction.py @@ -39,7 +39,7 @@ class ResourceGraph: ---------- cltype : :class:`ResourceType` object Type of the cluster. - graph : :class:`~graphix.graphsim.GraphState` object + graph : :class:`Graph` object Graph state of the cluster. """ @@ -59,7 +59,7 @@ def graph_to_fusion_network( max_ghz: float = np.inf, max_lin: float = np.inf, ) -> list[ResourceGraph]: - """Extract GHZ and linear cluster graph state decomposition of desired resource state :class:`~graphix.graphsim.GraphState`. + """Extract GHZ and linear cluster graph state decomposition of desired resource state :class:`Graph`. Extraction algorithm is based on [1]. @@ -67,7 +67,7 @@ def graph_to_fusion_network( Parameters ---------- - graph : :class:`~graphix.graphsim.GraphState` object + graph : :class:`Graph` object Graph state. phasedict : dict Dictionary of phases for each node. diff --git a/graphix/graphsim.py b/graphix/graphsim.py new file mode 100644 index 000000000..7aaf3cf2d --- /dev/null +++ b/graphix/graphsim.py @@ -0,0 +1,517 @@ +"""Graph simulator.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypedDict + +import networkx as nx +import typing_extensions + +from graphix import utils +from graphix.clifford import Clifford +from graphix.measurements import outcome +from graphix.ops import Ops +from graphix.sim.statevec import Statevec + +if TYPE_CHECKING: + import functools + from collections.abc import Iterable, Mapping + + from graphix.measurements import Outcome + + +if TYPE_CHECKING: + Graph = nx.Graph[int] +else: + Graph = nx.Graph + + +class MBQCGraphNode(TypedDict): + """MBQC graph node attributes.""" + + sign: bool + loop: bool + hollow: bool + + +class GraphState(Graph): + """Graph state simulator implemented with :mod:`networkx`. + + Performs Pauli measurements on graph states. + + ref: M. Elliot, B. Eastin & C. Caves, JPhysA 43, 025301 (2010) + and PRA 77, 042307 (2008) + + Each node has attributes: + :*hollow*: True if node is hollow (has local H operator) + :*sign*: True if node has negative sign (local Z operator) + :*loop*: True if node has loop (local S operator) + """ + + nodes: functools.cached_property[Mapping[int, MBQCGraphNode]] # type: ignore[assignment] + + def __init__( + self, + nodes: Iterable[int] | None = None, + edges: Iterable[tuple[int, int]] | None = None, + vops: Mapping[int, Clifford] | None = None, + ) -> None: + """Instantiate a graph simulator. + + Parameters + ---------- + nodes : Iterable[int] + A container of nodes + edges : Iterable[tuple[int, int]] + list of tuples (i,j) for pairs to be entangled. + vops : Mapping[int, Clifford] + dict of local Clifford gates with keys for node indices and Cliffords + """ + super().__init__() + if nodes is not None: + self.add_nodes_from(nodes) + if edges is not None: + self.add_edges_from(edges) + if vops is not None: + self.apply_vops(vops) + + @typing_extensions.override + def add_nodes_from( # pyright: ignore[reportIncompatibleMethodOverride] + self, + nodes_for_adding: Iterable[int | tuple[int, MBQCGraphNode]], # type: ignore[override] + **attr: Any, + ) -> None: + """Wrap `networkx.Graph.add_nodes_from` to initialize MBQCGraphNode attributes.""" + nodes_for_adding = list(nodes_for_adding) + super().add_nodes_from(nodes_for_adding, **attr) # type: ignore[arg-type] + for data in nodes_for_adding: + u, mp = data if isinstance(data, tuple) else (data, MBQCGraphNode(sign=False, hollow=False, loop=False)) + for k, v_ in mp.items(): + dst = self.nodes[u] + v = bool(v_) + # Need to use literal inside brackets + match k: + case "sign": + dst["sign"] = v + case "hollow": + dst["hollow"] = v + case "loop": + dst["loop"] = v + case _: + msg = "Invalid node attribute." + raise ValueError(msg) + + @typing_extensions.override + def add_node( + self, + node_for_adding: int, + **attr: Any, + ) -> None: + """Wrap `networkx.Graph.add_node` to initialize MBQCGraphNode attributes.""" + self.add_nodes_from((node_for_adding,), **attr) + + def local_complement(self, node: int) -> None: + """Perform local complementation of a graph.""" + g = self.subgraph(self.neighbors(node)) + g_new: nx.Graph[int] = nx.complement(g) + self.remove_edges_from(g.edges) + self.add_edges_from(g_new.edges) + + def apply_vops(self, vops: Mapping[int, Clifford]) -> None: + """Apply local Clifford operators to the graph state from a dictionary. + + Parameters + ---------- + vops : Mapping[int, Clifford] + dict containing node indices as keys and local Clifford + + Returns + ------- + None + """ + for node, vop in vops.items(): + for lc in reversed(vop.hsz): + match lc: + case Clifford.Z: + self.z(node) + case Clifford.H: + self.h(node) + case Clifford.S: + self.s(node) + case _: + raise RuntimeError + + def extract_vops(self) -> dict[int, Clifford]: + """Apply local Clifford operators to the graph state from a dictionary. + + Returns + ------- + vops : dict[int, Clifford] + dict containing node indices as keys and local Cliffords + """ + vops: dict[int, Clifford] = {} + for i in self.nodes: + vop = Clifford.I + if self.nodes[i]["sign"]: + vop = Clifford.Z @ vop + if self.nodes[i]["loop"]: + vop = Clifford.S @ vop + if self.nodes[i]["hollow"]: + vop = Clifford.H @ vop + vops[i] = vop + return vops + + def flip_fill(self, node: int) -> None: + """Flips the fill (local H) of a node. + + Parameters + ---------- + node : int + graph node to flip the fill + + Returns + ------- + None + """ + self.nodes[node]["hollow"] = not self.nodes[node]["hollow"] + + def flip_sign(self, node: int) -> None: + """Flip the sign (local Z) of a node. + + Note that application of Z gate is different from `flip_sign` + if there exist an edge from the node. + + Parameters + ---------- + node : int + graph node to flip the sign + + Returns + ------- + None + """ + self.nodes[node]["sign"] = not self.nodes[node]["sign"] + + def advance(self, node: int) -> None: + """Flip the loop (local S) of a node. + + If the loop already exist, sign is also flipped, + reflecting the relation SS=Z. + Note that application of S gate is different from `advance` + if there exist an edge from the node. + + Parameters + ---------- + node : int + graph node to advance the loop. + + Returns + ------- + None + """ + if self.nodes[node]["loop"]: + self.nodes[node]["loop"] = False + self.flip_sign(node) + else: + self.nodes[node]["loop"] = True + + def h(self, node: int) -> None: + """Apply H gate to a qubit (node). + + Parameters + ---------- + node : int + graph node to apply H gate + + Returns + ------- + None + """ + self.flip_fill(node) + + def s(self, node: int) -> None: + """Apply S gate to a qubit (node). + + Parameters + ---------- + node : int + graph node to apply S gate + + Returns + ------- + None + """ + if self.nodes[node]["hollow"]: + if self.nodes[node]["loop"]: + self.flip_fill(node) + self.nodes[node]["loop"] = False + self.local_complement(node) + for i in self.neighbors(node): + self.advance(i) + else: + self.local_complement(node) + for i in self.neighbors(node): + self.advance(i) + if self.nodes[node]["sign"]: + for i in self.neighbors(node): + self.flip_sign(i) + else: # solid + self.advance(node) + + def z(self, node: int) -> None: + """Apply Z gate to a qubit (node). + + Parameters + ---------- + node : int + graph node to apply Z gate + + Returns + ------- + None + """ + if self.nodes[node]["hollow"]: + for i in self.neighbors(node): + self.flip_sign(i) + if self.nodes[node]["loop"]: + self.flip_sign(node) + else: # solid + self.flip_sign(node) + + def equivalent_graph_e1(self, node: int) -> None: + """Tranform a graph state to a different graph state representing the same stabilizer state. + + This rule applies only to a node with loop. + + Parameters + ---------- + node1 : int + A graph node with a loop to apply rule E1 + + Returns + ------- + None + """ + if not self.nodes[node]["loop"]: + raise ValueError("node must have loop") + self.flip_fill(node) + self.local_complement(node) + for i in self.neighbors(node): + self.advance(i) + self.flip_sign(node) + if self.nodes[node]["sign"]: + for i in self.neighbors(node): + self.flip_sign(i) + + def equivalent_graph_e2(self, node1: int, node2: int) -> None: + """Tranform a graph state to a different graph state representing the same stabilizer state. + + This rule applies only to two connected nodes without loop. + + Parameters + ---------- + node1, node2 : int + connected graph nodes to apply rule E2 + + Returns + ------- + None + """ + if (node1, node2) not in self.edges and (node2, node1) not in self.edges: + raise ValueError("nodes must be connected by an edge") + if self.nodes[node1]["loop"] or self.nodes[node2]["loop"]: + raise ValueError("nodes must not have loop") + sg1 = self.nodes[node1]["sign"] + sg2 = self.nodes[node2]["sign"] + self.flip_fill(node1) + self.flip_fill(node2) + # local complement along edge between node1, node2 + self.local_complement(node1) + self.local_complement(node2) + self.local_complement(node1) + for i in iter(set(self.neighbors(node1)) & set(self.neighbors(node2))): + self.flip_sign(i) + if sg1: + self.flip_sign(node1) + for i in self.neighbors(node1): + self.flip_sign(i) + if sg2: + self.flip_sign(node2) + for i in self.neighbors(node2): + self.flip_sign(i) + + def equivalent_fill_node(self, node: int) -> int: + """Fill the chosen node by graph transformation rules E1 and E2. + + If the selected node is hollow and isolated, it cannot be filled + and warning is thrown. + + Parameters + ---------- + node : int + node to fill. + + Returns + ------- + result : int + if the selected node is hollow and isolated, *result* is 1. + if filled and isolated, 2. + otherwise it is 0. + """ + if self.nodes[node]["hollow"]: + if self.nodes[node]["loop"]: + self.equivalent_graph_e1(node) + return 0 + # node = hollow and loopless + if utils.iter_empty(self.neighbors(node)): + return 1 + for i in self.neighbors(node): + if not self.nodes[i]["loop"]: + self.equivalent_graph_e2(node, i) + return 0 + # if all neighbor has loop, pick one and apply E1, then E1 to the node. + i = next(self.neighbors(node)) + self.equivalent_graph_e1(i) # this gives loop to node. + self.equivalent_graph_e1(node) + return 0 + if utils.iter_empty(self.neighbors(node)): + return 2 + return 0 + + def measure_x(self, node: int, choice: Outcome = 0) -> Outcome: + """Perform measurement in X basis. + + According to original paper, we realise X measurement by + applying H gate to the measured node before Z measurement. + + Parameters + ---------- + node : int + qubit index to be measured + choice : int, 0 or 1 + choice of measurement outcome. observe (-1)^choice + + Returns + ------- + result : int + measurement outcome. 0 or 1. + """ + if choice not in {0, 1}: + raise ValueError("choice must be 0 or 1") + # check if isolated + if utils.iter_empty(self.neighbors(node)): + if self.nodes[node]["hollow"] or self.nodes[node]["loop"]: + choice_ = choice + elif self.nodes[node]["sign"]: # isolated and state is |-> + choice_ = 1 + else: # isolated and state is |+> + choice_ = 0 + self.remove_node(node) + return choice_ + self.h(node) + return self.measure_z(node, choice=choice) + + def measure_y(self, node: int, choice: Outcome = 0) -> Outcome: + """Perform measurement in Y basis. + + According to original paper, we realise Y measurement by + applying S,Z and H gate to the measured node before Z measurement. + + Parameters + ---------- + node : int + qubit index to be measured + choice : int, 0 or 1 + choice of measurement outcome. observe (-1)^choice + + Returns + ------- + result : int + measurement outcome. 0 or 1. + """ + if choice not in {0, 1}: + raise ValueError("choice must be 0 or 1") + self.s(node) + self.z(node) + self.h(node) + return self.measure_z(node, choice=choice) + + def measure_z(self, node: int, choice: Outcome = 0) -> Outcome: + """Perform measurement in Z basis. + + To realize the simple Z measurement on undecorated graph state, + we first fill the measured node (remove local H gate) + + Parameters + ---------- + node : int + qubit index to be measured + choice : int, 0 or 1 + choice of measurement outcome. observe (-1)^choice + + Returns + ------- + result : int + measurement outcome. 0 or 1. + """ + if choice not in {0, 1}: + raise ValueError("choice must be 0 or 1") + isolated = self.equivalent_fill_node(node) + if choice: + for i in self.neighbors(node): + self.flip_sign(i) + result = choice if not isolated else outcome(self.nodes[node]["sign"]) + self.remove_node(node) + return result + + def draw(self, fill_color: str = "C0") -> None: + """Draw decorated graph state. + + Negative nodes are indicated by negative sign of node labels. + + Parameters + ---------- + fill_color : str + optional, fill color of nodes + """ + nqubit = len(self.nodes) + nodes = list(self.nodes) + edges: list[tuple[int, int]] = list(self.edges) + labels = {i: i for i in iter(self.nodes)} + colors = [fill_color for _ in range(nqubit)] + for i in range(nqubit): + if self.nodes[nodes[i]]["loop"]: + edges.append((nodes[i], nodes[i])) + if self.nodes[nodes[i]]["hollow"]: + colors[i] = "white" + if self.nodes[nodes[i]]["sign"]: + labels[nodes[i]] = -1 * labels[nodes[i]] + g: nx.Graph[int] = nx.Graph() + g.add_nodes_from(nodes) + g.add_edges_from(edges) + nx.draw(g, labels=labels, node_color=colors, edgecolors="k") + + def to_statevector(self) -> Statevec: + """Convert the graph state into a state vector.""" + node_list = list(self.nodes) + nqubit = len(self.nodes) + gstate = Statevec(nqubit=nqubit) + # map graph node indices into 0 - (nqubit-1) for qubit indexing in statevec + imapping = {node_list[i]: i for i in range(nqubit)} + mapping = [node_list[i] for i in range(nqubit)] + for i, j in self.edges: + gstate.entangle((imapping[i], imapping[j])) + for i in range(nqubit): + if self.nodes[mapping[i]]["sign"]: + gstate.evolve_single(Ops.Z, i) + for i in range(nqubit): + if self.nodes[mapping[i]]["loop"]: + gstate.evolve_single(Ops.S, i) + for i in range(nqubit): + if self.nodes[mapping[i]]["hollow"]: + gstate.evolve_single(Ops.H, i) + return gstate + + def isolated_nodes(self) -> list[int]: + """Return a list of isolated nodes (nodes with no edges).""" + return list(nx.isolates(self)) From e8adbf263c2d40d54bd15e181ae4a30d0872d654 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Mon, 11 May 2026 14:04:11 +0200 Subject: [PATCH 30/40] Fix URL --- docs/source/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 14ec73e3e..a828719e7 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -172,7 +172,7 @@ Performing Pauli measurements +++++++++++++++++++++++++++++ It is known that quantum circuit consisting of Pauli basis states, Clifford gates and Pauli measurements can be simulated classically (see `Gottesman-Knill theorem -`). +`_). We can call :meth:`~graphix.pattern.Pattern.remove_pauli_measurements()` (method of the :class:`~graphix.pattern.Pattern` object) to optimize the measurement pattern. We get an updated measurement pattern without Pauli measurements as follows: From e33a760c311f27f9096f9c50990d580800e50456 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Mon, 11 May 2026 16:52:48 +0200 Subject: [PATCH 31/40] Clarify `test_step_4_no_flow` and `try_pivot_x_with_output_node` --- graphix/remove_pauli_measurements.py | 11 +++++------ tests/test_remove_pauli_measurements.py | 5 +++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 0697138ed..de075518b 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -97,7 +97,6 @@ def from_standardized_pattern( ------- PauliPushingCut The cut between Pauli measurements and non-Pauli measurements. - """ pattern._warn_non_inferred_pauli_measurements(stacklevel=stacklevel + 1) @@ -451,9 +450,9 @@ def try_remove_x_with_internal_neighbor(self) -> bool: def try_pivot_x_with_output_node(self) -> bool: """ - Find an X measurement connected to an output node and pivot it if any. + Find an X measurement connected to an output node that is not also an input and pivot it if any. - Implements Theorem 4.12, Step 4. + Implements Lemma 4.11 and Theorem 4.12, Step 4. Returns ------- @@ -548,9 +547,9 @@ def remove_pauli_measurements(cut: PauliPushingCut) -> StandardizedPattern: This function removes all non-input Y and Z measured nodes and all non-input X measured nodes connected to any other internal vertex. Furthermore, if any non-input X measured node is connected to an - output node, pivoting these nodes enables eliminating further - nodes. In particular, if the pattern has flow, all non-input - Pauli measurements are removed. + output node that is not also an input, pivoting these nodes + enables eliminating further nodes. In particular, if the pattern + has flow, all non-input Pauli measurements are removed. Parameters ---------- diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index de86bb2fd..869227577 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -230,6 +230,11 @@ def test_step_4() -> None: def test_step_4_no_flow() -> None: + # This example tests the case of a pattern that contains a + # non-input X-measured node 1 which is connected to an output node + # 0, where the node 0 is also an input. In this situation Lemma + # 4.11 cannot be applied; this exercices the filtering implemented + # in the `try_pivot_x_with_output_node` method. pattern = Pattern(input_nodes=(0,), output_nodes=(0,), cmds=[Command.N(1), Command.E((0, 1)), Command.M(1)]) standardized_pattern = StandardizedPattern.from_pattern(pattern) cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) From 9f59cde8b5c627e4c7be6f27a19f9a6fd6cc97ce Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 12 May 2026 12:24:48 +0200 Subject: [PATCH 32/40] Add a note about nondeterministic patterns --- graphix/remove_pauli_measurements.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index de075518b..5474525e9 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -551,6 +551,9 @@ def remove_pauli_measurements(cut: PauliPushingCut) -> StandardizedPattern: enables eliminating further nodes. In particular, if the pattern has flow, all non-input Pauli measurements are removed. + Note that if the pattern is nondeterministic, only the 0-branch is + preserved. + Parameters ---------- cut: PauliPushingCut From daf1d45e58ca6e455cbf071b38fbc74647e0869a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 07:38:48 +0200 Subject: [PATCH 33/40] Restore `test_graphsim.py` --- tests/test_graphsim.py | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/test_graphsim.py diff --git a/tests/test_graphsim.py b/tests/test_graphsim.py new file mode 100644 index 000000000..180c38929 --- /dev/null +++ b/tests/test_graphsim.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import networkx as nx +import numpy as np +import numpy.typing as npt + +from graphix.clifford import Clifford +from graphix.fundamentals import ANGLE_PI, Plane, angle_to_rad +from graphix.graphsim import GraphState +from graphix.ops import Ops +from graphix.sim.statevec import Statevec + +if TYPE_CHECKING: + from graphix.fundamentals import Angle + + +def graph_state_to_statevec(g: GraphState) -> Statevec: + node_list = list(g.nodes) + nqubit = len(g.nodes) + gstate = Statevec(nqubit=nqubit) + imapping = {node_list[i]: i for i in range(nqubit)} + mapping = [node_list[i] for i in range(nqubit)] + for i, j in g.edges: + gstate.entangle((imapping[i], imapping[j])) + for i in range(nqubit): + if g.nodes[mapping[i]]["sign"]: + gstate.evolve_single(Ops.Z, i) + for i in range(nqubit): + if g.nodes[mapping[i]]["loop"]: + gstate.evolve_single(Ops.S, i) + for i in range(nqubit): + if g.nodes[mapping[i]]["hollow"]: + gstate.evolve_single(Ops.H, i) + return gstate + + +def meas_op( + angle: Angle, vop: Clifford = Clifford.I, plane: Plane = Plane.XY, choice: int = 0 +) -> npt.NDArray[np.complex128]: + """Return the projection operator for given measurement angle and local Clifford op (VOP). + + .. seealso:: :mod:`graphix.clifford` + + Parameters + ---------- + angle : Angle + original measurement angle in units of π + vop : int + index of local Clifford (vop), see graphq.clifford.CLIFFORD + plane : 'XY', 'YZ' or 'ZX' + measurement plane on which angle shall be defined + choice : 0 or 1 + choice of measurement outcome. measured eigenvalue would be (-1)**choice. + + Returns + ------- + op : numpy array + projection operator + + """ + assert choice in {0, 1} + rad_angle = angle_to_rad(angle) + match plane: + case Plane.XY: + vec = (np.cos(rad_angle), np.sin(rad_angle), 0) + case Plane.YZ: + vec = (0, np.cos(rad_angle), np.sin(rad_angle)) + case Plane.XZ: + vec = (np.cos(rad_angle), 0, np.sin(rad_angle)) + op_mat = np.eye(2, dtype=np.complex128) / 2 + for i in range(3): + op_mat += (-1) ** (choice) * vec[i] * Clifford(i + 1).matrix / 2 + return (vop.conj.matrix @ op_mat @ vop.matrix).astype(np.complex128, copy=False) + + +class TestGraphSim: + def test_fig2(self) -> None: + """Three single-qubit measurements presented in Fig.2 of M. Elliot et al (2010).""" + nqubit = 6 + edges = [(0, 1), (1, 2), (3, 4), (4, 5), (0, 3), (1, 4), (2, 5)] + g = GraphState(nodes=np.arange(nqubit), edges=edges) + gstate = graph_state_to_statevec(g) + g.measure_x(0) + gstate.evolve_single(meas_op(0), 0) # x meas + gstate.normalize() + gstate.remove_qubit(0) + gstate2 = graph_state_to_statevec(g) + assert gstate.isclose(gstate2) + + g.measure_y(1, choice=0) + gstate.evolve_single(meas_op(0.5 * ANGLE_PI), 0) # y meas + gstate.normalize() + gstate.remove_qubit(0) + gstate2 = graph_state_to_statevec(g) + assert gstate.isclose(gstate2) + + g.measure_z(3) + gstate.evolve_single(meas_op(0.5 * ANGLE_PI, plane=Plane.YZ), 1) # z meas + gstate.normalize() + gstate.remove_qubit(1) + gstate2 = graph_state_to_statevec(g) + assert gstate.isclose(gstate2) + + def test_e2(self) -> None: + nqubit = 6 + edges = [(0, 1), (1, 2), (3, 4), (4, 5), (0, 3), (1, 4), (2, 5)] + g = GraphState(nodes=np.arange(nqubit), edges=edges) + g.h(3) + gstate = graph_state_to_statevec(g) + + g.equivalent_graph_e2(3, 4) + gstate2 = graph_state_to_statevec(g) + assert gstate.isclose(gstate2) + + g.equivalent_graph_e2(4, 0) + gstate3 = graph_state_to_statevec(g) + assert gstate.isclose(gstate3) + + g.equivalent_graph_e2(4, 5) + gstate4 = graph_state_to_statevec(g) + assert gstate.isclose(gstate4) + + g.equivalent_graph_e2(0, 3) + gstate5 = graph_state_to_statevec(g) + assert gstate.isclose(gstate5) + + g.equivalent_graph_e2(0, 3) + gstate6 = graph_state_to_statevec(g) + assert gstate.isclose(gstate6) + + def test_e1(self) -> None: + nqubit = 6 + edges = [(0, 1), (1, 2), (3, 4), (4, 5), (0, 3), (1, 4), (2, 5)] + g = GraphState(nodes=np.arange(nqubit), edges=edges) + g.nodes[3]["loop"] = True + gstate = graph_state_to_statevec(g) + g.equivalent_graph_e1(3) + + gstate2 = graph_state_to_statevec(g) + assert gstate.isclose(gstate2) + g.z(4) + gstate = graph_state_to_statevec(g) + g.equivalent_graph_e1(4) + gstate2 = graph_state_to_statevec(g) + assert gstate.isclose(gstate2) + g.equivalent_graph_e1(4) + gstate3 = graph_state_to_statevec(g) + assert gstate.isclose(gstate3) + + def test_local_complement(self) -> None: + nqubit = 6 + edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)] + exp_edges = [(0, 1), (1, 2), (0, 2), (2, 3), (3, 4), (4, 0)] + g = GraphState(nodes=np.arange(nqubit), edges=edges) + g.local_complement(1) + exp_g = GraphState(nodes=np.arange(nqubit), edges=exp_edges) + assert nx.utils.graphs_equal(g, exp_g) From 5b9c1302cd666c727ff0ad6ac8fbf373ec0da777 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 07:54:38 +0200 Subject: [PATCH 34/40] Remove `test_standardize_domains_and_clifford` --- tests/test_pattern.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/test_pattern.py b/tests/test_pattern.py index a471d863a..7dc073a6f 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -427,23 +427,6 @@ def test_standardize_two_cliffords(self, fx_bg: PCG64, jumps: int) -> None: state_p = pattern.simulate_pattern() assert state_p.isclose(state_ref) - # @pytest.mark.parametrize("jumps", range(1, 48)) - # def test_standardize_domains_and_clifford(self, fx_bg: PCG64, jumps: int) -> None: - # rng = Generator(fx_bg.jumped(jumps)) - # x, z = rng.integers(2, size=2) - # c = rng.integers(len(Clifford)) - # pattern = Pattern(input_nodes=[0]) - # pattern.results[1] = x - # pattern.add(X(node=0, domain={1})) - # pattern.results[2] = z - # pattern.add(Z(node=0, domain={2})) - # pattern.add(C(node=0, clifford=Clifford(c))) - # pattern_ref = pattern.copy() - # pattern.standardize() - # state_ref = pattern_ref.simulate_pattern() - # state_p = pattern.simulate_pattern() - # assert state_p.isclose(state_ref) - # Simple pattern composition def test_compose_1(self) -> None: i1_lst = [0] From 408a3796373a70993208801e6cb33547e2e6edeb Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 10:23:52 +0200 Subject: [PATCH 35/40] Add comment for `pauli_measurements` --- graphix/remove_pauli_measurements.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 5474525e9..27db1022b 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -285,6 +285,9 @@ def _apply_clifford(self, node: Node, clifford: Clifford) -> None: if spec.pauli_measurement is not None: axis = spec.pauli_measurement.axis spec.pauli_measurement = spec.pauli_measurement.clifford(clifford) + # Update pauli_measurements: sets in `pauli_measurements` + # dict only cover non-input nodes, so this update is + # skipped when the node is an input. if node in self.input_node_set: return new_axis = spec.pauli_measurement.axis From ef57707eaade862bfcdf529057771d405dad8848 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 10:24:14 +0200 Subject: [PATCH 36/40] Mention removal of `Pattern.results` and `incorporate_pauli_results` --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722623e57..587b29737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - #168, #498: `Pattern.remove_pauli_measurements` replaces `Pattern.perform_pauli_measurements`. The new algorithm removes all non-input Pauli nodes from patterns that have a flow and returns a pattern that is equivalent for every input state. + - Field `Pattern.results` and function `incorporate_pauli_results` removed. - #490: Exposed more common classes and methods to top level `__init__.py`. - Renamed `Instruction`, `InstructionWithoutRZZ` and `Command` to `InstructionType`, `InstructionTypeWithoutRZZ` and `CommandType` respectively. From bc5fd62b04bcfb8d077504852679b21e60779ba7 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 10:24:58 +0200 Subject: [PATCH 37/40] Update docstring --- graphix/qasm3_exporter.py | 5 ++--- tests/test_qasm3_exporter.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 8ef80efbf..7e4a9336d 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -120,9 +120,8 @@ def pattern_to_qasm3(pattern: Pattern, input_state: dict[int, State] | State = B qubits if the pattern has been Pauli-presimulated, and it may include Boolean expressions using xor (`^`) if some domains contain multiple qubits. These features are not supported by - `qiskit-qasm3-import`. The functions - :func:`graphix.optimization.incorporate_pauli_results` and - :func:`graphix.optimization.single_qubit_domains` transform any + `qiskit-qasm3-import`. The function + :func:`graphix.optimization.single_qubit_domains` transforms any pattern into an equivalent one such that exporting to OpenQASM 3.0 produces a circuit that can be imported into Qiskit. diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index ea643b7b6..e0b4d590d 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -46,9 +46,9 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: See :func:`test_qasm3_exporter_to_qiskit:test_to_qasm3_random_circuit`, - where the result is validated. The current test does not go through the - normalization passes ``incorporate_pauli_results`` and ``single_qubit_domains``, - so it exercises execution paths that are not tested elsewhere. + where the result is validated. The current test does not go + through the normalization pass ``single_qubit_domains``, so it + exercises execution paths that are not tested elsewhere. """ rng = Generator(fx_bg.jumped(jumps)) nqubits = 5 From 3a92ea6a271c57d0421385d5fe39c77224b61a0c Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 10:33:22 +0200 Subject: [PATCH 38/40] Update uv.lock (pin qiskit `==2.3.1`) --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index c80acabd5..46b5e2481 100644 --- a/uv.lock +++ b/uv.lock @@ -993,7 +993,7 @@ requires-dist = [ { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "pytest-mpl", marker = "extra == 'dev'" }, { name = "pyzx", marker = "extra == 'extra'", specifier = ">=0.10.0" }, - { name = "qiskit", marker = "extra == 'dev'", specifier = "==2.3.1" }, + { name = "qiskit", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "qiskit-aer", marker = "extra == 'dev'" }, { name = "qiskit-qasm3-import", marker = "extra == 'dev'" }, { name = "quimb" }, From 05b9a99b9151e3e6cf2da2c276818d13ea6dbdb3 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 12:53:16 +0200 Subject: [PATCH 39/40] Fix `try_pivot_x_with_output_node` --- graphix/remove_pauli_measurements.py | 2 +- tests/test_remove_pauli_measurements.py | 34 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 27db1022b..7b6dd44a1 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -468,7 +468,7 @@ def try_pivot_x_with_output_node(self) -> bool: v = next(iter(non_input_output_nodes), None) if v is None: continue - self.pivot_vertices(node, v) + self.pivot_vertices(new_node, v) return True return False diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index 869227577..6c1455537 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -287,3 +287,37 @@ def test_pattern_remove_pauli_measurements_output_nodes() -> None: pattern = og.to_pattern() pattern.remove_pauli_measurements() pattern.simulate_pattern() + + +def test_try_pivot_x_with_output_node_after_pivot() -> None: + # This test checks that `try_pivot_x_with_output_node` applies + # `pivot_vertices` using `new_node` rather than the original + # `node`. + # + # In practice this situation is unlikely to arise: for `node != new_node` + # to occur, a pivot must have already been applied to `node`. Yet, + # after such a pivot we would need `new_node` to be measured in X, which + # implies that `node` was originally measured in Z. The removal strategy + # would then delete `node` before the pivot could take place. + # + # Consequently, this test guarantees that `try_pivot_x_with_output_node` + # works correctly regardless of the removal strategy and maintains the + # intended invariant, even though the earlier bug (pivoting with the + # original node) was not observable through the public API. + pattern = Pattern( + cmds=[ + Command.N(0), + Command.N(1), + Command.N(2), + Command.E((0, 1)), + Command.E((0, 2)), + Command.M(0), + Command.M(1, Measurement.Z), + ] + ) + standardized_pattern = StandardizedPattern.from_pattern(pattern) + cut = PauliPushingCut.from_standardized_pattern(standardized_pattern) + process = _RemovePauliMeasurements(cut) + process.remove_x_with_internal_neighbor(0, 1, Sign.PLUS) + # Fail if pivot is applied to the original node + process.try_pivot_x_with_output_node() From 70d0ad7e44f0361433d94b365e52145027e6fdc7 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 13 May 2026 13:08:30 +0200 Subject: [PATCH 40/40] Nit-picking --- graphix/remove_pauli_measurements.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/graphix/remove_pauli_measurements.py b/graphix/remove_pauli_measurements.py index 7b6dd44a1..a0e292109 100644 --- a/graphix/remove_pauli_measurements.py +++ b/graphix/remove_pauli_measurements.py @@ -38,7 +38,7 @@ from graphix.optimization import StandardizedPattern if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping from collections.abc import Set as AbstractSet from typing import TypeAlias @@ -301,7 +301,7 @@ def local_complement(self, u: Node) -> None: Implements Lemma 2.31 and 4.3 [BMBdF+21]. """ - n_u = set(self.graph.neighbors(u)) + n_u = list(self.graph.neighbors(u)) _complement_subgraph(self.graph, n_u) # |+⟩⟨+| + exp(-iπ/2) |-⟩⟨-| = H S† H self._apply_clifford(u, Clifford.H @ Clifford.SDG @ Clifford.H) @@ -322,9 +322,9 @@ def pivot_vertices(self, u: Node, v: Node) -> None: n_u = set(self.graph.neighbors(u)) n_v = set(self.graph.neighbors(v)) - only_u = n_u - n_v - {v} - only_v = n_v - n_u - {u} inter = n_u & n_v + only_u = n_u - inter - {v} + only_v = n_v - inter - {u} _complement_edges(self.graph, only_u, only_v) _complement_edges(self.graph, only_u, inter) @@ -518,7 +518,7 @@ def to_standardized_pattern(self) -> StandardizedPattern: ) -def _complement_subgraph(graph: nx.Graph[Node], s: set[Node]) -> None: +def _complement_subgraph(graph: nx.Graph[Node], s: Iterable[Node]) -> None: """Complement edges in a given subgraph.""" all_pairs = set(itertools.combinations(s, 2)) existing = all_pairs & graph.edges()