"""********************************************************************************
* 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
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import jax
import jax.numpy as jnp
import numpy as np
from jax.tree_util import register_pytree_node_class
from qrisp.alg_primitives.reflection import reflection
from qrisp.core import QuantumVariable
from qrisp.core.gate_application_functions import gphase, h, measure, reset, ry, x, z
from qrisp.environments import conjugate, control, invert
from qrisp.jasp import (
RUS,
count_ops,
depth,
expectation_value,
jrange,
num_qubits,
)
from qrisp.jasp import (
check_for_tracing_mode as is_tracing,
)
from qrisp.jasp.tracing_logic import QuantumVariableTemplate
from qrisp.qtypes import QuantumBool
if TYPE_CHECKING:
from jax.typing import ArrayLike
from qrisp.interface.backend import BackendLike
[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)
#
# 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}
"""
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
for anc in ancillas:
reset(anc)
anc.delete()
return success_bool, *operands
return rus_function
[docs]
def expectation_value(
self,
operand_prep: Callable[..., Any],
shots: int = 100,
backend: "BackendLike | None" = 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 : BackendLike, optional
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
"""
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):
self.apply(*operands)
h(qbl)
return qbl
# Dynamic (Jasp) mode
if is_tracing():
@jax.jit
def post_processor(val):
return jnp.where(val == 0, 1, -1)
def ev_function_dynamic(*args):
ev = expectation_value(state_prep, shots=shots, post_processor=post_processor)(*args)
return ev * self.alpha
return ev_function_dynamic
# Static mode
def ev_function_static(*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_static
[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``, ``depth`` and ``num_qubits`` decorators to obtain gate counts, circuit depth,
and 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.
.. warning::
The :ref:`depth <depth>` metric is an experimental feature and may not behave as expected in certain edge cases.
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 dagger(self) -> BlockEncoding:
r"""Returns a new BlockEncoding representing the Hermitian conjugate of the operator.
For a block-encoded operator $A$ with block-encoding unitary $U_A$, this method returns a new BlockEncoding with unitary $U_A^{\dagger}$.
The resulting block-encoding represents the operator $A^{\dagger}$ with the same scaling factor $\alpha$.
Returns
-------
BlockEncoding
A new BlockEncoding instance representing the Hermitian conjugate of the operator.
Examples
--------
Define a block-encoding and obtain its Hermitian conjugate.
::
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_dg = BE.dagger()
"""
def new_unitary(*args):
with invert():
self.unitary(*args)
return BlockEncoding(
self.alpha,
self._anc_templates,
new_unitary,
num_ops=self.num_ops,
is_hermitian=self.is_hermitian,
)
[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_hermitian(*args):
self.unitary(*args)
reflection(args[:m])
return BlockEncoding(
self.alpha,
self._anc_templates,
new_unitary_hermitian,
num_ops=self.num_ops,
is_hermitian=True,
)
# 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,
)
def _hermitianization(self) -> BlockEncoding:
r"""Returns a BlockEncoding representing the `qubitization walk operator via Hermitianization <https://arxiv.org/pdf/2312.00723>`_.
For a block-encoded (**not** necessarily Hermitian) operator $A$,
this method returns a BlockEncoding of the qubitization walk operator via Hermitianization.
The operator $A$ is encoded in the upper right block (measure new ancilla QuantumBool in $\ket{1}$):
.. math::
\begin{pmatrix} \mathbb{0} & A \\ A^{\dagger} & \mathbb{0} \end{pmatrix}
"""
n = self.num_ancs
def new_unitary(*args):
anc = args[0]
self_ancs = args[1 : n + 1]
operands = args[n + 1 :]
x(anc)
with control(anc, ctrl_state=0):
self.unitary(*self_ancs, *operands)
with control(anc, ctrl_state=1):
with invert():
self.unitary(*self_ancs, *operands)
reflection(self_ancs)
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,
)