Qubitization and QSVT

Given a block encoding (see Block encodings), qubitization builds a walk operator W whose eigenphases encode the spectrum of H / alpha, and quantum singular value transformation (QSVT) applies a polynomial to that spectrum by interleaving walk steps with signal-processing phases. Both Walk and QSVT are generic over the encoding — they consume any object satisfying the BlockEncoding protocol — and both have controlled variants. QSVT-based Hamiltonian simulation is one of the two time-evolution routes in the library; Trotter (product-formula simulation) is the other (product-formula) route.

Everything below is pure-Python CUDA-Q kernels needing only cudaq and numpy. Qubit-ordering and normalization conventions live in Conventions and Reference. Import the object surface from the package root:

from cudaq_algorithms import Walk, QSVT, PhaseSequence

The qubitization walk

Walk wraps an encoding and emits the qubitization walk operator W and its powers. The constructor holds the problem; the method calls hold the choices:

walk = Walk(enc)
walk.kernel(power=3)                  # PREPARE + W^3 + UNPREPARE
walk.kernel(power=3, uncompute=False) # ... without UNPREPARE
walk.adjoint_kernel(power=2)          # (W dagger)^2
walk.roundtrip_kernel(power=2)        # W^2 (W dagger)^2 == identity
walk.controlled_kernel(power=2)       # controlled walks

The flagged block of a single walk step is -H/alpha (the sign is folded into the walk construction), so on an eigenstate with eigenvalue lambda the good subspace realizes the Chebyshev response at lambda / alpha with no caller-side negation. roundtrip_kernel composes a walk with its adjoint and must collapse to the identity — the property tests pin this.

Chebyshev moments

Walk measures Chebyshev moments of the (normalized) Hamiltonian through cudaq.observe, which is a hardware-legitimate measurement path:

walk.moment(psi, k)                   # <T_k(H/alpha)> via cudaq.observe
walk.moments(psi, 8)                  # even/odd observable convention

from cudaq_algorithms import reflection_observable, select_observable
reflection_observable(enc)            # 2|0..0><0..0| - I  (SpinOperator)
select_observable(enc)                # sum_i sign_i |i><i| x P_i

There are two moment observables. The even moments use the reflection observable 2|0..0><0..0| - I, which needs only the register geometry, so Walk derives it without an encoding hook. The odd moments use the encoding-specific select_observable, measured after PREPARE and p walk steps (no UNPREPARE), whose expectation is <T_{2p+1}(H/alpha)>. For an LCU this is sum_i sign_i |i><i|_anc x P_i; encodings that cannot supply it (for example DoubleFactorizedEncoding) raise NotImplementedError on select_observable, and only even moments are available there.

QSVT and phase sequences

A PhaseSequence is the list of signal-processing angles that define the polynomial to apply; QSVT interleaves them with walk steps:

seq = PhaseSequence(phases)                      # projector convention
seq = PhaseSequence(phases, convention="qsp")    # QSPPACK convention,
                                                 # converted automatically
seq = PhaseSequence(phases, walk_directions=["forward", "adjoint"])

transformer = QSVT(enc)
kernel = transformer.kernel(seq)                 # @cudaq.kernel(state)
controlled = transformer.controlled_kernel(seq)  # controlled sequence

PhaseSequence accepts phases in either the projector convention (default) or the QSPPACK "qsp" convention, which it converts automatically; walk_directions lets a sequence mix forward and adjoint walk steps.

Sign convention: the walk block-encodes -H/alpha and the circuits fold the sign in, so on an eigenstate with eigenvalue lambda the good-subspace block implements p(lambda / alpha) — no caller-side negation. The 2x2 signal-model reference implementing this convention lives in tests/python/test_qsvt.py (reference_response), where it serves as the test oracle. Phase generation stays external (see below): the primitives consume whatever phase list they are given.

Hamiltonian simulation

Real-time evolution exp(-iHt) is not a single polynomial (it is complex), so QSVT realizes its real and imaginary parts separately — a cosine and a sine polynomial in H/alpha — and they are recombined:

from cudaq_algorithms import recover_real_time_evolution

evolved = recover_real_time_evolution(cos_state, sin_state,
                                      cos_phases, sin_phases)

Phase-angle generation is external to the library. The primitives consume whatever PhaseSequence they are handed, so any source of angles works — you can supply your own. QSPPACK is one optional, example-only way to compute angles for a target function; it is not a runtime dependency of cudaq_algorithms. The worked example hamiltonian_simulation_qsvt.py on the Hamiltonian-simulation examples page uses QSPPACK (and scipy) to generate the cosine/sine phases, runs QSVT time evolution, and compares against exact diagonalization, reaching ~1e-15 state error. The getting-started series applies the same machinery to a different function: 07_matrix_inversion_qsvt.py (Getting-started walkthrough) inverts a linear system through a polynomial approximation of \(1/x\) — the QSVT reading of HHL — changing nothing but the phase sequence.

State preparation injection

Every kernel factory (Walk, QSVT, and Trotter) takes an optional state_prep kernel with signature (qubits: cudaq.qview). Without it, factories return @cudaq.kernel(state) circuits that load the input state as data (the simulation-friendly form). With it, they return zero-argument circuits — the system register is allocated in |0...0>, state_prep runs on it, and the operation follows — directly sampleable and fully synthesizable, with the statevector abstracted away from the caller:

@cudaq.kernel
def my_prep(qubits: cudaq.qview):
    rx(0.37, qubits[0])
    ry(-0.52, qubits[1])

kernel = walk.kernel(power=3, state_prep=my_prep)   # zero-arg circuit
counts = cudaq.sample(kernel)
moment = walk.moment(None, 3, state_prep=my_prep)   # hardware-path moment

Contract: state_prep acts only on the system register it is handed, which arrives in |0...0> with width num_system — not verifiable at factory time, so a mismatched prep fails at launch.

Hardware-shaped vs. simulation-only

cudaq.get_state is a simulator-only API, so nothing in pauli_lcu, qubitization, or qsvt calls it. Statevector conveniences are isolated in cudaq_algorithms.sim_utils:

from cudaq_algorithms import sim_utils as sim

good = sim.good_subspace(enc, state)  # postselect the |0..0>-ancilla block
hpsi = sim.action(enc, psi)           # (H/alpha)|psi>
out  = sim.transform(transformer, psi, seq)
psi0 = sim.state_from(ket)            # precision-aware cudaq.State

Walk.moment / moments stay in the library proper: they measure through cudaq.observe, which is a hardware-legitimate path.

Escape hatch at every level: the module-level kernels compose inside user kernels, with enc.kernel_args supplying the flattened arrays they take as arguments. They live in their module namespaces — their names are too generic for the package root — as cudaq_algorithms.pauli_lcu.{prepare, select, apply, walk, adjoint_walk, apply_phase_sequence, ...} and cudaq_algorithms.common_kernels.{reflect_about_zero, signal_phase, ...}.

Simulator selection

Tests and examples default to qpp-cpu and honor CUDA-Q’s standard CUDAQ_DEFAULT_SIMULATOR variable (e.g. nvidia-fp64 for the GPU statevector simulator). Use an fp64 target for the test suite — the nvidia target is fp32 and misses the 1e-8..1e-10 tolerances (precision, not correctness). State construction goes through state_from, which matches the input dtype to the active target’s precision (cudaq.complex()), since fp32 simulators reject complex128 initial-state data.