From 83cdae7154c9a7fd10b3a91f093a8d7aa9ead7c1 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 15 Jan 2026 09:18:14 +0100 Subject: [PATCH 01/26] wip --- graphix/circ_ext/__init__.py | 1 + graphix/circ_ext/compilation.py | 201 +++++++++++++++ graphix/circ_ext/extraction.py | 434 ++++++++++++++++++++++++++++++++ graphix/flow/core.py | 36 +++ 4 files changed, 672 insertions(+) create mode 100644 graphix/circ_ext/__init__.py create mode 100644 graphix/circ_ext/compilation.py create mode 100644 graphix/circ_ext/extraction.py diff --git a/graphix/circ_ext/__init__.py b/graphix/circ_ext/__init__.py new file mode 100644 index 00000000..7543b3c2 --- /dev/null +++ b/graphix/circ_ext/__init__.py @@ -0,0 +1 @@ +"""Utilities for circuit extraction and compilation.""" diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py new file mode 100644 index 00000000..243ef93c --- /dev/null +++ b/graphix/circ_ext/compilation.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from copy import deepcopy +from dataclasses import dataclass +from itertools import chain, pairwise +from typing import TYPE_CHECKING + +from graphix.circ_ext.extraction import PauliExponentialDAG +from graphix.fundamentals import ANGLE_PI +from graphix.sim.base_backend import NodeIndex +from graphix.transpiler import Circuit + +if TYPE_CHECKING: + from collections.abc import Sequence + + from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG + from graphix.command import Node + + +@dataclass(frozen=True) +class CompilationPass: + pexp_cp: PauliExponentialDAGCompilationPass + cm_cp: CliffordMapCompilationPass + + def er_to_circuit(self, er: ExtractionResult) -> Circuit: + if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): + raise ValueError("The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes.") + circuit = self.cm_cp.add_to_circuit(er.clifford_map) + return self.pexp_cp.add_to_circuit(er.pexp_dag, circuit) + + +class PauliExponentialDAGCompilationPass(ABC): + """Abstract base class to implement a compilation procedure for a Pauli Exponential DAG.""" + + @staticmethod + @abstractmethod + def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + r"""Add a Pauli exponential rotation to a circuit. + + Parameters + ---------- + pexp_dag: PauliExponentialDAG + The Pauli exponential rotation to be added to the circuit. + circuit : Circuit or None, optional + The circuit to which the operation is added. If ``None``, a new + ``Circuit`` instance is created. Default is ``None``. + copy : bool, optional + If ``True``, the operation is applied to a deep copy of ``circuit`` and + the modified copy is returned. Otherwise, the input circuit is modified + in place. Default is ``False``. + + Returns + ------- + Circuit + The circuit with the operation applied. + + Raises + ------ + ValueError + If the input circuit is not compatible with ``pexp_dag.output_nodes``. + """ + + +class CliffordMapCompilationPass(ABC): + """Abstract base class to implement a compilation procedure for a Clifford Map.""" + + @abstractmethod + def add_to_circuit(self, clifford_map: CliffordMap, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + """Add the Clifford map to a quantum circuit. + + Parameters + ---------- + clifford_map: CliffordMap + The Clifford map to be added to the circuit. + circuit : Circuit + The quantum circuit to which the Clifford map is added. + copy : bool, optional + If ``True``, operate on a deep copy of ``circuit`` and return it. + Otherwise, the input circuit is modified in place. Default is + ``False``. + + Returns + ------- + Circuit + The circuit with the operation applied. + + Raises + ------ + ValueError + If the input circuit is not compatible with ``clifford_map.output_nodes``. + NotImplementedError + If the Clifford map represents an isometry, i.e., ``len(clifford_map.input_nodes) != len(clifford_map.output_nodes)``. + """ + + +class LadderPass(PauliExponentialDAGCompilationPass): + + @staticmethod + def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + circuit = initialize_circuit(pexp_dag.output_nodes, circuit, copy) + outputs_mapping = NodeIndex() + outputs_mapping.extend(pexp_dag.output_nodes) + + for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): + pexp = pexp_dag.pauli_exponentials[node] + LadderPass.add_pexp(pexp, outputs_mapping, circuit) + + return circuit + + @staticmethod + def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circuit) -> None: + r"""Add the Pauli exponential unitary to a quantum circuit. + + For a Pauli string acting on multiple qubits, the unitary is decomposed into a sequence of basis changes, CNOT gates, and a single :math:`R_Z` rotation: + + .. math:: + + R_Z(\phi) = \exp \left(-i \frac{\phi}{2} Z \right), + + with effective angle :math:`\phi = -2\alpha`, where :math:`\alpha` is the angle encoded in `self.angle`. Basis changes map :math:`X` and :math:`Y` operators to the :math:`Z` basis before entangling the qubits in a CNOT ladder. + + Parameters + ---------- + circuit : CircuitMBQC + The quantum circuit to which the Pauli exponential is added. `circuit` is modified in place. + + Notes + ----- + It is assumed that the ``x``, ``y``, and ``z`` node sets of the Pauli string in the exponential are well-formed, i.e., contain only output nodes and are pairwise disjoint. + + See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 + for additional information. + """ + if pexp.angle == 0: # No rotation + return + + nodes = sorted( + pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes, + key=outputs_mapping.index, + ) + sign = -1 if pexp.pauli_string.negative_sign else 1 + angle = -2 * pexp.angle * sign + + if len(nodes) == 0: # Identity + return + + if len(nodes) == 1: + n0 = nodes[0] + q0 = outputs_mapping.index(n0) + if n0 in pexp.pauli_string.x_nodes: + circuit.rx(q0, angle) + elif n0 in pexp.pauli_string.y_nodes: + circuit.ry(q0, angle) + else: + circuit.rz(q0, angle) + return + + LadderPass.add_basis_change(pexp, outputs_mapping, nodes[0], circuit) + + for n1, n2 in pairwise(nodes): + LadderPass.add_basis_change(pexp, outputs_mapping, n2, circuit) + q1, q2 = outputs_mapping.index(n1), outputs_mapping.index(n2) + circuit.cnot(control=q1, target=q2) + + circuit.rz(q2, angle) + + for n2, n1 in pairwise(nodes[::-1]): + q1, q2 = outputs_mapping.index(n1), outputs_mapping.index(n2) + circuit.cnot(control=q1, target=q2) + LadderPass.add_basis_change(pexp, outputs_mapping, n2, circuit) + + LadderPass.add_basis_change(pexp, outputs_mapping, nodes[0], circuit) + + @staticmethod + def add_basis_change(pexp: PauliExponential, outputs_mapping: NodeIndex, node: Node, circuit: Circuit) -> None: + """Apply an X or a Y basis change to a given node.""" + qubit = outputs_mapping.index(node) + if node in pexp.pauli_string.x_nodes: + circuit.h(qubit) + elif node in pexp.pauli_string.y_nodes: + LadderPass.add_hy(qubit, circuit) + + @staticmethod + def add_hy(qubit: int, circuit: Circuit) -> None: + """Add a pi rotation around the z + y axis.""" + circuit.rz(qubit, ANGLE_PI / 2) + circuit.ry(qubit, ANGLE_PI / 2) + circuit.rz(qubit, ANGLE_PI / 2) + + +def initialize_circuit(output_nodes: Sequence[int], circuit: Circuit | None = None, copy: bool = False) -> Circuit: + n_qubits = len(output_nodes) + if circuit is None: + circuit = Circuit(n_qubits) + else: + if circuit.width != n_qubits: + raise ValueError(f"Circuit width ({circuit.width}) differs from number of outputs ({n_qubits}).") + if copy: + circuit = deepcopy(circuit) + return circuit diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py new file mode 100644 index 00000000..997983fa --- /dev/null +++ b/graphix/circ_ext/extraction.py @@ -0,0 +1,434 @@ +"""Module with tools for circuit extraction.""" + +from __future__ import annotations + +import dataclasses +from copy import copy +from dataclasses import dataclass +from itertools import combinations +from typing import TYPE_CHECKING + +from graphix.fundamentals import Angle, Plane, Sign +from graphix.measurements import Measurement, PauliMeasurement +from graphix.opengraph import OpenGraph +from graphix.pretty_print import SUBSCRIPTS + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from collections.abc import Set as AbstractSet + + from graphix.circ_ext.compilation import CompilationPass + from graphix.command import Node + from graphix.flow.core import PauliFlow + from graphix.parameter import Expression + from graphix.transpiler import Circuit + + +@dataclass(frozen=True) +class ExtractionResult: + """Dataclass to represent the output of the circuit-extraction algorithm introduced in Ref. [1]. + + Attributes + ---------- + pexp_dag: PauliExponentialDAG + Pauli exponential directed acyclical graph (DAG) representing a sequence multi-qubit rotations. + + clifford_map: CliffordMap + Clifford transformation. + + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + + pexp_dag: PauliExponentialDAG + clifford_map: CliffordMap + + # TODO: Update docstring + def to_circuit(self, cp: CompilationPass) -> Circuit: + """Transpile the extraction result to circuit. + + Transpilation is only supported when the pair Pauli-exponential DAG and Clifford map represents a unitary transformation. + + Returns + ------- + Circuit + Quantum circuit represented as a set of instructions. + """ + return cp.er_to_circuit(self) + + +@dataclass(frozen=True) +class PauliString: + """Dataclass representing a Pauli string over a set of MBQC nodes. + + Attributes + ---------- + x_nodes : AbstractSet[int] + Nodes on which a Pauli X operator is applied. + y_nodes : AbstractSet[int] + Nodes on which a Pauli Y operator is applied. + z_nodes : AbstractSet[int] + Nodes on which a Pauli Z operator is applied. + negative_sign : bool + Boolean flag indicating a -1 phase in the Pauli string if ``True``. + """ + + x_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) + y_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) + z_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) + negative_sign: bool = dataclasses.field(default_factory=lambda: False) + + @staticmethod + def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: + """Extract the Pauli string of a measured node and its focused correction set. + + Parameters + ---------- + flow : PauliFlow[AbstractMeasurement] + A focused Pauli flow. The resulting Pauli string is extracted from its correction function. + node : int + A measured node whose associated Pauli string is computed. + + Returns + ------- + PauliString + Primary extraction string associated to the input measured nodes. The sets in the returned `PauliString` instance are disjoint. + + Notes + ----- + See Eq. (13) and Lemma 4.4 in Ref. [1]. The phase of the Pauli string is given by Eq. (37). + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + og = flow.og + c_set = set(flow.correction_function[node]) + odd_c_set = og.odd_neighbors(c_set) + inter_c_odd_set = c_set & odd_c_set + + x_corrections = frozenset((c_set - odd_c_set).intersection(og.output_nodes)) + y_corrections = frozenset(inter_c_odd_set.intersection(og.output_nodes)) + z_corrections = frozenset((odd_c_set - c_set).intersection(og.output_nodes)) + + # Sign computation. + negative_sign = False + + # One phase flip per edge between adjacent vertices in the correction set. + for edge in combinations(c_set, 2): + negative_sign ^= edge in og.graph.edges() + + # One phase flip per two Ys in the graph state stabilizer. + negative_sign ^= bool(len(inter_c_odd_set) // 2 % 2) + + # One phase flip per node in the graph state stabilizer that is absorbed from a Pauli measurement with angle π. + for n, meas in og.measurements.items(): + if n in (c_set | odd_c_set) and (pm := PauliMeasurement.try_from(meas.plane, meas.angle)): + negative_sign ^= pm.sign == Sign.MINUS + + # One phase flip if measured on the YZ plane. + negative_sign ^= flow.get_measurement_label(node) == Plane.YZ + + return PauliString(x_corrections, y_corrections, z_corrections, negative_sign) + + def __str__(self) -> str: + """Return a string representation of the Pauli string.""" + pauli_str: list[str] = ["-" if self.negative_sign else "+"] + for p, nodes in zip(["X", "Y", "Z"], [self.x_nodes, self.y_nodes, self.z_nodes], strict=True): + pauli_str.extend(f"{p}{str(node).translate(SUBSCRIPTS)}" for node in nodes) + + return "".join(pauli_str) + + +@dataclass(frozen=True) +class PauliExponential: + r"""Dataclass representing a Pauli exponential over a set of MBQC nodes. + + A Pauli exponential corresponds to the unitary operator + + .. math:: + + U(\alpha) = \exp \left(i \frac{alpha}{2} P\right), + + where :math:`\alpha` is a real-valued angle and :math:`P` is a Pauli string. + + Attributes + ---------- + angle : Angle | Expression + The Pauli exponential angle :math:`\alpha` in units of :math:`\pi`. When extracted from a corrected node, it corresponds to the node's measurement divided by two. + pauli_string : PauliString + The signed Pauli string :math:`P` specifying the tensor product of Pauli operators acting on the corresponding MBQC nodes. + """ + + angle: Angle | Expression + pauli_string: PauliString + + @staticmethod + def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponential: + """Extract the Pauli exponential of a measured node and its focused correction set. + + Parameters + ---------- + flow : PauliFlow[AbstractMeasurement] + A focused Pauli flow. The resulting Pauli string is extracted from its correction function. + node : int + A measured node whose associated Pauli string is computed. + + Returns + ------- + PauliExponential + Primary extraction string associated to the input measured nodes. The sets in the returned `PauliString` instance are disjoint. + + Notes + ----- + See Eq. (13) and Lemma 4.4 in Ref. [1]. The phase of the Pauli string is given by Eq. (37). + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + pauli_string = flow.pauli_strings[node] + meas = flow.og.measurements[node] + # We don't extract any rotation from Pauli Measurements. This is equivalent to setting the angle to 0. + angle = 0 if PauliMeasurement.try_from(meas.plane, meas.angle) else meas.angle / 2 + + return PauliExponential(angle, pauli_string) + + +@dataclass(frozen=True) +class PauliExponentialDAG: + """Dataclass to represent a multi-qubit rotation formed by a sequence of Pauli exponentials extracted from a pattern. + + Attributes + ---------- + pauli_exponentials: Mapping[int, PauliExponential] + Mapping between measured nodes (``keys``) and Pauli exponentials (``values``). + partial_order_layers: Sequence[AbstractSet[int]] + Partial order between the Pauli exponentials in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. The pattern's output nodes are always in layer 0. + output_nodes: Sequence[int] + Output nodes on which the Pauli exponential rotation acts. + + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + + pauli_exponentials: Mapping[int, PauliExponential] + partial_order_layers: Sequence[AbstractSet[int]] + output_nodes: Sequence[int] + + @staticmethod + def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: + """Extract a Pauli exponential rotation from a focused Pauli flow. + + This routine associates a Pauli exponential to each measured node in ``flow``. The flow's partial order defines a partial order between the Pauli exponentials such that Pauli exponentials in the same layer commute. + + Parameters + ---------- + flow : PauliFlow[AbstractMeasurement] + A focused Pauli flow. + + Returns + ------- + PauliExponentialRotation + + Notes + ----- + See Definition 3.3 and Example C.13 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + pauli_strings = {node: PauliExponential.from_measured_node(flow, node) for node in flow.correction_function} + + return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) + + +@dataclass(frozen=True) +class CliffordMap: + """Dataclass to represent a Clifford map. + + A Clifford map describes a linear transformation between the space of input qubits and the space of output qubits. It is encoded as a map from the Pauli-group generators (X and Z) over the input nodes to Pauli strings over the output nodes. + + Attributes + ---------- + x_map: Mapping[int, PauliString] + Map for the X generators. ``keys`` correspond to input nodes and ``values`` to their corresponding Pauli string over the outputs nodes. + z_map: Mapping[int, PauliString] + Map for the Z generators. ``keys`` correspond to input nodes and ``values`` to their corresponding Pauli string over the outputs nodes. + input_nodes: Sequence[int] + Sequence of inputs nodes. + output_nodes: Sequence[int] + Sequence of outputs nodes. + + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + + x_map: Mapping[int, PauliString] + z_map: Mapping[int, PauliString] + input_nodes: Sequence[int] + output_nodes: Sequence[int] + + @staticmethod + def from_focused_flow(flow: PauliFlow[Measurement]) -> CliffordMap: + """Extract a Clifford map from a focused Pauli flow. + + This routine associates a two Pauli strings (one per generator of the Pauli group, X and Z) to each input node in ``flow.og``. + + Parameters + ---------- + flow : PauliFlow[AbstractMeasurement] + A focused Pauli flow. + + Returns + ------- + CliffordMap + + Notes + ----- + See Definition 3.3 and Example C.13 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + x_map = CliffordMap.x_map_from_focused_flow(flow) + z_map = CliffordMap.z_map_from_focused_flow(flow) + return CliffordMap(x_map, z_map, flow.og.input_nodes, flow.og.output_nodes) + + @staticmethod + def z_map_from_focused_flow(flow: PauliFlow[Measurement]) -> dict[int, PauliString]: + """Extract a map between Z over the input nodes and Pauli strings over the output nodes from a focused Pauli flow. + + If the input node is a measured node, the resulting Pauli string is given by the correction set. If the input node is also an output node, the resulting Pauli string is Z (representing the identity map). + + Parameters + ---------- + flow : PauliFlow[AbstractMeasurement] + A focused Pauli flow. + + Returns + ------- + dict[int, PauliString] + Map between input nodes (``keys``) and Pauli strings over the output nodes (``values``). + + Notes + ----- + See Definition 3.3 and Example C.13 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + z_map: dict[int, PauliString] = {} + iset = set(flow.og.input_nodes) + + # This is done when extracting a PauliExponentialRotation too. + for node in iset.intersection(flow.og.measurements.keys()): + z_map[node] = flow.pauli_strings[node] + + for node in iset.intersection(flow.og.output_nodes): + z_map[node] = PauliString(z_nodes=frozenset({node})) + + return z_map + + @staticmethod + def x_map_from_focused_flow(flow: PauliFlow[Measurement]) -> Mapping[int, PauliString]: + """Extract a map between X over the input nodes and Pauli strings over the output nodes from a focused Pauli flow. + + The resulting Pauli string is given by the correction set of a focused flow of the extended open graph. + + Parameters + ---------- + flow : PauliFlow[AbstractMeasurement] + A focused Pauli flow. + + Returns + ------- + dict[int, PauliString] + Map between input nodes (``keys``) and Pauli strings over the output nodes (``values``). + + Notes + ----- + See Definition 3.3 and Example C.13 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + og = flow.og + og_extended, ancillary_inputs_map = extend_input(og) + flow_extended = og_extended.extract_pauli_flow() + + # It's better to call the `PauliString` constructor instead of the cached property `flow_extended.pauli_strings` since the latter will compute a `PauliString` for _every_ node in the correction function and we just need it for the input nodes. + x_map_ancillas = {node: PauliString.from_measured_node(flow_extended, node) for node in og_extended.input_nodes} + + return {input_node: x_map_ancillas[ancillary_inputs_map[input_node]] for input_node in og.input_nodes} + + def __str__(self) -> str: + """Return a string representation of the Clifford map.""" + cm_str: list[str] = [] + + nodes = self.x_map.keys() + for node in nodes: + for st, mappings in zip(["Z", "X"], [self.z_map, self.x_map], strict=True): + pauli_str = str(mappings[node]) + cm_str.append(f"{st}{str(node).translate(SUBSCRIPTS)} → {pauli_str}\n") + + return "".join(cm_str) + + +def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], dict[int, int]]: + r"""Extend the inputs of a given open graph. + + For every input node :math:`v`, a new node :math:`u` and edge :math:`(u, v)` are added to the open graph. Node :math:`u` is measured in plane :math:`XY` with angle :math:`\alpha = 0` and replaces :math:`v` in the open graph's sequence of input nodes. + + Parameters + ---------- + og: OpenGraph[Measurement] + Open graph whose input nodes are extended. + + Returns + ------- + OpenGraph[Measurement] + Open graph with the extended inputs. + dict[int, int] + Mapping between previous (``key``) and new (``value``) input nodes. + + Notes + ----- + This operation preserves the Pauli flow. + """ + ancillary_inputs_map: dict[int, int] = {} + fresh_node = max(og.graph.nodes) + 1 + graph = og.graph.copy() + input_nodes = list(og.input_nodes) + new_input_nodes: list[int] = [] + while input_nodes: + input_node = input_nodes.pop() + graph.add_edge(input_node, fresh_node) + ancillary_inputs_map[input_node] = fresh_node + new_input_nodes.append(fresh_node) + fresh_node += 1 + + output_nodes = copy(og.output_nodes) + measurements = {**og.measurements, **dict.fromkeys(new_input_nodes, Measurement(0, Plane.XY))} + + # We reverse the inputs order to match the order of initial inputs. + return OpenGraph(graph, new_input_nodes[::-1], output_nodes, measurements), ancillary_inputs_map diff --git a/graphix/flow/core.py b/graphix/flow/core.py index bdf9ee7c..6edeb52d 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -7,6 +7,7 @@ from collections.abc import Sequence from copy import copy from dataclasses import dataclass +from functools import cached_property from typing import TYPE_CHECKING, Generic, TypeVar import networkx as nx @@ -16,6 +17,7 @@ # `override` introduced in Python 3.12, `assert_never` introduced in Python 3.11 import graphix.pattern +from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponentialDAG, PauliString from graphix.command import E, M, N, X, Z from graphix.flow._find_gpflow import ( CorrectionMatrix, @@ -665,6 +667,40 @@ def xreplace( # noqa: PYI019 new_og = self.og.xreplace(assignment) return dataclasses.replace(self, og=new_og) + @cached_property + def pauli_strings(self: PauliFlow[Measurement]) -> dict[int, PauliString]: + # check if `self` is focused + return {node: PauliString.from_measured_node(self, node) for node in self.correction_function} + + # TODO: Up docstring + # TODO: add assume is focused. + def extract_circuit(self: PauliFlow[Measurement]) -> ExtractionResult: + """Extract a circuit from an MBQC pattern. + + Parameters + ---------- + pattern : Pattern + An MBQC pattern with Pauli flow. + + Returns + ------- + ExtractionResult + Wrapper over a Pauli-exponential DAG and a Clifford map encoding the linear transformation implemented by the input pattern. + + Notes + ----- + This method implements the algorithm in [1]. The extraction of the focused Pauli flow of the underlying open graph of the input pattern is done with the algorithm in [2]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + [2] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + pexp_dag = PauliExponentialDAG.from_focused_flow(self) + clifford_map = CliffordMap.from_focused_flow(self) + + return ExtractionResult(pexp_dag=pexp_dag, clifford_map=clifford_map) + @dataclass(frozen=True) class GFlow(PauliFlow[_PM_co], Generic[_PM_co]): From abc36df94d054253f3fbf0b00bb07bc4e2c486b6 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 15 Jan 2026 12:44:54 +0100 Subject: [PATCH 02/26] Add tests and docs --- graphix/circ_ext/compilation.py | 150 ++++++++++++++++++----- graphix/circ_ext/extraction.py | 10 +- graphix/flow/core.py | 68 +++++++++-- tests/test_circ_extraction.py | 205 ++++++++++++++++++++++++++++++++ tests/test_flow_core.py | 19 +++ tests/test_opengraph.py | 14 +++ 6 files changed, 417 insertions(+), 49 deletions(-) create mode 100644 tests/test_circ_extraction.py diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 243ef93c..4c3a3f55 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -1,3 +1,5 @@ +"""Compilation passes to transform the result of the circuit extraction algorithm into a quantum circuit.""" + from __future__ import annotations from abc import ABC, abstractmethod @@ -20,12 +22,47 @@ @dataclass(frozen=True) class CompilationPass: + """Dataclass to bundle the two compilation passes necessary to obtain a quantum circuit from a `ExtractionResult`. + + Attributes + ---------- + pexp_cp: PauliExponentialDAGCompilationPass + Compilation pass to synthesize a Pauli exponential DAG. + cm_cp: CliffordMapCompilationPass + Compilation pass to synthesize a Clifford map. + """ + pexp_cp: PauliExponentialDAGCompilationPass cm_cp: CliffordMapCompilationPass def er_to_circuit(self, er: ExtractionResult) -> Circuit: + """Convert a circuit extraction result into a quantum circuit representation. + + This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) extraction result. It performs a validation check to ensure that the output nodes of both components are identical. + + Parameters + ---------- + er : ExtractionResult + The result of the extraction process, containing both the ``clifford_map`` and the ``pexp_dag``. + + Returns + ------- + Circuit + A quantum circuit that combines the Clifford map operations followed by the Pauli exponential operations. + + Raises + ------ + ValueError + If the output nodes of ``er.pexp_dag`` and ``er.clifford_map`` do not match, indicating an incompatible extraction result. + + Notes + ----- + The conversion relies on the internal compilation passes ``self.cm_cp`` (Clifford Map Circuit Processor) and ``self.pexp_cp`` (Pauli Exponential Circuit Processor) to handle the low-level circuit synthesis. + """ if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): - raise ValueError("The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes.") + raise ValueError( + "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." + ) circuit = self.cm_cp.add_to_circuit(er.clifford_map) return self.pexp_cp.add_to_circuit(er.pexp_dag, circuit) @@ -36,19 +73,16 @@ class PauliExponentialDAGCompilationPass(ABC): @staticmethod @abstractmethod def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None, copy: bool = False) -> Circuit: - r"""Add a Pauli exponential rotation to a circuit. + r"""Add a Pauli exponential DAG to a circuit. Parameters ---------- pexp_dag: PauliExponentialDAG The Pauli exponential rotation to be added to the circuit. - circuit : Circuit or None, optional - The circuit to which the operation is added. If ``None``, a new - ``Circuit`` instance is created. Default is ``None``. + circuit : Circuit or ``None``, optional + The circuit to which the operation is added. If ``None``, a new ``Circuit`` instance is created with a width matching the number of output nodes in ``pexp_dag``. Default is ``None``. copy : bool, optional - If ``True``, the operation is applied to a deep copy of ``circuit`` and - the modified copy is returned. Otherwise, the input circuit is modified - in place. Default is ``False``. + If ``True``, the operation is applied to a deep copy of ``circuit`` and the modified copy is returned. Otherwise, the input circuit is modified in place. Default is ``False``. Returns ------- @@ -65,8 +99,9 @@ def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None class CliffordMapCompilationPass(ABC): """Abstract base class to implement a compilation procedure for a Clifford Map.""" + @staticmethod @abstractmethod - def add_to_circuit(self, clifford_map: CliffordMap, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + def add_to_circuit(clifford_map: CliffordMap, circuit: Circuit | None = None, copy: bool = False) -> Circuit: """Add the Clifford map to a quantum circuit. Parameters @@ -95,10 +130,28 @@ def add_to_circuit(self, clifford_map: CliffordMap, circuit: Circuit | None = No class LadderPass(PauliExponentialDAGCompilationPass): + r"""Compilation pass to synthetize a Pauli exponential DAG by using a ladder decomposition. + + Pauli exponentials in the DAG are compiled sequentially following an arbitrary total order compatible with the DAG. Each Pauli exponential is decomposed into a sequence of basis changes, CNOT gates, and a single :math:`R_Z` rotation: + + .. math:: + + R_Z(\phi) = \exp \left(-i \frac{\phi}{2} Z \right), + + with effective angle :math:`\phi = -2\alpha`, where :math:`\alpha` is the angle encoded in `self.angle`. Basis changes map :math:`X` and :math:`Y` operators to the :math:`Z` basis before entangling the qubits in a CNOT ladder. + + Notes + ----- + See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 for additional information. + """ @staticmethod def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None, copy: bool = False) -> Circuit: - circuit = initialize_circuit(pexp_dag.output_nodes, circuit, copy) + """Add a Pauli exponential DAG to a circuit. + + See documentation in :meth:`PauliExponentialDAGCompilationPass.add_to_circuit` for additional information. + """ + circuit = initialize_circuit(pexp_dag.output_nodes, circuit, copy) # May raise value error outputs_mapping = NodeIndex() outputs_mapping.extend(pexp_dag.output_nodes) @@ -112,25 +165,16 @@ def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circuit) -> None: r"""Add the Pauli exponential unitary to a quantum circuit. - For a Pauli string acting on multiple qubits, the unitary is decomposed into a sequence of basis changes, CNOT gates, and a single :math:`R_Z` rotation: - - .. math:: - - R_Z(\phi) = \exp \left(-i \frac{\phi}{2} Z \right), - - with effective angle :math:`\phi = -2\alpha`, where :math:`\alpha` is the angle encoded in `self.angle`. Basis changes map :math:`X` and :math:`Y` operators to the :math:`Z` basis before entangling the qubits in a CNOT ladder. + This method modifies the input circuit in-place. Parameters ---------- - circuit : CircuitMBQC - The quantum circuit to which the Pauli exponential is added. `circuit` is modified in place. + circuit : Circuit + The quantum circuit to which the Pauli exponential is added. Notes ----- It is assumed that the ``x``, ``y``, and ``z`` node sets of the Pauli string in the exponential are well-formed, i.e., contain only output nodes and are pairwise disjoint. - - See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 - for additional information. """ if pexp.angle == 0: # No rotation return @@ -174,7 +218,21 @@ def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circui @staticmethod def add_basis_change(pexp: PauliExponential, outputs_mapping: NodeIndex, node: Node, circuit: Circuit) -> None: - """Apply an X or a Y basis change to a given node.""" + """Apply an X or a Y basis change to a given node if required by the Pauli string. + + This method modifies the input circuit in-place. + + Parameters + ---------- + pexp : PauliExponential + The Pauli exponential under consideration. + outputs_mapping : NodeIndex + Mapping between node numbers of the original MBQC pattern or open graph and qubit indices of the circuit. + node : Node + The node on which the basis-change operation is performed. + circuit : Circuit + The quantum circuit to which the basis change is added. + """ qubit = outputs_mapping.index(node) if node in pexp.pauli_string.x_nodes: circuit.h(qubit) @@ -183,19 +241,45 @@ def add_basis_change(pexp: PauliExponential, outputs_mapping: NodeIndex, node: N @staticmethod def add_hy(qubit: int, circuit: Circuit) -> None: - """Add a pi rotation around the z + y axis.""" + """Add a pi rotation around the z + y axis. + + This method modifies the input circuit in-place. + """ circuit.rz(qubit, ANGLE_PI / 2) circuit.ry(qubit, ANGLE_PI / 2) circuit.rz(qubit, ANGLE_PI / 2) def initialize_circuit(output_nodes: Sequence[int], circuit: Circuit | None = None, copy: bool = False) -> Circuit: - n_qubits = len(output_nodes) - if circuit is None: - circuit = Circuit(n_qubits) - else: - if circuit.width != n_qubits: - raise ValueError(f"Circuit width ({circuit.width}) differs from number of outputs ({n_qubits}).") - if copy: - circuit = deepcopy(circuit) - return circuit + """Initialize or validate a quantum circuit based on the provided output nodes. + + If no circuit is provided, a new one is created with a width matching the number of output nodes. If a circuit is provided, its width is validated against the number of output nodes. + + Parameters + ---------- + output_nodes : Sequence[int] + A sequence of integers representing the output nodes of the original MBQC pattern or open graph. The length of this sequence determines the required circuit width. + circuit : Circuit, optional + An existing circuit to initialize. If ``None`` (default), a new `Circuit` object is instantiated. + copy : bool, optional + If ``True`` and an existing `circuit` is provided, a deep copy of the circuit is returned to avoid mutating the original object. Defaults to ``False``. + + Returns + ------- + Circuit + The initialized quantum circuit. + + Raises + ------ + ValueError + If the provided ``circuit`` width does not match the length of ``output_nodes``. + """ + n_qubits = len(output_nodes) + if circuit is None: + circuit = Circuit(n_qubits) + else: + if circuit.width != n_qubits: + raise ValueError(f"Circuit width ({circuit.width}) differs from number of outputs ({n_qubits}).") + if copy: + circuit = deepcopy(circuit) + return circuit diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 997983fa..3ff5eb30 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -1,16 +1,14 @@ -"""Module with tools for circuit extraction.""" +"""Tools for circuit extraction.""" from __future__ import annotations import dataclasses -from copy import copy -from dataclasses import dataclass +from dataclasses import dataclass, replace from itertools import combinations from typing import TYPE_CHECKING from graphix.fundamentals import Angle, Plane, Sign from graphix.measurements import Measurement, PauliMeasurement -from graphix.opengraph import OpenGraph from graphix.pretty_print import SUBSCRIPTS if TYPE_CHECKING: @@ -20,6 +18,7 @@ from graphix.circ_ext.compilation import CompilationPass from graphix.command import Node from graphix.flow.core import PauliFlow + from graphix.opengraph import OpenGraph from graphix.parameter import Expression from graphix.transpiler import Circuit @@ -427,8 +426,7 @@ def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], di new_input_nodes.append(fresh_node) fresh_node += 1 - output_nodes = copy(og.output_nodes) measurements = {**og.measurements, **dict.fromkeys(new_input_nodes, Measurement(0, Plane.XY))} # We reverse the inputs order to match the order of initial inputs. - return OpenGraph(graph, new_input_nodes[::-1], output_nodes, measurements), ancillary_inputs_map + return replace(og, graph=graph, input_nodes=new_input_nodes[::-1], measurements=measurements), ancillary_inputs_map diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 6edeb52d..8fb0fbb8 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -667,29 +667,77 @@ def xreplace( # noqa: PYI019 new_og = self.og.xreplace(assignment) return dataclasses.replace(self, og=new_og) + def is_focused(self) -> bool: + """Verify if the input Pauli flow is focused. + + Returns + ------- + bool + ``True`` if the input Pauli flow is focused, ``False`` otherwise. + + Notes + ----- + This function verifies Definition 4.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + oc_set = self.og.measurements.keys() + + for corrected_node, correction_set in self.correction_function.items(): + odd_correction_set = self.og.odd_neighbors(correction_set) + symdiff_set = odd_correction_set.symmetric_difference(correction_set) + for node in oc_set - {corrected_node}: + meas_label = self.get_measurement_label(node) + if node in correction_set and meas_label not in {Plane.XY, Axis.X, Axis.Y}: + return False + if node in odd_correction_set and meas_label not in {Plane.XZ, Plane.YZ, Axis.Y, Axis.Z}: + return False + if meas_label == Axis.Y and node in symdiff_set: + return False + return True + @cached_property def pauli_strings(self: PauliFlow[Measurement]) -> dict[int, PauliString]: - # check if `self` is focused + """Compute the Pauli strings associated with each node in the correction function. + + This property requires the flow to be focused. + + Returns + ------- + dict[int, PauliString] + A dictionary where the keys are node indices (from the correction function) and the values are the computed `PauliString` objects. + + Raises + ------ + ValueError + If the flow is not focused (i.e., ``self.is_focused()`` is False). + + Notes + ----- + This property is cached; the dictionary is computed only once upon the first access and stored for subsequent calls. + See notes in `PauliString.from_measured_node` for additional information. + """ + if not self.is_focused(): + raise ValueError("Flow is not focused.") return {node: PauliString.from_measured_node(self, node) for node in self.correction_function} - # TODO: Up docstring - # TODO: add assume is focused. def extract_circuit(self: PauliFlow[Measurement]) -> ExtractionResult: - """Extract a circuit from an MBQC pattern. + """Extract a circuit from a flow. - Parameters - ---------- - pattern : Pattern - An MBQC pattern with Pauli flow. + This routine assumes that the flow ``self`` is focused (see Notes). Returns ------- ExtractionResult - Wrapper over a Pauli-exponential DAG and a Clifford map encoding the linear transformation implemented by the input pattern. + Wrapper over a Pauli-exponential DAG and a Clifford map encoding the linear transformation implemented by the input flow. Notes ----- - This method implements the algorithm in [1]. The extraction of the focused Pauli flow of the underlying open graph of the input pattern is done with the algorithm in [2]. + - This method implements the algorithm in [1]. + + - Flows are guaranteed to be focused if obtained from :func:`OpenGraph.extract_pauli_flow` or :func:`OpenGraph.extract_gflow` (see [2]). References ---------- diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py new file mode 100644 index 00000000..9b2a57fa --- /dev/null +++ b/tests/test_circ_extraction.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple + +import networkx as nx +import numpy as np +import pytest + +from graphix.circ_ext.compilation import LadderPass +from graphix.circ_ext.extraction import PauliExponential, PauliExponentialDAG, PauliString, extend_input +from graphix.flow.core import PauliFlow +from graphix.fundamentals import ANGLE_PI, Plane +from graphix.instruction import CNOT, RX, RY, RZ, H +from graphix.measurements import Measurement +from graphix.opengraph import OpenGraph +from graphix.sim.base_backend import NodeIndex +from graphix.transpiler import Circuit + +if TYPE_CHECKING: + from numpy.random import Generator + + +class TestPauliString: + def test_add_circuit(self, fx_rng: Generator) -> None: + angle = 0.3 + angle_rz = -2 * angle * ANGLE_PI + x_nodes = {1} + z_nodes = {4, 2} + pauli_string = PauliString(x_nodes=x_nodes, z_nodes=z_nodes) + + pexp = PauliExponential(angle, pauli_string) + + qc = Circuit(4) + outputs_mapping = NodeIndex() + outputs_mapping.extend([2, 1, 3, 4]) + + LadderPass.add_pexp(pexp, outputs_mapping, qc) # `qc` is modified in place + + qc_ref = Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, angle_rz), CNOT(0, 3), CNOT(3, 1), H(1)]) + + state = qc.simulate_statevector(rng=fx_rng).statevec + state_ref = qc_ref.simulate_statevector(rng=fx_rng).statevec + + assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1) + + +class PauliExpTestCase(NamedTuple): + p_exp: PauliExponentialDAG + qc: Circuit + + +class TestPauliExponential: + # Angles of Pauli exponentials are in units of pi + alpha = 0.3 * ANGLE_PI + + @pytest.mark.parametrize( + "test_case", + [ + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(alpha / 2, PauliString(z_nodes={1}, negative_sign=True)), + }, + partial_order_layers=[{1}, {0}], + output_nodes=[1], + ), + Circuit(width=1, instr=[RZ(0, alpha)]), + ), + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(alpha / 2, PauliString(x_nodes={1}, negative_sign=True)), + }, + partial_order_layers=[{1}, {0}], + output_nodes=[1], + ), + Circuit(width=1, instr=[RX(0, alpha)]), + ), + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(alpha / 2, PauliString(y_nodes={1}, negative_sign=True)), + }, + partial_order_layers=[{1}, {0}], + output_nodes=[1], + ), + Circuit(width=1, instr=[RY(0, alpha)]), + ), + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, negative_sign=True)), + 1: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, negative_sign=True)), + 2: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, negative_sign=True)), + }, + partial_order_layers=[{3}, {2}, {1}, {0}], + output_nodes=[3], + ), + Circuit(width=1, instr=[H(0)]), + ), + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3})), + 1: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={5})), + 2: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, z_nodes={5}, negative_sign=True)), + }, + partial_order_layers=[{5, 3}, {2}, {0, 1}], + output_nodes=[5, 3], # Node 5 -> qubit 0 (control), node 3 -> qubit 1 (target) + ), + Circuit(width=2, instr=[CNOT(1, 0)]), + ), + ], + ) + def test_to_circuit(self, test_case: PauliExpTestCase) -> None: + qc = LadderPass.add_to_circuit(test_case.p_exp) + state = qc.simulate_statevector().statevec + state_ref = test_case.qc.simulate_statevector().statevec + assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1) + + def test_from_focused_flow(self) -> None: + """Test example C.13. in Simmons, 2021.""" + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 2), (2, 4), (1, 4), (1, 3), (3, 4), (3, 5), (4, 6)]), + input_nodes=[0], + output_nodes=[5, 6], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.2, Plane.YZ), # YZ + 2: Measurement(0.3, Plane.XY), # XY + 3: Measurement(0.4, Plane.XY), # XY + 4: Measurement(0.5, Plane.YZ), # Y + }, + ) + + flow = PauliFlow( + og, + correction_function={ + 0: frozenset({2, 6}), + 1: frozenset({1, 3, 4, 6}), + 2: frozenset({3, 4, 5}), + 3: frozenset({5}), + 4: frozenset({6}), + }, + partial_order_layers=(frozenset({5, 6}), frozenset({3}), frozenset({1, 2}), frozenset({0, 4})), + ) + + flow.check_well_formed() + assert flow.is_focused() + + pexp_dag = PauliExponentialDAG.from_focused_flow(flow) + + pexp_dag_ref = PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(ANGLE_PI * 0.1 / 2, PauliString(x_nodes=frozenset({6}))), + 1: PauliExponential(ANGLE_PI * 0.2 / 2, PauliString(y_nodes=frozenset({6}), z_nodes=frozenset({5}))), + 2: PauliExponential( + ANGLE_PI * 0.3 / 2, PauliString(y_nodes=frozenset({5}), z_nodes=frozenset({6}), negative_sign=True) + ), + 3: PauliExponential(ANGLE_PI * 0.4 / 2, PauliString(x_nodes=frozenset({5}))), + 4: PauliExponential( + 0, PauliString(x_nodes=frozenset({6})) + ), # The angle is 0 (interpreted from the Pauli measurement). + }, + partial_order_layers=flow.partial_order_layers, + output_nodes=flow.og.output_nodes, + ) + + assert pexp_dag == pexp_dag_ref + + +def test_extend_input() -> None: + og = OpenGraph( + graph=nx.Graph([(1, 3), (2, 4), (3, 4), (3, 5), (4, 6)]), + input_nodes=[1, 2], + output_nodes=[5, 6], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XY), + 3: Measurement(0.3, Plane.XY), + 4: Measurement(0.4, Plane.XY), + }, + ) + + og_ref = OpenGraph( + graph=nx.Graph([(1, 3), (2, 4), (3, 4), (3, 5), (4, 6), (1, 8), (2, 7)]), + input_nodes=[8, 7], + output_nodes=[5, 6], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XY), + 3: Measurement(0.3, Plane.XY), + 4: Measurement(0.4, Plane.XY), + 7: Measurement(0, Plane.XY), + 8: Measurement(0, Plane.XY), + }, + ) + + og_ext, ancillary_inputs_map = extend_input(og) + + assert og_ext.isclose(og_ref) + assert ancillary_inputs_map == {1: 8, 2: 7} + + flow = og_ext.extract_pauli_flow() + assert flow.is_focused() diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 47894dd7..423e8933 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -469,6 +469,25 @@ def test_xreplace(self) -> None: assert flow_ref.correction_function == flow_test.correction_function assert flow_ref.partial_order_layers == flow_test.partial_order_layers + # Test focusing + def test_is_focused(self) -> None: + graph: nx.Graph[int] = nx.Graph([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (4, 6), (7, 6)]) + inputs = [0, 7] + outputs = [4, 5, 6] + measurements = dict.fromkeys([0, 1, 2, 3, 7], Plane.XY) + og = OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements) + + partial_order = ({4, 5, 6}, {3}, {2}, {1}, {0, 7}) + + cf = {0: {1}, 1: {2}, 2: {3}, 3: {4}, 7: {6}} + cf_focused = {0: {1, 3}, 1: {2, 4}, 2: {3}, 3: {4}, 7: {6}} + + flow = GFlow(og, cf, partial_order) + flow_focused = GFlow(og, cf_focused, partial_order) + + assert not flow.is_focused() + assert flow_focused.is_focused() + class TestXZCorrections: """Bundle for unit tests of :class:`XZCorrections`.""" diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 4a240905..c91409e7 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -866,6 +866,20 @@ def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> Non with pytest.raises(OpenGraphError, match=r"The open graph does not have a Pauli flow."): og.extract_pauli_flow() + @pytest.mark.parametrize("test_case", OPEN_GRAPH_FLOW_TEST_CASES) + def test_gflow_focused(self, test_case: OpenGraphFlowTestCase) -> None: + """Test that the algebraic flow-finding algorithm generated focused gflows.""" + if test_case.has_gflow: + gf = test_case.og.extract_gflow() + assert gf.is_focused() + + @pytest.mark.parametrize("test_case", OPEN_GRAPH_FLOW_TEST_CASES) + def test_pflow_focused(self, test_case: OpenGraphFlowTestCase) -> None: + """Test that the algebraic flow-finding algorithm generated focused Pauli flows.""" + if test_case.has_pflow: + pf = test_case.og.extract_pauli_flow() + assert pf.is_focused() + def test_double_entanglement(self) -> None: pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) pattern2 = pattern.extract_opengraph().to_pattern() From 5961716ff8aee0ae11914860e2b52be3df9c0865 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 13 Feb 2026 16:36:06 +0100 Subject: [PATCH 03/26] Change in compilation.py --- graphix/circ_ext/compilation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 4c3a3f55..14dcd890 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -140,6 +140,8 @@ class LadderPass(PauliExponentialDAGCompilationPass): with effective angle :math:`\phi = -2\alpha`, where :math:`\alpha` is the angle encoded in `self.angle`. Basis changes map :math:`X` and :math:`Y` operators to the :math:`Z` basis before entangling the qubits in a CNOT ladder. + Gate set: H, CNOT, RZ, RY + Notes ----- See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 for additional information. From 50977196587c1e45735c60c36450132d41e0ace9 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 16 Feb 2026 09:28:12 +0100 Subject: [PATCH 04/26] Update mentions to old get_ methods --- graphix/circ_ext/extraction.py | 2 +- graphix/flow/core.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 3ff5eb30..07b2e1c6 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -131,7 +131,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: negative_sign ^= pm.sign == Sign.MINUS # One phase flip if measured on the YZ plane. - negative_sign ^= flow.get_measurement_label(node) == Plane.YZ + negative_sign ^= flow.node_measurement_label(node) == Plane.YZ return PauliString(x_corrections, y_corrections, z_corrections, negative_sign) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index ae38b741..9609b42c 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -756,7 +756,7 @@ def is_focused(self) -> bool: odd_correction_set = self.og.odd_neighbors(correction_set) symdiff_set = odd_correction_set.symmetric_difference(correction_set) for node in oc_set - {corrected_node}: - meas_label = self.get_measurement_label(node) + meas_label = self.node_measurement_label(node) if node in correction_set and meas_label not in {Plane.XY, Axis.X, Axis.Y}: return False if node in odd_correction_set and meas_label not in {Plane.XZ, Plane.YZ, Axis.Y, Axis.Z}: From 3362ca6babdfad764a1d1a347410cdcdc89c4c7c Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 16 Feb 2026 10:12:33 +0100 Subject: [PATCH 05/26] Up docs --- graphix/circ_ext/extraction.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 07b2e1c6..12a22e59 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -47,12 +47,16 @@ class ExtractionResult: pexp_dag: PauliExponentialDAG clifford_map: CliffordMap - # TODO: Update docstring def to_circuit(self, cp: CompilationPass) -> Circuit: """Transpile the extraction result to circuit. Transpilation is only supported when the pair Pauli-exponential DAG and Clifford map represents a unitary transformation. + Parameters + ---------- + cp : CompilationPass + Compilation pass to synthesize the Pauli exponential DAG and the Clifford map in the extraction result. + Returns ------- Circuit @@ -338,7 +342,6 @@ def z_map_from_focused_flow(flow: PauliFlow[Measurement]) -> dict[int, PauliStri z_map: dict[int, PauliString] = {} iset = set(flow.og.input_nodes) - # This is done when extracting a PauliExponentialRotation too. for node in iset.intersection(flow.og.measurements.keys()): z_map[node] = flow.pauli_strings[node] @@ -380,18 +383,6 @@ def x_map_from_focused_flow(flow: PauliFlow[Measurement]) -> Mapping[int, PauliS return {input_node: x_map_ancillas[ancillary_inputs_map[input_node]] for input_node in og.input_nodes} - def __str__(self) -> str: - """Return a string representation of the Clifford map.""" - cm_str: list[str] = [] - - nodes = self.x_map.keys() - for node in nodes: - for st, mappings in zip(["Z", "X"], [self.z_map, self.x_map], strict=True): - pauli_str = str(mappings[node]) - cm_str.append(f"{st}{str(node).translate(SUBSCRIPTS)} → {pauli_str}\n") - - return "".join(cm_str) - def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], dict[int, int]]: r"""Extend the inputs of a given open graph. From 4a30a6279b32a051c7012f1dd0660b024a1c43a5 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 17 Feb 2026 17:53:07 +0100 Subject: [PATCH 06/26] Make consistent with new measurement API --- graphix/circ_ext/extraction.py | 9 +++++---- tests/test_circ_extraction.py | 36 +++++++++++++++++----------------- tests/test_opengraph.py | 4 ++-- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 12a22e59..ede5a921 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from graphix.fundamentals import Angle, Plane, Sign -from graphix.measurements import Measurement, PauliMeasurement +from graphix.measurements import Measurement from graphix.pretty_print import SUBSCRIPTS if TYPE_CHECKING: @@ -130,8 +130,9 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: negative_sign ^= bool(len(inter_c_odd_set) // 2 % 2) # One phase flip per node in the graph state stabilizer that is absorbed from a Pauli measurement with angle π. + # TODO: What happends here with parametric angles ? for n, meas in og.measurements.items(): - if n in (c_set | odd_c_set) and (pm := PauliMeasurement.try_from(meas.plane, meas.angle)): + if n in (c_set | odd_c_set) and (pm := meas.try_to_pauli()): negative_sign ^= pm.sign == Sign.MINUS # One phase flip if measured on the YZ plane. @@ -198,7 +199,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen pauli_string = flow.pauli_strings[node] meas = flow.og.measurements[node] # We don't extract any rotation from Pauli Measurements. This is equivalent to setting the angle to 0. - angle = 0 if PauliMeasurement.try_from(meas.plane, meas.angle) else meas.angle / 2 + angle = 0 if meas.try_to_pauli() else meas.downcast_bloch().angle / 2 return PauliExponential(angle, pauli_string) @@ -417,7 +418,7 @@ def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], di new_input_nodes.append(fresh_node) fresh_node += 1 - measurements = {**og.measurements, **dict.fromkeys(new_input_nodes, Measurement(0, Plane.XY))} + measurements = {**og.measurements, **dict.fromkeys(new_input_nodes, Measurement.XY(0))} # We reverse the inputs order to match the order of initial inputs. return replace(og, graph=graph, input_nodes=new_input_nodes[::-1], measurements=measurements), ancillary_inputs_map diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 9b2a57fa..8673edff 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -9,7 +9,7 @@ from graphix.circ_ext.compilation import LadderPass from graphix.circ_ext.extraction import PauliExponential, PauliExponentialDAG, PauliString, extend_input from graphix.flow.core import PauliFlow -from graphix.fundamentals import ANGLE_PI, Plane +from graphix.fundamentals import ANGLE_PI from graphix.instruction import CNOT, RX, RY, RZ, H from graphix.measurements import Measurement from graphix.opengraph import OpenGraph @@ -22,8 +22,8 @@ class TestPauliString: def test_add_circuit(self, fx_rng: Generator) -> None: - angle = 0.3 - angle_rz = -2 * angle * ANGLE_PI + angle = 0.3 * ANGLE_PI + angle_rz = -2 * angle x_nodes = {1} z_nodes = {4, 2} pauli_string = PauliString(x_nodes=x_nodes, z_nodes=z_nodes) @@ -125,11 +125,11 @@ def test_from_focused_flow(self) -> None: input_nodes=[0], output_nodes=[5, 6], measurements={ - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.2, Plane.YZ), # YZ - 2: Measurement(0.3, Plane.XY), # XY - 3: Measurement(0.4, Plane.XY), # XY - 4: Measurement(0.5, Plane.YZ), # Y + 0: Measurement.XY(0.1), # XY + 1: Measurement.YZ(0.2), # YZ + 2: Measurement.XY(0.3), # XY + 3: Measurement.XY(0.4), # XY + 4: Measurement.Y, # Y }, ) @@ -175,10 +175,10 @@ def test_extend_input() -> None: input_nodes=[1, 2], output_nodes=[5, 6], measurements={ - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.2, Plane.XY), - 3: Measurement(0.3, Plane.XY), - 4: Measurement(0.4, Plane.XY), + 1: Measurement.XY(0.1), + 2: Measurement.XY(0.2), + 3: Measurement.XY(0.3), + 4: Measurement.XY(0.4), }, ) @@ -187,12 +187,12 @@ def test_extend_input() -> None: input_nodes=[8, 7], output_nodes=[5, 6], measurements={ - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.2, Plane.XY), - 3: Measurement(0.3, Plane.XY), - 4: Measurement(0.4, Plane.XY), - 7: Measurement(0, Plane.XY), - 8: Measurement(0, Plane.XY), + 1: Measurement.XY(0.1), + 2: Measurement.XY(0.2), + 3: Measurement.XY(0.3), + 4: Measurement.XY(0.4), + 7: Measurement.XY(0), + 8: Measurement.XY(0), }, ) diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index e7e66410..c41cf85f 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -871,14 +871,14 @@ def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> Non def test_gflow_focused(self, test_case: OpenGraphFlowTestCase) -> None: """Test that the algebraic flow-finding algorithm generated focused gflows.""" if test_case.has_gflow: - gf = test_case.og.extract_gflow() + gf = test_case.og.to_bloch().extract_gflow() assert gf.is_focused() @pytest.mark.parametrize("test_case", OPEN_GRAPH_FLOW_TEST_CASES) def test_pflow_focused(self, test_case: OpenGraphFlowTestCase) -> None: """Test that the algebraic flow-finding algorithm generated focused Pauli flows.""" if test_case.has_pflow: - pf = test_case.og.extract_pauli_flow() + pf = test_case.og.infer_pauli_measurements().extract_pauli_flow() assert pf.is_focused() def test_double_entanglement(self) -> None: From bc6933050c6598ce80e2b30ef0b6f58968a03185 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 18 Feb 2026 11:37:48 +0100 Subject: [PATCH 07/26] Update tests with isclose --- tests/test_circ_extraction.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 8673edff..5dadcc55 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, NamedTuple import networkx as nx -import numpy as np import pytest from graphix.circ_ext.compilation import LadderPass @@ -41,7 +40,7 @@ def test_add_circuit(self, fx_rng: Generator) -> None: state = qc.simulate_statevector(rng=fx_rng).statevec state_ref = qc_ref.simulate_statevector(rng=fx_rng).statevec - assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1) + assert state.isclose(state_ref) class PauliExpTestCase(NamedTuple): @@ -116,7 +115,7 @@ def test_to_circuit(self, test_case: PauliExpTestCase) -> None: qc = LadderPass.add_to_circuit(test_case.p_exp) state = qc.simulate_statevector().statevec state_ref = test_case.qc.simulate_statevector().statevec - assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1) + assert state.isclose(state_ref) def test_from_focused_flow(self) -> None: """Test example C.13. in Simmons, 2021.""" From f24aae6fef8cbd655308482e60bbc41b0f72732d Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 18 Feb 2026 16:27:11 +0100 Subject: [PATCH 08/26] various fixes --- graphix/circ_ext/extraction.py | 174 ++++++++++++++++----------------- tests/test_circ_extraction.py | 6 +- 2 files changed, 88 insertions(+), 92 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index ede5a921..a15176e2 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -8,8 +8,7 @@ from typing import TYPE_CHECKING from graphix.fundamentals import Angle, Plane, Sign -from graphix.measurements import Measurement -from graphix.pretty_print import SUBSCRIPTS +from graphix.measurements import Measurement, PauliMeasurement if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -92,7 +91,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: Parameters ---------- - flow : PauliFlow[AbstractMeasurement] + flow : PauliFlow[Measurement] A focused Pauli flow. The resulting Pauli string is extracted from its correction function. node : int A measured node whose associated Pauli string is computed. @@ -130,24 +129,15 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: negative_sign ^= bool(len(inter_c_odd_set) // 2 % 2) # One phase flip per node in the graph state stabilizer that is absorbed from a Pauli measurement with angle π. - # TODO: What happends here with parametric angles ? for n, meas in og.measurements.items(): - if n in (c_set | odd_c_set) and (pm := meas.try_to_pauli()): - negative_sign ^= pm.sign == Sign.MINUS + if isinstance(meas, PauliMeasurement) and n in (c_set | odd_c_set): + negative_sign ^= meas.sign == Sign.MINUS # One phase flip if measured on the YZ plane. negative_sign ^= flow.node_measurement_label(node) == Plane.YZ return PauliString(x_corrections, y_corrections, z_corrections, negative_sign) - def __str__(self) -> str: - """Return a string representation of the Pauli string.""" - pauli_str: list[str] = ["-" if self.negative_sign else "+"] - for p, nodes in zip(["X", "Y", "Z"], [self.x_nodes, self.y_nodes, self.z_nodes], strict=True): - pauli_str.extend(f"{p}{str(node).translate(SUBSCRIPTS)}" for node in nodes) - - return "".join(pauli_str) - @dataclass(frozen=True) class PauliExponential: @@ -178,7 +168,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen Parameters ---------- - flow : PauliFlow[AbstractMeasurement] + flow : PauliFlow[Measurement] A focused Pauli flow. The resulting Pauli string is extracted from its correction function. node : int A measured node whose associated Pauli string is computed. @@ -199,7 +189,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen pauli_string = flow.pauli_strings[node] meas = flow.og.measurements[node] # We don't extract any rotation from Pauli Measurements. This is equivalent to setting the angle to 0. - angle = 0 if meas.try_to_pauli() else meas.downcast_bloch().angle / 2 + angle = 0 if isinstance(meas, PauliMeasurement) else meas.downcast_bloch().angle / 2 return PauliExponential(angle, pauli_string) @@ -238,7 +228,7 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: Parameters ---------- - flow : PauliFlow[AbstractMeasurement] + flow : PauliFlow[Measurement] A focused Pauli flow. Returns @@ -297,7 +287,7 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> CliffordMap: Parameters ---------- - flow : PauliFlow[AbstractMeasurement] + flow : PauliFlow[Measurement] A focused Pauli flow. Returns @@ -312,78 +302,10 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> CliffordMap: ---------- [1] Simmons, 2021 (arXiv:2109.05654). """ - x_map = CliffordMap.x_map_from_focused_flow(flow) - z_map = CliffordMap.z_map_from_focused_flow(flow) + z_map = clifford_z_map_from_focused_flow(flow) + x_map = clifford_x_map_from_focused_flow(flow) return CliffordMap(x_map, z_map, flow.og.input_nodes, flow.og.output_nodes) - @staticmethod - def z_map_from_focused_flow(flow: PauliFlow[Measurement]) -> dict[int, PauliString]: - """Extract a map between Z over the input nodes and Pauli strings over the output nodes from a focused Pauli flow. - - If the input node is a measured node, the resulting Pauli string is given by the correction set. If the input node is also an output node, the resulting Pauli string is Z (representing the identity map). - - Parameters - ---------- - flow : PauliFlow[AbstractMeasurement] - A focused Pauli flow. - - Returns - ------- - dict[int, PauliString] - Map between input nodes (``keys``) and Pauli strings over the output nodes (``values``). - - Notes - ----- - See Definition 3.3 and Example C.13 in Ref. [1]. - - References - ---------- - [1] Simmons, 2021 (arXiv:2109.05654). - """ - z_map: dict[int, PauliString] = {} - iset = set(flow.og.input_nodes) - - for node in iset.intersection(flow.og.measurements.keys()): - z_map[node] = flow.pauli_strings[node] - - for node in iset.intersection(flow.og.output_nodes): - z_map[node] = PauliString(z_nodes=frozenset({node})) - - return z_map - - @staticmethod - def x_map_from_focused_flow(flow: PauliFlow[Measurement]) -> Mapping[int, PauliString]: - """Extract a map between X over the input nodes and Pauli strings over the output nodes from a focused Pauli flow. - - The resulting Pauli string is given by the correction set of a focused flow of the extended open graph. - - Parameters - ---------- - flow : PauliFlow[AbstractMeasurement] - A focused Pauli flow. - - Returns - ------- - dict[int, PauliString] - Map between input nodes (``keys``) and Pauli strings over the output nodes (``values``). - - Notes - ----- - See Definition 3.3 and Example C.13 in Ref. [1]. - - References - ---------- - [1] Simmons, 2021 (arXiv:2109.05654). - """ - og = flow.og - og_extended, ancillary_inputs_map = extend_input(og) - flow_extended = og_extended.extract_pauli_flow() - - # It's better to call the `PauliString` constructor instead of the cached property `flow_extended.pauli_strings` since the latter will compute a `PauliString` for _every_ node in the correction function and we just need it for the input nodes. - x_map_ancillas = {node: PauliString.from_measured_node(flow_extended, node) for node in og_extended.input_nodes} - - return {input_node: x_map_ancillas[ancillary_inputs_map[input_node]] for input_node in og.input_nodes} - def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], dict[int, int]]: r"""Extend the inputs of a given open graph. @@ -418,7 +340,81 @@ def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], di new_input_nodes.append(fresh_node) fresh_node += 1 - measurements = {**og.measurements, **dict.fromkeys(new_input_nodes, Measurement.XY(0))} + measurements = {**og.measurements, **dict.fromkeys(new_input_nodes, Measurement.X)} # We reverse the inputs order to match the order of initial inputs. return replace(og, graph=graph, input_nodes=new_input_nodes[::-1], measurements=measurements), ancillary_inputs_map + + +def clifford_z_map_from_focused_flow(flow: PauliFlow[Measurement]) -> dict[int, PauliString]: + """Extract a map between Z over the input nodes and Pauli strings over the output nodes from a focused Pauli flow. + + If the input node is a measured node, the resulting Pauli string is given by the correction set. If the input node is also an output node, the resulting Pauli string is Z (representing the identity map). + + Parameters + ---------- + flow : PauliFlow[Measurement] + A focused Pauli flow. + + Returns + ------- + dict[int, PauliString] + Map between input nodes (``keys``) and Pauli strings over the output nodes (``values``). + + Notes + ----- + See Definition 3.3 and Example C.13 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + z_map: dict[int, PauliString] = {} + iset = set(flow.og.input_nodes) + + for node in iset.intersection(flow.og.measurements.keys()): + z_map[node] = flow.pauli_strings[node] + + for node in iset.intersection(flow.og.output_nodes): + z_map[node] = PauliString(z_nodes=frozenset({node})) + + return z_map + + +def clifford_x_map_from_focused_flow(flow: PauliFlow[Measurement]) -> Mapping[int, PauliString]: + """Extract a map between X over the input nodes and Pauli strings over the output nodes from a focused Pauli flow. + + The resulting Pauli string is given by the correction set of a focused flow of the extended open graph. + + Parameters + ---------- + flow : PauliFlow[Measurement] + A focused Pauli flow. + + Returns + ------- + dict[int, PauliString] + Map between input nodes (``keys``) and Pauli strings over the output nodes (``values``). + + Notes + ----- + See Definition 3.3 and Example C.13 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + """ + og = flow.og + og_extended, ancillary_inputs_map = extend_input(og) + + # Here it's crucial to not infer Pauli measurements to avoid converting measurements inadvertently. + flow_extended = og_extended.extract_pauli_flow() + + # `flow_extended` is guaranteed to be focused if `flow` is focused. + # This function assumes that `flow` is focused and does not check it. + # In the context for `CliffordMap.from_focused_flow` the check is performed when accessing the cached property `flow.pauli_strings` in the function `clifford_z_map_from_focused_flow`. + + # It's better to call the `PauliString` constructor instead of the cached property `flow_extended.pauli_strings` since the latter will compute a `PauliString` for _every_ node in the correction function and we just need it for the input nodes. + x_map_ancillas = {node: PauliString.from_measured_node(flow_extended, node) for node in og_extended.input_nodes} + + return {input_node: x_map_ancillas[ancillary_inputs_map[input_node]] for input_node in og.input_nodes} diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 5dadcc55..c297f710 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -190,8 +190,8 @@ def test_extend_input() -> None: 2: Measurement.XY(0.2), 3: Measurement.XY(0.3), 4: Measurement.XY(0.4), - 7: Measurement.XY(0), - 8: Measurement.XY(0), + 7: Measurement.X, + 8: Measurement.X, }, ) @@ -200,5 +200,5 @@ def test_extend_input() -> None: assert og_ext.isclose(og_ref) assert ancillary_inputs_map == {1: 8, 2: 7} - flow = og_ext.extract_pauli_flow() + flow = og_ext.infer_pauli_measurements().extract_pauli_flow() assert flow.is_focused() From 5a49b5581b92d6fbc913f6de2568e3d7aaaef3cd Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 18 Feb 2026 17:13:43 +0100 Subject: [PATCH 09/26] Fix pyright --- graphix/circ_ext/compilation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 14dcd890..76f56c5a 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -209,6 +209,7 @@ def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circui q1, q2 = outputs_mapping.index(n1), outputs_mapping.index(n2) circuit.cnot(control=q1, target=q2) + q2 = outputs_mapping.index(nodes[-1]) # To avoid pyright `reportPossiblyUnboundVariable` circuit.rz(q2, angle) for n2, n1 in pairwise(nodes[::-1]): From f99c285354cacdc714c36d7f8b77da2274b86331 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 26 Feb 2026 10:19:22 +0100 Subject: [PATCH 10/26] Add suggestions Thierry --- graphix/circ_ext/extraction.py | 35 ++++++++++++++-------------------- tests/test_fundamentals.py | 34 ++++++++++++++++----------------- tests/test_parameter.py | 2 +- tests/test_pauli.py | 16 ++++++++-------- 4 files changed, 40 insertions(+), 47 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index a15176e2..17909e52 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -4,11 +4,10 @@ import dataclasses from dataclasses import dataclass, replace -from itertools import combinations from typing import TYPE_CHECKING -from graphix.fundamentals import Angle, Plane, Sign -from graphix.measurements import Measurement, PauliMeasurement +from graphix.fundamentals import ParameterizedAngle, Plane, Sign +from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -18,7 +17,6 @@ from graphix.command import Node from graphix.flow.core import PauliFlow from graphix.opengraph import OpenGraph - from graphix.parameter import Expression from graphix.transpiler import Circuit @@ -122,15 +120,15 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: negative_sign = False # One phase flip per edge between adjacent vertices in the correction set. - for edge in combinations(c_set, 2): - negative_sign ^= edge in og.graph.edges() + negative_sign ^= og.graph.subgraph(c_set).number_of_edges() % 2 == 1 # One phase flip per two Ys in the graph state stabilizer. negative_sign ^= bool(len(inter_c_odd_set) // 2 % 2) # One phase flip per node in the graph state stabilizer that is absorbed from a Pauli measurement with angle π. - for n, meas in og.measurements.items(): - if isinstance(meas, PauliMeasurement) and n in (c_set | odd_c_set): + for n in c_set | odd_c_set: + meas = og.measurements.get(n, None) + if isinstance(meas, PauliMeasurement): negative_sign ^= meas.sign == Sign.MINUS # One phase flip if measured on the YZ plane. @@ -153,13 +151,13 @@ class PauliExponential: Attributes ---------- - angle : Angle | Expression + angle : ParameterizedAngle The Pauli exponential angle :math:`\alpha` in units of :math:`\pi`. When extracted from a corrected node, it corresponds to the node's measurement divided by two. pauli_string : PauliString The signed Pauli string :math:`P` specifying the tensor product of Pauli operators acting on the corresponding MBQC nodes. """ - angle: Angle | Expression + angle: ParameterizedAngle pauli_string: PauliString @staticmethod @@ -189,7 +187,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen pauli_string = flow.pauli_strings[node] meas = flow.og.measurements[node] # We don't extract any rotation from Pauli Measurements. This is equivalent to setting the angle to 0. - angle = 0 if isinstance(meas, PauliMeasurement) else meas.downcast_bloch().angle / 2 + angle = meas.angle / 2 if isinstance(meas, BlochMeasurement) else 0 return PauliExponential(angle, pauli_string) @@ -369,16 +367,11 @@ def clifford_z_map_from_focused_flow(flow: PauliFlow[Measurement]) -> dict[int, ---------- [1] Simmons, 2021 (arXiv:2109.05654). """ - z_map: dict[int, PauliString] = {} - iset = set(flow.og.input_nodes) - - for node in iset.intersection(flow.og.measurements.keys()): - z_map[node] = flow.pauli_strings[node] - - for node in iset.intersection(flow.og.output_nodes): - z_map[node] = PauliString(z_nodes=frozenset({node})) - - return z_map + # Nodes are either measured or outputs. + return { + node: flow.pauli_strings[node] if node in flow.og.measurements else PauliString(z_nodes=frozenset({node})) + for node in flow.og.input_nodes + } def clifford_x_map_from_focused_flow(flow: PauliFlow[Measurement]) -> Mapping[int, PauliString]: diff --git a/tests/test_fundamentals.py b/tests/test_fundamentals.py index 48adbbc9..2460fad2 100644 --- a/tests/test_fundamentals.py +++ b/tests/test_fundamentals.py @@ -50,32 +50,32 @@ def test_mul_int(self) -> None: def test_mul_float(self) -> None: left = Sign.PLUS * 1.0 assert isinstance(left, float) - assert left == float(Sign.PLUS) # noqa: RUF069 + assert left == float(Sign.PLUS) right = 1.0 * Sign.PLUS assert isinstance(right, float) - assert right == float(Sign.PLUS) # noqa: RUF069 + assert right == float(Sign.PLUS) left = Sign.MINUS * 1.0 assert isinstance(left, float) - assert left == float(Sign.MINUS) # noqa: RUF069 + assert left == float(Sign.MINUS) right = 1.0 * Sign.MINUS assert isinstance(right, float) - assert right == float(Sign.MINUS) # noqa: RUF069 + assert right == float(Sign.MINUS) def test_mul_complex(self) -> None: left = Sign.PLUS * complex(1) assert isinstance(left, complex) - assert left == complex(Sign.PLUS) # noqa: RUF069 + assert left == complex(Sign.PLUS) right = complex(1) * Sign.PLUS assert isinstance(right, complex) - assert right == complex(Sign.PLUS) # noqa: RUF069 + assert right == complex(Sign.PLUS) left = Sign.MINUS * complex(1) assert isinstance(left, complex) - assert left == complex(Sign.MINUS) # noqa: RUF069 + assert left == complex(Sign.MINUS) right = complex(1) * Sign.MINUS assert isinstance(right, complex) - assert right == complex(Sign.MINUS) # noqa: RUF069 + assert right == complex(Sign.MINUS) def test_int(self) -> None: # Necessary to justify `type: ignore` @@ -103,10 +103,10 @@ def test_properties(self, sign: Sign, is_imag: bool) -> None: assert ComplexUnit.from_properties(sign=sign, is_imag=is_imag).is_imag == is_imag def test_complex(self) -> None: - assert complex(ComplexUnit.ONE) == 1 # noqa: RUF069 - assert complex(ComplexUnit.J) == 1j # noqa: RUF069 - assert complex(ComplexUnit.MINUS_ONE) == -1 # noqa: RUF069 - assert complex(ComplexUnit.MINUS_J) == -1j # noqa: RUF069 + assert complex(ComplexUnit.ONE) == 1 + assert complex(ComplexUnit.J) == 1j + assert complex(ComplexUnit.MINUS_ONE) == -1 + assert complex(ComplexUnit.MINUS_J) == -1j def test_str(self) -> None: assert str(ComplexUnit.ONE) == "1" @@ -116,15 +116,15 @@ def test_str(self) -> None: @pytest.mark.parametrize(("lhs", "rhs"), itertools.product(ComplexUnit, ComplexUnit)) def test_mul_self(self, lhs: ComplexUnit, rhs: ComplexUnit) -> None: - assert complex(lhs * rhs) == complex(lhs) * complex(rhs) # noqa: RUF069 + assert complex(lhs * rhs) == complex(lhs) * complex(rhs) def test_mul_number(self) -> None: assert ComplexUnit.ONE * 1 == ComplexUnit.ONE assert 1 * ComplexUnit.ONE == ComplexUnit.ONE - assert ComplexUnit.ONE * 1.0 == ComplexUnit.ONE # noqa: RUF069 - assert 1.0 * ComplexUnit.ONE == ComplexUnit.ONE # noqa: RUF069 - assert ComplexUnit.ONE * complex(1) == ComplexUnit.ONE # noqa: RUF069 - assert complex(1) * ComplexUnit.ONE == ComplexUnit.ONE # noqa: RUF069 + assert ComplexUnit.ONE * 1.0 == ComplexUnit.ONE + assert 1.0 * ComplexUnit.ONE == ComplexUnit.ONE + assert ComplexUnit.ONE * complex(1) == ComplexUnit.ONE + assert complex(1) * ComplexUnit.ONE == ComplexUnit.ONE def test_neg(self) -> None: assert -ComplexUnit.ONE == ComplexUnit.MINUS_ONE diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 7970484a..520adfcf 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -25,7 +25,7 @@ def test_pattern_affine_operations() -> None: assert alpha + 1 + 1 == alpha + 2 assert alpha + alpha == 2 * alpha assert alpha - alpha == 0 - assert alpha / 2 == 0.5 * alpha # noqa: RUF069 + assert alpha / 2 == 0.5 * alpha assert -alpha + alpha == 0 beta = Placeholder("beta") with pytest.raises(PlaceholderOperationError): diff --git a/tests/test_pauli.py b/tests/test_pauli.py index 67069088..b867cef7 100644 --- a/tests/test_pauli.py +++ b/tests/test_pauli.py @@ -71,21 +71,21 @@ def test_iterate_false(self) -> None: cmp = list(Pauli.iterate(symbol_only=False)) assert len(cmp) == 16 assert cmp[0] == Pauli.I - assert cmp[1] == 1j * Pauli.I # noqa: RUF069 + assert cmp[1] == 1j * Pauli.I assert cmp[2] == -1 * Pauli.I - assert cmp[3] == -1j * Pauli.I # noqa: RUF069 + assert cmp[3] == -1j * Pauli.I assert cmp[4] == Pauli.X - assert cmp[5] == 1j * Pauli.X # noqa: RUF069 + assert cmp[5] == 1j * Pauli.X assert cmp[6] == -1 * Pauli.X - assert cmp[7] == -1j * Pauli.X # noqa: RUF069 + assert cmp[7] == -1j * Pauli.X assert cmp[8] == Pauli.Y - assert cmp[9] == 1j * Pauli.Y # noqa: RUF069 + assert cmp[9] == 1j * Pauli.Y assert cmp[10] == -1 * Pauli.Y - assert cmp[11] == -1j * Pauli.Y # noqa: RUF069 + assert cmp[11] == -1j * Pauli.Y assert cmp[12] == Pauli.Z - assert cmp[13] == 1j * Pauli.Z # noqa: RUF069 + assert cmp[13] == 1j * Pauli.Z assert cmp[14] == -1 * Pauli.Z - assert cmp[15] == -1j * Pauli.Z # noqa: RUF069 + assert cmp[15] == -1j * Pauli.Z def test_iter_meta(self) -> None: it = Pauli.iterate(symbol_only=False) From 8dd3ebcf402b58a45373ccec70a8aae8007e83bd Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 26 Feb 2026 10:39:21 +0100 Subject: [PATCH 11/26] Replace bool by Sign in PauliString --- graphix/circ_ext/compilation.py | 3 +-- graphix/circ_ext/extraction.py | 8 ++++---- tests/test_circ_extraction.py | 18 +++++++++--------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 76f56c5a..79593d53 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -185,8 +185,7 @@ def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circui pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes, key=outputs_mapping.index, ) - sign = -1 if pexp.pauli_string.negative_sign else 1 - angle = -2 * pexp.angle * sign + angle = -2 * pexp.angle * pexp.pauli_string.sign if len(nodes) == 0: # Identity return diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 17909e52..728202c9 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -74,14 +74,14 @@ class PauliString: Nodes on which a Pauli Y operator is applied. z_nodes : AbstractSet[int] Nodes on which a Pauli Z operator is applied. - negative_sign : bool - Boolean flag indicating a -1 phase in the Pauli string if ``True``. + sign : Sign + Phase of the Pauli string. """ x_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) y_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) z_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) - negative_sign: bool = dataclasses.field(default_factory=lambda: False) + sign: Sign = dataclasses.field(default_factory=lambda: Sign.PLUS) @staticmethod def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: @@ -134,7 +134,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: # One phase flip if measured on the YZ plane. negative_sign ^= flow.node_measurement_label(node) == Plane.YZ - return PauliString(x_corrections, y_corrections, z_corrections, negative_sign) + return PauliString(x_corrections, y_corrections, z_corrections, Sign.minus_if(negative_sign)) @dataclass(frozen=True) diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index c297f710..5909740b 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -8,7 +8,7 @@ from graphix.circ_ext.compilation import LadderPass from graphix.circ_ext.extraction import PauliExponential, PauliExponentialDAG, PauliString, extend_input from graphix.flow.core import PauliFlow -from graphix.fundamentals import ANGLE_PI +from graphix.fundamentals import ANGLE_PI, Sign from graphix.instruction import CNOT, RX, RY, RZ, H from graphix.measurements import Measurement from graphix.opengraph import OpenGraph @@ -58,7 +58,7 @@ class TestPauliExponential: PauliExpTestCase( PauliExponentialDAG( pauli_exponentials={ - 0: PauliExponential(alpha / 2, PauliString(z_nodes={1}, negative_sign=True)), + 0: PauliExponential(alpha / 2, PauliString(z_nodes={1}, sign=Sign.MINUS)), }, partial_order_layers=[{1}, {0}], output_nodes=[1], @@ -68,7 +68,7 @@ class TestPauliExponential: PauliExpTestCase( PauliExponentialDAG( pauli_exponentials={ - 0: PauliExponential(alpha / 2, PauliString(x_nodes={1}, negative_sign=True)), + 0: PauliExponential(alpha / 2, PauliString(x_nodes={1}, sign=Sign.MINUS)), }, partial_order_layers=[{1}, {0}], output_nodes=[1], @@ -78,7 +78,7 @@ class TestPauliExponential: PauliExpTestCase( PauliExponentialDAG( pauli_exponentials={ - 0: PauliExponential(alpha / 2, PauliString(y_nodes={1}, negative_sign=True)), + 0: PauliExponential(alpha / 2, PauliString(y_nodes={1}, sign=Sign.MINUS)), }, partial_order_layers=[{1}, {0}], output_nodes=[1], @@ -88,9 +88,9 @@ class TestPauliExponential: PauliExpTestCase( PauliExponentialDAG( pauli_exponentials={ - 0: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, negative_sign=True)), - 1: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, negative_sign=True)), - 2: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, negative_sign=True)), + 0: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, sign=Sign.MINUS)), + 1: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, sign=Sign.MINUS)), + 2: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, sign=Sign.MINUS)), }, partial_order_layers=[{3}, {2}, {1}, {0}], output_nodes=[3], @@ -102,7 +102,7 @@ class TestPauliExponential: pauli_exponentials={ 0: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3})), 1: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={5})), - 2: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, z_nodes={5}, negative_sign=True)), + 2: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, z_nodes={5}, sign=Sign.MINUS)), }, partial_order_layers=[{5, 3}, {2}, {0, 1}], output_nodes=[5, 3], # Node 5 -> qubit 0 (control), node 3 -> qubit 1 (target) @@ -154,7 +154,7 @@ def test_from_focused_flow(self) -> None: 0: PauliExponential(ANGLE_PI * 0.1 / 2, PauliString(x_nodes=frozenset({6}))), 1: PauliExponential(ANGLE_PI * 0.2 / 2, PauliString(y_nodes=frozenset({6}), z_nodes=frozenset({5}))), 2: PauliExponential( - ANGLE_PI * 0.3 / 2, PauliString(y_nodes=frozenset({5}), z_nodes=frozenset({6}), negative_sign=True) + ANGLE_PI * 0.3 / 2, PauliString(y_nodes=frozenset({5}), z_nodes=frozenset({6}), sign=Sign.MINUS) ), 3: PauliExponential(ANGLE_PI * 0.4 / 2, PauliString(x_nodes=frozenset({5}))), 4: PauliExponential( From ffefd8c561261a712dd246cd9f32da29b5a33a50 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 26 Feb 2026 10:43:12 +0100 Subject: [PATCH 12/26] Restore ruff noqa --- tests/test_fundamentals.py | 34 +++++++++++++++++----------------- tests/test_parameter.py | 2 +- tests/test_pauli.py | 16 ++++++++-------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_fundamentals.py b/tests/test_fundamentals.py index 2460fad2..48adbbc9 100644 --- a/tests/test_fundamentals.py +++ b/tests/test_fundamentals.py @@ -50,32 +50,32 @@ def test_mul_int(self) -> None: def test_mul_float(self) -> None: left = Sign.PLUS * 1.0 assert isinstance(left, float) - assert left == float(Sign.PLUS) + assert left == float(Sign.PLUS) # noqa: RUF069 right = 1.0 * Sign.PLUS assert isinstance(right, float) - assert right == float(Sign.PLUS) + assert right == float(Sign.PLUS) # noqa: RUF069 left = Sign.MINUS * 1.0 assert isinstance(left, float) - assert left == float(Sign.MINUS) + assert left == float(Sign.MINUS) # noqa: RUF069 right = 1.0 * Sign.MINUS assert isinstance(right, float) - assert right == float(Sign.MINUS) + assert right == float(Sign.MINUS) # noqa: RUF069 def test_mul_complex(self) -> None: left = Sign.PLUS * complex(1) assert isinstance(left, complex) - assert left == complex(Sign.PLUS) + assert left == complex(Sign.PLUS) # noqa: RUF069 right = complex(1) * Sign.PLUS assert isinstance(right, complex) - assert right == complex(Sign.PLUS) + assert right == complex(Sign.PLUS) # noqa: RUF069 left = Sign.MINUS * complex(1) assert isinstance(left, complex) - assert left == complex(Sign.MINUS) + assert left == complex(Sign.MINUS) # noqa: RUF069 right = complex(1) * Sign.MINUS assert isinstance(right, complex) - assert right == complex(Sign.MINUS) + assert right == complex(Sign.MINUS) # noqa: RUF069 def test_int(self) -> None: # Necessary to justify `type: ignore` @@ -103,10 +103,10 @@ def test_properties(self, sign: Sign, is_imag: bool) -> None: assert ComplexUnit.from_properties(sign=sign, is_imag=is_imag).is_imag == is_imag def test_complex(self) -> None: - assert complex(ComplexUnit.ONE) == 1 - assert complex(ComplexUnit.J) == 1j - assert complex(ComplexUnit.MINUS_ONE) == -1 - assert complex(ComplexUnit.MINUS_J) == -1j + assert complex(ComplexUnit.ONE) == 1 # noqa: RUF069 + assert complex(ComplexUnit.J) == 1j # noqa: RUF069 + assert complex(ComplexUnit.MINUS_ONE) == -1 # noqa: RUF069 + assert complex(ComplexUnit.MINUS_J) == -1j # noqa: RUF069 def test_str(self) -> None: assert str(ComplexUnit.ONE) == "1" @@ -116,15 +116,15 @@ def test_str(self) -> None: @pytest.mark.parametrize(("lhs", "rhs"), itertools.product(ComplexUnit, ComplexUnit)) def test_mul_self(self, lhs: ComplexUnit, rhs: ComplexUnit) -> None: - assert complex(lhs * rhs) == complex(lhs) * complex(rhs) + assert complex(lhs * rhs) == complex(lhs) * complex(rhs) # noqa: RUF069 def test_mul_number(self) -> None: assert ComplexUnit.ONE * 1 == ComplexUnit.ONE assert 1 * ComplexUnit.ONE == ComplexUnit.ONE - assert ComplexUnit.ONE * 1.0 == ComplexUnit.ONE - assert 1.0 * ComplexUnit.ONE == ComplexUnit.ONE - assert ComplexUnit.ONE * complex(1) == ComplexUnit.ONE - assert complex(1) * ComplexUnit.ONE == ComplexUnit.ONE + assert ComplexUnit.ONE * 1.0 == ComplexUnit.ONE # noqa: RUF069 + assert 1.0 * ComplexUnit.ONE == ComplexUnit.ONE # noqa: RUF069 + assert ComplexUnit.ONE * complex(1) == ComplexUnit.ONE # noqa: RUF069 + assert complex(1) * ComplexUnit.ONE == ComplexUnit.ONE # noqa: RUF069 def test_neg(self) -> None: assert -ComplexUnit.ONE == ComplexUnit.MINUS_ONE diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 520adfcf..7970484a 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -25,7 +25,7 @@ def test_pattern_affine_operations() -> None: assert alpha + 1 + 1 == alpha + 2 assert alpha + alpha == 2 * alpha assert alpha - alpha == 0 - assert alpha / 2 == 0.5 * alpha + assert alpha / 2 == 0.5 * alpha # noqa: RUF069 assert -alpha + alpha == 0 beta = Placeholder("beta") with pytest.raises(PlaceholderOperationError): diff --git a/tests/test_pauli.py b/tests/test_pauli.py index b867cef7..67069088 100644 --- a/tests/test_pauli.py +++ b/tests/test_pauli.py @@ -71,21 +71,21 @@ def test_iterate_false(self) -> None: cmp = list(Pauli.iterate(symbol_only=False)) assert len(cmp) == 16 assert cmp[0] == Pauli.I - assert cmp[1] == 1j * Pauli.I + assert cmp[1] == 1j * Pauli.I # noqa: RUF069 assert cmp[2] == -1 * Pauli.I - assert cmp[3] == -1j * Pauli.I + assert cmp[3] == -1j * Pauli.I # noqa: RUF069 assert cmp[4] == Pauli.X - assert cmp[5] == 1j * Pauli.X + assert cmp[5] == 1j * Pauli.X # noqa: RUF069 assert cmp[6] == -1 * Pauli.X - assert cmp[7] == -1j * Pauli.X + assert cmp[7] == -1j * Pauli.X # noqa: RUF069 assert cmp[8] == Pauli.Y - assert cmp[9] == 1j * Pauli.Y + assert cmp[9] == 1j * Pauli.Y # noqa: RUF069 assert cmp[10] == -1 * Pauli.Y - assert cmp[11] == -1j * Pauli.Y + assert cmp[11] == -1j * Pauli.Y # noqa: RUF069 assert cmp[12] == Pauli.Z - assert cmp[13] == 1j * Pauli.Z + assert cmp[13] == 1j * Pauli.Z # noqa: RUF069 assert cmp[14] == -1 * Pauli.Z - assert cmp[15] == -1j * Pauli.Z + assert cmp[15] == -1j * Pauli.Z # noqa: RUF069 def test_iter_meta(self) -> None: it = Pauli.iterate(symbol_only=False) From 95fd39339538785f36dc0fa84da6771b58e2d52e Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 26 Feb 2026 11:07:59 +0100 Subject: [PATCH 13/26] Replace sorting by filtering in add_pexp (Thierry suggestion) --- graphix/circ_ext/compilation.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 79593d53..d7400177 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -181,10 +181,11 @@ def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circui if pexp.angle == 0: # No rotation return - nodes = sorted( - pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes, - key=outputs_mapping.index, - ) + nodes = [ + node + for node in outputs_mapping + if node in pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes + ] angle = -2 * pexp.angle * pexp.pauli_string.sign if len(nodes) == 0: # Identity From 575a9ac78aa8b4eb6863d199d033b1161bd249d2 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 26 Feb 2026 11:33:16 +0100 Subject: [PATCH 14/26] Simplify API for compilation passes (Thierry's suggestion) --- graphix/circ_ext/compilation.py | 87 ++++++--------------------------- tests/test_circ_extraction.py | 3 +- 2 files changed, 17 insertions(+), 73 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index d7400177..1e73b905 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from copy import deepcopy from dataclasses import dataclass from itertools import chain, pairwise from typing import TYPE_CHECKING @@ -14,8 +13,6 @@ from graphix.transpiler import Circuit if TYPE_CHECKING: - from collections.abc import Sequence - from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG from graphix.command import Node @@ -63,8 +60,11 @@ def er_to_circuit(self, er: ExtractionResult) -> Circuit: raise ValueError( "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." ) - circuit = self.cm_cp.add_to_circuit(er.clifford_map) - return self.pexp_cp.add_to_circuit(er.pexp_dag, circuit) + n_qubits = len(er.pexp_dag.output_nodes) + circuit = Circuit(n_qubits) + self.cm_cp.add_to_circuit(er.clifford_map, circuit) + self.pexp_cp.add_to_circuit(er.pexp_dag, circuit) + return circuit class PauliExponentialDAGCompilationPass(ABC): @@ -72,27 +72,17 @@ class PauliExponentialDAGCompilationPass(ABC): @staticmethod @abstractmethod - def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: r"""Add a Pauli exponential DAG to a circuit. + The input circuit is modified in-place. + Parameters ---------- pexp_dag: PauliExponentialDAG The Pauli exponential rotation to be added to the circuit. - circuit : Circuit or ``None``, optional - The circuit to which the operation is added. If ``None``, a new ``Circuit`` instance is created with a width matching the number of output nodes in ``pexp_dag``. Default is ``None``. - copy : bool, optional - If ``True``, the operation is applied to a deep copy of ``circuit`` and the modified copy is returned. Otherwise, the input circuit is modified in place. Default is ``False``. - - Returns - ------- - Circuit - The circuit with the operation applied. - - Raises - ------ - ValueError - If the input circuit is not compatible with ``pexp_dag.output_nodes``. + circuit : Circuit + The circuit to which the operation is added. The input circuit is assumed to be compatible with ``pexp_dag.output_nodes``. """ @@ -101,29 +91,20 @@ class CliffordMapCompilationPass(ABC): @staticmethod @abstractmethod - def add_to_circuit(clifford_map: CliffordMap, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + def add_to_circuit(clifford_map: CliffordMap, circuit: Circuit) -> None: """Add the Clifford map to a quantum circuit. + The input circuit is modified in-place. + Parameters ---------- clifford_map: CliffordMap The Clifford map to be added to the circuit. circuit : Circuit - The quantum circuit to which the Clifford map is added. - copy : bool, optional - If ``True``, operate on a deep copy of ``circuit`` and return it. - Otherwise, the input circuit is modified in place. Default is - ``False``. - - Returns - ------- - Circuit - The circuit with the operation applied. + The quantum circuit to which the Clifford map is added. The input circuit is assumed to be compatible with ``clifford_map.output_nodes``. Raises ------ - ValueError - If the input circuit is not compatible with ``clifford_map.output_nodes``. NotImplementedError If the Clifford map represents an isometry, i.e., ``len(clifford_map.input_nodes) != len(clifford_map.output_nodes)``. """ @@ -148,12 +129,11 @@ class LadderPass(PauliExponentialDAGCompilationPass): """ @staticmethod - def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None, copy: bool = False) -> Circuit: + def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: """Add a Pauli exponential DAG to a circuit. See documentation in :meth:`PauliExponentialDAGCompilationPass.add_to_circuit` for additional information. """ - circuit = initialize_circuit(pexp_dag.output_nodes, circuit, copy) # May raise value error outputs_mapping = NodeIndex() outputs_mapping.extend(pexp_dag.output_nodes) @@ -161,8 +141,6 @@ def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit | None = None pexp = pexp_dag.pauli_exponentials[node] LadderPass.add_pexp(pexp, outputs_mapping, circuit) - return circuit - @staticmethod def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circuit) -> None: r"""Add the Pauli exponential unitary to a quantum circuit. @@ -251,38 +229,3 @@ def add_hy(qubit: int, circuit: Circuit) -> None: circuit.rz(qubit, ANGLE_PI / 2) circuit.ry(qubit, ANGLE_PI / 2) circuit.rz(qubit, ANGLE_PI / 2) - - -def initialize_circuit(output_nodes: Sequence[int], circuit: Circuit | None = None, copy: bool = False) -> Circuit: - """Initialize or validate a quantum circuit based on the provided output nodes. - - If no circuit is provided, a new one is created with a width matching the number of output nodes. If a circuit is provided, its width is validated against the number of output nodes. - - Parameters - ---------- - output_nodes : Sequence[int] - A sequence of integers representing the output nodes of the original MBQC pattern or open graph. The length of this sequence determines the required circuit width. - circuit : Circuit, optional - An existing circuit to initialize. If ``None`` (default), a new `Circuit` object is instantiated. - copy : bool, optional - If ``True`` and an existing `circuit` is provided, a deep copy of the circuit is returned to avoid mutating the original object. Defaults to ``False``. - - Returns - ------- - Circuit - The initialized quantum circuit. - - Raises - ------ - ValueError - If the provided ``circuit`` width does not match the length of ``output_nodes``. - """ - n_qubits = len(output_nodes) - if circuit is None: - circuit = Circuit(n_qubits) - else: - if circuit.width != n_qubits: - raise ValueError(f"Circuit width ({circuit.width}) differs from number of outputs ({n_qubits}).") - if copy: - circuit = deepcopy(circuit) - return circuit diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 5909740b..36fc1b3a 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -112,7 +112,8 @@ class TestPauliExponential: ], ) def test_to_circuit(self, test_case: PauliExpTestCase) -> None: - qc = LadderPass.add_to_circuit(test_case.p_exp) + qc = Circuit(len(test_case.p_exp.output_nodes)) + LadderPass.add_to_circuit(test_case.p_exp, qc) state = qc.simulate_statevector().statevec state_ref = test_case.qc.simulate_statevector().statevec assert state.isclose(state_ref) From d6a14641b02ed0265eb571892eb66205286567e4 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 26 Feb 2026 17:54:49 +0100 Subject: [PATCH 15/26] Lift indexing into extraction phase --- graphix/circ_ext/compilation.py | 45 ++++++++++++--------------------- graphix/circ_ext/extraction.py | 20 ++++++++++++++- tests/test_circ_extraction.py | 2 +- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 1e73b905..a56dc515 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -9,7 +9,6 @@ from graphix.circ_ext.extraction import PauliExponentialDAG from graphix.fundamentals import ANGLE_PI -from graphix.sim.base_backend import NodeIndex from graphix.transpiler import Circuit if TYPE_CHECKING: @@ -134,15 +133,12 @@ def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: See documentation in :meth:`PauliExponentialDAGCompilationPass.add_to_circuit` for additional information. """ - outputs_mapping = NodeIndex() - outputs_mapping.extend(pexp_dag.output_nodes) - for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): pexp = pexp_dag.pauli_exponentials[node] - LadderPass.add_pexp(pexp, outputs_mapping, circuit) + LadderPass.add_pexp(pexp, circuit) @staticmethod - def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circuit) -> None: + def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: r"""Add the Pauli exponential unitary to a quantum circuit. This method modifies the input circuit in-place. @@ -159,11 +155,7 @@ def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circui if pexp.angle == 0: # No rotation return - nodes = [ - node - for node in outputs_mapping - if node in pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes - ] + nodes = list(pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes) angle = -2 * pexp.angle * pexp.pauli_string.sign if len(nodes) == 0: # Identity @@ -171,34 +163,30 @@ def add_pexp(pexp: PauliExponential, outputs_mapping: NodeIndex, circuit: Circui if len(nodes) == 1: n0 = nodes[0] - q0 = outputs_mapping.index(n0) if n0 in pexp.pauli_string.x_nodes: - circuit.rx(q0, angle) + circuit.rx(n0, angle) elif n0 in pexp.pauli_string.y_nodes: - circuit.ry(q0, angle) + circuit.ry(n0, angle) else: - circuit.rz(q0, angle) + circuit.rz(n0, angle) return - LadderPass.add_basis_change(pexp, outputs_mapping, nodes[0], circuit) + LadderPass.add_basis_change(pexp, nodes[0], circuit) for n1, n2 in pairwise(nodes): - LadderPass.add_basis_change(pexp, outputs_mapping, n2, circuit) - q1, q2 = outputs_mapping.index(n1), outputs_mapping.index(n2) - circuit.cnot(control=q1, target=q2) + LadderPass.add_basis_change(pexp, n2, circuit) + circuit.cnot(control=n1, target=n2) - q2 = outputs_mapping.index(nodes[-1]) # To avoid pyright `reportPossiblyUnboundVariable` - circuit.rz(q2, angle) + circuit.rz(nodes[-1], angle) for n2, n1 in pairwise(nodes[::-1]): - q1, q2 = outputs_mapping.index(n1), outputs_mapping.index(n2) - circuit.cnot(control=q1, target=q2) - LadderPass.add_basis_change(pexp, outputs_mapping, n2, circuit) + circuit.cnot(control=n1, target=n2) + LadderPass.add_basis_change(pexp, n2, circuit) - LadderPass.add_basis_change(pexp, outputs_mapping, nodes[0], circuit) + LadderPass.add_basis_change(pexp, nodes[0], circuit) @staticmethod - def add_basis_change(pexp: PauliExponential, outputs_mapping: NodeIndex, node: Node, circuit: Circuit) -> None: + def add_basis_change(pexp: PauliExponential, node: Node, circuit: Circuit) -> None: """Apply an X or a Y basis change to a given node if required by the Pauli string. This method modifies the input circuit in-place. @@ -214,11 +202,10 @@ def add_basis_change(pexp: PauliExponential, outputs_mapping: NodeIndex, node: N circuit : Circuit The quantum circuit to which the basis change is added. """ - qubit = outputs_mapping.index(node) if node in pexp.pauli_string.x_nodes: - circuit.h(qubit) + circuit.h(node) elif node in pexp.pauli_string.y_nodes: - LadderPass.add_hy(qubit, circuit) + LadderPass.add_hy(node, circuit) @staticmethod def add_hy(qubit: int, circuit: Circuit) -> None: diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 728202c9..fb69e96b 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -8,6 +8,7 @@ from graphix.fundamentals import ParameterizedAngle, Plane, Sign from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement +from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -136,6 +137,13 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: return PauliString(x_corrections, y_corrections, z_corrections, Sign.minus_if(negative_sign)) + def remap(self, outputs_mapping: NodeIndex) -> PauliString: + """Remap nodes.""" + x_nodes = {outputs_mapping.index(n) for n in self.x_nodes} + y_nodes = {outputs_mapping.index(n) for n in self.y_nodes} + z_nodes = {outputs_mapping.index(n) for n in self.z_nodes} + return PauliString(frozenset(x_nodes), frozenset(y_nodes), frozenset(z_nodes), self.sign) + @dataclass(frozen=True) class PauliExponential: @@ -191,6 +199,10 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen return PauliExponential(angle, pauli_string) + def remap(self, outputs_mapping: NodeIndex) -> PauliExponential: + """Remap nodes.""" + return PauliExponential(self.angle, self.pauli_string.remap(outputs_mapping)) + @dataclass(frozen=True) class PauliExponentialDAG: @@ -241,7 +253,13 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: ---------- [1] Simmons, 2021 (arXiv:2109.05654). """ - pauli_strings = {node: PauliExponential.from_measured_node(flow, node) for node in flow.correction_function} + outputs_mapping = NodeIndex() + outputs_mapping.extend(flow.og.output_nodes) + + pauli_strings = { + node: PauliExponential.from_measured_node(flow, node).remap(outputs_mapping) + for node in flow.correction_function + } return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 36fc1b3a..2dee0ca6 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -33,7 +33,7 @@ def test_add_circuit(self, fx_rng: Generator) -> None: outputs_mapping = NodeIndex() outputs_mapping.extend([2, 1, 3, 4]) - LadderPass.add_pexp(pexp, outputs_mapping, qc) # `qc` is modified in place + LadderPass.add_pexp(pexp.remap(outputs_mapping), qc) # `qc` is modified in place qc_ref = Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, angle_rz), CNOT(0, 3), CNOT(3, 1), H(1)]) From e971fdedccdbf77b31cdc204fb9c21761b0915c7 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 26 Feb 2026 18:47:15 +0100 Subject: [PATCH 16/26] Update tests --- graphix/circ_ext/extraction.py | 5 +++++ tests/test_circ_extraction.py | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index fb69e96b..014c201b 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -263,6 +263,11 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) + def remap(self, outputs_mapping: NodeIndex) -> PauliExponentialDAG: + """Remap nodes.""" + pauli_exponentials = {node: pexp.remap(outputs_mapping) for node, pexp in self.pauli_exponentials.items()} + return PauliExponentialDAG(pauli_exponentials, self.partial_order_layers, self.output_nodes) + @dataclass(frozen=True) class CliffordMap: diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 2dee0ca6..a518ad74 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -113,7 +113,9 @@ class TestPauliExponential: ) def test_to_circuit(self, test_case: PauliExpTestCase) -> None: qc = Circuit(len(test_case.p_exp.output_nodes)) - LadderPass.add_to_circuit(test_case.p_exp, qc) + outputs_mapping = NodeIndex() + outputs_mapping.extend(test_case.p_exp.output_nodes) + LadderPass.add_to_circuit(test_case.p_exp.remap(outputs_mapping), qc) state = qc.simulate_statevector().statevec state_ref = test_case.qc.simulate_statevector().statevec assert state.isclose(state_ref) @@ -166,7 +168,9 @@ def test_from_focused_flow(self) -> None: output_nodes=flow.og.output_nodes, ) - assert pexp_dag == pexp_dag_ref + outputs_mapping = NodeIndex() + outputs_mapping.extend(pexp_dag_ref.output_nodes) + assert pexp_dag == pexp_dag_ref.remap(outputs_mapping) def test_extend_input() -> None: From 5903cf86149dc1835b311dde786aca4820722629 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 27 Feb 2026 10:07:03 +0100 Subject: [PATCH 17/26] Add mods remap --- graphix/circ_ext/compilation.py | 8 ++++++-- graphix/circ_ext/extraction.py | 10 ++-------- tests/test_circ_extraction.py | 4 +--- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index a56dc515..baa59569 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -9,6 +9,7 @@ from graphix.circ_ext.extraction import PauliExponentialDAG from graphix.fundamentals import ANGLE_PI +from graphix.sim.base_backend import NodeIndex from graphix.transpiler import Circuit if TYPE_CHECKING: @@ -61,8 +62,11 @@ def er_to_circuit(self, er: ExtractionResult) -> Circuit: ) n_qubits = len(er.pexp_dag.output_nodes) circuit = Circuit(n_qubits) + outputs_mapping = NodeIndex() + outputs_mapping.extend(er.pexp_dag.output_nodes) + self.cm_cp.add_to_circuit(er.clifford_map, circuit) - self.pexp_cp.add_to_circuit(er.pexp_dag, circuit) + self.pexp_cp.add_to_circuit(er.pexp_dag.remap(outputs_mapping), circuit) return circuit @@ -155,7 +159,7 @@ def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: if pexp.angle == 0: # No rotation return - nodes = list(pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes) + nodes = sorted(pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes) angle = -2 * pexp.angle * pexp.pauli_string.sign if len(nodes) == 0: # Identity diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 014c201b..268e80ea 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -8,7 +8,6 @@ from graphix.fundamentals import ParameterizedAngle, Plane, Sign from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement -from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -18,6 +17,7 @@ from graphix.command import Node from graphix.flow.core import PauliFlow from graphix.opengraph import OpenGraph + from graphix.sim.base_backend import NodeIndex from graphix.transpiler import Circuit @@ -253,13 +253,7 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: ---------- [1] Simmons, 2021 (arXiv:2109.05654). """ - outputs_mapping = NodeIndex() - outputs_mapping.extend(flow.og.output_nodes) - - pauli_strings = { - node: PauliExponential.from_measured_node(flow, node).remap(outputs_mapping) - for node in flow.correction_function - } + pauli_strings = {node: PauliExponential.from_measured_node(flow, node) for node in flow.correction_function} return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index a518ad74..3c304726 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -168,9 +168,7 @@ def test_from_focused_flow(self) -> None: output_nodes=flow.og.output_nodes, ) - outputs_mapping = NodeIndex() - outputs_mapping.extend(pexp_dag_ref.output_nodes) - assert pexp_dag == pexp_dag_ref.remap(outputs_mapping) + assert pexp_dag == pexp_dag_ref def test_extend_input() -> None: From fd5b48e57516dc6acb830d64b4ca9bbe36aba5ac Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 27 Feb 2026 10:32:15 +0100 Subject: [PATCH 18/26] Up docs --- graphix/circ_ext/compilation.py | 2 -- graphix/circ_ext/extraction.py | 23 ++++++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index baa59569..e08db8fa 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -199,8 +199,6 @@ def add_basis_change(pexp: PauliExponential, node: Node, circuit: Circuit) -> No ---------- pexp : PauliExponential The Pauli exponential under consideration. - outputs_mapping : NodeIndex - Mapping between node numbers of the original MBQC pattern or open graph and qubit indices of the circuit. node : Node The node on which the basis-change operation is performed. circuit : Circuit diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 268e80ea..bbc9e649 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -138,7 +138,18 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: return PauliString(x_corrections, y_corrections, z_corrections, Sign.minus_if(negative_sign)) def remap(self, outputs_mapping: NodeIndex) -> PauliString: - """Remap nodes.""" + """Remap nodes to qubit indices. + + Parameters + ---------- + outputs_mapping: NodeIndex + Mapping between node numbers of the original MBQC pattern or open graph and qubit indices of the circuit. + + Returns + ------- + PauliString + Pauli string defined on qubit indices. + """ x_nodes = {outputs_mapping.index(n) for n in self.x_nodes} y_nodes = {outputs_mapping.index(n) for n in self.y_nodes} z_nodes = {outputs_mapping.index(n) for n in self.z_nodes} @@ -200,7 +211,10 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen return PauliExponential(angle, pauli_string) def remap(self, outputs_mapping: NodeIndex) -> PauliExponential: - """Remap nodes.""" + """Remap nodes to qubit indices. + + See documentation in :meth:`PauliString.remap` for additional information. + """ return PauliExponential(self.angle, self.pauli_string.remap(outputs_mapping)) @@ -258,7 +272,10 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) def remap(self, outputs_mapping: NodeIndex) -> PauliExponentialDAG: - """Remap nodes.""" + """Remap nodes to qubit indices. + + See documentation in :meth:`PauliString.remap` for additional information. + """ pauli_exponentials = {node: pexp.remap(outputs_mapping) for node, pexp in self.pauli_exponentials.items()} return PauliExponentialDAG(pauli_exponentials, self.partial_order_layers, self.output_nodes) From c959197a597c024fbfd559eb1e33783a420f6d68 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 2 Mar 2026 09:49:32 +0100 Subject: [PATCH 19/26] wip --- graphix/circ_ext/compilation.py | 62 ++++++++++++++++++--------------- graphix/circ_ext/extraction.py | 15 ++++---- tests/test_circ_extraction.py | 4 +-- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index e08db8fa..3cd41f7d 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG - from graphix.command import Node @dataclass(frozen=True) @@ -66,12 +65,15 @@ def er_to_circuit(self, er: ExtractionResult) -> Circuit: outputs_mapping.extend(er.pexp_dag.output_nodes) self.cm_cp.add_to_circuit(er.clifford_map, circuit) - self.pexp_cp.add_to_circuit(er.pexp_dag.remap(outputs_mapping), circuit) + self.pexp_cp.add_to_circuit(er.pexp_dag.remap(outputs_mapping.index), circuit) return circuit class PauliExponentialDAGCompilationPass(ABC): - """Abstract base class to implement a compilation procedure for a Pauli Exponential DAG.""" + """Abstract base class to implement a compilation procedure for a Pauli Exponential DAG. + + Methods defined in this class must assume that the Pauli Exponential DAG has been remap, i.e., its Pauli strings are defined on qubit indices and not on node values. See :meth:`PauliString.remap` for additional information. + """ @staticmethod @abstractmethod @@ -159,39 +161,41 @@ def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: if pexp.angle == 0: # No rotation return - nodes = sorted(pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes) + # We assume that nodes in the Pauli strings have been mapped to qubits. + modified_qubits = [qubit for qubit in range(circuit.width) if qubit in pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes] angle = -2 * pexp.angle * pexp.pauli_string.sign - if len(nodes) == 0: # Identity + if len(modified_qubits) == 0: # Identity return - if len(nodes) == 1: - n0 = nodes[0] - if n0 in pexp.pauli_string.x_nodes: - circuit.rx(n0, angle) - elif n0 in pexp.pauli_string.y_nodes: - circuit.ry(n0, angle) + q0 = modified_qubits[0] + + if len(modified_qubits) == 1: + if q0 in pexp.pauli_string.x_nodes: + circuit.rx(q0, angle) + elif q0 in pexp.pauli_string.y_nodes: + circuit.ry(q0, angle) else: - circuit.rz(n0, angle) + circuit.rz(q0, angle) return - LadderPass.add_basis_change(pexp, nodes[0], circuit) + LadderPass.add_basis_change(pexp, q0, circuit) - for n1, n2 in pairwise(nodes): - LadderPass.add_basis_change(pexp, n2, circuit) - circuit.cnot(control=n1, target=n2) + for q1, q2 in pairwise(modified_qubits): + LadderPass.add_basis_change(pexp, q2, circuit) + circuit.cnot(control=q1, target=q2) - circuit.rz(nodes[-1], angle) + circuit.rz(modified_qubits[-1], angle) - for n2, n1 in pairwise(nodes[::-1]): - circuit.cnot(control=n1, target=n2) - LadderPass.add_basis_change(pexp, n2, circuit) + for q2, q1 in pairwise(modified_qubits[::-1]): + circuit.cnot(control=q1, target=q2) + LadderPass.add_basis_change(pexp, q2, circuit) - LadderPass.add_basis_change(pexp, nodes[0], circuit) + LadderPass.add_basis_change(pexp, modified_qubits[0], circuit) @staticmethod - def add_basis_change(pexp: PauliExponential, node: Node, circuit: Circuit) -> None: - """Apply an X or a Y basis change to a given node if required by the Pauli string. + def add_basis_change(pexp: PauliExponential, qubit: int, circuit: Circuit) -> None: + """Apply an X or a Y basis change to a given qubit if required by the Pauli string. This method modifies the input circuit in-place. @@ -199,15 +203,15 @@ def add_basis_change(pexp: PauliExponential, node: Node, circuit: Circuit) -> No ---------- pexp : PauliExponential The Pauli exponential under consideration. - node : Node - The node on which the basis-change operation is performed. + qubit : int + The qubit on which the basis-change operation is performed. circuit : Circuit The quantum circuit to which the basis change is added. """ - if node in pexp.pauli_string.x_nodes: - circuit.h(node) - elif node in pexp.pauli_string.y_nodes: - LadderPass.add_hy(node, circuit) + if qubit in pexp.pauli_string.x_nodes: + circuit.h(qubit) + elif qubit in pexp.pauli_string.y_nodes: + LadderPass.add_hy(qubit, circuit) @staticmethod def add_hy(qubit: int, circuit: Circuit) -> None: diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index bbc9e649..0eedaf17 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -10,14 +10,13 @@ from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Callable, Mapping, Sequence from collections.abc import Set as AbstractSet from graphix.circ_ext.compilation import CompilationPass from graphix.command import Node from graphix.flow.core import PauliFlow from graphix.opengraph import OpenGraph - from graphix.sim.base_backend import NodeIndex from graphix.transpiler import Circuit @@ -137,7 +136,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: return PauliString(x_corrections, y_corrections, z_corrections, Sign.minus_if(negative_sign)) - def remap(self, outputs_mapping: NodeIndex) -> PauliString: + def remap(self, outputs_mapping: Callable[[int], int]) -> PauliString: """Remap nodes to qubit indices. Parameters @@ -150,9 +149,9 @@ def remap(self, outputs_mapping: NodeIndex) -> PauliString: PauliString Pauli string defined on qubit indices. """ - x_nodes = {outputs_mapping.index(n) for n in self.x_nodes} - y_nodes = {outputs_mapping.index(n) for n in self.y_nodes} - z_nodes = {outputs_mapping.index(n) for n in self.z_nodes} + x_nodes = {outputs_mapping(n) for n in self.x_nodes} + y_nodes = {outputs_mapping(n) for n in self.y_nodes} + z_nodes = {outputs_mapping(n) for n in self.z_nodes} return PauliString(frozenset(x_nodes), frozenset(y_nodes), frozenset(z_nodes), self.sign) @@ -210,7 +209,7 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen return PauliExponential(angle, pauli_string) - def remap(self, outputs_mapping: NodeIndex) -> PauliExponential: + def remap(self, outputs_mapping: Callable[[int], int]) -> PauliExponential: """Remap nodes to qubit indices. See documentation in :meth:`PauliString.remap` for additional information. @@ -271,7 +270,7 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) - def remap(self, outputs_mapping: NodeIndex) -> PauliExponentialDAG: + def remap(self, outputs_mapping: Callable[[int], int]) -> PauliExponentialDAG: """Remap nodes to qubit indices. See documentation in :meth:`PauliString.remap` for additional information. diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 3c304726..0f5485c7 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -33,7 +33,7 @@ def test_add_circuit(self, fx_rng: Generator) -> None: outputs_mapping = NodeIndex() outputs_mapping.extend([2, 1, 3, 4]) - LadderPass.add_pexp(pexp.remap(outputs_mapping), qc) # `qc` is modified in place + LadderPass.add_pexp(pexp.remap(outputs_mapping.index), qc) # `qc` is modified in place qc_ref = Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, angle_rz), CNOT(0, 3), CNOT(3, 1), H(1)]) @@ -115,7 +115,7 @@ def test_to_circuit(self, test_case: PauliExpTestCase) -> None: qc = Circuit(len(test_case.p_exp.output_nodes)) outputs_mapping = NodeIndex() outputs_mapping.extend(test_case.p_exp.output_nodes) - LadderPass.add_to_circuit(test_case.p_exp.remap(outputs_mapping), qc) + LadderPass.add_to_circuit(test_case.p_exp.remap(outputs_mapping.index), qc) state = qc.simulate_statevector().statevec state_ref = test_case.qc.simulate_statevector().statevec assert state.isclose(state_ref) From 4e928fd712aa1bea610c006fdd7e3319fdcb4959 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 2 Mar 2026 11:56:13 +0100 Subject: [PATCH 20/26] Add test checking order of qubits. Add remap method to CliffordMap --- graphix/circ_ext/compilation.py | 23 +++++++++++++++-------- graphix/circ_ext/extraction.py | 27 ++++++++++++++++++--------- tests/test_circ_extraction.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 3cd41f7d..f4b78d74 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -34,7 +34,7 @@ class CompilationPass: def er_to_circuit(self, er: ExtractionResult) -> Circuit: """Convert a circuit extraction result into a quantum circuit representation. - This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) extraction result. It performs a validation check to ensure that the output nodes of both components are identical. + This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) in the extraction result. It performs a validation check to ensure that the output nodes of both components are identical and it maps the output node numbers to qubit indices. Parameters ---------- @@ -64,7 +64,7 @@ def er_to_circuit(self, er: ExtractionResult) -> Circuit: outputs_mapping = NodeIndex() outputs_mapping.extend(er.pexp_dag.output_nodes) - self.cm_cp.add_to_circuit(er.clifford_map, circuit) + self.cm_cp.add_to_circuit(er.clifford_map.remap(outputs_mapping.index), circuit) self.pexp_cp.add_to_circuit(er.pexp_dag.remap(outputs_mapping.index), circuit) return circuit @@ -72,7 +72,7 @@ def er_to_circuit(self, er: ExtractionResult) -> Circuit: class PauliExponentialDAGCompilationPass(ABC): """Abstract base class to implement a compilation procedure for a Pauli Exponential DAG. - Methods defined in this class must assume that the Pauli Exponential DAG has been remap, i.e., its Pauli strings are defined on qubit indices and not on node values. See :meth:`PauliString.remap` for additional information. + Methods defined in this class must assume that the Pauli Exponential DAG has been remap, i.e., its Pauli strings are defined on qubit indices instead of output nodes. See :meth:`PauliString.remap` for additional information. """ @staticmethod @@ -85,14 +85,17 @@ def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: Parameters ---------- pexp_dag: PauliExponentialDAG - The Pauli exponential rotation to be added to the circuit. + The Pauli exponential rotation to be added to the circuit. Its Pauli strings are assumed to be defined on qubit indices. circuit : Circuit The circuit to which the operation is added. The input circuit is assumed to be compatible with ``pexp_dag.output_nodes``. """ class CliffordMapCompilationPass(ABC): - """Abstract base class to implement a compilation procedure for a Clifford Map.""" + """Abstract base class to implement a compilation procedure for a Clifford Map. + + Methods defined in this class must assume that the Clifford Map has been remap, i.e., its Pauli strings are defined on qubit indices instead of output nodes. See :meth:`PauliString.remap` for additional information. + """ @staticmethod @abstractmethod @@ -104,7 +107,7 @@ def add_to_circuit(clifford_map: CliffordMap, circuit: Circuit) -> None: Parameters ---------- clifford_map: CliffordMap - The Clifford map to be added to the circuit. + The Clifford map to be added to the circuit. Its Pauli strings are assumed to be defined on qubit indices. circuit : Circuit The quantum circuit to which the Clifford map is added. The input circuit is assumed to be compatible with ``clifford_map.output_nodes``. @@ -156,13 +159,17 @@ def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: Notes ----- - It is assumed that the ``x``, ``y``, and ``z`` node sets of the Pauli string in the exponential are well-formed, i.e., contain only output nodes and are pairwise disjoint. + It is assumed that the ``x``, ``y``, and ``z`` node sets of the Pauli string in the exponential are well-formed, i.e., contain valid qubit indices and are pairwise disjoint. """ if pexp.angle == 0: # No rotation return # We assume that nodes in the Pauli strings have been mapped to qubits. - modified_qubits = [qubit for qubit in range(circuit.width) if qubit in pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes] + modified_qubits = [ + qubit + for qubit in range(circuit.width) + if qubit in pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes + ] angle = -2 * pexp.angle * pexp.pauli_string.sign if len(modified_qubits) == 0: # Identity diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 0eedaf17..f0229096 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -4,7 +4,7 @@ import dataclasses from dataclasses import dataclass, replace -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self from graphix.fundamentals import ParameterizedAngle, Plane, Sign from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement @@ -136,13 +136,13 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliString: return PauliString(x_corrections, y_corrections, z_corrections, Sign.minus_if(negative_sign)) - def remap(self, outputs_mapping: Callable[[int], int]) -> PauliString: + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: """Remap nodes to qubit indices. Parameters ---------- - outputs_mapping: NodeIndex - Mapping between node numbers of the original MBQC pattern or open graph and qubit indices of the circuit. + outputs_mapping: Callable[[int], int] + Mapping between node numbers of the original MBQC pattern or open graph and qubit indices of a quantum circuit. Returns ------- @@ -152,7 +152,7 @@ def remap(self, outputs_mapping: Callable[[int], int]) -> PauliString: x_nodes = {outputs_mapping(n) for n in self.x_nodes} y_nodes = {outputs_mapping(n) for n in self.y_nodes} z_nodes = {outputs_mapping(n) for n in self.z_nodes} - return PauliString(frozenset(x_nodes), frozenset(y_nodes), frozenset(z_nodes), self.sign) + return replace(self, x_nodes=frozenset(x_nodes), y_nodes=frozenset(y_nodes), z_nodes=frozenset(z_nodes)) @dataclass(frozen=True) @@ -209,12 +209,12 @@ def from_measured_node(flow: PauliFlow[Measurement], node: Node) -> PauliExponen return PauliExponential(angle, pauli_string) - def remap(self, outputs_mapping: Callable[[int], int]) -> PauliExponential: + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: """Remap nodes to qubit indices. See documentation in :meth:`PauliString.remap` for additional information. """ - return PauliExponential(self.angle, self.pauli_string.remap(outputs_mapping)) + return replace(self, pauli_string=self.pauli_string.remap(outputs_mapping)) @dataclass(frozen=True) @@ -270,13 +270,13 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> PauliExponentialDAG: return PauliExponentialDAG(pauli_strings, flow.partial_order_layers, flow.og.output_nodes) - def remap(self, outputs_mapping: Callable[[int], int]) -> PauliExponentialDAG: + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: """Remap nodes to qubit indices. See documentation in :meth:`PauliString.remap` for additional information. """ pauli_exponentials = {node: pexp.remap(outputs_mapping) for node, pexp in self.pauli_exponentials.items()} - return PauliExponentialDAG(pauli_exponentials, self.partial_order_layers, self.output_nodes) + return replace(self, pauli_exponentials=pauli_exponentials) @dataclass(frozen=True) @@ -337,6 +337,15 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> CliffordMap: x_map = clifford_x_map_from_focused_flow(flow) return CliffordMap(x_map, z_map, flow.og.input_nodes, flow.og.output_nodes) + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: + """Remap nodes to qubit indices. + + See documentation in :meth:`PauliString.remap` for additional information. + """ + x_map = {node: ps.remap(outputs_mapping) for node, ps in self.x_map.items()} + z_map = {node: ps.remap(outputs_mapping) for node, ps in self.z_map.items()} + return replace(self, x_map=x_map, z_map=z_map) + def extend_input(og: OpenGraph[Measurement]) -> tuple[OpenGraph[Measurement], dict[int, int]]: r"""Extend the inputs of a given open graph. diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 0f5485c7..e98b0084 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -120,6 +120,34 @@ def test_to_circuit(self, test_case: PauliExpTestCase) -> None: state_ref = test_case.qc.simulate_statevector().statevec assert state.isclose(state_ref) + def test_to_circuit_outputs_order(self) -> None: + pexp_map = {2: PauliExponential(0.1, PauliString(x_nodes=frozenset({1}), z_nodes=frozenset({0})))} + pol = [{0, 1}, {2}] + + outputs_1 = [0, 1] + outputs_2 = [1, 0] + + pexp_dag_1 = PauliExponentialDAG(pauli_exponentials=pexp_map, partial_order_layers=pol, output_nodes=outputs_1) + qc_1 = Circuit(2) + outputs_mapping_1 = NodeIndex() + outputs_mapping_1.extend(pexp_dag_1.output_nodes) + LadderPass.add_to_circuit(pexp_dag_1.remap(outputs_mapping_1.index), qc_1) + s_1 = qc_1.simulate_statevector().statevec + + pexp_dag_2 = PauliExponentialDAG(pauli_exponentials=pexp_map, partial_order_layers=pol, output_nodes=outputs_2) + qc_2 = Circuit(2) + outputs_mapping_2 = NodeIndex() + outputs_mapping_2.extend(pexp_dag_2.output_nodes) + LadderPass.add_to_circuit(pexp_dag_2.remap(outputs_mapping_2.index), qc_2) + + s_2 = qc_2.simulate_statevector().statevec + assert not s_1.isclose(s_2) + + qc_2.swap(0, 1) + s_2 = qc_2.simulate_statevector().statevec + + assert s_1.isclose(s_2) + def test_from_focused_flow(self) -> None: """Test example C.13. in Simmons, 2021.""" og = OpenGraph( From 568d4f9f1833bd4509ec8b82c003827ef374bb21 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 2 Mar 2026 12:03:49 +0100 Subject: [PATCH 21/26] Up CHANGELOG --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82dc15bb..0261fa55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #450: `Circuit.visit` and `BaseInstruction.visit` performs simple replacements on circuits and instructions, given an `InstructionVisitor`. +- #445: Circuit extraction. + - Added `PauliFlow.is_focused` to verify if a Pauli flow is focused and `PauliFlow.pauli_strings` associating a Pauli string to the every corrected node. + - Added `PauliFlow.extract_circuit` + - Added new module `graphix.circ_ext.extraction` to extract circuits from Pauli flows with the following new classes: + - `ExtractionResult` + - `PauliString` + - `PauliExponential` + - `PauliExponentialDAG` + - `CliffordMap` + - Added new module `graphix.circ_ext.compilation` to perform the transformation `ExtractionResult` -> `Graphix circuit` with the following new classes: + - `CompilationPass` + - `PauliExponentialDAGCompilationPass` + - `CliffordMapCompilationPass` + - `LadderPass` + + ### Fixed - #429 From c971b73de37e27903b4d89c37f08a7b7333868ed Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 2 Mar 2026 12:57:40 +0100 Subject: [PATCH 22/26] Remap input nodes of clifford map --- graphix/circ_ext/compilation.py | 5 ++++- graphix/circ_ext/extraction.py | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index f4b78d74..257e02c9 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -64,7 +64,10 @@ def er_to_circuit(self, er: ExtractionResult) -> Circuit: outputs_mapping = NodeIndex() outputs_mapping.extend(er.pexp_dag.output_nodes) - self.cm_cp.add_to_circuit(er.clifford_map.remap(outputs_mapping.index), circuit) + inputs_mapping = NodeIndex() + inputs_mapping.extend(er.clifford_map.input_nodes) + + self.cm_cp.add_to_circuit(er.clifford_map.remap(inputs_mapping.index, outputs_mapping.index), circuit) self.pexp_cp.add_to_circuit(er.pexp_dag.remap(outputs_mapping.index), circuit) return circuit diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index f0229096..bdc30d31 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -337,13 +337,23 @@ def from_focused_flow(flow: PauliFlow[Measurement]) -> CliffordMap: x_map = clifford_x_map_from_focused_flow(flow) return CliffordMap(x_map, z_map, flow.og.input_nodes, flow.og.output_nodes) - def remap(self, outputs_mapping: Callable[[int], int]) -> Self: + def remap(self, inputs_mapping: Callable[[int], int], outputs_mapping: Callable[[int], int]) -> Self: """Remap nodes to qubit indices. - See documentation in :meth:`PauliString.remap` for additional information. + Parameters + ---------- + inputs_mapping: Callable[[int], int] + Mapping between input node numbers of the original MBQC pattern or open graph and qubit indices of a quantum circuit. + outputs_mapping: Callable[[int], int] + Mapping between output node numbers of the original MBQC pattern or open graph and qubit indices of a quantum circuit. + + Returns + ------- + CliffordMap + Clifford map defined on qubit indices. """ - x_map = {node: ps.remap(outputs_mapping) for node, ps in self.x_map.items()} - z_map = {node: ps.remap(outputs_mapping) for node, ps in self.z_map.items()} + x_map = {inputs_mapping(node): ps.remap(outputs_mapping) for node, ps in self.x_map.items()} + z_map = {inputs_mapping(node): ps.remap(outputs_mapping) for node, ps in self.z_map.items()} return replace(self, x_map=x_map, z_map=z_map) From 3a6a3021f87bd496c564a3800ff9e4a0b6d12825 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 2 Mar 2026 13:00:52 +0100 Subject: [PATCH 23/26] Import Self from typing_extensions --- graphix/circ_ext/extraction.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index bdc30d31..c558fb88 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -4,7 +4,9 @@ import dataclasses from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING + +from typing_extensions import Self # Self introduced in 3.11 from graphix.fundamentals import ParameterizedAngle, Plane, Sign from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement From 7e5e450a370ea814ebbffa891efc64a5fc5d5ed6 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 3 Mar 2026 17:36:42 +0100 Subject: [PATCH 24/26] Turn static classes into functions and define default passes --- graphix/circ_ext/compilation.py | 184 +++++++++++--------------------- graphix/circ_ext/extraction.py | 12 ++- noxfile.py | 1 + tests/test_circ_extraction.py | 11 +- 4 files changed, 81 insertions(+), 127 deletions(-) diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 257e02c9..6ed41197 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -2,126 +2,20 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from dataclasses import dataclass from itertools import chain, pairwise from typing import TYPE_CHECKING -from graphix.circ_ext.extraction import PauliExponentialDAG from graphix.fundamentals import ANGLE_PI from graphix.sim.base_backend import NodeIndex from graphix.transpiler import Circuit if TYPE_CHECKING: - from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG - - -@dataclass(frozen=True) -class CompilationPass: - """Dataclass to bundle the two compilation passes necessary to obtain a quantum circuit from a `ExtractionResult`. - - Attributes - ---------- - pexp_cp: PauliExponentialDAGCompilationPass - Compilation pass to synthesize a Pauli exponential DAG. - cm_cp: CliffordMapCompilationPass - Compilation pass to synthesize a Clifford map. - """ - - pexp_cp: PauliExponentialDAGCompilationPass - cm_cp: CliffordMapCompilationPass - - def er_to_circuit(self, er: ExtractionResult) -> Circuit: - """Convert a circuit extraction result into a quantum circuit representation. - - This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) in the extraction result. It performs a validation check to ensure that the output nodes of both components are identical and it maps the output node numbers to qubit indices. - - Parameters - ---------- - er : ExtractionResult - The result of the extraction process, containing both the ``clifford_map`` and the ``pexp_dag``. - - Returns - ------- - Circuit - A quantum circuit that combines the Clifford map operations followed by the Pauli exponential operations. - - Raises - ------ - ValueError - If the output nodes of ``er.pexp_dag`` and ``er.clifford_map`` do not match, indicating an incompatible extraction result. - - Notes - ----- - The conversion relies on the internal compilation passes ``self.cm_cp`` (Clifford Map Circuit Processor) and ``self.pexp_cp`` (Pauli Exponential Circuit Processor) to handle the low-level circuit synthesis. - """ - if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): - raise ValueError( - "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." - ) - n_qubits = len(er.pexp_dag.output_nodes) - circuit = Circuit(n_qubits) - outputs_mapping = NodeIndex() - outputs_mapping.extend(er.pexp_dag.output_nodes) - - inputs_mapping = NodeIndex() - inputs_mapping.extend(er.clifford_map.input_nodes) - - self.cm_cp.add_to_circuit(er.clifford_map.remap(inputs_mapping.index, outputs_mapping.index), circuit) - self.pexp_cp.add_to_circuit(er.pexp_dag.remap(outputs_mapping.index), circuit) - return circuit - - -class PauliExponentialDAGCompilationPass(ABC): - """Abstract base class to implement a compilation procedure for a Pauli Exponential DAG. - - Methods defined in this class must assume that the Pauli Exponential DAG has been remap, i.e., its Pauli strings are defined on qubit indices instead of output nodes. See :meth:`PauliString.remap` for additional information. - """ - - @staticmethod - @abstractmethod - def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: - r"""Add a Pauli exponential DAG to a circuit. - - The input circuit is modified in-place. - - Parameters - ---------- - pexp_dag: PauliExponentialDAG - The Pauli exponential rotation to be added to the circuit. Its Pauli strings are assumed to be defined on qubit indices. - circuit : Circuit - The circuit to which the operation is added. The input circuit is assumed to be compatible with ``pexp_dag.output_nodes``. - """ - - -class CliffordMapCompilationPass(ABC): - """Abstract base class to implement a compilation procedure for a Clifford Map. - - Methods defined in this class must assume that the Clifford Map has been remap, i.e., its Pauli strings are defined on qubit indices instead of output nodes. See :meth:`PauliString.remap` for additional information. - """ - - @staticmethod - @abstractmethod - def add_to_circuit(clifford_map: CliffordMap, circuit: Circuit) -> None: - """Add the Clifford map to a quantum circuit. - - The input circuit is modified in-place. - - Parameters - ---------- - clifford_map: CliffordMap - The Clifford map to be added to the circuit. Its Pauli strings are assumed to be defined on qubit indices. - circuit : Circuit - The quantum circuit to which the Clifford map is added. The input circuit is assumed to be compatible with ``clifford_map.output_nodes``. + from collections.abc import Callable - Raises - ------ - NotImplementedError - If the Clifford map represents an isometry, i.e., ``len(clifford_map.input_nodes) != len(clifford_map.output_nodes)``. - """ + from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG -class LadderPass(PauliExponentialDAGCompilationPass): +def ladder_pass(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: r"""Compilation pass to synthetize a Pauli exponential DAG by using a ladder decomposition. Pauli exponentials in the DAG are compiled sequentially following an arbitrary total order compatible with the DAG. Each Pauli exponential is decomposed into a sequence of basis changes, CNOT gates, and a single :math:`R_Z` rotation: @@ -139,7 +33,6 @@ class LadderPass(PauliExponentialDAGCompilationPass): See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 for additional information. """ - @staticmethod def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: """Add a Pauli exponential DAG to a circuit. @@ -147,9 +40,8 @@ def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: """ for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): pexp = pexp_dag.pauli_exponentials[node] - LadderPass.add_pexp(pexp, circuit) + add_pexp(pexp, circuit) - @staticmethod def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: r"""Add the Pauli exponential unitary to a quantum circuit. @@ -189,21 +81,20 @@ def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: circuit.rz(q0, angle) return - LadderPass.add_basis_change(pexp, q0, circuit) + add_basis_change(pexp, q0, circuit) for q1, q2 in pairwise(modified_qubits): - LadderPass.add_basis_change(pexp, q2, circuit) + add_basis_change(pexp, q2, circuit) circuit.cnot(control=q1, target=q2) circuit.rz(modified_qubits[-1], angle) for q2, q1 in pairwise(modified_qubits[::-1]): circuit.cnot(control=q1, target=q2) - LadderPass.add_basis_change(pexp, q2, circuit) + add_basis_change(pexp, q2, circuit) - LadderPass.add_basis_change(pexp, modified_qubits[0], circuit) + add_basis_change(pexp, modified_qubits[0], circuit) - @staticmethod def add_basis_change(pexp: PauliExponential, qubit: int, circuit: Circuit) -> None: """Apply an X or a Y basis change to a given qubit if required by the Pauli string. @@ -221,9 +112,8 @@ def add_basis_change(pexp: PauliExponential, qubit: int, circuit: Circuit) -> No if qubit in pexp.pauli_string.x_nodes: circuit.h(qubit) elif qubit in pexp.pauli_string.y_nodes: - LadderPass.add_hy(qubit, circuit) + add_hy(qubit, circuit) - @staticmethod def add_hy(qubit: int, circuit: Circuit) -> None: """Add a pi rotation around the z + y axis. @@ -232,3 +122,59 @@ def add_hy(qubit: int, circuit: Circuit) -> None: circuit.rz(qubit, ANGLE_PI / 2) circuit.ry(qubit, ANGLE_PI / 2) circuit.rz(qubit, ANGLE_PI / 2) + + for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): + pexp = pexp_dag.pauli_exponentials[node] + add_pexp(pexp, circuit) + + +def er_to_circuit( + er: ExtractionResult, + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None = None, + cm_cp: Callable[[CliffordMap, Circuit], None] | None = None, +) -> Circuit: + """Convert a circuit extraction result into a quantum circuit representation. + + This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) in the extraction result. It performs a validation check to ensure that the output nodes of both components are identical and it maps the output node numbers to qubit indices. + + Parameters + ---------- + er : ExtractionResult + The result of the extraction process, containing both the ``clifford_map`` and the ``pexp_dag``. + + Returns + ------- + Circuit + A quantum circuit that combines the Clifford map operations followed by the Pauli exponential operations. + + Raises + ------ + ValueError + If the output nodes of ``er.pexp_dag`` and ``er.clifford_map`` do not match, indicating an incompatible extraction result. + + Notes + ----- + The conversion relies on the internal compilation passes ``self.cm_cp`` (Clifford Map Circuit Processor) and ``self.pexp_cp`` (Pauli Exponential Circuit Processor) to handle the low-level circuit synthesis. + """ + if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): + raise ValueError( + "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." + ) + if pexp_cp is None: + pexp_cp = ladder_pass + if cm_cp is None: + raise ValueError( + "Clifford-map pass is missing: there is still no default pass for Clifford map integrated in Graphix. You may use graphix-stim-compiler plugin." + ) + + n_qubits = len(er.pexp_dag.output_nodes) + circuit = Circuit(n_qubits) + outputs_mapping = NodeIndex() + outputs_mapping.extend(er.pexp_dag.output_nodes) + + inputs_mapping = NodeIndex() + inputs_mapping.extend(er.clifford_map.input_nodes) + + cm_cp(er.clifford_map.remap(inputs_mapping.index, outputs_mapping.index), circuit) + pexp_cp(er.pexp_dag.remap(outputs_mapping.index), circuit) + return circuit diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index c558fb88..9d07d0f5 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -15,7 +15,6 @@ from collections.abc import Callable, Mapping, Sequence from collections.abc import Set as AbstractSet - from graphix.circ_ext.compilation import CompilationPass from graphix.command import Node from graphix.flow.core import PauliFlow from graphix.opengraph import OpenGraph @@ -46,7 +45,11 @@ class ExtractionResult: pexp_dag: PauliExponentialDAG clifford_map: CliffordMap - def to_circuit(self, cp: CompilationPass) -> Circuit: + def to_circuit( + self, + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None = None, + cm_cp: Callable[[CliffordMap, Circuit], None] | None = None, + ) -> Circuit: """Transpile the extraction result to circuit. Transpilation is only supported when the pair Pauli-exponential DAG and Clifford map represents a unitary transformation. @@ -61,7 +64,10 @@ def to_circuit(self, cp: CompilationPass) -> Circuit: Circuit Quantum circuit represented as a set of instructions. """ - return cp.er_to_circuit(self) + # Circumvent import loop + from graphix.circ_ext.compilation import er_to_circuit # noqa: PLC0415 + + return er_to_circuit(self, pexp_cp=pexp_cp, cm_cp=cm_cp) @dataclass(frozen=True) diff --git a/noxfile.py b/noxfile.py index 2cbd79e7..d34c2b36 100644 --- a/noxfile.py +++ b/noxfile.py @@ -130,6 +130,7 @@ class ReverseDependency: version_constraint=VersionRange(upper=Version("3.14")), doctest_modules=False, ), + ReverseDependency("https://github.com/matulni/graphix-stim-compiler"), ], ) def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> None: diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index e98b0084..475b7ddd 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -5,7 +5,7 @@ import networkx as nx import pytest -from graphix.circ_ext.compilation import LadderPass +from graphix.circ_ext.compilation import ladder_pass from graphix.circ_ext.extraction import PauliExponential, PauliExponentialDAG, PauliString, extend_input from graphix.flow.core import PauliFlow from graphix.fundamentals import ANGLE_PI, Sign @@ -33,7 +33,8 @@ def test_add_circuit(self, fx_rng: Generator) -> None: outputs_mapping = NodeIndex() outputs_mapping.extend([2, 1, 3, 4]) - LadderPass.add_pexp(pexp.remap(outputs_mapping.index), qc) # `qc` is modified in place + pexp_dag = PauliExponentialDAG({0: pexp}, [{1, 4, 2}, {0}], [1, 4, 2]) + ladder_pass(pexp_dag.remap(outputs_mapping.index), qc) # `qc` is modified in place qc_ref = Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, angle_rz), CNOT(0, 3), CNOT(3, 1), H(1)]) @@ -115,7 +116,7 @@ def test_to_circuit(self, test_case: PauliExpTestCase) -> None: qc = Circuit(len(test_case.p_exp.output_nodes)) outputs_mapping = NodeIndex() outputs_mapping.extend(test_case.p_exp.output_nodes) - LadderPass.add_to_circuit(test_case.p_exp.remap(outputs_mapping.index), qc) + ladder_pass(test_case.p_exp.remap(outputs_mapping.index), qc) state = qc.simulate_statevector().statevec state_ref = test_case.qc.simulate_statevector().statevec assert state.isclose(state_ref) @@ -131,14 +132,14 @@ def test_to_circuit_outputs_order(self) -> None: qc_1 = Circuit(2) outputs_mapping_1 = NodeIndex() outputs_mapping_1.extend(pexp_dag_1.output_nodes) - LadderPass.add_to_circuit(pexp_dag_1.remap(outputs_mapping_1.index), qc_1) + ladder_pass(pexp_dag_1.remap(outputs_mapping_1.index), qc_1) s_1 = qc_1.simulate_statevector().statevec pexp_dag_2 = PauliExponentialDAG(pauli_exponentials=pexp_map, partial_order_layers=pol, output_nodes=outputs_2) qc_2 = Circuit(2) outputs_mapping_2 = NodeIndex() outputs_mapping_2.extend(pexp_dag_2.output_nodes) - LadderPass.add_to_circuit(pexp_dag_2.remap(outputs_mapping_2.index), qc_2) + ladder_pass(pexp_dag_2.remap(outputs_mapping_2.index), qc_2) s_2 = qc_2.simulate_statevector().statevec assert not s_1.isclose(s_2) From cbb5e3edf6b6f1996320ef53a26906f256becf2e Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 3 Mar 2026 18:35:06 +0100 Subject: [PATCH 25/26] Up docs and tests --- CHANGELOG.md | 8 +- graphix/circ_ext/compilation.py | 144 +++++++++++++++++--------------- graphix/circ_ext/extraction.py | 6 +- tests/test_circ_extraction.py | 48 ++++------- 4 files changed, 98 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0261fa55..b1c86fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,11 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PauliExponential` - `PauliExponentialDAG` - `CliffordMap` - - Added new module `graphix.circ_ext.compilation` to perform the transformation `ExtractionResult` -> `Graphix circuit` with the following new classes: - - `CompilationPass` - - `PauliExponentialDAGCompilationPass` - - `CliffordMapCompilationPass` - - `LadderPass` + - Added new module `graphix.circ_ext.compilation` to perform the transformation `ExtractionResult` -> `Graphix circuit` with the following new functions: + - `er_to_circuit` + - `pexp_ladder_pass` ### Fixed diff --git a/graphix/circ_ext/compilation.py b/graphix/circ_ext/compilation.py index 6ed41197..b7d19b76 100644 --- a/graphix/circ_ext/compilation.py +++ b/graphix/circ_ext/compilation.py @@ -15,9 +15,73 @@ from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG -def ladder_pass(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: - r"""Compilation pass to synthetize a Pauli exponential DAG by using a ladder decomposition. +def er_to_circuit( + er: ExtractionResult, + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None = None, + cm_cp: Callable[[CliffordMap, Circuit], None] | None = None, +) -> Circuit: + """Convert a circuit extraction result into a quantum circuit representation. + + This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) in the extraction result. It performs a validation check to ensure that the output nodes of both components are identical and it maps the output node numbers to qubit indices. + + Parameters + ---------- + er : ExtractionResult + The result of the extraction process, containing both the ``clifford_map`` and the ``pexp_dag``. + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Pauli exponential DAG. If ``None`` (default), :func:`pexp_ladder_pass` is employed. + cm_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Clifford map. If ``None`` (default), a `ValueError` is raised since there is still no default pass for Clifford map integrated in Graphix. + + Returns + ------- + Circuit + A quantum circuit that combines the Clifford map operations followed by the Pauli exponential operations. + + Raises + ------ + ValueError + If the output nodes of ``er.pexp_dag`` and ``er.clifford_map`` do not match, indicating an incompatible extraction result. + """ + if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): + raise ValueError( + "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." + ) + if pexp_cp is None: + pexp_cp = pexp_ladder_pass + if cm_cp is None: + raise ValueError( + "Clifford-map pass is missing: there is still no default pass for Clifford map integrated in Graphix. You may use graphix-stim-compiler plugin." + ) + + n_qubits = len(er.pexp_dag.output_nodes) + circuit = Circuit(n_qubits) + outputs_mapping = NodeIndex() + outputs_mapping.extend(er.pexp_dag.output_nodes) + + inputs_mapping = NodeIndex() + inputs_mapping.extend(er.clifford_map.input_nodes) + + cm_cp(er.clifford_map.remap(inputs_mapping.index, outputs_mapping.index), circuit) + pexp_cp(er.pexp_dag.remap(outputs_mapping.index), circuit) + return circuit + + +def pexp_ladder_pass(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: + r"""Add a Pauli exponential DAG to a circuit by using a ladder decomposition. + + The input circuit is modified in-place. This function assumes that the Pauli exponential DAG has been remap, i.e., its Pauli strings are defined on qubit indices instead of output nodes. See :meth:`PauliString.remap` for additional information. + + Parameters + ---------- + pexp_dag: PauliExponentialDAG + The Pauli exponential rotation to be added to the circuit. Its Pauli strings are assumed to be defined on qubit indices. + circuit : Circuit + The circuit to which the operation is added. The input circuit is assumed to be compatible with ``pexp_dag.output_nodes``. + + Notes + ----- Pauli exponentials in the DAG are compiled sequentially following an arbitrary total order compatible with the DAG. Each Pauli exponential is decomposed into a sequence of basis changes, CNOT gates, and a single :math:`R_Z` rotation: .. math:: @@ -28,24 +92,13 @@ def ladder_pass(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: Gate set: H, CNOT, RZ, RY - Notes - ----- See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 for additional information. """ - def add_to_circuit(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: - """Add a Pauli exponential DAG to a circuit. - - See documentation in :meth:`PauliExponentialDAGCompilationPass.add_to_circuit` for additional information. - """ - for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): - pexp = pexp_dag.pauli_exponentials[node] - add_pexp(pexp, circuit) - def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: r"""Add the Pauli exponential unitary to a quantum circuit. - This method modifies the input circuit in-place. + This function modifies the input circuit in-place. Parameters ---------- @@ -98,7 +151,7 @@ def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: def add_basis_change(pexp: PauliExponential, qubit: int, circuit: Circuit) -> None: """Apply an X or a Y basis change to a given qubit if required by the Pauli string. - This method modifies the input circuit in-place. + This function modifies the input circuit in-place. Parameters ---------- @@ -117,7 +170,14 @@ def add_basis_change(pexp: PauliExponential, qubit: int, circuit: Circuit) -> No def add_hy(qubit: int, circuit: Circuit) -> None: """Add a pi rotation around the z + y axis. - This method modifies the input circuit in-place. + This function modifies the input circuit in-place. + + Parameters + ---------- + qubit : int + The qubit on which the basis-change operation is performed. + circuit : Circuit + The quantum circuit to which the basis change is added. """ circuit.rz(qubit, ANGLE_PI / 2) circuit.ry(qubit, ANGLE_PI / 2) @@ -126,55 +186,3 @@ def add_hy(qubit: int, circuit: Circuit) -> None: for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): pexp = pexp_dag.pauli_exponentials[node] add_pexp(pexp, circuit) - - -def er_to_circuit( - er: ExtractionResult, - pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None = None, - cm_cp: Callable[[CliffordMap, Circuit], None] | None = None, -) -> Circuit: - """Convert a circuit extraction result into a quantum circuit representation. - - This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) in the extraction result. It performs a validation check to ensure that the output nodes of both components are identical and it maps the output node numbers to qubit indices. - - Parameters - ---------- - er : ExtractionResult - The result of the extraction process, containing both the ``clifford_map`` and the ``pexp_dag``. - - Returns - ------- - Circuit - A quantum circuit that combines the Clifford map operations followed by the Pauli exponential operations. - - Raises - ------ - ValueError - If the output nodes of ``er.pexp_dag`` and ``er.clifford_map`` do not match, indicating an incompatible extraction result. - - Notes - ----- - The conversion relies on the internal compilation passes ``self.cm_cp`` (Clifford Map Circuit Processor) and ``self.pexp_cp`` (Pauli Exponential Circuit Processor) to handle the low-level circuit synthesis. - """ - if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): - raise ValueError( - "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." - ) - if pexp_cp is None: - pexp_cp = ladder_pass - if cm_cp is None: - raise ValueError( - "Clifford-map pass is missing: there is still no default pass for Clifford map integrated in Graphix. You may use graphix-stim-compiler plugin." - ) - - n_qubits = len(er.pexp_dag.output_nodes) - circuit = Circuit(n_qubits) - outputs_mapping = NodeIndex() - outputs_mapping.extend(er.pexp_dag.output_nodes) - - inputs_mapping = NodeIndex() - inputs_mapping.extend(er.clifford_map.input_nodes) - - cm_cp(er.clifford_map.remap(inputs_mapping.index, outputs_mapping.index), circuit) - pexp_cp(er.pexp_dag.remap(outputs_mapping.index), circuit) - return circuit diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 9d07d0f5..28e82133 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -56,8 +56,10 @@ def to_circuit( Parameters ---------- - cp : CompilationPass - Compilation pass to synthesize the Pauli exponential DAG and the Clifford map in the extraction result. + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Pauli exponential DAG. If ``None`` (default), :func:`pexp_ladder_pass` is employed. + cm_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Clifford map. If ``None`` (default), a `ValueError` is raised since there is still no default pass for Clifford map integrated in Graphix. Returns ------- diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index 475b7ddd..616e79c7 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, NamedTuple +from typing import NamedTuple import networkx as nx import pytest -from graphix.circ_ext.compilation import ladder_pass +from graphix.circ_ext.compilation import pexp_ladder_pass from graphix.circ_ext.extraction import PauliExponential, PauliExponentialDAG, PauliString, extend_input from graphix.flow.core import PauliFlow from graphix.fundamentals import ANGLE_PI, Sign @@ -15,34 +15,6 @@ from graphix.sim.base_backend import NodeIndex from graphix.transpiler import Circuit -if TYPE_CHECKING: - from numpy.random import Generator - - -class TestPauliString: - def test_add_circuit(self, fx_rng: Generator) -> None: - angle = 0.3 * ANGLE_PI - angle_rz = -2 * angle - x_nodes = {1} - z_nodes = {4, 2} - pauli_string = PauliString(x_nodes=x_nodes, z_nodes=z_nodes) - - pexp = PauliExponential(angle, pauli_string) - - qc = Circuit(4) - outputs_mapping = NodeIndex() - outputs_mapping.extend([2, 1, 3, 4]) - - pexp_dag = PauliExponentialDAG({0: pexp}, [{1, 4, 2}, {0}], [1, 4, 2]) - ladder_pass(pexp_dag.remap(outputs_mapping.index), qc) # `qc` is modified in place - - qc_ref = Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, angle_rz), CNOT(0, 3), CNOT(3, 1), H(1)]) - - state = qc.simulate_statevector(rng=fx_rng).statevec - state_ref = qc_ref.simulate_statevector(rng=fx_rng).statevec - - assert state.isclose(state_ref) - class PauliExpTestCase(NamedTuple): p_exp: PauliExponentialDAG @@ -110,13 +82,23 @@ class TestPauliExponential: ), Circuit(width=2, instr=[CNOT(1, 0)]), ), + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(alpha / 2, PauliString(x_nodes={1}, z_nodes={4, 2})), + }, + partial_order_layers=[{1, 2, 3, 4}, {0}], + output_nodes=[2, 1, 3, 4], + ), + Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, -alpha), CNOT(0, 3), CNOT(3, 1), H(1)]), + ), ], ) def test_to_circuit(self, test_case: PauliExpTestCase) -> None: qc = Circuit(len(test_case.p_exp.output_nodes)) outputs_mapping = NodeIndex() outputs_mapping.extend(test_case.p_exp.output_nodes) - ladder_pass(test_case.p_exp.remap(outputs_mapping.index), qc) + pexp_ladder_pass(test_case.p_exp.remap(outputs_mapping.index), qc) state = qc.simulate_statevector().statevec state_ref = test_case.qc.simulate_statevector().statevec assert state.isclose(state_ref) @@ -132,14 +114,14 @@ def test_to_circuit_outputs_order(self) -> None: qc_1 = Circuit(2) outputs_mapping_1 = NodeIndex() outputs_mapping_1.extend(pexp_dag_1.output_nodes) - ladder_pass(pexp_dag_1.remap(outputs_mapping_1.index), qc_1) + pexp_ladder_pass(pexp_dag_1.remap(outputs_mapping_1.index), qc_1) s_1 = qc_1.simulate_statevector().statevec pexp_dag_2 = PauliExponentialDAG(pauli_exponentials=pexp_map, partial_order_layers=pol, output_nodes=outputs_2) qc_2 = Circuit(2) outputs_mapping_2 = NodeIndex() outputs_mapping_2.extend(pexp_dag_2.output_nodes) - ladder_pass(pexp_dag_2.remap(outputs_mapping_2.index), qc_2) + pexp_ladder_pass(pexp_dag_2.remap(outputs_mapping_2.index), qc_2) s_2 = qc_2.simulate_statevector().statevec assert not s_1.isclose(s_2) From c5238c16725cec26242e6f1fa2fa5a451ef72d46 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 3 Mar 2026 18:54:31 +0100 Subject: [PATCH 26/26] Fix ruff --- graphix/circ_ext/extraction.py | 48 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py index 28e82133..23e57b7f 100644 --- a/graphix/circ_ext/extraction.py +++ b/graphix/circ_ext/extraction.py @@ -25,14 +25,6 @@ class ExtractionResult: """Dataclass to represent the output of the circuit-extraction algorithm introduced in Ref. [1]. - Attributes - ---------- - pexp_dag: PauliExponentialDAG - Pauli exponential directed acyclical graph (DAG) representing a sequence multi-qubit rotations. - - clifford_map: CliffordMap - Clifford transformation. - Notes ----- See Definition 3.3 in Ref. [1]. @@ -40,6 +32,14 @@ class ExtractionResult: References ---------- [1] Simmons, 2021 (arXiv:2109.05654). + + Attributes + ---------- + pexp_dag: PauliExponentialDAG + Pauli exponential directed acyclical graph (DAG) representing a sequence multi-qubit rotations. + + clifford_map: CliffordMap + Clifford transformation. """ pexp_dag: PauliExponentialDAG @@ -231,6 +231,14 @@ def remap(self, outputs_mapping: Callable[[int], int]) -> Self: class PauliExponentialDAG: """Dataclass to represent a multi-qubit rotation formed by a sequence of Pauli exponentials extracted from a pattern. + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + Attributes ---------- pauli_exponentials: Mapping[int, PauliExponential] @@ -239,14 +247,6 @@ class PauliExponentialDAG: Partial order between the Pauli exponentials in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. The pattern's output nodes are always in layer 0. output_nodes: Sequence[int] Output nodes on which the Pauli exponential rotation acts. - - Notes - ----- - See Definition 3.3 in Ref. [1]. - - References - ---------- - [1] Simmons, 2021 (arXiv:2109.05654). """ pauli_exponentials: Mapping[int, PauliExponential] @@ -295,6 +295,14 @@ class CliffordMap: A Clifford map describes a linear transformation between the space of input qubits and the space of output qubits. It is encoded as a map from the Pauli-group generators (X and Z) over the input nodes to Pauli strings over the output nodes. + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + Attributes ---------- x_map: Mapping[int, PauliString] @@ -305,14 +313,6 @@ class CliffordMap: Sequence of inputs nodes. output_nodes: Sequence[int] Sequence of outputs nodes. - - Notes - ----- - See Definition 3.3 in Ref. [1]. - - References - ---------- - [1] Simmons, 2021 (arXiv:2109.05654). """ x_map: Mapping[int, PauliString]