Source code for qrisp.block_encodings.block_encoding

"""
********************************************************************************
* Copyright (c) 2026 the Qrisp authors
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License, v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is
* available at https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************
"""

from __future__ import annotations
import inspect
from dataclasses import dataclass
from jax.tree_util import register_pytree_node_class
import jax
import jax.numpy as jnp
import numpy as np
import numpy.typing as npt
from qrisp.core import QuantumVariable
from qrisp.alg_primitives.reflection import reflection
from qrisp.core.gate_application_functions import gphase, h, ry, x, z
from qrisp.environments import conjugate, control, invert
from qrisp.interface import BackendClient
from qrisp.jasp import (
    count_ops,
    depth,
    num_qubits,
    jrange,
    qache,
    check_for_tracing_mode as is_tracing,
)
from qrisp.jasp.tracing_logic import QuantumVariableTemplate
from qrisp.operators import QubitOperator, FermionicOperator
from qrisp.qtypes import QuantumBool, QuantumFloat
from scipy.sparse import csr_array, csr_matrix
from typing import Any, Callable, TYPE_CHECKING, Union

if TYPE_CHECKING:
    from jax.typing import ArrayLike

MatrixType = Union[npt.NDArray[Any], csr_array, csr_matrix]


[docs] @register_pytree_node_class @dataclass(frozen=False) class BlockEncoding: r""" Central structure for representing block-encodings. Block-encoding is a foundational technique that enables the implementation of non-unitary operations on a quantum computer by embedding them into a larger unitary operator. Given an operator $A$, we embed a scaled version $A/\alpha$ into the upper-left block of a unitary matrix $U_{A}$: .. math:: U_A= \begin{pmatrix} A/\alpha & *\\ * & * \end{pmatrix} More formally, a block-encoding of an operator $A$ (not necessarily unitary) acting on a Hilbert space $\mathcal H_{s}$ is a unitary acting on $\mathcal H_a\otimes H_s$ (for some auxiliary Hilbert space $\mathcal H_a$) such that .. math:: \|A - \alpha (\bra{0}_a \otimes \mathbb I_s) U_A (\ket{0}_a \otimes \mathbb I_s) \| \leq \epsilon where - $\alpha\geq \|A\|$ is a subnormalization factor (or scaling factor) that ensures $A/\alpha$ has singular values within the unit disk. - $\epsilon\geq 0$ represents the approximation error. The block-encoding is termed exact if $\epsilon=0$, meaning the upper-left block of $U_{A}$ is exactly $A/\alpha$. **Implementation mechanism** To apply the operator $A$ to a quantum state $\ket{\psi}$: - Prepare the system in state $\ket{0}_a \otimes \ket{\psi}_s$. - Apply the unitary $U_A$. - Post-select by measuring the ancillas. If the result is $\ket{0}_a$, the remaining state is $\dfrac{A\ket{\psi}}{\|A\ket{\psi}\|}$. - The success probability of this operation is given by .. math:: P_{\text{success}} = \dfrac{\|A\ket{\psi}\|^2}{\alpha^2} Parameters ---------- alpha : ArrayLike The scalar scaling factor. ancillas : list[QuantumVariable | QuantumVariableTemplate] A list of QuantumVariables or QuantumVariableTemplates. These serve as templates for the ancilla variables used in the block-encoding. unitary : Callable A function ``unitary(*ancillas, *operands)`` applying the block-encoding unitary. It receives the ancilla and operand QuantumVariables as arguments. num_ops : int The number of operand quantum variables. The default is 1. is_hermitian : bool Indicates whether the block-encoding unitary is Hermitian. The default is False. Attributes ---------- alpha : ArrayLike The scalar scaling factor. unitary : Callable A function ``unitary(*ancillas, *operands)`` applying the block-encoding unitary. It receives the ancilla and operand QuantumVariables as arguments. num_ops : int The number of operand quantum variables. num_ancs : int The number of ancilla quantum variables. is_hermitian : bool Indicates whether the block-encoding unitary is Hermitian. Notes ----- - The **shape** of the block-encoded operator is determined by the size of the operand variables to which the block-encoding is applied. E.g., if a block-encoded $4\times 4$ matrix $A$ is applied to a 3-qubit QuantumVariable, then a block-encoding of the $8\times 8$ matrix $\tilde{A}=\mathbb{I}\otimes A$ is applied. This is consistent with the convention that non-occuring indices in a Pauli string are treated as identities. Static-shaped block-encodings may be introduced in a future release. - The ``is_hermitian`` attribute indicates whether the block-encoding unitary $U_A$ is Hermitian. This is distinct from the operator $A$ being being Hermitian. A Hermitian operator $A$ can be block-encoded using a non-Hermitian unitary $U_A$. Conversely, if the unitary $U_A$ is Hermitian, then the encoded operator must also be Hermitian. Examples -------- **Example 1: Pauli Block Encoding** Define a :ref:`QubitOperator` repesenting a Heisenberg Hamiltonian, and construct a block-encoding based on LCU for its Pauli strings. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = sum(X(i)*X(i+1) + Y(i)*Y(i+1) + Z(i)*Z(i+1) for i in range(3)) BE = BlockEncoding.from_operator(H) # Apply the Hermitian operator to an initial system state # Prepare initial system state def operand_prep(): operand = QuantumFloat(4) h(operand[0]) return operand @terminal_sampling def main(): return BE.apply_rus(operand_prep)() main() # {0.0: 0.6428571295525347, 2.0: 0.2857142963579722, 1.0: 0.07142857408949305} **Example 2: Custom LCU Block Encoding** Define a block-encoding for a discrete Laplace operator in one dimension with periodic boundary conditions. :: import numpy as np N = 8 I = np.eye(N) A = 2*I - np.eye(N, k=1) - np.eye(N, k=-1) A[0, N-1] = -1 A[N-1, 0] = -1 print(A) #[[ 2. -1. 0. 0. 0. 0. 0. -1.] # [-1. 2. -1. 0. 0. 0. 0. 0.] # [ 0. -1. 2. -1. 0. 0. 0. 0.] # [ 0. 0. -1. 2. -1. 0. 0. 0.] # [ 0. 0. 0. -1. 2. -1. 0. 0.] # [ 0. 0. 0. 0. -1. 2. -1. 0.] # [ 0. 0. 0. 0. 0. -1. 2. -1.] # [-1. 0. 0. 0. 0. 0. -1. 2.]] This matrix is decomposed as linear combination of three unitaries: the identity $I$, and two shift operators $V\colon\ket{k}\rightarrow-\ket{k+1\mod N}$ and $V^{\dagger}\colon\ket{k}\rightarrow-\ket{k-1\mod N}$. :: from qrisp import * from qrisp.block_encodings import BlockEncoding def I(qv): pass def V(qv): qv += 1 gphase(np.pi, qv[0]) def V_dg(qv): qv -= 1 gphase(np.pi, qv[0]) unitaries = [I, V, V_dg] coeffs = np.array([2.0, 1.0, 1.0]) BE = BlockEncoding.from_lcu(coeffs, unitaries) Apply the operator to the initial system state $\ket{0}$. :: # Prepare initial system state |0> def operand_prep(): return QuantumFloat(3) @terminal_sampling def main(): operand = BE.apply_rus(operand_prep)() return operand main() # {0.0: 0.6666666567325588, 7.0: 0.16666667908430155, 1.0: 0.1666666641831397} To perform quantum resource estimation for the quantum program (not counting repetitions), replace the ``@terminal_sampling`` decorator with ``@count_ops(meas_behavior="0")``: :: @count_ops(meas_behavior="0") def main(): operand = BE.apply_rus(operand_prep)() return operand main() # {'s': 4, 'gphase': 2, 'u3': 6, 't': 14, 't_dg': 16, 'x': 5, 'cx': 54, # 'p': 2, 'h': 16, 'measure': 10} To perform resource estimations for the block-encoding use :meth:`resources`: :: BE.resources(QuantumFloat(3)) # {'gate counts': {'s': 4, 't_dg': 16, 'h': 16, 't': 14, 'gphase': 2, # 'p': 2, 'x': 5, 'cx': 54, 'u3': 6, 'measure': 4}, 'depth': 48, 'qubits': 9} """ def __init__( self, alpha: "ArrayLike", ancillas: list[QuantumVariable | QuantumVariableTemplate], unitary: Callable[..., None], num_ops: int = 1, is_hermitian: bool = False, ) -> None: self.alpha = alpha # Templates for the ancilla variables. self._anc_templates: list[QuantumVariableTemplate] = [ anc.template() if isinstance(anc, QuantumVariable) else anc for anc in ancillas ] self.unitary = unitary self.is_hermitian = is_hermitian self.num_ancs = len(ancillas) # More robust than inferring the number of operands for the unitary via inspect. self.num_ops = num_ops def tree_flatten(self): """ PyTree flatten for JAX. Returns ------- tuple A pair `(children, aux_data)` where `children` is a tuple containing the digits array, and `aux_data` is `None`. """ children = ( self.alpha, self._anc_templates, ) aux_data = ( self.unitary, self.num_ops, self.is_hermitian, ) return (children, aux_data) @classmethod def tree_unflatten(cls, aux_data, children): """ PyTree unflatten for JAX. Parameters ---------- aux_data : Any Auxiliary data (unused, expected `None`). children : tuple Tuple containing the digits array. Returns ------- BigInteger Reconstructed instance. """ return cls(*children, *aux_data)
[docs] @classmethod def from_operator( cls: "BlockEncoding", O: QubitOperator | FermionicOperator ) -> BlockEncoding: r""" Constructs a BlockEncoding from an operator. Parameters ---------- O : QubitOperator | FermionicOperator The operator to be block-encoded. Returns ------- BlockEncoding A BlockEncoding representing the Hermitian part $(O+O^{\dagger})/2$. Notes ----- - Block encoding based on Pauli decomposition $O=\sum_i\alpha_i P_i$ where $\alpha_i$ are real positive coefficients and $P_i$ are Pauli strings (including the respective sign). - **Normalization**: The block-encoding normalization factor is $\alpha = \sum_i \alpha_i$. Examples -------- >>> from qrisp.block_encodings import BlockEncoding >>> from qrisp.operators import X, Y, Z >>> H = X(0)*X(1) + 0.2*Y(0)*Y(1) >>> B = BlockEncoding.from_operator(H) """ if isinstance(O, FermionicOperator): O = O.to_qubit_operator() unitaries, coeffs = O.unitaries() return cls.from_lcu(coeffs, unitaries, is_hermitian=True)
[docs] @classmethod def from_array(cls: "BlockEncoding", A: MatrixType) -> BlockEncoding: r""" Constructs a BlockEncoding from a 2-D array. Parameters ---------- A : ndarray | csr_array | csr_matrix 2-D array of shape ``(N,N,)`` for a power of two ``N``. Returns ------- BlockEncoding A BlockEncoding representing the Hermitian part $(A+A^{\dagger})/2$. Raises ------ ValueError If ``A`` is not a 2-D square matrix. ValueError If the dimension of ``A`` is not a power of two. Notes ----- - Block encoding based on Pauli decomposition $O=\sum_i\alpha_i P_i$ where $\alpha_i$ are real positive coefficients and $P_i$ are Pauli strings (including the respective sign). - **Normalization**: The block-encoding normalization factor is $\alpha = \sum_i \alpha_i$. Examples -------- >>> import numpy as np >>> from qrisp.block_encodings import BlockEncoding >>> A = np.array([[0,1,0,1],[1,0,0,0],[0,0,1,0],[1,0,0,0]]) >>> B = BlockEncoding.from_array(A) """ shape = A.shape # 1. Check if the array is 2D and square if len(shape) != 2 or shape[0] != shape[1]: raise ValueError(f"Array must be square (N, N), but got {shape}.") # 2. Check if N is a power of two N = shape[0] if not (N > 0 and (N & (N - 1)) == 0): raise ValueError(f"Size N={N} must be a power of two.") O = QubitOperator.from_matrix(A, reverse_endianness=True) return cls.from_operator(O)
[docs] @classmethod def from_lcu( cls: "BlockEncoding", coeffs: npt.NDArray[np.number], unitaries: list[Callable[..., Any]], num_ops: int = 1, is_hermitian: bool = False, ) -> BlockEncoding: r""" Constructs a BlockEncoding using the Linear Combination of Unitaries (LCU) protocol. For an LCU block encoding, consider a linear combination of unitaries: .. math:: O = \sum_{i=0}^{M-1} \alpha_i U_i where $\alpha_i$ are real non-negative coefficients such that $\sum_i \alpha_i = \alpha$, and $U_i$ are unitaries acting on the same operand quantum variables. The block encoding unitary is constructed via the LCU protocol: .. math:: U = \text{PREP} \cdot \text{SEL} \cdot \text{PREP}^{\dagger} where: * **SEL** (Select, in Qrisp: :ref:`q_switch <qswitch>`) applies each unitary $U_i$ conditioned on the auxiliary variable state $\ket{i}_a$: .. math:: \text{SEL} = \sum_{i=0}^{M-1} \ket{i}\bra{i} \otimes U_i * **PREP** (Prepare) prepares the state representing the coefficients: .. math:: \text{PREP} \ket{0}_a = \sum_{i=0}^{M-1} \sqrt{\frac{\alpha_i}{\alpha}} \ket{i}_a Parameters ---------- coeffs : ArrayLike 1-D array of non-negative coefficients $\alpha_i$. unitaries : list[Callable] List of functions, where each ``U(*operands)`` applies a unitary transformation in-place to the provided quantum variables. All functions must accept the same signature and operate on the same set of operands. num_ops : int The number of operand quantum variables. The default is 1. is_hermitian : bool Indicates whether the block-encoding unitary is Hermitian. The default is False. Set to True, if all provided unitaries are Hermitian. Returns ------- BlockEncoding A BlockEncoding using LCU. Raises ------ ValueError If any entry in ``coeffs`` is negative, as the LCU protocol only supports positive coefficients. Notes ----- - **Normalization**: The block-encoding normalization factor is $\alpha = \sum_i \alpha_i$. Examples -------- :: from qrisp import * from qrisp.block_encodings import BlockEncoding def f0(x): x-=1 def f1(x): x+=1 BE = BlockEncoding.from_lcu(np.array([1., 1.]), [f0, f1]) @terminal_sampling def main(): return BE.apply_rus(lambda : QuantumFloat(2))() main() # {1.0: 0.5, 3.0: 0.5} """ from qrisp.alg_primitives.state_preparation import prepare from qrisp.jasp import q_switch m = len(coeffs) n = (m - 1).bit_length() # Number of qubits for index variable # Ensure coeffs has size 2 ** n by zero padding coeffs = np.pad(coeffs, (0, (1 << n) - m)) alpha = np.sum(coeffs) if np.any(coeffs < 0): raise ValueError( f"Negative coefficients detected: {coeffs}. Only positive values are supported." ) if m == 1: return BlockEncoding( alpha, [], unitaries[0], num_ops=num_ops, is_hermitian=is_hermitian ) @qache def unitary(*args): # LCU = PREP SEL PREP_dg with conjugate(prepare)(args[0], np.sqrt(coeffs / alpha)): q_switch(args[0], unitaries, *args[1:]) return BlockEncoding( alpha, [QuantumFloat(n).template()], unitary, num_ops=num_ops, is_hermitian=is_hermitian, )
# # Utilities #
[docs] def create_ancillas(self) -> list[QuantumVariable]: r""" Returns a list of ancilla QuantumVariables for the BlockEncoding. Returns ------- list[QuantumVariable] A list of ancilla QuantumVariables in state $\ket{0}$. """ anc_list = [] for template in self._anc_templates: anc_list.append(template.construct()) return anc_list
[docs] def apply(self, *operands: QuantumVariable) -> list[QuantumVariable]: r""" Applies the BlockEncoding unitary to the given operands. Parameters ---------- *operands : QuantumVariable QuantumVariables serving as operands for the block-encoding. Returns ------- list[QuantumVariable] A list of ancilla QuantumVariables used in the application. Must be measured in $0$ for success of the block-encoding application. Raises ------ ValueError If the number of provided operands does not match the required number of operands (self.num_ops). Examples -------- **Example 1:** Define a block-encoding and apply it using :ref:`RUS`. :: import numpy as np from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) def operand_prep(phi): qv = QuantumFloat(2) ry(phi, qv[0]) return qv @RUS def apply_be(BE, phi): qv = operand_prep(phi) ancillas = BE.apply(qv) # Alternatively, also use: # ancillas = BE.create_ancillas() # BE.unitary(*ancillas, qv) bools = jnp.array([(measure(anc) == 0) for anc in ancillas]) success_bool = jnp.all(bools) # garbage collection [reset(anc) for anc in ancillas] [anc.delete() for anc in ancillas] return success_bool, qv @terminal_sampling def main(BE): qv = apply_be(BE, np.pi / 4) return qv main(BE) #{3: 0.6828427278345078, 0: 0.17071065215630213, 2: 0.11715730494804945, 1: 0.02928931506114055} For convenience, the :meth:`apply_rus` method directly applies the block-encoding using RUS. **Example 2:** Define a block-encoding and apply it using **post-selection**. :: import numpy as np from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) def operand_prep(phi): qv = QuantumFloat(2) ry(phi, qv[0]) return qv def main(BE): operand = operand_prep(np.pi / 4) ancillas = BE.apply(operand) return operand, ancillas operand, ancillas = main(BE) res_dict = multi_measurement([operand] + ancillas) # Post-selection on ancillas being in |0> state filtered_dict = {k[0]: p for k, p in res_dict.items() \ if all(x == 0 for x in k[1:])} success_prob = sum(filtered_dict.values()) filtered_dict = {k: p / success_prob for k, p in filtered_dict.items()} filtered_dict #{3: 0.6828427278345078, 0: 0.17071065215630213, 2: 0.11715730494804945, 1: 0.02928931506114055} """ if len(operands) != self.num_ops: raise ValueError( f"Operation expected {self.num_ops} operands, but got {len(operands)}." ) ancillas = self.create_ancillas() self.unitary(*ancillas, *operands) return ancillas
[docs] def apply_rus(self, operand_prep: Callable[..., Any]) -> Callable[..., Any]: r""" Applies the BlockEncoding using :ref:`RUS`. Parameters ---------- operand_prep : Callable A function ``operand_prep(*args)`` that prepares and returns the operand QuantumVariables. Returns ------- Callable A function ``rus_function(*args)`` with the same signature as ``operand_prep``. It prepares the operands and ancillas, and applies the block-encoding unitary within a repeat-until-success protocol. Returns the transformed operand QuantumVariables. Raises ------ TypeError If ``operand_prep`` is not a callable object. ValueError If the number of provided operands does not match the required number of operands (self.num_ops). Examples -------- Define a block-encoding and apply it using :ref:`RUS`. :: import numpy as np from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) def operand_prep(phi): qv = QuantumFloat(2) ry(phi, qv[0]) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)(np.pi / 4) return qv main(BE) #{3: 0.6828427278345078, 0: 0.17071065215630213, 2: 0.11715730494804945, 1: 0.02928931506114055} """ from qrisp.core.gate_application_functions import measure, reset from qrisp.jasp import RUS if not callable(operand_prep): raise TypeError( f"Expected 'operand_prep' to be a callable, but got {type(operand_prep).__name__}." ) @RUS def rus_function(*args): operands = operand_prep(*args) if not isinstance(operands, tuple): operands = (operands,) if len(operands) != self.num_ops: raise ValueError( f"Operation expected {self.num_ops} operands, but got {len(operands)}." ) ancillas = self.create_ancillas() self.unitary(*ancillas, *operands) bools = jnp.array([(measure(anc) == 0) for anc in ancillas]) success_bool = jnp.all(bools) # garbage collection [reset(anc) for anc in ancillas] [anc.delete() for anc in ancillas] return success_bool, *operands return rus_function
[docs] def expectation_value( self, operand_prep: Callable[..., Any], shots: int = 100, backend: BackendClient = None, ) -> Callable[..., Any]: r""" Measures the expectation value of the operator using the Hadamard test protocol. For a block-encoded **Hermitian** operator $A$ and a state $\ket{\psi}$, this method measures the expectation value $\langle\psi|A|\psi\rangle$. Parameters ---------- operand_prep : Callable A function ``operand_prep(*args)`` that prepares and returns the operand QuantumVariables. shots : int The amount of samples to take to compute the expectation value. The default is 100. backend : BackendClient The backend on which to evaluate the quantum circuit. By default the Qrisp simulator is used. Ignored in Jasp mode. Returns ------- Callable A function ``ev_function(*args)`` with the same signature as ``operand_prep`` returning - Jasp mode: a Jax array containing the expactation value, - Standard mode: a NumPy float representing the expectation value. Notes ----- - **Precision:** The number of shots $N$ required for target precision $\epsilon$ scales quadratically with the inverse precision $(N\propto 1/\epsilon^2)$. Raises ------ TypeError If ``operand_prep`` is not a callable object. ValueError If the number of provided operands does not match the required number of operands (self.num_ops). Examples -------- **Example 1: Jasp Mode (Dynamic Execution)** :: import numpy as np from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) def operand_prep(phi): qv = QuantumFloat(2) ry(phi, qv[0]) return qv @jaspify(terminal_sampling=True) def main(BE): ev = BE.expectation_value(operand_prep, shots=10000)(np.pi / 4) return ev ev = main(BE) print(ev) # 0.3429 **Example 2: Standard Mode (Static Execution)** :: # Using the same Hamiltonian and prep function from the previous example ev = BE.expectation_value(operand_prep, shots=10000)(np.pi / 4) print(ev) # 0.3426 """ from qrisp.core.gate_application_functions import measure, reset from qrisp.jasp import expectation_value if not callable(operand_prep): raise TypeError( f"Expected 'operand_prep' to be a callable, but got {type(operand_prep).__name__}." ) def state_prep(*args): operands = operand_prep(*args) if not isinstance(operands, tuple): operands = (operands,) if len(operands) != self.num_ops: raise ValueError( f"Operation expected {self.num_ops} operands, but got {len(operands)}." ) # Hadamard test qbl = QuantumBool() h(qbl) with control(qbl): ancillas = self.apply(*operands) h(qbl) return qbl # Dynamic (Jasp) mode if is_tracing(): @jax.jit def post_processor(x): return jnp.where(x == 0, 1, -1) def ev_function(*args): ev = expectation_value( state_prep, shots=shots, post_processor=post_processor )(*args) return ev * self.alpha return ev_function # Static mode def ev_function(*args): qbl = state_prep(*args) res_dict = qbl.get_measurement(shots=shots, backend=backend) ev = res_dict.get(0, 0) - res_dict.get(1, 0) return np.float64(ev * jnp.float64(self.alpha)) return ev_function
[docs] def resources( self, *operands: QuantumVariable, meas_behavior: str | Callable = "0", max_qubits: int = 1024, max_allocations: int = 1000, ): r""" Estimate the quantum resources required for the BlockEncoding. This method uses the ``count_ops`` and ``depth`` decorators to obtain gate counts, circuit depth, and (in future release) qubit usage for a single execution of block-encoding ``.unitary``. Unlike :meth:`apply_rus`, it does not run the simulator and does not include repetitions from the :ref:`RUS` procedure. Parameters ---------- *operands : QuantumVariable QuantumVariables serving as operands for the block-encoding. meas_behavior : str or callable, optional Specifies the measurement outcome to assume during the tracing process (e.g., "0", or "1"). The default is "0". max_qubits : int, optional The maximum number of qubits supported for depth computation. Default is 1024. max_allocations : int, optional The maximum number of allocation/deallocation events supported for tracking. Default is 1000. Returns ------- dict A dictionary containing resource metrics with the following structure: - "gate counts" : A dictionary of counted quantum operations. - "depth": The circuit depth as an integer. Raises ------ ValueError If the number of provided operands does not match the required number of operands (self.num_ops). Examples -------- **Example 1:** Estimate the quantum resources for a block-encoded Pauli operator. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) res_dict = BE.resources(QuantumFloat(2)) print(res_dict) # {'gate counts': {'x': 3, 'cz': 2, 'u3': 2, 'cx': 4, 'gphase': 2}, # 'depth': 12, 'qubits': 4} **Example 2:** Estimate the quantum resources for applying the Quantum Eigenvalue Transform. :: from qrisp import * from qrisp.algorithms.gqsp import QET from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) # real, fixed parity polynomial p = np.array([0, 1, 0, 1]) BE_QET = QET(BE, p) res_dict = BE_QET.resources(QuantumFloat(2)) print(res_dict) # {'gate counts': {'x': 11, 'cz': 8, 'rx': 2, 'u3': 6, 'cx': 16, # 'gphase': 6, 'p': 2}, 'depth': 42, 'qubits': 5} """ if len(operands) != self.num_ops: raise ValueError( f"Operation expected {self.num_ops} operands, but got {len(operands)}." ) ops_templates = [op.template() for op in operands] def operand_prep(): operands = [temp.construct() for temp in ops_templates] return operands def main(): operands = operand_prep() ancillas = self.create_ancillas() self.unitary(*ancillas, *operands) return operands circuit_depth = depth(meas_behavior=meas_behavior, max_qubits=max_qubits)( main )() gate_counts = count_ops(meas_behavior=meas_behavior)(main)() qubit_counts = num_qubits( meas_behavior=meas_behavior, max_allocations=max_allocations )(main)() return { "gate counts": gate_counts, "depth": circuit_depth, "qubits": qubit_counts["peak_allocations"], }
[docs] def qubitization(self) -> BlockEncoding: r""" Returns a BlockEncoding representing the `qubitization walk operator <https://quantum-journal.org/papers/q-2019-07-12-163/>`_. For a block-encoded **Hermitian** operator $A$ with normalization factor $\alpha$, this method returns a BlockEncoding of the qubitization walk operator $W$ satisfying $W^k=T_k(A/\alpha)$ where $T_k$ is the $k$-Chebyshev polynomial of the first kind. The action of $W$ partitions the Hilbert space into a direct sum of two-dimensional invariant subspaces giving it the name "qubitization". For an eigenstate $\ket{\lambda}$ of $A$ with eigenvalue $\lambda$, the two-dimensional space is spanned by - $\ket{\phi_1} = \ket{0}_a\ket{\lambda}$ - $\ket{\phi_2} = \frac{(W-\lambda/\alpha\mathbb I)\ket{\phi_1}}{\sqrt{1-(\lambda/\alpha)^2}}$ In this subspace, $W$ implements a Pauli-Y rotaion by angle $\theta=-2\arccos(\lambda/\alpha)$, i.e., $W=e^{i\arccos(\lambda/\alpha)Y}$. If the block-encoding unitary $U$ is Hermitian (i.e., $U^2=\mathbb I$), then $W=R U$ where $R = (2\ket{0}_a\bra{0}_a - \mathbb I)$ is the reflection around the state $\ket{0}_a$ of the ancilla variables. Otherwise, $W = R \tilde{U}$ where $\tilde{U} = (H \otimes \mathbb I)(\ket{0}\bra{0} \otimes U) + (\ket{1}\bra{1} \otimes U^{\dagger})(H \otimes \mathbb I)$ is a Hermitian block-encoding of $A$ requiring one additional ancilla qubit. Returns ------- BlockEncoding A new BlockEncoding instance representing the qubitization walk operator. Notes ----- - **Normalization**: The resulting block-encoding maintains the same scaling factor $\alpha$ as the original. References ---------- - Low & Chuang (2019) `Hamiltonian Simulation by Qubitization <https://quantum-journal.org/papers/q-2019-07-12-163/>`_. Examples -------- Define a block-encoding and apply the qubitization transformation. :: from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = X(0)*Y(1) + 0.5*Z(0)*X(1) BE = BlockEncoding.from_operator(H) BE_walk = BE.qubitization() """ m = len(self._anc_templates) # W = U if m == 0: return self if self.is_hermitian: # W = (2*|0><0| - I) U def new_unitary(*args): self.unitary(*args) reflection(args[:m]) return BlockEncoding( self.alpha, self._anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=True, ) else: # W = (2*|0><0| - I) U_tilde, U_tilde = (H ⊗ I)(|0><0| ⊗ U) + (|1><1| ⊗ U†)(H ⊗ I) is Hermitian # block-encoding of A=(A+A†)/2 if A is Hermitian. # We conjugate by (H ⊗ I) to achieve that the new ancilla is initialized and projected in |0>, # i.e., A is in the upper left block. # A more general Hermitization is: # W = C0-(2*|0><0| - I) C1-(2*|0><0| - I) U_tilde # C0-(2*|0><0| - I) performs reflection of args[0] beign |0> # C1-(2*|0><0| - I) performs reflection of args[0] beign |1> # U_tilde = (|0><1| ⊗ U) + (|1><0| ⊗ U†) is Hermitian # In this case, the new ancilla is initialized and projected in |1>, # i.e., A is not in the upper left block. def new_unitary(*args): with conjugate(h)(args[0]): # (|0><0| ⊗ U) with control(args[0], ctrl_state=0): self.unitary(*args[1:]) # (|1><1| ⊗ U†) with control(args[0], ctrl_state=1): with invert(): self.unitary(*args[1:]) reflection(args[0 : 1 + m]) new_anc_templates = [QuantumBool().template()] + self._anc_templates return BlockEncoding( self.alpha, new_anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=True, )
[docs] def chebyshev(self, k: int, rescale: bool = True) -> BlockEncoding: r""" Returns a BlockEncoding representing $k$-th Chebyshev polynomial of the first kind applied to the operator. For a block-encoded **Hermitian** operator $A$ with normalization factor $\alpha$, this method returns a BlockEncoding of the rescaled operator $T_k(A)$ if ``rescale=True``, or $T_k(A/\alpha)$ if ``rescale=False``. Parameters ---------- k : int The order of the Chebyshev polynomial. Must be a non-negative integer. rescale : bool, optional If True (default), the method returns the a block-encoding of $T_k(A)$, If False, the method returns a block-encoding of $T_k(A/\alpha)$. Returns ------- BlockEncoding A new BlockEncoding instance representing the Chebyshev polynomial transformation. Notes ----- - The Chebyshev polynomial approach is useful for polynomial approximations and spectral methods. - Should be used sparingly, primarily to combine a few block encodings. For larger-scale polynomial transformations, Quantum Signal Processing (QSP) is the superior method (see :meth:`poly`). - **Normalization**: - ``rescale=True``: The normalization factor is determined by the Quantum Eigenvalue Transform (QET). - ``rescale=False``: If $k=1$, the resulting block-encoding maintains the same scaling factor $\alpha$ as the original. Otherwise, the scaling factor is $1$. Examples -------- Define a block-encoding and apply the Chebyshev polynomial transformation. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H = X(0)*X(1) + 0.5*Z(0)*Z(1) BE = BlockEncoding.from_operator(H) # Apply Chebyshev polynomial of order 2 BE_cheb = BE.chebyshev(2) def operand_prep(): qv = QuantumFloat(2) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)() return qv main(BE_cheb) """ if rescale: from qrisp.algorithms.gqsp.qet import QET p = np.zeros(k + 1) p[-1] = 1.0 return QET(self, p, kind="Chebyshev") m = len(self._anc_templates) iterations = k // 2 # Following https://math.berkeley.edu/~linlin/qasc/qasc_notes.pdf (page 104): # T_{2k}(A) = (U_dg R U R)^k # T_{2k+1}(A) = (U R) (U_dg R U R)^k if k % 2 == 0: def new_unitary(*args): for _ in jrange(0, iterations): reflection(args[:m]) with conjugate(self.unitary)(*args): reflection(args[:m]) else: def new_unitary(*args): for _ in jrange(0, iterations): reflection(args[:m]) with conjugate(self.unitary)(*args): reflection(args[:m]) reflection(args[:m]) self.unitary(*args) new_alpha = self.alpha if k == 1 else 1 return BlockEncoding( new_alpha, self._anc_templates, new_unitary, num_ops=self.num_ops )
# # Arithmetic #
[docs] def __add__(self, other: BlockEncoding) -> BlockEncoding: r""" Returns a BlockEncoding of the sum of two operators. This method implements the linear combination $A + B$ via the LCU (Linear Combination of Unitaries) framework, where $A$ and $B$ are the operators encoded by the respective instances. Parameters ---------- other : BlockEncoding The BlockEncoding instance to be added. Returns ------- BlockEncoding A new BlockEncoding instance representing the operator sum. Notes ----- - Can only be used when both BlockEncodings have the same operand structure. - The ``+`` operator should be used sparingly, primarily to combine a few block encodings. For larger-scale polynomial transformations, Quantum Signal Processing (QSP) is the superior method. Examples -------- Define two block-encodings and add them. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H1 = X(0)*X(1) + 0.2*Y(0)*Y(1) H2 = Z(0)*Z(1) + X(2) H3 = H1 + H2 BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE3 = BlockEncoding.from_operator(H3) BE_add = BE1 + BE2 def operand_prep(): qv = QuantumFloat(3) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)() return qv res_be3 = main(BE3) res_be_add = main(BE_add) print("Result from BE of H1 + H2: ", res_be3) print("Result from BE1 + BE2: ", res_be_add) # Result from BE of H1 + H2: {0: 0.37878788804466035, 4: 0.37878788804466035, 3: 0.24242422391067933} # Result from BE1 + BE2: {0: 0.37878789933341894, 4: 0.37878789933341894, 3: 0.24242420133316217} """ if not isinstance(other, BlockEncoding): return NotImplemented alpha = self.alpha beta = other.alpha m = len(self._anc_templates) n = len(other._anc_templates) def prep(qb, arr): theta = 2 * jnp.arctan(arr[1] / arr[0]) ry(theta, qb) def new_unitary(*args): self_ancs = args[1 : 1 + m] other_ancs = args[1 + m : 1 + m + n] operands = args[1 + m + n :] with conjugate(prep)( args[0], jnp.sqrt(jnp.array([alpha, beta]) / (alpha + beta)), ): with control(args[0], ctrl_state=0): self.unitary(*self_ancs, *operands) with control(args[0], ctrl_state=1): other.unitary(*other_ancs, *operands) new_anc_templates = ( [QuantumBool().template()] + self._anc_templates + other._anc_templates ) new_alpha = alpha + beta return BlockEncoding( new_alpha, new_anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=self.is_hermitian and other.is_hermitian, )
[docs] def __sub__(self, other: BlockEncoding) -> BlockEncoding: r""" Returns a BlockEncoding of the difference between two operators. This method implements the subtraction $A - B$ using a linear combination of unitaries (LCU), where $A$ is the operator encoded by this instance and $B$ is the operator encoded by 'other'. Parameters ---------- other : BlockEncoding The BlockEncoding instance to be subtracted. Returns ------- BlockEncoding A new BlockEncoding representing the operator difference. Notes ----- - Can only be used when both BlockEncodings have the same operand structure. - The ``-`` operator should be used sparingly, primarily to combine a few block encodings. For larger-scale polynomial transformations, Quantum Signal Processing (QSP) is the superior method. Examples -------- Define two block-encodings and subtract them. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H1 = X(0)*X(1) + 0.2*Y(0)*Y(1) H2 = Z(0)*Z(1) + X(2) H3 = H1 - H2 BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE3 = BlockEncoding.from_operator(H3) BE_sub = BE1 - BE2 def operand_prep(): qv = QuantumFloat(3) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)() return qv res_be3 = main(BE3) res_be_sub = main(BE_sub) print("Result from BE of H1 - H2: ", res_be3) print("Result from BE1 - BE2: ", res_be_sub) # Result from BE of H1 - H2: {0: 0.37878788804466035, 4: 0.37878788804466035, 3: 0.24242422391067933} # Result from BE1 - BE2: {0: 0.37878789933341894, 4: 0.37878789933341894, 3: 0.24242420133316217} """ if not isinstance(other, BlockEncoding): return NotImplemented alpha = self.alpha beta = other.alpha m = len(self._anc_templates) n = len(other._anc_templates) def prep(qb, arr): theta = 2 * jnp.arctan(arr[1] / arr[0]) ry(theta, qb) def new_unitary(*args): self_ancs = args[1 : 1 + m] other_ancs = args[1 + m : 1 + m + n] operands = args[1 + m + n :] with conjugate(prep)( args[0], jnp.sqrt(jnp.array([alpha, beta]) / (alpha + beta)), ): z(args[0]) # Apply Z gate to flip the sign for subtraction with control(args[0], ctrl_state=0): self.unitary(*self_ancs, *operands) with control(args[0], ctrl_state=1): other.unitary(*other_ancs, *operands) new_anc_templates = ( [QuantumBool().template()] + self._anc_templates + other._anc_templates ) new_alpha = alpha + beta return BlockEncoding( new_alpha, new_anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=self.is_hermitian and other.is_hermitian, )
[docs] def __mul__(self, other: "ArrayLike") -> BlockEncoding: r""" Returns a BlockEncoding of the scaled operator. This method implements the scalar multiplication $c \cdot A$, where $A$ is the operator encoded by this instance and $c$ is the provided scalar. Parameters ---------- other : ArrayLike The scalar scaling factor (coefficient) to apply. Can be a Python float, a JAX/NumPy scalar, or a 0-dimensional array. Returns ------- BlockEncoding A new BlockEncoding instance representing the scaled operator. Notes ----- - Multiplying by a scalar $c$ results in a new BlockEncoding of $cA$ by updating $\alpha \rightarrow c\alpha$. Examples -------- Define two block-encodings and implement their scaled sum as a new block encoding. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z # Commuting operators H1 and H2 H1 = X(0)*X(1) + 0.2*Y(0)*Y(1) H2 = Z(0)*Z(1) + X(2) H3 = 2*H1 + H2 BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE3 = BlockEncoding.from_operator(H3) BE_mul = 2*BE1 + BE2 BE_mul_r = BE1*2 + BE2 def operand_prep(): qv = QuantumFloat(3) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)() return qv res_be3 = main(BE3) res_be_mul = main(BE_mul) res_be_mul_r = main(BE_mul_r) print("Result from BE of 2 * H1 + H2: ", res_be3) print("Result from 2 * BE1 + BE2: ", res_be_mul) print("Result from BE1 * 2 + BE2: ", res_be_mul_r) # Result from BE of 2 * H1 + H2: {3.0: 0.5614033770142979, 0.0: 0.21929831149285103, 4.0: 0.21929831149285103} # Result from 2 * BE1 + BE2: {3.0: 0.5614033770142979, 0.0: 0.21929831149285103, 4.0: 0.21929831149285103} # Result from BE1 * 2 + BE2: {3.0: 0.5614033770142979, 0.0: 0.21929831149285103, 4.0: 0.21929831149285103} """ from jax.typing import ArrayLike if isinstance(other, ArrayLike): def new_unitary(*args): self.unitary(*args) with control(other < 0): gphase(np.pi, args[0][0]) return BlockEncoding( self.alpha * jnp.abs(other), self._anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=self.is_hermitian, ) return NotImplemented
[docs] def __matmul__(self, other: "ArrayLike" | BlockEncoding) -> BlockEncoding: r""" Returns a BlockEncoding of the product of two operators. This method implements the operator product $A \cdot B$ by composing two BlockEncodings, where $A$ and $B$ are the operators encoded by the respective instances. Parameters ---------- other : BlockEncoding The BlockEncoding instance to be multiplied. Returns ------- BlockEncoding A new BlockEncoding representing the operator product. Notes ----- - Can only be used when both BlockEncodings have the same operand structure. - The ``@`` operator should be used sparingly, primarily to combine a few block encodings. For larger-scale polynomial transformations, Quantum Signal Processing (QSP) is the superior method. - The product of two Hermitian operators A and B is Hermitian if and only if they commute, i.e., AB = BA. Examples -------- Define two block-encodings and multiply them. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z # Commuting operators H1 and H2 H1 = X(0)*X(1) + 0.2*Y(0)*Y(1) H2 = Z(0)*Z(1) + X(2) H3 = H1 * H2 BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE3 = BlockEncoding.from_operator(H3) BE_mul = BE1 @ BE2 def operand_prep(): qv = QuantumFloat(3) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)() return qv res_be3 = main(BE3) res_be_mul = main(BE_mul) print("Result from BE of H1 * H2: ", res_be3) print("Result from BE1 @ BE2: ", res_be_mul) # Result from BE of H1 * H2: {3.0: 0.5, 7.0: 0.5} # Result from BE1 @ BE2: {3.0: 0.5, 7.0: 0.5} """ if not isinstance(other, BlockEncoding): return NotImplemented m = len(self._anc_templates) n = len(other._anc_templates) def new_unitary(*args): other_args = args[m : m + n] + args[m + n :] other.unitary(*other_args) self_args = args[:m] + args[m + n :] self.unitary(*self_args) new_anc_templates = self._anc_templates + other._anc_templates new_alpha = self.alpha * other.alpha return BlockEncoding( new_alpha, new_anc_templates, new_unitary, num_ops=self.num_ops )
__radd__ = __add__ __rmul__ = __mul__
[docs] def kron(self, other: BlockEncoding) -> BlockEncoding: r""" Returns a BlockEncoding of the Kronecker product (tensor product) of two operators. This method implements the operator $A \otimes B$, where $A$ and $B$ are the operators encoded by the respective instances. Following the construction in Chapter 10.2 in `Dalzell et al. <https://arxiv.org/abs/2310.03011>`_, the resulting BlockEncoding is formed by the tensor product of the underlying unitaries, $U_A \otimes U_B$. Parameters ---------- other : BlockEncoding The BlockEncoding instance to be tensored. Returns ------- BlockEncoding A new BlockEncoding representing the tensor product $A \otimes B$. Notes ----- - **Normalization**: The normalization factors ($\alpha$) are combined multiplicatively. - The ``kron`` operator maps the operands of self to the first set of operands and the operands of other to the remaining operands in a single unified unitary. - The ``kron`` operator should be used sparingly, primarily to combine a few block encodings. - A more qubit-efficient implementation of the Kronecker product can be found in `this paper <https://arxiv.org/pdf/2509.15779>`_ and will be implemented in future updates. Examples -------- **Example 1:** Define two block-encodings and perform their Kronecker product. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H1 = X(0)*X(1) + 0.2*Y(0)*Y(1) H2 = Z(0)*Z(1) + X(2) BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE_composed = BE1.kron(BE2) n1 = H1.find_minimal_qubit_amount() n2 = H2.find_minimal_qubit_amount() def operand_prep(): qv1 = QuantumVariable(n1) qv2 = QuantumVariable(n2) return qv1, qv2 @terminal_sampling def main(BE): return BE.apply_rus(operand_prep)() result = main(BE_composed) print("Result from BE1.kron(BE2): ", result) **Example 2:** Perform multiple Kronecker products of block-encodings in sequence. :: from qrisp import * from qrisp.operators import X, Y, Z H1 = X(0)*X(1) H2 = Z(0)*Z(1) H3 = Y(0)*Y(1) BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE3 = BlockEncoding.from_operator(H3) # Compose BE1 with the composition of BE2 and BE3 BE_composed = BE1.kron(BE2.kron(BE3)) n1 = H1.find_minimal_qubit_amount() n2 = H2.find_minimal_qubit_amount() n3 = H3.find_minimal_qubit_amount() def operand_prep(): qv1 = QuantumVariable(n1) qv2 = QuantumVariable(n2) qv3 = QuantumVariable(n3) return qv1, qv2, qv3 @terminal_sampling def main(BE): return BE.apply_rus(operand_prep)() result = main(BE_composed) print("Result from BE1.kron(BE2.kron(BE3)): ", result) """ m = len(self._anc_templates) n = len(other._anc_templates) def new_unitary(*args): self_ancs = args[:m] other_ancs = args[m : m + n] operands = args[m + n :] self.unitary(*self_ancs, *operands[: self.num_ops]) other.unitary(*other_ancs, *operands[self.num_ops :]) new_anc_templates = self._anc_templates + other._anc_templates new_alpha = self.alpha * other.alpha return BlockEncoding( new_alpha, new_anc_templates, new_unitary, num_ops=self.num_ops + other.num_ops, is_hermitian=self.is_hermitian and other.is_hermitian, )
[docs] def __neg__(self) -> BlockEncoding: r""" Returns a BlockEncoding of the negated operator. This method implements the transformation $A \to -A$ by scaling the encoded operator by $-1$. Returns ------- BlockEncoding A new BlockEncoding instance representing the operator $-A$. Examples -------- Define a block-encoding and negate it. :: from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z H1 = X(0)*X(1) - 0.2*Y(0)*Y(1) H2 = 0.2*Y(0)*Y(1) - X(0)*X(1) BE1 = BlockEncoding.from_operator(H1) BE2 = BlockEncoding.from_operator(H2) BE3 = -BE1 def operand_prep(): qv = QuantumFloat(3) return qv @terminal_sampling def main(BE): qv = BE.apply_rus(operand_prep)() return qv res_be2 = main(BE2) res_be_neg = main(BE3) print("Result from BE of H2 = - H1: ", res_be2) print("Result from - BE1: ", res_be_neg) # Result from BE of H2 = - H1: {3.0: 1.0} # Result from - BE1: {3.0: 1.0} """ def new_unitary(*args): self.unitary(*args) gphase(np.pi, args[0][0]) return BlockEncoding( self.alpha, self._anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=self.is_hermitian, )
# # Transformations #
[docs] def inv(self, eps: float, kappa: float) -> BlockEncoding: r""" Returns a BlockEncoding approximating the matrix inversion of the operator. For a block-encoded **Hermitian** matrix $A$ with normalization factor $\alpha$, this function returns a BlockEncoding of an operator $\tilde{A}^{-1}$ such that $\|\tilde{A}^{-1} - A^{-1}\| \leq \epsilon$. The inversion is implemented via Quantum Eigenvalue Transformation (QET) using a polynomial approximation of $1/x$ over the domain $D_{\kappa} = [-1, -1/\kappa] \cup [1/\kappa, 1]$. Parameters ---------- eps : float The target precision $\epsilon$. kappa : float An upper bound for the condition number $\kappa$ of $A$. This value defines the "gap" around zero where the function $1/x$ is not approximated. Returns ------- BlockEncoding A new BlockEncoding instance representing an approximation of the inverse $A^{-1}$. Notes ----- - **Complexity**: The polynomial degree scales as $\mathcal{O}(\kappa \log(\kappa/\epsilon))$. - It is assumed that the eigenvalues of $A/\alpha$ lie within $D_{\kappa}$. References ---------- - Childs et. al (2017) `Quantum algorithm for systems of linear equations with exponentially improved dependence on precision <https://arxiv.org/pdf/1511.02306>`_. Examples -------- Define a QSLP and solve it using :meth:`inv`. First, define a Hermitian matrix $A$ and a right-hand side vector $\vec{b}$. :: import numpy as np A = np.array([[0.73255474, 0.14516978, -0.14510851, -0.0391581], [0.14516978, 0.68701415, -0.04929867, -0.00999921], [-0.14510851, -0.04929867, 0.76587818, -0.03420339], [-0.0391581, -0.00999921, -0.03420339, 0.58862043]]) b = np.array([0, 1, 1, 1]) kappa = np.linalg.cond(A) print("Condition number of A: ", kappa) # Condition number of A: 1.8448536035491883 Generate a block-encoding of $A$ and use :meth:`inv` to find a block-encoding approximating $A^{-1}$. :: from qrisp import * from qrisp.block_encodings import BlockEncoding BA = BlockEncoding.from_array(A) BA_inv = BA.inv(0.01, 2) # Prepares operand variable in state |b> def prep_b(): operand = QuantumVariable(2) prepare(operand, b) return operand @terminal_sampling def main(): operand = BA_inv.apply_rus(prep_b)() return operand res_dict = main() amps = np.sqrt([res_dict.get(i, 0) for i in range(len(b))]) Finally, compare the quantum simulation result with the classical solution: :: c = (np.linalg.inv(A) @ b) / np.linalg.norm(np.linalg.inv(A) @ b) print("QUANTUM SIMULATION\n", amps, "\nCLASSICAL SOLUTION\n", c) # QUANTUM SIMULATION # [0.02844496 0.55538449 0.53010186 0.64010231] # CLASSICAL SOLUTION # [0.02944539 0.55423278 0.53013239 0.64102936] """ from qrisp.algorithms.gqsp import inversion # The operator is unitary (up to scaling). if self.num_ancs == 0: if not self.is_hermitian: def new_unitary(*args): with invert(): self.unitary(*args) else: # The operator is a reflection (up to scaling). new_unitary = self.unitary return BlockEncoding( 1.0 / self.alpha, self._anc_templates, new_unitary, num_ops=self.num_ops, is_hermitian=self.is_hermitian, ) return inversion(self, eps, kappa)
[docs] def sim(self, t: "ArrayLike" = 1, N: int = 1) -> BlockEncoding: r""" Returns a BlockEncoding approximating Hamiltonian simulation of the operator. For a block-encoded Hamiltonian $H$, this method returns a BlockEncoding of an approximation of the unitary evolution operator $e^{-itH}$ for a given time $t$. The approximation is based on the Jacobi-Anger expansion into Bessel functions of the first kind ($J_n$): .. math :: e^{-it\cos(\theta)} \approx \sum_{n=-N}^{N}(-i)^nJ_n(t)e^{in\theta} Parameters ---------- t : ArrayLike The scalar evolution time $t$. The default is 1.0. N : int The truncation order $N$ of the expansion. A higher order provides better approximation for larger $t$ or higher precision requirements. Default is 1. Returns ------- BlockEncoding A new BlockEncoding instance representing an approximation of the unitary $e^{-itH}$. Notes ----- - **Precision**: The truncation error scales (decreases) super-exponentially with $N$. For a fixed $t$, choosing $N > |t|$ ensures rapid convergence. - **Normalization**: The resulting operator is nearly unitary, meaning its block-encoding normalization factor $\alpha$ will be close to 1. References ---------- - Low & Chuang (2019) `Hamiltonian Simulation by Qubitization <https://quantum-journal.org/papers/q-2019-07-12-163/>`_. - Motlagh & Wiebe (2025) `Generalized Quantum Signal Processing <https://journals.aps.org/prxquantum/pdf/10.1103/PRXQuantum.5.020368>`_. Examples -------- Generate an Ising Hamiltonian $H$ and apply Hamiltonian simulation $e^{-itH}$ to the inital system state $\ket{0}$. :: # For larger systems, restart the kernel and adjust simulator precision # import os # os.environ["QRISP_SIMULATOR_FLOAT_THRESH"] = "1e-10" from qrisp import * from qrisp.block_encodings import BlockEncoding from qrisp.operators import X, Y, Z def create_ising_hamiltonian(L, J, B): H = sum(-J * Z(i) * Z(i + 1) for i in range(L-1)) \ + sum(B * X(i) for i in range(L)) return H L = 4 H = create_ising_hamiltonian(L, 0.25, 0.5) BE = BlockEncoding.from_operator(H) # Prepare inital system state |psi> = |0> def operand_prep(): return QuantumFloat(L) # Prepare state|psi(t)> = e^{itH} |psi> def psi(t): BE_sim = BE.sim(t=t, N=8) operand = BE_sim.apply_rus(operand_prep)() return operand @terminal_sampling def main(t): return psi(t) res_dict = main(0.5) amps = np.sqrt([res_dict.get(i, 0) for i in range(2 ** L)]) print(amps) #[0.88288218 0.224682 0.22269639 0.05723058 0.22269632 0.05669449 # 0.0570588 0.01457775 0.22468192 0.05717859 0.05669445 0.0145699 # 0.05723059 0.01456992 0.01457775 0.00372438] Finally, compare the quantum simulation result with the classical solution: :: import scipy as sp H_mat = H.to_array() # Prepare state|psi(t)> = e^{itH} |psi> def psi_(t): # Prepare inital system state |psi> = |0> psi0 = np.zeros(2**H.find_minimal_qubit_amount()) psi0[0] = 1 psi = sp.linalg.expm(-1.0j * t * H_mat) @ psi0 psi = psi / np.linalg.norm(psi) return psi c = np.abs(psi_(0.5)) print(c) #[0.88288217 0.22468197 0.22269638 0.05723056 0.22269638 0.05669446 # 0.05705877 0.01457772 0.22468197 0.0571786 0.05669446 0.01456988 # 0.05723056 0.01456988 0.01457772 0.00372439] """ from qrisp.algorithms.gqsp import hamiltonian_simulation return hamiltonian_simulation(self, t, N)
[docs] def poly( self, p: "ArrayLike", kind: Literal["Polynomial", "Chebyshev"] = "Polynomial" ) -> BlockEncoding: r""" Returns a BlockEncoding representing a polynomial transformation of the operator. For a block-encoded **Hermitian** matrix $A$ and a (complex) polynomial $p(z)$, this method returns a BlockEncoding of the operator $p(A)$. This is achieved using Generalized Quantum Eigenvalue Transformation (GQET). Parameters ---------- p : ArrayLike 1-D array containing the polynomial coefficients, ordered from lowest order term to highest. kind : {"Polynomial", "Chebyshev"} The basis in which the coefficients are defined. - ``"Polynomial"``: $p(x) = \sum c_i x^i$ - ``"Chebyshev"``: $p(x) = \sum c_i T_i(x)$, where $T_i$ are Chebyshev polynomials of the first kind. Default is ``"Polynomial"``. Returns ------- BlockEncoding A new Block-Encoding instance representing the transformed operator $p(A)$. Examples -------- Define a Hermitian matrix $A$ and a vector $\vec{b}$. :: import numpy as np A = np.array([[0.73255474, 0.14516978, -0.14510851, -0.0391581], [0.14516978, 0.68701415, -0.04929867, -0.00999921], [-0.14510851, -0.04929867, 0.76587818, -0.03420339], [-0.0391581, -0.00999921, -0.03420339, 0.58862043]]) b = np.array([0, 1, 1, 1]) Generate a block-encoding $A$ of and use :meth:`poly` to find a block-encoding of $p(A)$. :: from qrisp import * from qrisp.block_encodings import BlockEncoding BA = BlockEncoding.from_array(A) BA_poly = BA.poly(np.array([1.,2.,1.])) # Prepares operand variable in state |b> def prep_b(): operand = QuantumVariable(2) prepare(operand, b) return operand @terminal_sampling def main(): operand = BA_poly.apply_rus(prep_b)() return operand res_dict = main() amps = np.sqrt([res_dict.get(i, 0) for i in range(len(b))]) Finally, compare the quantum simulation result with the classical solution: :: c = (np.eye(4) + 2 * A + A @ A) @ b c = c / np.linalg.norm(c) print("QUANTUM SIMULATION\n", amps, "\nCLASSICAL SOLUTION\n", c) # QUANTUM SIMULATION # [0.02986315 0.57992481 0.62416743 0.52269535] # CLASSICAL SOLUTION # [-0.02986321 0.57992485 0.6241675 0.52269522] """ from qrisp.algorithms.gqsp import GQET if isinstance(p, list): p = np.array(p) return GQET(self, p, kind=kind)