"""``assert_state_close`` — state-vector level assertion.
Compares a circuit's resulting state vector against an expected target
(either a complex array or a named state from
:mod:`qtest.assertions._state_library`).
Two comparison modes
--------------------
* ``global_phase=True`` (default) — compare via **fidelity**
:math:`F = |\\langle\\psi|\\varphi\\rangle|^{2}`. The assertion succeeds
when :math:`F \\ge 1 - \\text{tolerance}`. This is **invariant under a
global phase** :math:`e^{i\\theta}` and is almost always the right notion
of "the same state".
* ``global_phase=False`` — compare via raw Euclidean distance
:math:`\\lVert\\psi - \\varphi\\rVert_{2}`. The assertion succeeds when
this distance is strictly less than ``tolerance``. **Phase-sensitive**:
two vectors differing only by an overall :math:`e^{i\\theta}` will fail.
"""
from __future__ import annotations
from typing import Any, Union
import numpy as np
from qtest.assertions._state_library import get_state
from qtest.backends import Backend
from qtest.backends.registry import get_backend
from qtest.config import get_config
from qtest.metrics import fidelity
from qtest.noise import NoiseModel
from qtest.noise.resolve import resolve_noise_model
# Accepted types for the *expected_state* parameter.
ExpectedState = Union[str, "list[complex]", "tuple[complex, ...]", np.ndarray]
# Floating-point slack absorbed by the fidelity comparison so that
# bit-equal state vectors don't fail at tolerance=0 due to ULP-level
# round-off (~2.2e-16 per double-precision op).
_FP_SLACK = 1e-12
# --------------------------------------------------------------------------- #
# Public API #
# --------------------------------------------------------------------------- #
[docs]
def assert_state_close(
circuit: Any,
expected_state: ExpectedState,
tolerance: float = 1e-6,
global_phase: bool = True,
backend: Backend | None = None,
noise_model: NoiseModel | str | None = None,
msg: str | None = None,
) -> None:
"""Assert that *circuit*'s state vector equals *expected_state*.
Parameters
----------
circuit
Backend-native circuit object (e.g. ``qiskit.QuantumCircuit``).
Should contain no measurement / reset / classical operations
— otherwise the backend will reject it when extracting the state.
expected_state
Either a name from :mod:`qtest.assertions._state_library`
(e.g. ``"bell"``, ``"ghz_3"``), or a 1-D complex array / list of
amplitudes with unit Euclidean norm.
tolerance
Maximum permitted infidelity (when ``global_phase=True``) or L2
distance (when ``global_phase=False``). Defaults to ``1e-6``.
global_phase
Whether to ignore a global phase factor when comparing. Defaults
to ``True`` (recommended).
backend
Backend to extract the state vector with. Defaults to the
configured default backend; must satisfy
:attr:`Backend.supports_statevector`.
noise_model
Optional :class:`qtest.noise.NoiseModel` (or preset name). When given,
the circuit is evolved into a (generally mixed) **density matrix** under
the noise and compared to *expected_state* via fidelity; ``None`` (the
default) keeps the exact state-vector comparison. Noisy comparison
requires ``global_phase=True`` (fidelity is the only sensible notion for
mixed states).
msg
Optional prefix prepended to the assertion failure message.
Raises
------
ValueError
For malformed input (unknown state name, wrong shape, non-unit
norm, backend without state-vector support, etc.).
AssertionError
When the circuit's state vector diverges from *expected_state*
beyond ``tolerance``.
Examples
--------
>>> from qiskit import QuantumCircuit
>>> qc = QuantumCircuit(2); qc.h(0); qc.cx(0, 1)
>>> assert_state_close(qc, "bell") # doctest: +SKIP
"""
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}")
expected = _coerce_expected_state(expected_state)
if backend is None:
backend = get_backend(get_config().default_backend)
if not backend.supports_statevector:
raise ValueError(
f"Backend {backend.name!r} does not support state-vector "
"extraction (supports_statevector is False). Use a state-vector-"
"capable backend such as QiskitBackend."
)
resolved_noise = resolve_noise_model(noise_model)
if resolved_noise is not None:
_assert_state_close_noisy(
circuit=circuit,
expected=expected,
tolerance=tolerance,
global_phase=global_phase,
backend=backend,
noise_model=resolved_noise,
user_msg=msg,
)
return
actual = np.asarray(backend.get_statevector(circuit), dtype=complex)
if actual.ndim != 1:
raise ValueError(f"Backend returned a non-1-D state vector of shape {actual.shape}")
if actual.shape != expected.shape:
raise ValueError(
f"State vector shape mismatch: circuit produced {actual.shape}, "
f"expected {expected.shape}. Check qubit counts."
)
if global_phase:
fid = float(np.clip(fidelity(actual, expected), 0.0, 1.0))
passed = fid >= 1.0 - tolerance - _FP_SLACK
measured_value = fid
threshold_desc = f">= {1.0 - tolerance:.6g}"
metric_label = "fidelity"
else:
diff = float(np.linalg.norm(actual - expected))
passed = diff <= tolerance + _FP_SLACK
measured_value = diff
threshold_desc = f"<= {tolerance:.6g}"
metric_label = "L2 distance"
if not passed:
raise AssertionError(
_format_failure_message(
circuit=circuit,
tolerance=tolerance,
metric_label=metric_label,
measured_value=measured_value,
threshold_desc=threshold_desc,
expected=expected,
actual=actual,
global_phase=global_phase,
user_msg=msg,
)
)
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
def _assert_state_close_noisy(
*,
circuit: Any,
expected: np.ndarray,
tolerance: float,
global_phase: bool,
backend: Backend,
noise_model: NoiseModel,
user_msg: str | None,
) -> None:
"""Noisy variant of :func:`assert_state_close` using density-matrix fidelity.
Under noise the circuit produces a mixed state, so the only meaningful
comparison is fidelity against the (pure) *expected* target — there is no
state vector to take an L2 distance of.
"""
if not global_phase:
raise ValueError(
"global_phase=False is not supported with a noise_model: a noisy "
"circuit yields a mixed state, for which only fidelity "
"(global_phase=True) is well defined."
)
if not hasattr(backend, "get_density_matrix"):
raise ValueError(
f"Backend {backend.name!r} does not support density-matrix "
"extraction, which is required for noisy state comparison."
)
rho = np.asarray(backend.get_density_matrix(circuit, noise_model=noise_model), dtype=complex)
dim = expected.shape[0]
if rho.shape != (dim, dim):
raise ValueError(
f"Density matrix shape mismatch: circuit produced {rho.shape}, "
f"expected ({dim}, {dim}). Check qubit counts."
)
fid = float(np.clip(fidelity(rho, expected), 0.0, 1.0))
if fid >= 1.0 - tolerance - _FP_SLACK:
return
lines: list[str] = []
if user_msg:
lines.extend([user_msg, ""])
lines.append("State mismatch under noise")
lines.append("")
lines.append(f" Circuit: {_circuit_summary(circuit)}")
lines.append(f" Backend: {backend.name}")
lines.append(f" Noise: {noise_model.label}")
lines.append(f" Tolerance: {tolerance:g}")
lines.append(f" Fidelity: {fid:.6f} (expected >= {1.0 - tolerance:.6g})")
lines.append("")
lines.append(" Expected (pure) state:")
lines.append(_format_state(expected))
raise AssertionError("\n".join(lines))
def _coerce_expected_state(expected_state: ExpectedState) -> np.ndarray:
"""Convert *expected_state* into a validated 1-D complex unit vector."""
if isinstance(expected_state, str):
vec = get_state(expected_state)
elif isinstance(expected_state, np.ndarray):
vec = np.asarray(expected_state, dtype=complex)
elif isinstance(expected_state, (list, tuple)):
if not expected_state:
raise ValueError("expected_state list/tuple must be non-empty")
vec = np.asarray(expected_state, dtype=complex)
else:
raise ValueError(
f"expected_state must be str | list | tuple | np.ndarray, "
f"got {type(expected_state).__name__}"
)
if vec.ndim != 1:
raise ValueError(f"expected_state must be 1-D, got shape {vec.shape}")
if vec.size == 0:
raise ValueError("expected_state must be non-empty")
# Dimension must be a power of two — every n-qubit state has 2**n amplitudes.
if vec.size & (vec.size - 1):
raise ValueError(f"expected_state length must be a power of 2, got {vec.size}")
norm = float(np.linalg.norm(vec))
if not np.isclose(norm, 1.0, atol=1e-6):
raise ValueError(f"expected_state must have unit Euclidean norm, got {norm:.6g}")
return vec
def _format_amplitude(z: complex) -> str:
"""Render a complex amplitude with consistent significant digits."""
real = z.real
imag = z.imag
return f"{real:+.4f} {imag:+.4f}j"
def _format_state(state: np.ndarray, *, max_entries: int = 16) -> str:
"""Render a state vector as ``|basis>: amp`` lines.
For large vectors only the top-``max_entries`` amplitudes (by magnitude)
are shown, followed by an ellipsis line.
"""
n = state.size
n_qubits = int(np.log2(n))
if n <= max_entries:
indices: list[int] = list(range(n))
else:
ranked = sorted(range(n), key=lambda i: -abs(state[i]))[:max_entries]
indices = sorted(ranked)
lines = [f" |{i:0{n_qubits}b}>: {_format_amplitude(complex(state[i]))}" for i in indices]
if n > max_entries:
lines.append(f" ... ({n - max_entries} more amplitudes hidden)")
return "\n".join(lines)
def _circuit_summary(circuit: Any) -> str:
name = getattr(circuit, "name", None)
n_qubits = getattr(circuit, "num_qubits", None)
if name and n_qubits is not None:
return f"{name} ({n_qubits} qubits)"
if name:
return str(name)
if n_qubits is not None:
return f"<{type(circuit).__name__} num_qubits={n_qubits}>"
return f"<{type(circuit).__name__}>"
def _format_failure_message(
*,
circuit: Any,
tolerance: float,
metric_label: str,
measured_value: float,
threshold_desc: str,
expected: np.ndarray,
actual: np.ndarray,
global_phase: bool,
user_msg: str | None,
) -> str:
"""Build the multi-line ``AssertionError`` payload."""
lines: list[str] = []
if user_msg:
lines.append(user_msg)
lines.append("")
lines.append("State vector mismatch")
lines.append("")
lines.append(f" Circuit: {_circuit_summary(circuit)}")
lines.append(f" Tolerance: {tolerance:g}")
lines.append(f" Global-phase ignored: {global_phase}")
lines.append(f" {metric_label}: {measured_value:.6f} (expected {threshold_desc})")
lines.append("")
lines.append(" Expected state:")
lines.append(_format_state(expected))
lines.append("")
lines.append(" Measured state:")
lines.append(_format_state(actual))
return "\n".join(lines)