QROM Phase-Gradient Rotations¶
Download Notebook - qrom_phase_gradient_rotation.ipynb
This notebook demonstrates how to use QROMRotations together with the RotationPhaseGradient Guppy struct to implement a table of rotations whose angles are selected by a QROM index register.
Motivation¶
Phase-gradient rotations are useful when a circuit needs to apply many different rotations selected by an index register, for example in QROM-based state preparation and basis rotations.
The key idea is that we do not synthesize a separate arbitrary-angle rotation for every table entry. Instead, we:
prepare a phase-gradient register once,
use QROM to write the selected fixed-point bit string into a binary target register,
use controlled addition into the phase-gradient register to kick the phase back onto an ancilla, and
uncompute the QROM target again.
This keeps the rotation synthesis cost as a one-time cost in the reusable phase-gradient preparation, while each indexed rotation query only uses QROM compute/uncompute plus the phase-gradient kickback primitive.
The same overall QROM workflow also works with register-incremented rotations: the QROM still loads a little-endian fixed-point word into a data register, but the rotation box interprets that word directly as a sequence of controlled per-bit rotations instead of adding it into a phase-gradient register.
In this notebook we use a concrete 16-bit example: the QROM has 16 entries, and entry j stores the 16-bit little-endian encoding of the increment j. So the table acts like a compact library of indexed fixed-point rotations.
from math import atan2, ceil, log2
from typing import Any, no_type_check
import numpy as np
import pandas as pd
from guppylang import comptime, guppy
from guppylang.std.builtins import array
from guppylang.std.debug import state_output
from guppylang.std.quantum import discard, discard_array, h, qubit
from selene_sim import Quest
from guppyalgos.algorithms.select.qrom import qrom_unary_iteration
from guppyalgos.primitives.rotations import (
RotationAxisZ,
RotationPhaseGradient,
)
from guppyalgos.primitives.state_preparation.phase_gradient import Convention, phase_gradient
from guppyalgos.utils import (
int_to_bits,
bits_to_int,
phase_distance_mod_2,
qarray,
)
from guppyalgos.tests.helpers import (
assert_allclose_ignorephase,
extract_state_branches_in_superposition,
)
What the QROM phase-gradient rotation does¶
Let the QROM store little-endian fixed-point integers
where \(j\) is the QROM index and \(d\) is the number of phase bits. If the QROM target is initialized to \(|0^d\rangle\), then the QROM compute step prepares
Now let \(|F_d\rangle\) be the standard little-endian phase-gradient state
Controlled addition of \(x^{(j)}\) into \(|F_d\rangle\) kicks back a positive phase onto the ancilla because RotationPhaseGradient flips the controlled addition with X gates on the target:
with half-turn rotation parameter
After the final QROM uncompute, the net indexed operation is
So on each QROM index \(j\), the circuit applies the positive kickback rotation determined by the fixed-point word stored at that index, while restoring the QROM target register back to zero.
n_phase_qubits = 4
n_index_elements = 2 ** n_phase_qubits
data_input = [
int_to_bits(index, n_phase_qubits)
for index in range(n_index_elements)
]
n_index_qubits = ceil(log2(len(data_input)))
def kickback_theta(bits: list[bool]) -> float:
encoded_integer = bits_to_int(bits)
return float(2.0 * encoded_integer / (2 ** len(bits)))
pd.DataFrame(
{
"index j": list(range(len(data_input))),
"stored bits x^(j)": data_input,
"encoded integer x^(j)": [bits_to_int(bits) for bits in data_input],
"kickback angle 2 x^(j) / 2^d": [kickback_theta(bits) for bits in data_input],
}
)
| index j | stored bits x^(j) | encoded integer x^(j) | kickback angle 2 x^(j) / 2^d | |
|---|---|---|---|---|
| 0 | 0 | [False, False, False, False] | 0 | 0.000 |
| 1 | 1 | [True, False, False, False] | 1 | 0.125 |
| 2 | 2 | [False, True, False, False] | 2 | 0.250 |
| 3 | 3 | [True, True, False, False] | 3 | 0.375 |
| 4 | 4 | [False, False, True, False] | 4 | 0.500 |
| 5 | 5 | [True, False, True, False] | 5 | 0.625 |
| 6 | 6 | [False, True, True, False] | 6 | 0.750 |
| 7 | 7 | [True, True, True, False] | 7 | 0.875 |
| 8 | 8 | [False, False, False, True] | 8 | 1.000 |
| 9 | 9 | [True, False, False, True] | 9 | 1.125 |
| 10 | 10 | [False, True, False, True] | 10 | 1.250 |
| 11 | 11 | [True, True, False, True] | 11 | 1.375 |
| 12 | 12 | [False, False, True, True] | 12 | 1.500 |
| 13 | 13 | [True, False, True, True] | 13 | 1.625 |
| 14 | 14 | [False, True, True, True] | 14 | 1.750 |
| 15 | 15 | [True, True, True, True] | 15 | 1.875 |
Testing the QROM phase-gradient rotation¶
def expected_ry_state(theta: float) -> np.ndarray:
"""Returns the expected state vector for a rotation around the Y axis by angle theta."""
phase = np.pi * theta / 2.0
return np.array([np.cos(phase), np.sin(phase)], dtype=np.complex128)
def extract_rz_theta(state: np.ndarray) -> float:
"""Extract theta from Rz(theta)|+>, up to global phase."""
relative_phase = np.angle(state[1] / state[0])
return float((relative_phase / np.pi) % 2.0)
Run the QROM phase-gradient rotation in superposition¶
from guppyalgos.primitives.rotations import QROMRotations
from guppyalgos.utils.guppy.gates import transversal
def run_phase_gradient_qrom_rotation_superposition(
data_input: list[list[bool]],
) -> dict[str, Any]:
n_data_qubits = len(data_input[0])
n_index_qubits = ceil(log2(len(data_input)))
qrom_compute = qrom_unary_iteration(data_input)
qrom_uncompute = qrom_unary_iteration(data_input)
fourier_state = phase_gradient(n_data_qubits, convention=Convention.Standard)
@guppy
@no_type_check
def main() -> None:
index_qreg = qarray(comptime(n_index_qubits))
data_qreg = qarray(comptime(n_data_qubits))
rotation_target = qubit()
phase_state = qarray(comptime(n_data_qubits))
fourier_state(phase_state)
rotation = RotationPhaseGradient(
phase_state, RotationAxisZ()
)
transversal(h, index_qreg)
h(rotation_target)
qrom_rot = QROMRotations(
qrom_compute[array[qubit, comptime(n_data_qubits)]],
rotation,
qrom_uncompute[array[qubit, comptime(n_data_qubits)]],
)
qrom_rot.compose(index_qreg, data_qreg, rotation_target)
state_output("index", index_qreg)
state_output("ancilla", rotation_target)
state_output("qrom_target", data_qreg)
discard_array(index_qreg)
discard_array(data_qreg)
discard_array(qrom_rot.rotation_box.phase_gradient)
discard(rotation_target)
res = main.emulator(n_qubits=n_index_qubits + (3 * n_data_qubits) + 1).run()
return Quest.extract_states_dict(res.results[0].entries)
Check that every extracted branch angle matches the stored input¶
For each index j, the table shows the positive kickback angle \(2x^{(j)}/2^d\) from the derivation and the extracted branch angle reported back in the same positive convention as the derivation.
expected_zero = np.zeros(2**n_phase_qubits, dtype=np.complex128)
expected_zero[0] = 1.0
states = run_phase_gradient_qrom_rotation_superposition(data_input)
qrom_target_state = states["qrom_target"].get_single_state()
assert_allclose_ignorephase(qrom_target_state, expected_zero)
# The branch helper accepts little-endian bitstrings, matching the QROM data.
index_bitstrings_le = [
int_to_bits(index, n_index_qubits)
for index in range(len(data_input))
]
projected_ancilla_states = extract_state_branches_in_superposition(
states,
"index",
["ancilla"],
index_bitstrings_le,
)
rows = []
for index, bits in enumerate(data_input):
projected_ancilla = projected_ancilla_states[tuple(index_bitstrings_le[index])]
input_positive_kickback_angle = kickback_theta(bits)
ancilla_state = projected_ancilla.state.state
extracted_theta = extract_rz_theta(ancilla_state)
rows.append(
{
"index j": index,
"stored bits x^(j)": bits,
"encoded integer x^(j)": bits_to_int(bits),
"input positive kickback angle": input_positive_kickback_angle,
"extracted branch angle": extracted_theta,
"angle error mod 2": phase_distance_mod_2(extracted_theta, input_positive_kickback_angle),
}
)
branch_table = pd.DataFrame(rows)
branch_table
| index j | stored bits x^(j) | encoded integer x^(j) | input positive kickback angle | extracted branch angle | angle error mod 2 | |
|---|---|---|---|---|---|---|
| 0 | 0 | [False, False, False, False] | 0 | 0.000 | 0.000 | 0.000000e+00 |
| 1 | 1 | [True, False, False, False] | 1 | 0.125 | 0.125 | 2.775558e-16 |
| 2 | 2 | [False, True, False, False] | 2 | 0.250 | 0.250 | 1.665335e-16 |
| 3 | 3 | [True, True, False, False] | 3 | 0.375 | 0.375 | 1.665335e-16 |
| 4 | 4 | [False, False, True, False] | 4 | 0.500 | 0.500 | 0.000000e+00 |
| 5 | 5 | [True, False, True, False] | 5 | 0.625 | 0.625 | 1.110223e-16 |
| 6 | 6 | [False, True, True, False] | 6 | 0.750 | 0.750 | 6.661338e-16 |
| 7 | 7 | [True, True, True, False] | 7 | 0.875 | 0.875 | 0.000000e+00 |
| 8 | 8 | [False, False, False, True] | 8 | 1.000 | 1.000 | 0.000000e+00 |
| 9 | 9 | [True, False, False, True] | 9 | 1.125 | 1.125 | 4.440892e-16 |
| 10 | 10 | [False, True, False, True] | 10 | 1.250 | 1.250 | 8.881784e-16 |
| 11 | 11 | [True, True, False, True] | 11 | 1.375 | 1.375 | 4.440892e-16 |
| 12 | 12 | [False, False, True, True] | 12 | 1.500 | 1.500 | 0.000000e+00 |
| 13 | 13 | [True, False, True, True] | 13 | 1.625 | 1.625 | 4.440892e-16 |
| 14 | 14 | [False, True, True, True] | 14 | 1.750 | 1.750 | 8.881784e-16 |
| 15 | 15 | [True, True, True, True] | 15 | 1.875 | 1.875 | 2.220446e-16 |