Source code for mimiqcircuits.operations.operator

#
# Copyright © 2022-2023 University of Strasbourg. All Rights Reserved.
# Copyright © 2023-2025 QPerfect. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""AbstractOperator base class."""

from abc import abstractmethod
import numbers

import numpy as np
import symengine as se
from mimiqcircuits._sympycheck import _is_basic, _is_sympy_matrix

from mimiqcircuits import Operation


class UnexpectedSymbolics(ValueError):
    """Raised when :func:`unwrappedmatrix` hits a parameter that can't be
    evaluated to a number."""

    def __init__(self, op):
        super().__init__(
            f"Unexpected symbolic expression in {op}. "
            "Try to evaluate or define all symbols."
        )


def _try_unwrap(p):
    """Try to reduce ``p`` to a plain Python number.

    Returns ``(value, True)`` on success and ``(p, False)`` when ``p`` is
    genuinely symbolic. Numeric SymEngine/SymPy expressions like
    ``symengine.pi`` or ``2*pi/3`` round-trip through ``complex`` to a
    Python ``float``/``int``/``complex``.
    """
    if isinstance(p, bool):
        return int(p), True
    if isinstance(p, numbers.Integral):
        return int(p), True
    if isinstance(p, numbers.Real):
        return float(p), True
    if isinstance(p, numbers.Complex):
        return complex(p), True
    if isinstance(p, np.generic):
        return p.item(), True
    if _is_basic(p):
        if getattr(p, "free_symbols", None):
            return p, False
        try:
            c = complex(p)
        except (TypeError, ValueError):
            return p, False
        if c.imag == 0.0:
            r = c.real
            if r.is_integer():
                return int(r), True
            return r, True
        return c, True
    return p, False


_SE_ZERO = se.Integer(0)


def _smart_evalf(x):
    """Evaluate a SymEngine expression to a float/complex, preserving
    symbolic terms and keeping integer zeros as integers.

    Also collapses ``a + 0.0*I`` → ``a`` (real) and normalizes IEEE-754
    signed zeros to ``0.0``. Together these two rules make the output of
    matrix inversion and other algebraic operations reproducible and
    clean, which the old ``sp.simplify`` used to achieve indirectly.
    """
    if getattr(x, "free_symbols", None):
        return x
    if isinstance(x, se.Integer) and int(x) == 0:
        return x
    try:
        v = x.evalf()
    except AttributeError:
        return x
    # Canonicalize numeric results: fold away exact-zero imaginary parts
    # and normalize -0.0 → 0.0. ``complex(v)`` handles RealDouble,
    # ComplexDouble, Integer, Rational, and the symengine Float families.
    try:
        c = complex(v)
    except (TypeError, ValueError):
        return v
    real = c.real + 0.0  # IEEE-754 quirk: this turns -0.0 into 0.0
    imag = c.imag + 0.0
    if imag == 0.0:
        return _SE_ZERO if real == 0.0 else se.RealDouble(real)
    # Keep an explicit real part so ``0.0 + b*I`` still reads naturally
    # for users comparing with the documented matrix forms. Build
    # ``I*b + a`` rather than ``a + I*b`` — SymEngine's evaluator otherwise
    # re-introduces a signed zero into the real part when ``b`` is
    # negative.
    return se.I * se.RealDouble(imag) + se.RealDouble(real)


def _as_se_matrix(m):
    """Normalize the output of a ``_matrix()`` override to ``se.Matrix``.

    Some overrides build their matrix as a ``numpy.ndarray`` (generalized
    gates doing ``np.kron``) or as a ``sympy.Matrix`` (e.g. ``Power``
    using ``sympy``'s matrix power). Convert both into SymEngine so the
    downstream ``applyfunc``/display path is uniform.
    """
    if isinstance(m, np.ndarray):
        return se.Matrix(m.tolist())
    if _is_sympy_matrix(m):
        return se.Matrix(m.tolist())
    return m


def _to_ndarray(m):
    """Convert a SymEngine (or already-NumPy) matrix to ``complex128``."""
    if isinstance(m, np.ndarray):
        return m.astype(np.complex128, copy=False)
    return np.asarray(m.tolist(), dtype=np.complex128)


def _matrix_to_ndarray(op, m):
    """``complex128`` copy of the matrix ``m`` held by ``op``.

    Raises :class:`UnexpectedSymbolics` when an entry is still symbolic.
    """
    if not isinstance(m, np.ndarray):
        if any(getattr(e, "free_symbols", None) for e in m):
            raise UnexpectedSymbolics(op)
    return np.array(_to_ndarray(m), dtype=np.complex128)


_UNSET = object()


def _canonical_instance(cls):
    """Default-constructed instance of ``cls``, or ``None`` if it needs arguments."""
    inst = cls.__dict__.get("_CANONICAL_INSTANCE", _UNSET)
    if inst is _UNSET:
        try:
            inst = cls()
        except (TypeError, ValueError):
            inst = None
        setattr(cls, "_CANONICAL_INSTANCE", inst)
    return inst


[docs] class AbstractOperator(Operation): r"""Supertype for all N-qubit operators. Note that objects of type `AbstractOperator` do not need to be unitary. Operators can be used to define Kraus channels (noise) (see :class:`krauschannel`), or to compute expectation values (see :class:`ExpectationValue`). However, they will return an error if directly applied to states. See Also: :func:`matrix`, :func:`isunitary` """ _name = None _num_qubits = None _qregsizes = None _num_bits = 0 _parnames = () _num_cregs = 0 _num_qregs = 1 _num_zvars = 0 _cregsizes = None
[docs] def opname(self): return self.__class__.__name__
@abstractmethod def _matrix(self): """Return the matrix representation of the operator. This method should be overridden in subclasses to provide the specific matrix corresponding to the operator. Returns: symengine.Matrix or sympy.Matrix: The matrix representation of the operator. """ pass
[docs] def iswrapper(self): """Check if the operator is a wrapper around another operator. This method should be overridden in subclasses to return `True` if the operator is acting as a wrapper around another operation or object, and `False` otherwise. Returns: bool: Always returns `False` in the base class. Subclasses should override this method to provide the appropriate logic. """ return False
def __str__(self): return f"{self.opname()}"
[docs] @staticmethod def isunitary(): """Check if the object is unitary. By default, this method returns `False` unless explicitly overridden in a subclass. """ return False
[docs] def supports_canonical_rewrite(self): """Only gate-like operators participate in canonical rewrite by default.""" import mimiqcircuits as mc return isinstance(self, mc.Gate)
def _hascachedmatrix(self): """Whether this operator's matrix may be stored on its class. Two kinds of operator are fully determined by their type: plain operators without parameters, and named wrappers such as ``GateT`` (``Power(GateZ(), 1/4)``) or ``GateCX`` whose constructor ignores its arguments. The second kind is recognised by comparing the instance to a default-constructed one, so a wrapper subclass that does take arguments, or a generic ``Control``/``Power`` that cannot be built without them, is never cached. """ if not self.iswrapper(): return not self._parnames canonical = _canonical_instance(type(self)) return canonical is not None and self.__dict__ == canonical.__dict__ def _classmatrix(self): """The matrix stored on the class, built on first use. Only valid when :meth:`_hascachedmatrix` holds. Callers must not mutate the result. """ cls = type(self) cached = cls.__dict__.get("_MATRIX_CACHE") if cached is None: cached = _as_se_matrix(self._matrix()).applyfunc(_smart_evalf) # Published only once complete, so a concurrent reader sees # either nothing or the finished matrix; a duplicate build is # harmless because both results are equal. setattr(cls, "_MATRIX_CACHE", cached) return cached
[docs] def matrix(self): """Return the SymEngine matrix representation of the operator. Operators fully determined by their type (parameter-free gates and named wrappers such as ``GateT`` or ``GateCX``) build their matrix once per class and return a fresh copy of it on each call, so mutating the result never affects later calls. Numeric parametric operators build a SymEngine matrix through :meth:`_matrix` and then evaluate each non-zero entry to a floating-point scalar. Symbolic operators return the SymEngine expressions without simplifying them; callers that want a simplified form apply their own simplification. Returns: symengine.Matrix: The matrix representation. """ if self._hascachedmatrix(): return se.DenseMatrix(self._classmatrix()) # applyfunc(_smart_evalf) is cheap on symbolic entries (a # free_symbols check) and converts any literal numeric entry # (``1`` → ``1.0``) the same way the old sympy pipeline did, # without paying for sympy. return _as_se_matrix(self._matrix()).applyfunc(_smart_evalf)
def _matrix_numeric(self, *params): """Build the matrix for fully-numeric parameters as a NumPy array. ``params`` are the unwrapped values of :meth:`getparams`, for overrides that build the matrix from plain numbers. The default ignores them, builds the SymEngine matrix through :meth:`_matrix` and casts it to ``complex128``; :meth:`unwrappedmatrix` has already checked that no parameter is symbolic. Gates where this is too slow override it with native ``math``/``cmath`` operations — see :mod:`mimiqcircuits.numerics`. """ return _to_ndarray(self._matrix())
[docs] def inverse(self): """Raise an error, as non-unitary operators cannot be inverted. This method is not implemented for non-unitary operators and will raise a `NotImplementedError` if called. Raises: NotImplementedError: If the method is called. """ raise NotImplementedError("Cannot invert non-unitary operator")
[docs] def rescale(self, scale): """Return a copy of the operator multiplied by ``scale``. Every parameter in :attr:`parnames` is multiplied by ``scale``, which is the right thing for operators linear in their parameters (projectors, sigmas, :class:`DiagonalOp`, :class:`Operator`). Operators that are not linear in their parameters override this. Raises: NotImplementedError: If the operator has no parameters to scale. """ if not self.parnames: raise NotImplementedError(f"rescale not implemented for {self.opname()}") return type(self)(*(getattr(self, name) * scale for name in self.parnames))
[docs] def rescale_inplace(self, scale): """Multiply the operator by ``scale`` in place and return it. In-place counterpart of :meth:`rescale`. Raises: NotImplementedError: If the operator has no parameters to scale. """ if not self.parnames: raise NotImplementedError( f"rescale_inplace not implemented for {self.opname()}" ) for name in self.parnames: setattr(self, name, getattr(self, name) * scale) return self
[docs] def power(self, n): """Raise an error, as powers of non-unitary operators are not supported. This method is not implemented for non-unitary operators and will raise a `NotImplementedError` if called. Args: n (int): The exponent to which the operator would be raised. Raises: NotImplementedError: If the method is called. """ raise NotImplementedError("Cannot take power of non-unitary operator")
[docs] def unwrappedmatrix(self): """Numeric matrix representation as a ``numpy.ndarray[complex128]``. Simulators and anything else that wants raw numbers (e.g. TensorWeaver) should call this instead of :meth:`matrix`. It bypasses SymEngine entirely for gates that override :meth:`_matrix_numeric` — see :mod:`mimiqcircuits.numerics` — and otherwise falls back to building the SymEngine matrix and casting it. Operators fully determined by their type cache the result at class level as a read-only array. Raises: UnexpectedSymbolics: If any parameter is still symbolic. Returns: numpy.ndarray: ``complex128`` matrix. """ if self._hascachedmatrix(): cls = type(self) cached = cls.__dict__.get("_UNWRAPPED_CACHE") if cached is None: cached = self._matrix_numeric() cached.flags.writeable = False setattr(cls, "_UNWRAPPED_CACHE", cached) return cached unwrapped = [] for p in self.getparams(): v, ok = _try_unwrap(p) if not ok: raise UnexpectedSymbolics(self) unwrapped.append(v) return self._matrix_numeric(*unwrapped)
[docs] def evaluate(self, d): """ Substitute the symbolic parameters of the operator with numerical values. This method evaluates the operator's symbolic parameters using the values provided in the dictionary `d`. If the operator has no parameters, it returns the same instance. Otherwise, it creates a new instance of the operator with updated numerical parameters. Parameters: d (dict): A dictionary where keys are symbolic parameter names and values are values for substitution. Example: >>> from symengine import * >>> from mimiqcircuits import * >>> theta = symbols('theta') >>> op = GateRX(theta) >>> evaluated_op = op.evaluate({'theta': 0.5}) >>> print(evaluated_op) RX(0.5) """ if len(self.parnames) == 0: return self params = self.getparams() for i in range(len(params)): if isinstance(params[i], (int, float)): continue params[i] = params[i].subs(d) return type(self)(*params)
__all__ = ["AbstractOperator"]