Source code for graphix.measurements

"""Data structure for single-qubit measurements in MBQC."""

from __future__ import annotations

import math
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from typing import (
    TYPE_CHECKING,
    ClassVar,
    Literal,
)

# override introduced in Python 3.12
from typing_extensions import override

from graphix import parameter
from graphix.fundamentals import (
    ANGLE_PI,
    AbstractMeasurement,
    AbstractPlanarMeasurement,
    Angle,
    Axis,
    ParameterizedAngle,
    Plane,
    Sign,
)
from graphix.pauli import Pauli

if TYPE_CHECKING:
    from collections.abc import Iterator, Mapping
    from typing import Self, TypeAlias

    from graphix.clifford import Clifford
    from graphix.parameter import ExpressionOrSupportsFloat, Parameter

Outcome: TypeAlias = Literal[0, 1]


def outcome(b: bool) -> Outcome:
    """Return 1 if True, 0 if False."""
    return 1 if b else 0


def toggle_outcome(outcome: Outcome) -> Outcome:
    """Toggle outcome."""
    return 1 if outcome == 0 else 0


[docs] @dataclass(frozen=True) class Measurement(AbstractMeasurement): r"""An MBQC measurement. Base class for :class:`BlochMeasurement` and :class:`PauliMeasurement`. This class contains three class variables ``X``, ``Y``, and ``Z`` for the positive Pauli measurements on the three axes, and three static methods ``XY``, ``YZ``, and ``XZ``, each parameterized by an angle and returning a Bloch measurement on each of the three planes. The three static methods ``XY``, ``YZ``, and ``XZ`` are capitalized, contrary to what PEP 8 prescribes, to remain consistent with the names of the Pauli measurements and to match the names of the planes in the :class:`Plane` enum. """ # The actual values for the class variables ``X``, ``Y``, and # ``Z`` are assigned latter in this file, once # ``PauliMeasurement`` is defined. X: ClassVar[PauliMeasurement] Y: ClassVar[PauliMeasurement] Z: ClassVar[PauliMeasurement]
[docs] @staticmethod def XY(angle: ParameterizedAngle) -> BlochMeasurement: # noqa: N802 """Return a Bloch measurement on the XY plane.""" return BlochMeasurement(angle, Plane.XY)
[docs] @staticmethod def YZ(angle: ParameterizedAngle) -> BlochMeasurement: # noqa: N802 """Return a Bloch measurement on the YZ plane.""" return BlochMeasurement(angle, Plane.YZ)
[docs] @staticmethod def XZ(angle: ParameterizedAngle) -> BlochMeasurement: # noqa: N802 """Return a Bloch measurement on the XZ plane.""" return BlochMeasurement(angle, Plane.XZ)
[docs] @abstractmethod def clifford(self, clifford_gate: Clifford) -> Self: r"""Return a new measurement command with a :class:`Clifford` applied. Parameters ---------- clifford_gate : Clifford Clifford gate to apply before the measurement. Returns ------- Self Equivalent measurement representing the pattern ``MC``. Notes ----- - The return type is ``Self``, meaning that a Clifford applied to a Bloch measurement returns a Bloch measurement, and a Clifford applied to a Pauli measurement returns a Pauli measurement. - The method :func:`Measurement.clifford` does not always commute with the method :func:`Measurement.to_bloch`: the underlying Pauli measurement will be the same but the Bloch representation can be on different planes. Examples -------- >>> from graphix.clifford import Clifford >>> from graphix.measurements import Measurement, PauliMeasurement >>> Measurement.XY(0.25).clifford(Clifford.H) Measurement.YZ(1.75) >>> Measurement.X.clifford(Clifford.S) -Measurement.Y >>> for pauli in PauliMeasurement: ... for clifford in Clifford: ... assert pauli.to_bloch().clifford(clifford).try_to_pauli() == pauli.clifford(clifford) >>> Measurement.Y.clifford(Clifford.H).to_bloch() Measurement.XY(1.5) >>> Measurement.Y.to_bloch().clifford(Clifford.H) Measurement.YZ(1.5) """
[docs] @abstractmethod def to_bloch(self) -> BlochMeasurement: """Return the measurement description as an angle and a plane on the Bloch sphere. There is no unique Bloch representation for each Pauli measurement. For instance, >>> from graphix.measurements import Measurement >>> Measurement.XY(0.5).try_to_pauli() == Measurement.YZ(0.5).try_to_pauli() == Measurement.Y True This method follows the convention illustrated below: >>> from graphix.measurements import PauliMeasurement >>> for pm in PauliMeasurement: ... print(f"{pm}.to_bloch() == {pm.to_bloch()}") +X.to_bloch() == Measurement.XY(0) -X.to_bloch() == Measurement.XY(1) +Y.to_bloch() == Measurement.XY(0.5) -Y.to_bloch() == Measurement.XY(1.5) +Z.to_bloch() == Measurement.YZ(0) -Z.to_bloch() == Measurement.YZ(1) """
[docs] @abstractmethod def downcast_bloch(self) -> BlochMeasurement: """Return the measurement description if it is already given as an angle and a plane on the Bloch sphere; raise :class:`TypeError` otherwise. Examples -------- >>> from graphix.measurements import Measurement >>> Measurement.XY(0).downcast_bloch() Measurement.XY(0) >>> Measurement.X.downcast_bloch() Traceback (most recent call last): ... TypeError: Bloch measurement expected, but Pauli measurement was found. """
[docs] @abstractmethod def try_to_pauli(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMeasurement | None: """Return the measurement description as a Pauli measurement if possible, or ``None`` otherwise. Parameters ---------- rel_tol : float, optional Relative tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``1e-9``. abs_tol : float, optional Absolute tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``0.0``. Returns ------- PauliMeasurement | None If ``self`` is already an instance of :class:`PauliMeasurement`, the function returns ``self``. If ``self`` is an instance of :class:`BlochMeasurement`, then either the measurement is close to a Pauli measurement (i.e., the angle is close to an integer multiple of π/2) and the corresponding Pauli measurement is returned, or it is not and ``None`` is returned. Notes ----- A measurement with a parameterized angle is not considered as Pauli, but can become a Pauli measurement after substitution. Examples -------- >>> from graphix.measurements import Measurement >>> Measurement.XY(0.5).try_to_pauli() Measurement.Y >>> Measurement.Y.try_to_pauli() Measurement.Y >>> Measurement.XY(0.25).try_to_pauli() is None True >>> from graphix.parameter import Placeholder >>> alpha = Placeholder("alpha") >>> Measurement.XY(alpha).try_to_pauli() is None True >>> Measurement.XY(alpha).subs(alpha, 0.5).try_to_pauli() Measurement.Y """
[docs] @abstractmethod def to_pauli_or_bloch(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMeasurement | BlochMeasurement: """Return the measurement description as a Pauli measurement if possible, a Bloch measurement otherwise. Parameters ---------- rel_tol : float, optional Relative tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``1e-9``. abs_tol : float, optional Absolute tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``0.0``. Returns ------- PauliMeasurement | BlochMeasurement If ``self`` is already an instance of :class:`PauliMeasurement`, the function returns ``self``. If ``self`` is an instance of :class:`BlochMeasurement`, then either the measurement is close to a Pauli measurement (i.e., the angle is close to an integer multiple of π/2) and the corresponding Pauli measurement is returned, or it is not and ``self`` is returned. Examples -------- >>> from graphix.measurements import Measurement >>> Measurement.XY(0.5).to_pauli_or_bloch() Measurement.Y >>> Measurement.XY(0.25).to_pauli_or_bloch() Measurement.XY(0.25) """
[docs] @abstractmethod def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: """Substitute a parameter with a value or expression in measurement angles."""
[docs] @abstractmethod def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: """Perform parallel substitution of multiple parameters in measurement angles."""
[docs] @dataclass(frozen=True) class BlochMeasurement(AbstractPlanarMeasurement, Measurement): r"""An MBQC measurement described by an angle and a plane. Attributes ---------- angle : ExpressionOrFloat The angle of the measurement in units of :math:`\pi`. Should be between [0, 2). plane : graphix.fundamentals.Plane The measurement plane. """ angle: ParameterizedAngle plane: Plane @override def __repr__(self) -> str: """Return an evaluable represention of the Bloch measurement. This representation assumes that :class:`Measurement` is in the scope. The static methods ``Measurement.XY``, ``Measurement.YZ``, and ``Measurement.XZ`` are used to refer to the planes. """ return f"Measurement.{self.plane.name}({self.angle})"
[docs] @override def to_bloch(self) -> BlochMeasurement: """Return ``self`` (overridden from :class:`Measurement`).""" return self
[docs] @override def downcast_bloch(self) -> BlochMeasurement: """Return ``self`` (overridden from :class:`Measurement`).""" return self
[docs] @override def try_to_pauli(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMeasurement | None: if not isinstance(self.angle, (int, float)): return None angle_double = 2 * self.angle angle_double_round = round(angle_double) if not math.isclose(angle_double, angle_double_round, rel_tol=rel_tol, abs_tol=abs_tol): return None angle_double_mod_4 = angle_double_round % 4 axis = self.plane.cos if angle_double_mod_4 % 2 == 0 else self.plane.sin sign = Sign.minus_if(angle_double_mod_4 >= 2) return PauliMeasurement(axis, sign)
[docs] @override def to_pauli_or_bloch(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMeasurement | BlochMeasurement: pm = self.try_to_pauli(rel_tol=rel_tol, abs_tol=abs_tol) return self if pm is None else pm
[docs] @override def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: """Determine whether two measurements are close in angle and share the same plane. This method compares the angle of the current measurement with that of another measurement, using :func:`math.isclose` when both angles are floats. The planes must match exactly for the measurements to be considered close. A measurement represented as ``BlochMeasurement`` is never considered to be close to a measurement represented as ``PauliMeasurement``. Parameters ---------- other : AbstractMeasurement The measurement to compare against. rel_tol : float, optional Relative tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``1e-9``. abs_tol : float, optional Absolute tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``0.0``. Returns ------- bool ``True`` if both measurements lie in the same plane and their angles are equal or close within the given tolerances; ``False`` otherwise. Examples -------- >>> from graphix.measurements import Measurement >>> from graphix.fundamentals import Plane >>> Measurement.XY(0).isclose(Measurement.XY(0)) True >>> Measurement.XY(0).isclose(Measurement.YZ(0)) False >>> Measurement.XY(0.1).isclose(Measurement.XY(0)) False >>> Measurement.XY(0).isclose(Measurement.X) False """ if not isinstance(other, BlochMeasurement): return False return ( math.isclose(self.angle, other.angle, rel_tol=rel_tol, abs_tol=abs_tol) if isinstance(self.angle, float) and isinstance(other.angle, float) else self.angle == other.angle ) and self.plane == other.plane
[docs] @override def to_plane(self) -> Plane: return self.plane
[docs] @override def clifford(self, clifford_gate: Clifford) -> BlochMeasurement: new_plane = Plane.from_axes(*(PauliMeasurement(axis).clifford(clifford_gate).axis for axis in self.plane.axes)) cos_pauli = PauliMeasurement(self.plane.cos).clifford(clifford_gate) sin_pauli = PauliMeasurement(self.plane.sin).clifford(clifford_gate) exchange = cos_pauli.axis != new_plane.cos # We make sure that if 0 <= self.angle < 2 * ANGLE_PI, then # the new angle will be such that 0 <= angle < 2 * ANGLE_PI. angle = 2 * ANGLE_PI - self.angle if exchange == (cos_pauli.sign == sin_pauli.sign) else self.angle add_term: Angle = 0 if cos_pauli.sign == Sign.MINUS: add_term += ANGLE_PI if exchange: add_term = ANGLE_PI / 2 + add_term angle += add_term if isinstance(angle, (int, float)) and angle >= 2 * ANGLE_PI: angle -= 2 * ANGLE_PI return BlochMeasurement(angle, new_plane)
[docs] @override def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> BlochMeasurement: return BlochMeasurement(parameter.subs(self.angle, variable, substitute), self.plane)
[docs] @override def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> BlochMeasurement: return BlochMeasurement(parameter.xreplace(self.angle, assignment), self.plane)
class PauliMeasurementMeta(ABCMeta): """Metaclass implementing `iter(PauliMeasurement)`.""" def __iter__(cls) -> Iterator[PauliMeasurement]: """Iterate over Pauli measurements.""" return (PauliMeasurement(axis, sign) for axis in Axis for sign in Sign)
[docs] @dataclass(frozen=True) class PauliMeasurement(Measurement, metaclass=PauliMeasurementMeta): """Pauli measurement.""" axis: Axis sign: Sign = Sign.PLUS @override def __repr__(self) -> str: """Return an evaluable represention of the Pauli measurement. This representation assumes that :class:`Measurement` is in the scope. The class variables ``Measurement.X``, ``Measurement.Y``, and ``Measurement.Z`` are used to refer to the axes, and unary minus is used for negative sign. """ result = f"Measurement.{self.axis.name}" if self.sign == Sign.MINUS: return f"-{result}" return result @override def __str__(self) -> str: """Return a human-readable representation of the Pauli measurement. Pauli measurements are represented with two characters: their sign (``+`` or ``-``) and their axis (``X``, ``Y``, or ``Z``). Examples -------- >>> from graphix.measurements import Measurement >>> str(Measurement.X) '+X' >>> str(-Measurement.X) '-X' """ return f"{self.sign}{self.axis.name}" def __pos__(self) -> PauliMeasurement: """Return the Pauli measurement itself. Example ------- >>> from graphix.measurements import Measurement >>> +Measurement.X Measurement.X """ return self def __neg__(self) -> PauliMeasurement: """Return the Pauli measurement with the opposite sign. Examples -------- >>> -Measurement.X -Measurement.X >>> -(-Measurement.X) Measurement.X """ return PauliMeasurement(self.axis, -self.sign)
[docs] def to_pauli(self) -> Pauli: """Return the Pauli gate. This method returns an instance of :class:`Pauli` and should not be confused with :meth:`try_to_pauli`, which overrides the method from :class:`Measurement`, and returns ``self``. Examples -------- >>> from graphix.measurements import Measurement >>> Measurement.X.to_pauli() Pauli.X >>> (-Measurement.Y).to_pauli() -Pauli.Y """ return self.sign * Pauli.from_axis(self.axis)
[docs] @override def to_bloch(self) -> BlochMeasurement: match self.axis: case Axis.X: if self.sign == Sign.PLUS: return Measurement.XY(0) return Measurement.XY(1) case Axis.Y: if self.sign == Sign.PLUS: return Measurement.XY(0.5) return Measurement.XY(1.5) case Axis.Z: if self.sign == Sign.PLUS: return Measurement.YZ(0) return Measurement.YZ(1)
[docs] @override def downcast_bloch(self) -> BlochMeasurement: """Raise :class:`TypeError` (overridden from :class:`Measurement`).""" raise TypeError("Bloch measurement expected, but Pauli measurement was found.")
[docs] @override def try_to_pauli(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMeasurement: """Return ``self`` (overridden from :class:`Measurement`).""" return self
[docs] @override def to_pauli_or_bloch(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMeasurement: """Return ``self`` (overridden from :class:`Measurement`).""" return self
[docs] @override def to_plane_or_axis(self) -> Axis: """Return ``self.axis`` (overridden from :class:`Measurement`).""" return self.axis
[docs] @override def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: return self == other
[docs] @override def clifford(self, clifford_gate: Clifford) -> PauliMeasurement: pauli = clifford_gate.measure(self.to_pauli()) return PauliMeasurement(pauli.axis, pauli.unit.sign)
[docs] @override def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: return self
[docs] @override def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: return self
# These fields have been declared in the definition of the # ``Measurement`` class, but are only assigned here, now that # ``PauliMeasurement`` is defined. Measurement.X = PauliMeasurement(Axis.X) Measurement.Y = PauliMeasurement(Axis.Y) Measurement.Z = PauliMeasurement(Axis.Z)