Generic register composition¶
Download Notebook - abstract_construction.ipynb
Larger quantum algorithms are often assembled from interchangeable component boxes that act on quantum registers.
A generic Guppy struct stores the component functions.
Its
composemethod defines how their quantum-register types connect.A generic quantum-register type may be one qubit array or a struct bundling several quantum registers.
Reusing the middle type makes incompatible connections a compile-time error.
from guppylang.decorator import guppy
from guppylang.std.angles import angle
from guppylang.std.builtins import Function, array, nat, output
from guppylang.std.quantum import collect_measurements, cx, h, measure_array, qubit, rz
from guppyalgos.utils import qarray, transversal
Generic quantum-register types¶
A generic type can stand for a complete quantum-register shape. The same higher-order function can therefore accept an operation over an array, a tuple, or a struct containing several quantum registers.
The concrete operation and argument determine
Registersat the call site.Reusing
Registersin both positions ensures that the operation accepts the supplied shape.Guppy checks this compatibility at compile time.
@guppy
def apply_register_operation[Registers](
operation: Function[[Registers], None],
qregs: Registers,
) -> None:
operation(qregs)
A qubit array¶
The most common quantum register is array[qubit, n]. Its width \(n\) is part of its type and is inferred when the function is used.
An \(n\)-qubit register has state space
The operation below applies a Hadamard gate to every qubit, giving \(H^{\otimes n}\). On an all-zero input, this prepares a uniform superposition:
@guppy
def array_operation[n: nat](qreg: array[qubit, n]) -> None:
transversal(h, qreg)
A tuple of qubits¶
A fixed tuple is useful when its positions have distinct roles. Here the tuple describes a two-qubit target. Starting from \(|00\rangle\), the following operation prepares a Bell pair:
In this expression, the first ket position is the first tuple element and controls the CX gate.
@guppy
def tuple_operation(qregs: tuple[qubit, qubit]) -> None:
h(qregs[0])
cx(qregs[0], qregs[1])
A struct containing quantum registers¶
A Guppy struct gives names to a bundle of related quantum registers. The complete struct can be used as one generic quantum-register type while its operation still accesses each field directly.
Here, the work and target registers each contain \(n\) qubits. Together, they have state space \(\mathcal{H}_{\mathrm{work}}\otimes\mathcal{H}_{\mathrm{target}}\). Starting with both registers at zero, the operation creates \(n\) Bell pairs:
@guppy.struct
class WorkAndTarget[n: nat]:
work_qreg: array[qubit, n]
target_qreg: array[qubit, n]
@guppy
def struct_operation[n: nat](qregs: WorkAndTarget[n]) -> None:
transversal(h, qregs.work_qreg)
transversal(cx, qregs.work_qreg, qregs.target_qreg)
Several generic quantum-register types¶
Composed algorithms can keep quantum registers with different roles independent. For example, PrepRegisters selects an operation and TargetRegisters is acted on by it. Either type may be an array, tuple, or struct.
@guppy
def apply_select[PrepRegisters, TargetRegisters](
select: Function[[PrepRegisters, TargetRegisters], None],
prep_qreg: PrepRegisters,
target_qreg: TargetRegisters,
) -> None:
select(prep_qreg, target_qreg)
Advanced register composition¶
BoxComposition follows the same pattern as composable algorithm structs such as LCU: it owns its component boxes and exposes a typed compose method.
box_0acceptsRegister0andRegister1.box_1accepts the sameRegister1type andRegister2.The boxes may otherwise have different function signatures; only
box_1accepts an angle.
Let \(\mathcal{H}_j\) be the state space of Registerj, and let \(U_0\) and \(U_1(\theta)\) represent the two boxes. The complete operation acts on \(\mathcal{H}_0\otimes\mathcal{H}_1\otimes\mathcal{H}_2\):
Read the product from right to left: box_0 acts first on registers \(0\) and \(1\), then box_1 acts on registers \(1\) and \(2\). Each identity \(I_j\) leaves the unused register unchanged.
The repeated Register1 type ensures that both boxes agree on the shared register’s complete shape.
@guppy.struct
class BoxComposition[Register0, Register1, Register2]:
box_0: Function[[Register0, Register1], None]
box_1: Function[[Register1, Register2, angle], None]
@guppy
def compose(
self,
qreg_0: Register0,
qreg_1: Register1,
qreg_2: Register2,
theta: angle,
) -> None:
self.box_0(qreg_0, qreg_1)
self.box_1(qreg_1, qreg_2, theta)
Every bundle contains one quantum register¶
First, each generic type is instantiated as one array[qubit, n]. The three registers therefore contain \(3n\) qubits in total:
The component functions are specialized before they are stored because higher-rank polymorphic function values are not supported.
@guppy
def bell_transversal[n: nat](
left_qreg: array[qubit, n], shared: array[qubit, n]
) -> None:
transversal(h, left_qreg)
transversal(cx, left_qreg, shared)
@guppy
def rz_cx_transversal[n: nat](
shared: array[qubit, n], right_qreg: array[qubit, n], theta: angle
) -> None:
for i in range(len(shared)):
rz(shared[i], theta)
transversal(cx, shared, right_qreg)
n_qubits = 3
@guppy
def single_register_bundles() -> None:
qreg_0 = qarray(n_qubits)
qreg_1 = qarray(n_qubits)
qreg_2 = qarray(n_qubits)
composition = BoxComposition(
bell_transversal[n_qubits],
rz_cx_transversal[n_qubits],
)
composition.compose(
qreg_0, qreg_1, qreg_2, angle(0.25)
)
output("qreg_0", collect_measurements(measure_array(qreg_0)))
output("qreg_1", collect_measurements(measure_array(qreg_1)))
output("qreg_2", collect_measurements(measure_array(qreg_2)))
single_results = (
single_register_bundles.emulator(n_qubits=3 * n_qubits)
.with_seed(42)
.with_shots(100)
.run()
)
print("Single-register bundles:", single_results.collated_counts())
The middle bundle contains two quantum registers¶
The composer itself does not change. Instead, Register1 is instantiated as MiddleBundle[n], containing two independently addressable quantum registers:
The middle bundle now contains \(2n\) qubits, bringing the total to \(4n\).
The composition equation above still applies: only the shape of \(\mathcal{H}_1\) has changed. Both boxes must accept the complete bundle, so the shared boundary remains type safe.
@guppy.struct
class MiddleBundle[n: nat]:
upper_qreg: array[qubit, n]
lower_qreg: array[qubit, n]
@guppy
def bundled_box_0[n: nat](
left_qreg: array[qubit, n], middle: MiddleBundle[n]
) -> None:
transversal(h, left_qreg)
transversal(cx, left_qreg, middle.upper_qreg)
transversal(cx, left_qreg, middle.lower_qreg)
@guppy
def bundled_box_1[n: nat](
middle: MiddleBundle[n], right_qreg: array[qubit, n], theta: angle
) -> None:
for i in range(n):
rz(middle.upper_qreg[i], theta)
rz(middle.lower_qreg[i], theta)
transversal(cx, middle.upper_qreg, right_qreg)
transversal(cx, middle.lower_qreg, right_qreg)
@guppy
def two_register_middle_bundle() -> None:
qreg_0 = qarray(n_qubits)
middle = MiddleBundle(qarray(n_qubits), qarray(n_qubits))
qreg_2 = qarray(n_qubits)
composition = BoxComposition(
bundled_box_0[n_qubits],
bundled_box_1[n_qubits],
)
composition.compose(
qreg_0, middle, qreg_2, angle(0.25)
)
output("qreg_0", collect_measurements(measure_array(qreg_0)))
output("middle_upper", collect_measurements(measure_array(middle.upper_qreg)))
output("middle_lower", collect_measurements(measure_array(middle.lower_qreg)))
output("qreg_2", collect_measurements(measure_array(qreg_2)))
bundled_results = (
two_register_middle_bundle.emulator(n_qubits=4 * n_qubits)
.with_seed(42)
.with_shots(100)
.run()
)
print("Two-register middle bundle:", bundled_results.collated_counts())
Development practices¶
Store related component functions in a generic Guppy struct when they form one reusable abstraction.
Put the wiring in the struct’s
composemethod.Use
Function, rather thantyping.Callable, for Guppy function values.Reuse the same type variable at connected boundaries so Guppy checks compatibility.
A type parameter can represent an array or a struct containing several quantum registers.