From 83cdae7154c9a7fd10b3a91f093a8d7aa9ead7c1 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 15 Jan 2026 09:18:14 +0100 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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]):