Source code for qarp.blocks._state_preparation.uniform_superposition_block
from typing import List, Optional
import numpy as np
from qarp.blocks._block import SimpleBlock
from qarp.blocks._prepares_known_state import prepares_known_state
from ._multi_controlled import _apply_mc_ry
[docs]
@prepares_known_state
class UniformSuperpositionBlock(SimpleBlock):
def __init__(
self,
M: int,
n_qubits: Optional[int] = None,
target_qubits: Optional[List[int]] = None,
name: str = "UniformSuperposition",
):
r"""Prepare :math:`\frac{1}{\sqrt{M}}\sum_{j=0}^{M-1}|j\rangle` for
arbitrary integer ``M`` (not just a power of two), exact and
ancilla-free, with :math:`L = \lceil\log_2 M\rceil`. Cites Shukla &
Vedula, *Quantum Inf. Process.* **23**, 38 (2024), whose Algorithm 1
costs :math:`O(L)` single-control gates — which this block does
**not** yet implement.
Writes ``M`` in binary and decomposes ``[0, M)`` into
``popcount(M)`` dyadic blocks (Hadamard-only sub-ranges) threaded
by a single sequence of multi-controlled rotations processed MSB to
LSB — at each set bit of ``M``, a controlled ``Ry`` splits into
"take this whole dyadic block" (batch-``Ry(π/2)`` the remaining low
qubits, controlled on the whole path so far) versus "keep going".
**Cost (measured, CNOTs after decomposition on
``clifford_t_rz_gateset``, ``O0``).** Every multi-controlled
rotation is a Barenco cascade over an ancilla-free ``mcx``, so the
total scales ≈ O(L⁴): for ``M = 2^L - 1`` (every bit set) it is
4 816 at ``L = 8`` (dense ``SynthesizedStateBlock``: 508), 82 920 at
``L = 12`` (≈ 8 k), 527 136 at ``L = 16`` (≈ 131 k, ratio 4.0) and
2 031 736 at ``L = 20`` (≈ 2.1 M, 0.97). It is therefore *more*
expensive than dense synthesis up to ``L ≈ 20`` and only marginally
cheaper beyond; the cited Algorithm 1 needs ≈ 60 gates at ``L = 20``.
The rewrite to that construction is a declared follow-up.
Args:
M: number of basis states to uniformly superpose,
:math:`1 \le M \le 2^n`.
n_qubits: register width; defaults to :math:`\lceil\log_2
M\rceil` (the minimum that fits). A wider register leaves
the extra high qubits at :math:`|0\rangle`.
target_qubits, name: standard Block kwargs.
"""
if M < 1:
raise ValueError(f"M must be >= 1, got {M}.")
min_qubits = max(1, int(np.ceil(np.log2(M)))) if M > 1 else 1
if n_qubits is None:
n_qubits = min_qubits
elif n_qubits < min_qubits:
raise ValueError(f"n_qubits ({n_qubits}) is too small to hold M={M} states.")
self.M = M
super().__init__(n_qubits, target_qubits=target_qubits, name=name)
[docs]
def build_vanilla(self) -> None:
path: List[tuple] = []
remaining = self.M
for k in range(self.n_qubits - 1, -1, -1):
weight = 1 << k
if remaining == weight:
for q in range(k):
_apply_mc_ry(self, path, q, np.pi / 2)
return
if remaining > weight:
theta = 2 * np.arccos(np.sqrt(weight / remaining))
_apply_mc_ry(self, path, k, theta)
block_path = path + [(k, 0)]
for q in range(k):
_apply_mc_ry(self, block_path, q, np.pi / 2)
path = path + [(k, 1)]
remaining -= weight
# remaining < weight: this bit is forced 0 within the active
# thread by construction (never written to), nothing to emit.
[docs]
def target_statevector(self) -> np.ndarray:
"""The uniform distribution over ``{0, ..., M-1}`` — definitional,
like ``SynthesizedStateBlock``'s: the target *is* the input."""
psi = np.zeros(2**self.n_qubits, dtype=complex)
psi[: self.M] = 1.0 / np.sqrt(self.M)
return psi