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..76f56c5a --- /dev/null +++ b/graphix/circ_ext/compilation.py @@ -0,0 +1,288 @@ +"""Compilation passes to transform the result of the circuit extraction algorithm into a quantum circuit.""" + +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: + """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." + ) + 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 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 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``. + """ + + +class CliffordMapCompilationPass(ABC): + """Abstract base class to implement a compilation procedure for a Clifford Map.""" + + @staticmethod + @abstractmethod + def add_to_circuit(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): + 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. + + Gate set: H, CNOT, RZ, RY + + 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: + """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) + + 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. + + This method modifies the input circuit in-place. + + Parameters + ---------- + 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. + """ + 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) + + q2 = outputs_mapping.index(nodes[-1]) # To avoid pyright `reportPossiblyUnboundVariable` + 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 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) + 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. + + 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: + """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 new file mode 100644 index 00000000..a15176e2 --- /dev/null +++ b/graphix/circ_ext/extraction.py @@ -0,0 +1,420 @@ +"""Tools for circuit extraction.""" + +from __future__ import annotations + +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 + +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.opengraph import OpenGraph + 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 + + 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 + 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[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. + + 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 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) + + +@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[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. + + 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 isinstance(meas, PauliMeasurement) else meas.downcast_bloch().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[Measurement] + 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[Measurement] + 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). + """ + 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) + + +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 + + 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/graphix/flow/core.py b/graphix/flow/core.py index 30634d68..4f964eb7 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, @@ -731,6 +733,88 @@ 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.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}: + 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]: + """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} + + def extract_circuit(self: PauliFlow[Measurement]) -> ExtractionResult: + """Extract a circuit from a 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 flow. + + Notes + ----- + - 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 + ---------- + [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]): diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py new file mode 100644 index 00000000..c297f710 --- /dev/null +++ b/tests/test_circ_extraction.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple + +import networkx as nx +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 +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_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]) + + 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 state.isclose(state_ref) + + +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 state.isclose(state_ref) + + 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.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 + }, + ) + + 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.XY(0.1), + 2: Measurement.XY(0.2), + 3: Measurement.XY(0.3), + 4: Measurement.XY(0.4), + }, + ) + + 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.XY(0.1), + 2: Measurement.XY(0.2), + 3: Measurement.XY(0.3), + 4: Measurement.XY(0.4), + 7: Measurement.X, + 8: Measurement.X, + }, + ) + + 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.infer_pauli_measurements().extract_pauli_flow() + assert flow.is_focused() diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 8513514a..01d262fa 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -473,6 +473,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 b0f319e5..ed316e3d 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -863,6 +863,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.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.infer_pauli_measurements().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()