Source code for paulicirc.circuits

"""Circuits of Pauli gadgets."""

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.

# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

from __future__ import annotations
from collections.abc import Iterable, Iterator
from fractions import Fraction
from math import ceil, log10
import sys
from typing import (
    TYPE_CHECKING,
    Any,
    Literal,
    Self,
    Sequence,
    SupportsFloat,
    SupportsIndex,
    TypeAlias,
    cast,
    final,
    overload,
)
import numpy as np


from .utils.numpy import (
    RNG,
    Complex128Array1D,
    Complex128Array2D,
    ComplexArray1D,
    FloatArray1D,
    UInt8Array1D,
    UInt8Array2D,
    UIntArray1D,
    canonicalize_phase,
    numba_jit,
)
from .gadgets import (
    PHASE_DTYPE,
    PHASE_NBYTES,
    Gadget,
    PauliArray,
    Phase,
    PhaseArray,
    PauliArray2D,
    QubitIdxs,
    are_same_phases,
    decode_phases,
    encode_phases,
    gadget_data_len,
    invert_phases,
    commute_gadget_pair,
)

if __debug__:
    from typing_validation import validate

if TYPE_CHECKING:
    try:
        from qiskit import QuantumCircuit as QiskitQuantumCircuit  # type: ignore[import-untyped]
    except ModuleNotFoundError:
        pass


CircuitData: TypeAlias = UInt8Array2D
"""Type alias for data encoding a circuit of Pauli gadgets."""

CommutationCodeArray: TypeAlias = UInt8Array1D
"""
A 1D array of commutation codes, used by :meth:`Circuit.commute`.

See :class:`Gadget.commute_past` for a description of the commutation procedure
and associated commutation code conventions.
"""


[docs] def zero_circ(m: int, n: int) -> CircuitData: """ Low-level function. Returns a circuit with ``m`` gadgets on ``n`` qubits, where all gadgets have no legs and zero phase. Presumes that the number ``n`` of qubits is divisible by 4. """ ncols = PHASE_NBYTES - (-n // 4) return np.zeros((m, ncols), dtype=np.uint8)
[docs] def rand_circ(m: int, n: int, *, rng: RNG) -> CircuitData: """ Low-level function. Returns a uniformly random circuit with ``m`` gadgets on ``n`` qubits. """ ncols = PHASE_NBYTES - (-n // 4) data = rng.integers(0, 256, (m, ncols), dtype=np.uint8) if n % 4 != 0: # zeroes out the padding leg bits (up to 6 bits) mask = np.uint8(0b11111111 << 2 * (-n % 4) & 0b11111111) data[:, -PHASE_NBYTES - 1] &= mask # sets the phase bytes phases = rng.uniform(0.0, 2 * np.pi, size=m).astype(PHASE_DTYPE) data[:, -PHASE_NBYTES:] = encode_phases(phases) return data
[docs] @numba_jit def get_circuit_legs(circ: CircuitData) -> PauliArray2D: """ Low-level function. Extract a 2D array of leg information from given circuit data. The returned array has values in ``range(4)``, where the encoding is explained in :obj:`~paulicirc.gadgets.GadgetData`. """ leg_bytes = circ[:, :-PHASE_NBYTES] m, n = leg_bytes.shape legs = np.zeros((m, 4 * n), dtype=np.uint8) legs[:, ::4] = (leg_bytes & 0b11_00_00_00) >> 6 legs[:, 1::4] = (leg_bytes & 0b00_11_00_00) >> 4 legs[:, 2::4] = (leg_bytes & 0b00_00_11_00) >> 2 legs[:, 3::4] = leg_bytes & 0b00_00_00_11 return legs
[docs] def set_circuit_legs(circ: CircuitData, legs: PauliArray2D) -> None: """ Low-level function. Sets leg information in the given circuit data. The input array should have values in ``range(4)``, where the encoding is explained in :obj:`~paulicirc.gadgets.GadgetData`. """ _, n = legs.shape leg_data = circ[:, :-PHASE_NBYTES] leg_data[:, :] = 0 leg_data[:, : -(-(n - 0) // 4)] |= legs[:, 0::4] << 6 # type: ignore leg_data[:, : -(-(n - 1) // 4)] |= legs[:, 1::4] << 4 # type: ignore leg_data[:, : -(-(n - 2) // 4)] |= legs[:, 2::4] << 2 # type: ignore leg_data[:, : -(-(n - 3) // 4)] |= legs[:, 3::4]
[docs] @numba_jit def transversal_set_gadget_leg_at( circ: CircuitData, p: int, qs: UIntArray1D, start: int, end: int ) -> None: """ Low-level function. Sets single leg data to the given circuit data, transversally over multiple gadget indices and qubits. """ _qs = np.array(qs, np.uint64) shifts = 2 * (3 - _qs % 4) idxs = np.arange(start, end) circ[idxs, _qs // 4] &= 0b11111111 ^ (0b11 << shifts) circ[idxs, _qs // 4] |= np.uint8(p) << shifts
[docs] @numba_jit def transversal_set_phase( circ: CircuitData, phase: Phase, start: int, end: int ) -> None: """ Low-level function. Sets phase data, transversally over multiple gadget indices. """ circ[start:end, -PHASE_NBYTES:] = np.array( [phase % (2 * np.pi)], dtype=np.float64 ).view(np.uint8)
[docs] def commute_circuit(circ: CircuitData, codes: CommutationCodeArray) -> CircuitData: """ Low-level function. Commutes subsequent gadget pairs in the circuit according to the given codes. Expects the number of codes to be ``m//2``, where ``m`` is the number of gadgets. See :class:`Gadget.commute_past` for a description of the commutation procedure and associated commutation code conventions. """ m, _n = circ.shape _m = m + m // 2 + 2 * (m % 2) exp_circ = np.zeros((_m, _n), dtype=np.uint8) exp_circ[::3] = circ[::2] exp_circ[1 : _m - 2 * (m % 2) : 3] = circ[1::2] exp_circ[2 : _m - (m % 2) : 3, -1] = codes % 8 reshaped_exp_circ = exp_circ.reshape(_m // 3, 3 * _n) np.apply_along_axis(commute_gadget_pair, 1, reshaped_exp_circ) return exp_circ[~np.all(exp_circ == 0, axis=1)] # type: ignore
[docs] def unitary_from_gadgets( self: Iterable[Gadget], num_qubits: int, canonical_phase: bool = True, _use_cupy: bool = False, # currently in alpha ) -> Complex128Array2D: """ Low-level function. Returns the unitary matrix associated to the given sequence of gadgets. """ res: Complex128Array2D = np.eye(2**num_qubits, dtype=np.complex128) if _use_cupy: import cupy as cp # type: ignore[import-untyped] res = cp.asarray(res) for gadget in self: gadget_u = gadget.unitary(canonical_phase=False) if _use_cupy: gadget_u = cp.asarray(res) res = gadget_u @ res if _use_cupy: res = cp.asnumpy(res).astype(np.complex128) if canonical_phase: canonicalize_phase(res) return res
[docs] def statevec_from_gadgets( self: Iterable[Gadget], input: ComplexArray1D | FloatArray1D, canonical_phase: bool = True, _use_cupy: bool = False, # currently in alpha ) -> Complex128Array1D: """ Low-level function. Computes the statevector resulting from the application of the given gadgets to the given input statevector. """ assert validate(input, ComplexArray1D | FloatArray1D) res = input.astype(np.complex128) if _use_cupy: import cupy as cp res = cp.asarray(res) for gadget in self: gadget_u = gadget.unitary(canonical_phase=False) if _use_cupy: gadget_u = cp.asarray(res) res = gadget_u @ res if canonical_phase: canonicalize_phase(res) return res
[docs] @final class CircuitListing: """A listing for a quantum circuit.""" _circuit: Circuit _selection: tuple[int, int] | None __slots__ = ("__weakref__", "_circuit", "_selection")
[docs] def __new__( cls, circuit: Circuit, selection: tuple[int, int] | None = None ) -> Self: """ Instantiates a new listing for the given circuit, with optional starting and/or stopping gadget indices. :meta public: """ if selection is not None: start, stop = selection start, stop, _ = slice(start, stop).indices(len(circuit)) selection = (start, stop) self = super().__new__(cls) self._circuit = circuit self._selection = selection return self
def __getitem__( self, idx: int | slice[int | None, int | None, None] ) -> CircuitListing: if self._selection is not None: raise ValueError("Circuit listings can only be sliced once.") if isinstance(idx, int): return CircuitListing(self._circuit, (idx, idx + 1)) start, stop, _ = idx.indices(len(self._circuit)) return CircuitListing(self._circuit, (start, stop))
[docs] def __repr__(self) -> str: """ Creates a string listing of the circuit, with gadgets listed one per line, in the format ``idx phase paulistr``. :meta public: """ circuit = self._circuit if self._selection is not None: start, stop = self._selection if stop <= start or stop <= 0: return "" else: start, stop = 0, len(circuit) data = tuple( (g.phase_str, g.leg_paulistr) for g in circuit.iter_gadgets(start=start, stop=stop, fast=True) ) _max_phase_strlen = max(len(s) for s, _ in data) data = tuple( (f"{phase_str: >{_max_phase_strlen}}", paulistr) for phase_str, paulistr in data ) num_idx_digits = int(ceil(log10(stop))) idx_range = range(start, stop) return "\n".join( f"{str(idx): >{num_idx_digits}} {phase_str} {paulistr}" for idx, (phase_str, paulistr) in zip(idx_range, data) )
[docs] @final class Circuit: """A quantum circuit, represented as a sequential composition of Pauli gadgets."""
[docs] @classmethod def zero(cls, num_gadgets: int, num_qubits: int) -> Self: """ Constructs a circuit with the given number of gadgets and qubits, where all gadgets have no legs and zero phase. """ assert Circuit.__validate_circ_shape(num_gadgets, num_qubits) data = zero_circ(num_gadgets, num_qubits) return cls(data, num_qubits)
[docs] @classmethod def random( cls, num_gadgets: int, num_qubits: int, *, rng: int | RNG | None = None ) -> Self: """ Constructs a circuit with the given number of gadgets and qubits, where all gadgets have random legs and random phase. """ assert Circuit.__validate_circ_shape(num_gadgets, num_qubits) if not isinstance(rng, RNG): rng = np.random.default_rng(rng) data = rand_circ(num_gadgets, num_qubits, rng=rng) return cls(data, num_qubits)
[docs] @classmethod def from_gadgets( cls, gadgets: Iterable[Gadget], num_qubits: int | None = None ) -> Self: """Constructs a circuit from the given gadgets.""" gadgets = list(gadgets) assert Circuit.__validate_gadgets(gadgets, num_qubits) if num_qubits is None: num_qubits = gadgets[0].num_qubits data = np.array([g._data for g in gadgets], dtype=np.uint8).reshape( len(gadgets), gadget_data_len(num_qubits) ) return cls(data, num_qubits)
[docs] @staticmethod def from_bytes(bs: bytes) -> Circuit: """ Deserializes a circuit from bytes. See :meth:`Circuit.__bytes__` for discussion of the encoding. """ if len(bs) <= 10: raise ValueError("Circuit encodings are >= 18B.") if bs[0] != 0x01: raise ValueError("First byte must be 0x01.") if bs[1] != 0x04: raise NotImplementedError( f"Phase dtype code 0x{bs[2]:0>2x} not implemented." "The only phase dtype currently implemented is 0x04 for float64." ) num_gadgets = int.from_bytes(bs[2:10], byteorder="big", signed=False) num_qubits = int.from_bytes(bs[10:18], byteorder="big", signed=False) if num_gadgets == 0: return Circuit(zero_circ(num_gadgets, num_qubits), num_qubits) data = np.frombuffer(memoryview(bs)[18:], dtype=np.uint8).reshape( num_gadgets, -1 ) expected_ncols = PHASE_NBYTES - (-num_qubits // 4) if data.shape[1] != expected_ncols: raise ValueError( f"Expected data of shape ({num_gadgets}, {expected_ncols})" f", found data of shape {data.shape} instead." ) return Circuit(data, num_qubits)
_data: CircuitData _num_qubits: int __slots__ = ("__weakref__", "_data", "_num_qubits")
[docs] def __new__(cls, data: CircuitData, num_qubits: int | None = None) -> Self: """ Constructs a gadget circuit from the given data. :meta public: """ assert Circuit.__validate_new_args(data, num_qubits) if num_qubits is None: num_qubits = (data.shape[1] - PHASE_NBYTES) * 4 self = super().__new__(cls) self._data = data self._num_qubits = num_qubits return self
@property def num_gadgets(self) -> int: """Number of gadgets in the circuit.""" return len(self._data) @property def num_qubits(self) -> int: """Number of qubits in the circuit.""" return self._num_qubits @property def phases(self) -> PhaseArray: """Array of phases for the gadgets in the circuit.""" return decode_phases(self._data[:, -PHASE_NBYTES:]) @phases.setter def phases(self, value: PhaseArray | Sequence[SupportsFloat | Fraction]) -> None: """Sets phases for the gadgets in the circuit.""" if not isinstance(value, np.ndarray): assert validate(value, Sequence[SupportsFloat | Fraction]) value = np.array( [ ( float(phase) if isinstance(phase, SupportsFloat) else Gadget.frac2phase(phase) ) for phase in value ], dtype=PHASE_DTYPE, ) assert self.__validate_phases_value(value) self._data[:, -PHASE_NBYTES:] = encode_phases(value) @property def legs(self) -> PauliArray2D: """The 2D array of gadget legs for this circuit.""" return get_circuit_legs(self._data)[:, : self._num_qubits] @legs.setter def legs( self, value: PauliArray2D | Sequence[str | PauliArray | Sequence[int]] ) -> None: """Sets the legs of the circuit.""" if isinstance(value, np.ndarray): assert self.__validate_legs_value(value) set_circuit_legs(self._data, value) else: assert validate(value, Sequence[Any]) for idx, line in enumerate(value): self[idx].legs = line # type: ignore @property def is_zero(self) -> bool: """Whether the circuit is all zero (legs set to ``_``, phases set to 0).""" return not np.any(self._data) @property def listing(self) -> CircuitListing: """Returns a listing of the circuit.""" return CircuitListing(self)
[docs] def clone(self) -> Self: """Creates a copy of the gadget circuit.""" return Circuit(self._data.copy(), self._num_qubits)
[docs] def inverse(self) -> Self: """ Returns the inverse of this graph, with both phases and gadget order inverted. """ inverse = self[::-1].clone() inverse.invert_phases() return inverse
[docs] def invert_phases(self) -> None: """Inverts phases inplace, keeping gadget order unchanged.""" invert_phases(self._data[:, -PHASE_NBYTES:])
[docs] def commute(self, codes: Sequence[int] | CommutationCodeArray) -> Self: """ Commutes adjacent gadget pairs in the circuit according to the given commutation codes. See :class:`Gadget.commute_past` for a description of the commutation procedure and associated commutation code conventions. """ codes = np.asarray(codes, dtype=np.uint8) assert self.__validate_commutation_codes(codes) if len(self) == 0: return self.clone() return Circuit(commute_circuit(self._data, codes), self._num_qubits)
[docs] def unitary( self, *, canonical_phase: bool = True, _use_cupy: bool = False, # currently in alpha ) -> Complex128Array2D: """Returns the unitary matrix associated to this Pauli gadget circuit.""" return unitary_from_gadgets(self, self.num_qubits, canonical_phase, _use_cupy)
[docs] def statevec( self, input: ComplexArray1D | FloatArray1D, canonical_phase: bool = True, _use_cupy: bool = False, # currently in alpha ) -> Complex128Array1D: """ Computes the statevector resulting from the application of this gadget circuit to the given input statevector. """ return statevec_from_gadgets(self, input, canonical_phase, _use_cupy)
[docs] def iter_gadgets( self, *, start: int = 0, stop: int | None = None, fast: bool = False ) -> Iterable[Gadget]: """ Iterates over the gadgets in the circuit. If ``fast`` is set to ``True``, the gadgets yielded are ephemeral: they should not be stored, as the same object will be reused in each iteration. """ data = self._data[start:stop] if len(data) == 0: return if not fast: for row in data: yield Gadget(row, self._num_qubits) else: g = Gadget(self._data[0], self._num_qubits, _ephemeral=True) for row in data: g._data = row yield g
def __iter__(self) -> Iterator[Gadget]: """ Iterates over the gadgets in the circuit. :meta public: """ for row in self._data: yield Gadget(row, self._num_qubits) @overload def __getitem__(self, idx: SupportsIndex) -> Gadget: ... @overload def __getitem__(self, idx: slice | list[SupportsIndex]) -> Circuit: ...
[docs] def __getitem__( self, idx: SupportsIndex | slice | list[SupportsIndex] ) -> Gadget | Circuit: """ Accesses the gadget at a given index, or selects/slices a sub-circuit. :meta public: """ if isinstance(idx, SupportsIndex): return Gadget(self._data[int(idx)], self._num_qubits) assert validate(idx, slice | list[SupportsIndex]) return Circuit(self._data[idx, :], self._num_qubits) # type: ignore[index]
@overload def __setitem__(self, idx: SupportsIndex, value: Gadget) -> None: ... @overload def __setitem__(self, idx: slice | list[SupportsIndex], value: Circuit) -> None: ...
[docs] def __setitem__( self, idx: SupportsIndex | slice | list[SupportsIndex], value: Gadget | Circuit, ) -> None: """ Writes a gadget at the given index of this circuit, or writes a sub-circuit onto the given selection/slice of this circuit. :meta public: """ assert self.__validate_setitem_args(idx, value) self._data[idx, :] = value._data # type: ignore[index]
def __len__(self) -> int: """ Number of gadgets in the circuit. :meta public: """ return len(self._data) def __eq__(self, other: Any) -> bool: if not isinstance(other, Circuit): return NotImplemented return ( self.num_qubits == other.num_qubits and self.num_gadgets == other.num_gadgets and np.array_equal(self._data[:-PHASE_NBYTES], other._data[:-PHASE_NBYTES]) and are_same_phases(self.phases, other.phases) ) def __repr__(self) -> str: m, n = self.num_gadgets, self.num_qubits return f"<Circuit: {m} gadgets, {n} qubits>" def __sizeof__(self) -> int: return ( object.__sizeof__(self) + self._num_qubits.__sizeof__() + self._data.__sizeof__() )
[docs] def __bytes__(self) -> bytes: """ Serializes a circuit to bytes: - 1B fixed to 0x01, to indicate a :class:`Circuit` encoding - 1B encoding the circuit phase dtype (currently, fixed to 0x04 for float64) - 8B encoding the number of gadgets, encoded as unsigned big endian - 8B encoding the number of qubits, encoded as unsigned big endian - the bytes of the underlying data array, using :meth:`numpy.ndarray.tobytes` :meta public: """ return b"".join( [ b"\x01", # 0x01 = Circuit b"\x04", # phase dtype = float64 self.num_gadgets.to_bytes(8, byteorder="big", signed=False), self.num_qubits.to_bytes(8, byteorder="big", signed=False), self._data.tobytes(), ] )
def to_qiskit(self) -> QiskitQuantumCircuit: try: from qiskit import QuantumCircuit as QiskitQuantumCircuit from qiskit.circuit.library import PauliEvolutionGate as QiskitPauliEvolutionGate # type: ignore[import-untyped] from qiskit.quantum_info import Pauli as QiskitPauli # type: ignore[import-untyped] except ModuleNotFoundError: raise ModuleNotFoundError("The 'qiskit' package is not installed.") qiskit_circ = QiskitQuantumCircuit(num_qubits := self.num_qubits) for g in self: gate = QiskitPauliEvolutionGate( QiskitPauli(g.leg_paulistr.replace("_", "I")[::-1]), g.phase / 2 ) qiskit_circ.append(gate, range(num_qubits)) return qiskit_circ if __debug__: @staticmethod def __validate_circ_shape(num_gadgets: int, num_qubits: int) -> Literal[True]: """Validates the shape of a circuit.""" validate(num_gadgets, SupportsIndex) validate(num_qubits, SupportsIndex) num_gadgets = int(num_gadgets) num_qubits = int(num_qubits) if num_gadgets < 0: raise ValueError("Number of gadgets must be non-negative.") if num_qubits < 0: raise ValueError("Number of qubits must be non-negative.") return True @staticmethod def __validate_new_args( data: CircuitData, num_qubits: int | None ) -> Literal[True]: """Validates the arguments of the :meth:`__new__` method.""" validate(data, CircuitData) if num_qubits is not None: validate(num_qubits, SupportsIndex) num_qubits = int(num_qubits) if num_qubits < 0: raise ValueError("Number of qubits must be non-negative.") if num_qubits > data.shape[1] * 4: raise ValueError("Number of qubits exceeds circuit width.") return True @staticmethod def __validate_gadgets( gadgets: Sequence[Gadget], num_qubits: int | None ) -> Literal[True]: validate(gadgets, Sequence[Gadget]) if num_qubits is None: if not gadgets: raise ValueError( "At least one gadget must be supplied if num_qubits is omitted." ) num_qubits = gadgets[0].num_qubits for gadget in gadgets: if gadget.num_qubits != num_qubits: raise ValueError("All gadgets must have the same number of qubits.") return True def __validate_setitem_args( self, idx: SupportsIndex | slice | list[SupportsIndex], value: Gadget | Circuit, ) -> Literal[True]: """Validates the arguments to the :meth:`__setitem__` method.""" if isinstance(idx, SupportsIndex): validate(value, Gadget) else: validate(value, Circuit) m_lhs = len(self._data[idx]) # type: ignore[index] m_rhs = cast(Circuit, value).num_gadgets if m_lhs != m_rhs: raise ValueError( "Mismatch in number of gadgets while writing sub-circuit:" f"selection has {m_lhs} gadgets, rhs has {m_rhs}" ) if self.num_qubits != value.num_qubits: raise ValueError( "Mismatch in number of qubits while writing circuit gadgets:" f" lhs has {self.num_qubits} qubits, rhs has {value.num_qubits}." ) return True def __validate_phases_value(self, value: PhaseArray) -> Literal[True]: """Validates the value of the :attr:`phases` property.""" validate(value, PhaseArray) if len(value) != self.num_gadgets: raise ValueError("Number of phases does not match number of gadgets.") return True def __validate_legs_value(self, legs: PauliArray2D) -> Literal[True]: """Validates the value of the :attr:`legs` property.""" validate(legs, PauliArray2D) if legs.shape != (self.num_gadgets, self.num_qubits): raise ValueError("Shape of legs does not match shape of circuit.") return True def __validate_commutation_codes( self, codes: CommutationCodeArray ) -> Literal[True]: """Validates commutation codes passed to :meth:`commute`.""" if len(codes) != self.num_gadgets // 2: raise ValueError( f"Expected {self.num_gadgets//2} communication codes," f"found {len(codes)} instead." ) if np.any(codes >= 8): raise ValueError("Communication codes must be in range(8).") return True