diff --git a/CHANGELOG.md b/CHANGELOG.md index bf672471..2f64d399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #450: `Circuit.visit` and `BaseInstruction.visit` performs simple replacements on circuits and instructions, given an `InstructionVisitor`. +- #445: Circuit extraction. + - Added `PauliFlow.is_focused` to verify if a Pauli flow is focused and `PauliFlow.pauli_strings` associating a Pauli string to the every corrected node. + - Added `PauliFlow.extract_circuit` + - Added new module `graphix.circ_ext.extraction` to extract circuits from Pauli flows with the following new classes: + - `ExtractionResult` + - `PauliString` + - `PauliExponential` + - `PauliExponentialDAG` + - `CliffordMap` + - Added new module `graphix.circ_ext.compilation` to perform the transformation `ExtractionResult` -> `Graphix circuit` with the following new functions: + - `er_to_circuit` + - `pexp_ladder_pass` + + ### Fixed - #429 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..b7d19b76 --- /dev/null +++ b/graphix/circ_ext/compilation.py @@ -0,0 +1,188 @@ +"""Compilation passes to transform the result of the circuit extraction algorithm into a quantum circuit.""" + +from __future__ import annotations + +from itertools import chain, pairwise +from typing import TYPE_CHECKING + +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 Callable + + from graphix.circ_ext.extraction import CliffordMap, ExtractionResult, PauliExponential, PauliExponentialDAG + + +def er_to_circuit( + er: ExtractionResult, + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None = None, + cm_cp: Callable[[CliffordMap, Circuit], None] | None = None, +) -> Circuit: + """Convert a circuit extraction result into a quantum circuit representation. + + This method synthesizes a circuit by sequentially applying the Clifford map and the Pauli exponential DAG (Directed Acyclic Graph) in the extraction result. It performs a validation check to ensure that the output nodes of both components are identical and it maps the output node numbers to qubit indices. + + Parameters + ---------- + er : ExtractionResult + The result of the extraction process, containing both the ``clifford_map`` and the ``pexp_dag``. + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Pauli exponential DAG. If ``None`` (default), :func:`pexp_ladder_pass` is employed. + cm_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Clifford map. If ``None`` (default), a `ValueError` is raised since there is still no default pass for Clifford map integrated in Graphix. + + Returns + ------- + Circuit + A quantum circuit that combines the Clifford map operations followed by the Pauli exponential operations. + + Raises + ------ + ValueError + If the output nodes of ``er.pexp_dag`` and ``er.clifford_map`` do not match, indicating an incompatible extraction result. + """ + if list(er.pexp_dag.output_nodes) != list(er.clifford_map.output_nodes): + raise ValueError( + "The Pauli Exponential DAG and the Clifford Map in the Extraction Result are incompatible since they have different output nodes." + ) + if pexp_cp is None: + pexp_cp = pexp_ladder_pass + + if cm_cp is None: + raise ValueError( + "Clifford-map pass is missing: there is still no default pass for Clifford map integrated in Graphix. You may use graphix-stim-compiler plugin." + ) + + n_qubits = len(er.pexp_dag.output_nodes) + circuit = Circuit(n_qubits) + outputs_mapping = NodeIndex() + outputs_mapping.extend(er.pexp_dag.output_nodes) + + inputs_mapping = NodeIndex() + inputs_mapping.extend(er.clifford_map.input_nodes) + + cm_cp(er.clifford_map.remap(inputs_mapping.index, outputs_mapping.index), circuit) + pexp_cp(er.pexp_dag.remap(outputs_mapping.index), circuit) + return circuit + + +def pexp_ladder_pass(pexp_dag: PauliExponentialDAG, circuit: Circuit) -> None: + r"""Add a Pauli exponential DAG to a circuit by using a ladder decomposition. + + The input circuit is modified in-place. This function assumes that the Pauli exponential DAG has been remap, i.e., its Pauli strings are defined on qubit indices instead of output nodes. See :meth:`PauliString.remap` for additional information. + + Parameters + ---------- + pexp_dag: PauliExponentialDAG + The Pauli exponential rotation to be added to the circuit. Its Pauli strings are assumed to be defined on qubit indices. + circuit : Circuit + The circuit to which the operation is added. The input circuit is assumed to be compatible with ``pexp_dag.output_nodes``. + + Notes + ----- + Pauli exponentials in the DAG are compiled sequentially following an arbitrary total order compatible with the DAG. Each Pauli exponential is decomposed into a sequence of basis changes, CNOT gates, and a single :math:`R_Z` rotation: + + .. math:: + + 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 + + See https://quantumcomputing.stackexchange.com/questions/5567/circuit-construction-for-hamiltonian-simulation/11373#11373 for additional information. + """ + + def add_pexp(pexp: PauliExponential, circuit: Circuit) -> None: + r"""Add the Pauli exponential unitary to a quantum circuit. + + This function 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 valid qubit indices and are pairwise disjoint. + """ + if pexp.angle == 0: # No rotation + return + + # We assume that nodes in the Pauli strings have been mapped to qubits. + modified_qubits = [ + qubit + for qubit in range(circuit.width) + if qubit in pexp.pauli_string.x_nodes | pexp.pauli_string.y_nodes | pexp.pauli_string.z_nodes + ] + angle = -2 * pexp.angle * pexp.pauli_string.sign + + if len(modified_qubits) == 0: # Identity + return + + q0 = modified_qubits[0] + + if len(modified_qubits) == 1: + if q0 in pexp.pauli_string.x_nodes: + circuit.rx(q0, angle) + elif q0 in pexp.pauli_string.y_nodes: + circuit.ry(q0, angle) + else: + circuit.rz(q0, angle) + return + + add_basis_change(pexp, q0, circuit) + + for q1, q2 in pairwise(modified_qubits): + add_basis_change(pexp, q2, circuit) + circuit.cnot(control=q1, target=q2) + + circuit.rz(modified_qubits[-1], angle) + + for q2, q1 in pairwise(modified_qubits[::-1]): + circuit.cnot(control=q1, target=q2) + add_basis_change(pexp, q2, circuit) + + add_basis_change(pexp, modified_qubits[0], circuit) + + def add_basis_change(pexp: PauliExponential, qubit: int, circuit: Circuit) -> None: + """Apply an X or a Y basis change to a given qubit if required by the Pauli string. + + This function modifies the input circuit in-place. + + Parameters + ---------- + pexp : PauliExponential + The Pauli exponential under consideration. + qubit : int + The qubit on which the basis-change operation is performed. + circuit : Circuit + The quantum circuit to which the basis change is added. + """ + if qubit in pexp.pauli_string.x_nodes: + circuit.h(qubit) + elif qubit in pexp.pauli_string.y_nodes: + add_hy(qubit, circuit) + + def add_hy(qubit: int, circuit: Circuit) -> None: + """Add a pi rotation around the z + y axis. + + This function modifies the input circuit in-place. + + Parameters + ---------- + qubit : int + The qubit on which the basis-change operation is performed. + circuit : Circuit + The quantum circuit to which the basis change is added. + """ + circuit.rz(qubit, ANGLE_PI / 2) + circuit.ry(qubit, ANGLE_PI / 2) + circuit.rz(qubit, ANGLE_PI / 2) + + for node in chain(*reversed(pexp_dag.partial_order_layers[1:])): + pexp = pexp_dag.pauli_exponentials[node] + add_pexp(pexp, circuit) diff --git a/graphix/circ_ext/extraction.py b/graphix/circ_ext/extraction.py new file mode 100644 index 00000000..23e57b7f --- /dev/null +++ b/graphix/circ_ext/extraction.py @@ -0,0 +1,475 @@ +"""Tools for circuit extraction.""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +from typing_extensions import Self # Self introduced in 3.11 + +from graphix.fundamentals import ParameterizedAngle, Plane, Sign +from graphix.measurements import BlochMeasurement, Measurement, PauliMeasurement + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + from collections.abc import Set as AbstractSet + + from graphix.command import Node + from graphix.flow.core import PauliFlow + from graphix.opengraph import OpenGraph + from graphix.transpiler import Circuit + + +@dataclass(frozen=True) +class ExtractionResult: + """Dataclass to represent the output of the circuit-extraction algorithm introduced in Ref. [1]. + + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + + Attributes + ---------- + pexp_dag: PauliExponentialDAG + Pauli exponential directed acyclical graph (DAG) representing a sequence multi-qubit rotations. + + clifford_map: CliffordMap + Clifford transformation. + """ + + pexp_dag: PauliExponentialDAG + clifford_map: CliffordMap + + def to_circuit( + self, + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None = None, + cm_cp: Callable[[CliffordMap, Circuit], None] | None = None, + ) -> Circuit: + """Transpile the extraction result to circuit. + + Transpilation is only supported when the pair Pauli-exponential DAG and Clifford map represents a unitary transformation. + + Parameters + ---------- + pexp_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Pauli exponential DAG. If ``None`` (default), :func:`pexp_ladder_pass` is employed. + cm_cp: Callable[[PauliExponentialDAG, Circuit], None] | None + Compilation pass to synthetize a Clifford map. If ``None`` (default), a `ValueError` is raised since there is still no default pass for Clifford map integrated in Graphix. + + Returns + ------- + Circuit + Quantum circuit represented as a set of instructions. + """ + # Circumvent import loop + from graphix.circ_ext.compilation import er_to_circuit # noqa: PLC0415 + + return er_to_circuit(self, pexp_cp=pexp_cp, cm_cp=cm_cp) + + +@dataclass(frozen=True) +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. + sign : Sign + Phase of the Pauli string. + """ + + x_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) + y_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) + z_nodes: AbstractSet[int] = dataclasses.field(default_factory=frozenset) + sign: Sign = dataclasses.field(default_factory=lambda: Sign.PLUS) + + @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. + negative_sign ^= og.graph.subgraph(c_set).number_of_edges() % 2 == 1 + + # One phase flip per two Ys in the graph state stabilizer. + negative_sign ^= bool(len(inter_c_odd_set) // 2 % 2) + + # One phase flip per node in the graph state stabilizer that is absorbed from a Pauli measurement with angle π. + for n in c_set | odd_c_set: + meas = og.measurements.get(n, None) + if isinstance(meas, PauliMeasurement): + negative_sign ^= meas.sign == Sign.MINUS + + # One phase flip if measured on the YZ plane. + negative_sign ^= flow.node_measurement_label(node) == Plane.YZ + + return PauliString(x_corrections, y_corrections, z_corrections, Sign.minus_if(negative_sign)) + + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: + """Remap nodes to qubit indices. + + Parameters + ---------- + outputs_mapping: Callable[[int], int] + Mapping between node numbers of the original MBQC pattern or open graph and qubit indices of a quantum circuit. + + Returns + ------- + PauliString + Pauli string defined on qubit indices. + """ + x_nodes = {outputs_mapping(n) for n in self.x_nodes} + y_nodes = {outputs_mapping(n) for n in self.y_nodes} + z_nodes = {outputs_mapping(n) for n in self.z_nodes} + return replace(self, x_nodes=frozenset(x_nodes), y_nodes=frozenset(y_nodes), z_nodes=frozenset(z_nodes)) + + +@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 : ParameterizedAngle + The Pauli exponential angle :math:`\alpha` in units of :math:`\pi`. When extracted from a corrected node, it corresponds to the node's measurement divided by two. + pauli_string : PauliString + The signed Pauli string :math:`P` specifying the tensor product of Pauli operators acting on the corresponding MBQC nodes. + """ + + angle: ParameterizedAngle + 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 = meas.angle / 2 if isinstance(meas, BlochMeasurement) else 0 + + return PauliExponential(angle, pauli_string) + + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: + """Remap nodes to qubit indices. + + See documentation in :meth:`PauliString.remap` for additional information. + """ + return replace(self, pauli_string=self.pauli_string.remap(outputs_mapping)) + + +@dataclass(frozen=True) +class PauliExponentialDAG: + """Dataclass to represent a multi-qubit rotation formed by a sequence of Pauli exponentials extracted from a pattern. + + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + + Attributes + ---------- + pauli_exponentials: Mapping[int, PauliExponential] + 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. + """ + + 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) + + def remap(self, outputs_mapping: Callable[[int], int]) -> Self: + """Remap nodes to qubit indices. + + See documentation in :meth:`PauliString.remap` for additional information. + """ + pauli_exponentials = {node: pexp.remap(outputs_mapping) for node, pexp in self.pauli_exponentials.items()} + return replace(self, pauli_exponentials=pauli_exponentials) + + +@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. + + Notes + ----- + See Definition 3.3 in Ref. [1]. + + References + ---------- + [1] Simmons, 2021 (arXiv:2109.05654). + + 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. + """ + + 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 remap(self, inputs_mapping: Callable[[int], int], outputs_mapping: Callable[[int], int]) -> Self: + """Remap nodes to qubit indices. + + Parameters + ---------- + inputs_mapping: Callable[[int], int] + Mapping between input node numbers of the original MBQC pattern or open graph and qubit indices of a quantum circuit. + outputs_mapping: Callable[[int], int] + Mapping between output node numbers of the original MBQC pattern or open graph and qubit indices of a quantum circuit. + + Returns + ------- + CliffordMap + Clifford map defined on qubit indices. + """ + x_map = {inputs_mapping(node): ps.remap(outputs_mapping) for node, ps in self.x_map.items()} + z_map = {inputs_mapping(node): ps.remap(outputs_mapping) for node, ps in self.z_map.items()} + return replace(self, x_map=x_map, z_map=z_map) + + +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). + """ + # Nodes are either measured or outputs. + return { + node: flow.pauli_strings[node] if node in flow.og.measurements else PauliString(z_nodes=frozenset({node})) + for node in flow.og.input_nodes + } + + +def clifford_x_map_from_focused_flow(flow: PauliFlow[Measurement]) -> Mapping[int, PauliString]: + """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 c6e3de23..26ae39ed 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, @@ -730,6 +732,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/noxfile.py b/noxfile.py index 2cbd79e7..d34c2b36 100644 --- a/noxfile.py +++ b/noxfile.py @@ -130,6 +130,7 @@ class ReverseDependency: version_constraint=VersionRange(upper=Version("3.14")), doctest_modules=False, ), + ReverseDependency("https://github.com/matulni/graphix-stim-compiler"), ], ) def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> None: diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py new file mode 100644 index 00000000..616e79c7 --- /dev/null +++ b/tests/test_circ_extraction.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import NamedTuple + +import networkx as nx +import pytest + +from graphix.circ_ext.compilation import pexp_ladder_pass +from graphix.circ_ext.extraction import PauliExponential, PauliExponentialDAG, PauliString, extend_input +from graphix.flow.core import PauliFlow +from graphix.fundamentals import ANGLE_PI, Sign +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 + + +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}, sign=Sign.MINUS)), + }, + 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}, sign=Sign.MINUS)), + }, + 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}, sign=Sign.MINUS)), + }, + 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}, sign=Sign.MINUS)), + 1: PauliExponential(ANGLE_PI / 4, PauliString(x_nodes={3}, sign=Sign.MINUS)), + 2: PauliExponential(ANGLE_PI / 4, PauliString(z_nodes={3}, sign=Sign.MINUS)), + }, + partial_order_layers=[{3}, {2}, {1}, {0}], + output_nodes=[3], + ), + 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}, sign=Sign.MINUS)), + }, + partial_order_layers=[{5, 3}, {2}, {0, 1}], + output_nodes=[5, 3], # Node 5 -> qubit 0 (control), node 3 -> qubit 1 (target) + ), + Circuit(width=2, instr=[CNOT(1, 0)]), + ), + PauliExpTestCase( + PauliExponentialDAG( + pauli_exponentials={ + 0: PauliExponential(alpha / 2, PauliString(x_nodes={1}, z_nodes={4, 2})), + }, + partial_order_layers=[{1, 2, 3, 4}, {0}], + output_nodes=[2, 1, 3, 4], + ), + Circuit(width=4, instr=[H(1), CNOT(3, 1), CNOT(0, 3), RZ(0, -alpha), CNOT(0, 3), CNOT(3, 1), H(1)]), + ), + ], + ) + def test_to_circuit(self, test_case: PauliExpTestCase) -> None: + qc = Circuit(len(test_case.p_exp.output_nodes)) + outputs_mapping = NodeIndex() + outputs_mapping.extend(test_case.p_exp.output_nodes) + pexp_ladder_pass(test_case.p_exp.remap(outputs_mapping.index), qc) + state = qc.simulate_statevector().statevec + state_ref = test_case.qc.simulate_statevector().statevec + assert state.isclose(state_ref) + + def test_to_circuit_outputs_order(self) -> None: + pexp_map = {2: PauliExponential(0.1, PauliString(x_nodes=frozenset({1}), z_nodes=frozenset({0})))} + pol = [{0, 1}, {2}] + + outputs_1 = [0, 1] + outputs_2 = [1, 0] + + pexp_dag_1 = PauliExponentialDAG(pauli_exponentials=pexp_map, partial_order_layers=pol, output_nodes=outputs_1) + qc_1 = Circuit(2) + outputs_mapping_1 = NodeIndex() + outputs_mapping_1.extend(pexp_dag_1.output_nodes) + pexp_ladder_pass(pexp_dag_1.remap(outputs_mapping_1.index), qc_1) + s_1 = qc_1.simulate_statevector().statevec + + pexp_dag_2 = PauliExponentialDAG(pauli_exponentials=pexp_map, partial_order_layers=pol, output_nodes=outputs_2) + qc_2 = Circuit(2) + outputs_mapping_2 = NodeIndex() + outputs_mapping_2.extend(pexp_dag_2.output_nodes) + pexp_ladder_pass(pexp_dag_2.remap(outputs_mapping_2.index), qc_2) + + s_2 = qc_2.simulate_statevector().statevec + assert not s_1.isclose(s_2) + + qc_2.swap(0, 1) + s_2 = qc_2.simulate_statevector().statevec + + assert s_1.isclose(s_2) + + def test_from_focused_flow(self) -> None: + """Test example C.13. in Simmons, 2021.""" + og = OpenGraph( + 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}), sign=Sign.MINUS) + ), + 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 0cf48c1a..bf6e1816 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -889,6 +889,20 @@ def test_large_linear_graph(self) -> None: assert flow.correction_function == c_ref assert flow.partial_order_layers == pol_ref + @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()