Source code for qarp.blocks._primitives.block_encoding_block

import itertools
from typing import Any, List, Optional, Sequence, Union

import numpy as np

from qarp.operators import QubitOperator

from ...operators import LinearCombinationUnitaries
from .._block import CompositeBlockBase
from .._state_preparation.synthesized_state_block import SynthesizedStateBlock
from .select_block import SelectBlock, _infer_target_size


[docs] class BlockEncodingBlock(CompositeBlockBase): r"""Pattern B composite: block-encode an operator ``A`` via LCU. Decomposes ``A = Σ_i c_i U_i`` into Pauli strings and assembles the standard ``Prep† · Select · Prep`` LCU circuit. The block-encoded operator on the ``|0…0⟩_anc`` subspace is ``A / λ`` where ``λ = Σ_i |c_i|`` (stored as ``self.lambda_norm``). ``Prep`` loads real amplitudes ``√(|c_i|/λ)`` on the ancilla register; each LCU phase ``φ_i = arg(c_i)`` is carried by the corresponding ``SelectBlock`` entry and lowered through the multi-controlled ``GPhase`` decomposition. """ def __init__( self, A: Optional[Union[np.ndarray, QubitOperator]] = None, target_qubits: Optional[List[int]] = None, name: str = "BlockEncoding", *, coefficients: Optional[Sequence[complex]] = None, unitaries: Optional[Sequence[Any]] = None, ): """Args: A: Operator to block-encode, either as an `np.ndarray` (general, read in the qarpx LSB basis — row/column bit ``k`` ↔ target qubit ``k`` — so the encoded block is ``A`` itself, no bit-reversal) or a `QubitOperator` (sparse Pauli sum). Mutually exclusive with explicit ``coefficients``/``unitaries`` input. target_qubits, name: standard Block kwargs. coefficients: LCU coefficients ``c_i`` (keyword-only; requires ``unitaries``). unitaries: LCU unitaries ``U_i`` (keyword-only; requires ``coefficients``). Entries may be any unitary accepted by ``SelectBlock``: a Block, ``(phase, Block)``, ``(phase, pauli_string)``, or ``(phase, pauli_dict)``. """ if (unitaries is None) != (coefficients is None): raise TypeError("Explicit LCU input requires both coefficients and unitaries.") self.A = A # LCU decomposition lives in __init__ so that consumers (Qubitization, # QSVT) can read self.unitaries and self.lambda_norm before .build(). if unitaries is not None and coefficients is not None: if A is not None: raise TypeError( "Pass either an operator A or explicit coefficients/unitaries, not both." ) coeffs, select_unitaries, raw_coeffs = self._preprocess_explicit_lcu( coefficients=coefficients, unitaries=unitaries, ) else: if isinstance(A, np.ndarray): dec = LinearCombinationUnitaries(A).decomposition() elif isinstance(A, QubitOperator): dec = self._qubit_operator_decomposition(A) else: raise TypeError( "Expected a np.ndarray or QubitOperator in BlockEncoding, " "or provide both coefficients and unitaries for explicit LCU input." ) coeffs, select_unitaries, raw_coeffs = self._preprocess_decomposition(dec) self.coefficients = coeffs self.lcu_coefficients = raw_coeffs self.unitaries = select_unitaries self.lambda_norm = float(np.sum(self.coefficients)) if self.lambda_norm <= 0.0: raise ValueError("LCU coefficients cannot all be zero.") num_unitaries = len(self.unitaries) self.num_controls = max(1, int(np.ceil(np.log2(num_unitaries)))) target_size = _infer_target_size(self.unitaries, context="LCU") num_qubits = self.num_controls + target_size super().__init__( n_qubits=num_qubits, target_qubits=target_qubits, name=name, )
[docs] def lambda_factor(self) -> float: """Compatibility shim: returns ``self.lambda_norm``.""" return self.lambda_norm
[docs] def build_vanilla(self) -> None: n_anc = self.num_controls N_anc = 2**n_anc sqrt_norm = np.sqrt(np.asarray(self.coefficients) / self.lambda_norm) # Align amplitudes with SelectBlock's `itertools.product([False, True], # repeat=n_anc)` enumeration: entry index i corresponds to control # tuple `t = product[i]`, where `t[k]` is the value of qubit `k` (LSB # convention). So the ancilla integer for entry i is # `sum_k int(t[k]) << k`. ctrl_tuples = list(itertools.product([False, True], repeat=n_anc)) amps = np.zeros(N_anc, dtype=complex) for i in range(len(self.unitaries)): anc_int = sum(int(b) << k for k, b in enumerate(ctrl_tuples[i])) amps[anc_int] = sqrt_norm[i] # 1. Prep on the ancilla register (real amplitudes; phases live in Select). prep = SynthesizedStateBlock( n_qubits=n_anc, amplitudes=list(amps), name="Prep", ) prep.build() prep.target_qubits = list(range(n_anc)) self.add_child(prep) # 2. Select on all qubits — each entry carries its LCU phase. select = SelectBlock(self.unitaries, n_anc, name="Select") select.target_qubits = list(range(self.n_qubits)) self.add_wired_child(select) # 3. Prep† on the ancilla register. Lazy Python-wrapper dagger # (deepcopy + flag): `add_child` materialises it into concrete # daggered commands, keeping the child deepcopy-safe. prep_dag = prep.dagger() prep_dag.target_qubits = list(range(n_anc)) self.add_child(prep_dag)
@staticmethod def _qubit_operator_decomposition(qop: QubitOperator) -> List[tuple]: """Convert a QubitOperator into a `[(pauli_string, coeff)]` list (character k of each string acts on qubit k).""" if not qop.terms: return [] n_qubits = max((i for term in qop.terms for i, _ in term), default=-1) + 1 n_qubits = max(1, n_qubits) decomposition: List[tuple] = [] for term, coeff in qop.terms.items(): string = ["I"] * n_qubits for index, pauli in term: string[index] = pauli decomposition.append(("".join(string), coeff)) return decomposition @staticmethod def _preprocess_decomposition(decomposition) -> tuple: """Split ``[(unitary_spec, coeff)]`` into magnitudes and Select entries.""" if not decomposition: raise ValueError("LCU decomposition cannot be empty.") coeffs: List[float] = [] raw_coeffs: List[complex] = [] unitaries: List[Any] = [] for unitary, coeff in decomposition: c = complex(coeff) raw_coeffs.append(c) coeffs.append(float(abs(c))) unitaries.append((float(np.angle(c)), unitary)) return coeffs, unitaries, raw_coeffs @staticmethod def _preprocess_explicit_lcu( *, coefficients: Sequence[complex], unitaries: Sequence[Any], ) -> tuple: if not unitaries: raise ValueError("unitaries list cannot be empty.") coeff_array = np.asarray(coefficients, dtype=complex) if coeff_array.ndim != 1: raise ValueError("coefficients must be a one-dimensional sequence.") if len(coeff_array) != len(unitaries): raise ValueError( "coefficients and unitaries must have the same length: " f"{len(coeff_array)} != {len(unitaries)}." ) if not np.all(np.isfinite(coeff_array.real)) or not np.all(np.isfinite(coeff_array.imag)): raise ValueError("coefficients must be finite.") coeffs: List[float] = [] raw_coeffs: List[complex] = [] select_unitaries: List[Any] = [] for coeff, unitary in zip(coeff_array, unitaries, strict=True): c = complex(coeff) raw_coeffs.append(c) coeffs.append(float(abs(c))) select_unitaries.append( BlockEncodingBlock._add_phase_to_select_entry( unitary, float(np.angle(c)), ) ) return coeffs, select_unitaries, raw_coeffs @staticmethod def _add_phase_to_select_entry(entry: Any, phase: float) -> Any: """Fold a coefficient phase into a SelectBlock-compatible entry.""" if isinstance(entry, tuple) and len(entry) == 2: existing_phase, unitary = entry return (float(existing_phase) + phase, unitary) return (phase, entry)