Source code for qtest.assertions.entanglement

"""``assert_entangled`` / ``assert_separable`` — entanglement-aware assertions.

Both operate on the *pure* state a circuit prepares (or a raw state vector).
Entanglement is judged via the entanglement entropy of a bipartition: the von
Neumann entropy of the reduced density matrix on a subsystem is ``0`` iff that
cut is a product state, and positive otherwise.

* :func:`assert_entangled` — the state is entangled across the chosen cut
  (or, with ``qubits=None``, is not a fully-product state).
* :func:`assert_separable` — the state is a product across the chosen cut
  (or, with ``qubits=None``, is fully separable on every qubit).
"""

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
from qtest.metrics import entanglement_entropy

# Default entropy threshold (bits) separating "separable" from "entangled".
_DEFAULT_TOL = 1e-9


[docs] def assert_entangled( circuit: Any, qubits: list[int] | None = None, tolerance: float = _DEFAULT_TOL, backend: Backend | None = None, msg: str | None = None, ) -> None: """Assert that *circuit*'s state is entangled. Parameters ---------- circuit A backend-native circuit (no measurements) or a 1-D state-vector array. qubits Subsystem defining the bipartition ``qubits | rest``. The assertion passes when the entanglement entropy across this cut exceeds *tolerance*. When ``None``, the state must be entangled *somewhere* — i.e. it is not a fully-product state (some single-qubit reduced state is mixed). tolerance Entropy threshold in bits. Entanglement entropy must be strictly greater than this to count as entangled. backend Backend used to extract the state vector (defaults to the configured backend). msg Optional prefix prepended to the assertion failure message. Raises ------ AssertionError When the state is separable across the chosen cut (entropy ≤ tolerance). """ state, n = _get_state(circuit, backend) entropy, label = _bipartition_entropy(state, n, qubits, want_max=True) if entropy > tolerance: return raise AssertionError( _format_failure( circuit=circuit, n=n, entropy=entropy, tolerance=tolerance, cut_label=label, expected="entangled (entropy > tolerance)", user_msg=msg, ) )
[docs] def assert_separable( circuit: Any, qubits: list[int] | None = None, tolerance: float = 1e-9, backend: Backend | None = None, msg: str | None = None, ) -> None: """Assert that *circuit*'s state is separable (no entanglement). Parameters ---------- circuit A backend-native circuit (no measurements) or a 1-D state-vector array. qubits Subsystem defining the bipartition ``qubits | rest``. The assertion passes when the entanglement entropy across this cut is at most *tolerance*. When ``None``, *every* qubit must be unentangled (the state is a full product state). tolerance Entropy threshold in bits. Entanglement entropy must be ≤ this. backend Backend used to extract the state vector. msg Optional prefix prepended to the assertion failure message. Raises ------ AssertionError When the state is entangled across the chosen cut (entropy > tolerance). """ state, n = _get_state(circuit, backend) entropy, label = _bipartition_entropy(state, n, qubits, want_max=True) if entropy <= tolerance: return raise AssertionError( _format_failure( circuit=circuit, n=n, entropy=entropy, tolerance=tolerance, cut_label=label, expected="separable (entropy <= tolerance)", user_msg=msg, ) )
# --------------------------------------------------------------------------- # # Helpers # # --------------------------------------------------------------------------- # def _get_state(circuit: Any, backend: Backend | None) -> tuple[np.ndarray, int]: """Return ``(state_vector, num_qubits)`` from a circuit or raw array.""" if isinstance(circuit, (np.ndarray, list, tuple)): vec = np.asarray(circuit, dtype=complex) if vec.ndim != 1 or vec.size < 2 or (vec.size & (vec.size - 1)): raise ValueError( f"state vector must be 1-D with a power-of-two length ≥ 2, got {vec.shape}" ) return vec, vec.size.bit_length() - 1 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.") vec = np.asarray(backend.get_statevector(circuit), dtype=complex) if vec.ndim != 1 or vec.size < 2 or (vec.size & (vec.size - 1)): raise ValueError(f"backend returned an invalid state vector of shape {vec.shape}") return vec, vec.size.bit_length() - 1 def _bipartition_entropy( state: np.ndarray, n: int, qubits: list[int] | None, want_max: bool ) -> tuple[float, str]: """Return ``(entropy, cut_label)`` for the chosen bipartition. With *qubits* given, the entropy is that of the ``qubits | rest`` cut. With ``qubits=None`` the worst-case single-qubit entropy is returned, which is positive iff the state is not a full product state. """ if qubits is not None: if not qubits or any(q < 0 or q >= n for q in qubits): raise ValueError(f"qubits must be a non-empty subset of [0, {n}); got {qubits}") return entanglement_entropy(state, qubits, n), f"{sorted(set(qubits))} | rest" if n < 2: raise ValueError("entanglement requires at least 2 qubits; pass qubits for a 1-qubit cut") per_qubit = [entanglement_entropy(state, [q], n) for q in range(n)] idx = int(np.argmax(per_qubit)) if want_max else int(np.argmin(per_qubit)) return per_qubit[idx], f"qubit {idx} | rest (worst of all single-qubit cuts)" def _circuit_summary(circuit: Any) -> str: if isinstance(circuit, np.ndarray): return f"<state vector dim={circuit.size}>" 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)" return str(name) if name else f"<{type(circuit).__name__}>" def _format_failure( *, circuit: Any, n: int, entropy: float, tolerance: float, cut_label: str, expected: str, user_msg: str | None, ) -> str: lines: list[str] = [] if user_msg: lines.extend([user_msg, ""]) lines.append("Entanglement assertion failed") lines.append("") lines.append(f" Circuit: {_circuit_summary(circuit)}") lines.append(f" Qubits: {n}") lines.append(f" Bipartition: {cut_label}") lines.append(f" Entanglement entropy: {entropy:.6g} bits") lines.append(f" Tolerance: {tolerance:g}") lines.append(f" Expected: {expected}") return "\n".join(lines)