Source code for qtest.assertions.unitary
"""``assert_unitary`` — verify that an operation is unitary.
Accepts a matrix (``np.ndarray``), a Qiskit ``Gate`` (anything exposing
``to_matrix()``), or a quantum circuit (anything the configured backend can
extract a unitary from). Checks both :math:`U^{\\dagger} U \\approx I` and
:math:`U U^{\\dagger} \\approx I`, reporting the worst-case off-diagonal /
diagonal deviation.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from qtest.backends import Backend
from qtest.backends.registry import get_backend
from qtest.config import get_config
# Floating-point slack added to user-supplied tolerance to absorb 1-2 ULP
# round-off from the matrix multiplies (~size * machine epsilon).
_FP_SLACK = 1e-14
# --------------------------------------------------------------------------- #
# Public API #
# --------------------------------------------------------------------------- #
[docs]
def assert_unitary(
operation: Any,
tolerance: float = 1e-9,
backend: Backend | None = None,
msg: str | None = None,
) -> None:
"""Assert that *operation* represents a unitary operator.
Parameters
----------
operation
One of:
* ``np.ndarray`` — used directly as the candidate matrix.
* Object with a ``to_matrix()`` method (e.g. ``qiskit.circuit.Gate``)
— its matrix representation is used.
* Anything else — passed to :meth:`Backend.get_unitary` (treated as
a circuit).
tolerance
Maximum permitted entry-wise deviation
:math:`\\max_{ij} |(U^\\dagger U)_{ij} - I_{ij}|`. Defaults to ``1e-9``.
backend
Backend used to extract a unitary from circuit-shaped inputs.
Ignored for arrays and gate objects.
msg
Optional prefix prepended to the assertion failure message.
Raises
------
ValueError
If the matrix is not 2-D, not square, or has zero size.
AssertionError
If ``operation`` deviates from unitarity beyond ``tolerance``.
"""
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
raise ValueError(f"tolerance must be a real number, got {tolerance!r}")
if tolerance < 0.0:
raise ValueError(f"tolerance must be non-negative, got {tolerance}")
matrix = _to_matrix(operation, backend)
_validate_matrix_shape(matrix)
d = matrix.shape[0]
identity = np.eye(d, dtype=complex)
left = matrix.conj().T @ matrix
right = matrix @ matrix.conj().T
left_dev = float(np.max(np.abs(left - identity)))
right_dev = float(np.max(np.abs(right - identity)))
deviation = max(left_dev, right_dev)
if deviation <= tolerance + _FP_SLACK:
return
raise AssertionError(
_format_failure(
operation=operation,
matrix=matrix,
tolerance=tolerance,
left_dev=left_dev,
right_dev=right_dev,
user_msg=msg,
)
)
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
def _to_matrix(operation: Any, backend: Backend | None) -> np.ndarray:
"""Coerce *operation* into a complex 2-D ``ndarray``."""
if isinstance(operation, np.ndarray):
return np.asarray(operation, dtype=complex)
if hasattr(operation, "to_matrix") and callable(operation.to_matrix):
mat = operation.to_matrix()
return np.asarray(mat, dtype=complex)
# Treat as a circuit and ask the backend for its unitary.
if backend is None:
backend = get_backend(get_config().default_backend)
return np.asarray(backend.get_unitary(operation), dtype=complex)
def _validate_matrix_shape(matrix: np.ndarray) -> None:
if matrix.ndim != 2:
raise ValueError(f"unitary matrix must be 2-D, got array of shape {matrix.shape}")
if matrix.size == 0:
raise ValueError("unitary matrix must be non-empty")
if matrix.shape[0] != matrix.shape[1]:
raise ValueError(f"unitary matrix must be square, got shape {matrix.shape}")
def _operation_summary(operation: Any) -> str:
name = getattr(operation, "name", None)
if isinstance(operation, np.ndarray):
return f"<ndarray shape={operation.shape}>"
n_qubits = getattr(operation, "num_qubits", None)
if name and n_qubits is not None:
return f"{name} ({n_qubits} qubits)"
if name:
return str(name)
return f"<{type(operation).__name__}>"
def _format_failure(
*,
operation: Any,
matrix: np.ndarray,
tolerance: float,
left_dev: float,
right_dev: float,
user_msg: str | None,
) -> str:
lines: list[str] = []
if user_msg:
lines.append(user_msg)
lines.append("")
lines.append("Operation is not unitary")
lines.append("")
lines.append(f" Operation: {_operation_summary(operation)}")
lines.append(f" Matrix shape: {matrix.shape}")
lines.append(f" Tolerance: {tolerance:g}")
lines.append(f" max|U†U - I|: {left_dev:.3e}")
lines.append(f" max|UU† - I|: {right_dev:.3e}")
lines.append("")
lines.append(
" Frobenius norm of U†U - I: "
f"{float(np.linalg.norm(matrix.conj().T @ matrix - np.eye(matrix.shape[0]))):.3e}"
)
return "\n".join(lines)