Skip to content

Circuit Builder

CircuitBuilder builds a circuit one gate at a time. Its style follows Qiskit, Braket and CUDA Quantum: qc.rx(angle, qubit=0) adds a gate and returns the builder, so that calls can be chained. Use it when no ready-made block fits, or when you want to write a part of a circuit and then use it in a larger composition.

from eigenkit import CircuitBuilder

qc = CircuitBuilder(n_qubits=3)
weights = qc.add_weights(6)
inputs = qc.add_inputs(3)

# Encoding.
for q in range(3):
    qc.ry(inputs[q], qubit=q)

# Two variational layers + CNOT ladder.
for depth in range(2):
    for q in range(3):
        qc.ry(weights[depth * 3 + q], qubit=q)
    for q in range(2):
        qc.cnot(q, q + 1)

spec = qc.build()

spec is a CircuitSpec, the same data type, independent of any provider, that assemble() returns. Pass it directly to Circuit:

from eigenkit import Circuit, all_z

circuit = Circuit(blocks=spec, observables=all_z(3))  # numpy interface + sim by default

Circuit also accepts the builder itself and calls build() for you, so Circuit(blocks=qc, observables=all_z(3)) is the same circuit. In both cases, the circuit gets the gates added so far: gates added to qc afterwards are not part of it.

Declaring weights and inputs

add_weights(n) and add_inputs(n) return lists of WeightRef and InputRef. These are symbolic handles to the trainable parameters and to the input vector. The gate methods also accept constants, which are wrapped as Constant automatically:

qc = CircuitBuilder(n_qubits=2)
weights = qc.add_weights(2)

# Trainable rotation.
qc.rz(weights[0], qubit=0)

# Constant rotation: 0.5 is wrapped as Constant(0.5).
qc.rx(0.5, qubit=1)

Available gates

Method Gate Parameters
rx(angle, qubit) RX(angle) One Parameter.
ry(angle, qubit) RY(angle) One Parameter.
rz(angle, qubit) RZ(angle) One Parameter.
h(qubit) The Hadamard gate. None.
cnot(control, target) The controlled-NOT (CNOT) gate. None.
x(qubit) The Pauli X gate. None.
z(qubit) The Pauli Z gate. None.
mcx(*qubits) A multi-controlled X gate. Every qubit except the last is a control. None.
mcz(*qubits) A multi-controlled Z gate. Every qubit except the last is a control. None.
phase_shift(angle, qubit) A phase shift, which applies e^(iφ) to \|1⟩ and leaves \|0⟩ unchanged. One Parameter.
swap(qubit0, qubit1) The SWAP gate. None.
cry(angle, control, target) A controlled RY gate. One Parameter.
s(qubit) The S gate, a quarter turn about Z. None.
sdg(qubit) The S† gate, the inverse of s(). None.
cphase(angle, control, target) A controlled phase shift, which is symmetric in its two qubits. One Parameter.
pauli_exponential(paulis, angle, coefficient=1.0) exp(-i · coefficient · angle · P) for a Pauli product P, given as (operator, qubit) pairs on different qubits. One Parameter.

pauli_exponential is the Trotter building block that the cost and mixer layers of the Quantum Approximate Optimization Algorithm (QAOA) and the excitation operators of unitary coupled cluster with singles and doubles (UCCSD) are made of. It is decomposed into the core set of gates.

to_block() is not a gate. It wraps what has been built so far as a CustomBlock, so that a part of a circuit built gate by gate can be placed in a list of blocks. add_weights(count), add_inputs(count) and build() are the other three methods that are not gates.

Parameter expressions

Gate angles are not raw values. They are expression trees that the backends evaluate when the circuit runs. The most common nodes are these:

Node Meaning
Constant(0.5) A literal value. An int or a float is wrapped automatically.
WeightRef(i) The trainable weight number i. add_weights() returns these.
InputRef(i) The input number i. add_inputs() returns these.
Scale(expr, factor) The product factor * expr.
Negate(expr) The negation -expr.
Sum((a, b, ...)) The sum of the expressions.
Multiply(a, b) The product of two expressions, for example lambda * x for trainable feature scaling.

You rarely need to name these nodes yourself. The handles from add_weights() and add_inputs() support ordinary Python arithmetic, and each operator builds exactly the node that its constructor would build. So the two ways of writing give the same intermediate representation, and you can mix them freely:

import math

qc = CircuitBuilder(n_qubits=2)
weights = qc.add_weights(2)
inputs = qc.add_inputs(2)

# 2*pi * x_0.
qc.rx(2 * math.pi * inputs[0], qubit=0)

# -w_0 (useful for adjoints).
qc.rz(-weights[0], qubit=1)

# w_0 + x_1.
qc.ry(weights[0] + inputs[1], qubit=0)

# Trainable feature scaling, w_1 * x_0.
qc.rx(weights[1] * inputs[0], qubit=1)

# A ZZ-style feature-map angle, 2 * (pi - x_0) * (pi - x_1).
qc.cnot(0, 1)
qc.phase_shift(2 * ((math.pi - inputs[0]) * (math.pi - inputs[1])), qubit=1)
qc.cnot(0, 1)

+, -, *, / and the unary - work on every node, with plain numbers allowed on either side. Two details are worth knowing:

  • Multiplication chooses the node for you. A constant factor becomes a Scale, which a backend folds into the angle of the gate. Only a symbol times a symbol becomes a Multiply. It is easy to get this wrong when you build the nodes by hand, and nothing warns you when you do.
  • Addition is flattened. a + b + c is one Sum with three terms, not a Sum inside another Sum.

You can divide only by a number (x / 2 is Scale(x, 0.5)). The intermediate representation has no division node, so x / y raises a TypeError. The explicit constructors are still available from eigenkit.core.ir, for code that builds expression trees programmatically:

from eigenkit.core.ir import Multiply, Negate, Scale, Sum

qc.rx(Scale(inputs[0], factor=2 * math.pi), qubit=0)  # same as 2*pi * inputs[0]
qc.rz(Negate(weights[0]), qubit=1)  # same as -weights[0]
qc.ry(Sum((weights[0], inputs[1])), qubit=0)  # same as weights[0] + inputs[1]
qc.rx(Multiply(weights[1], inputs[0]), qubit=1)  # same as weights[1] * inputs[0]

On backends without their own automatic differentiation, a Multiply makes the gradient use the parameter-shift rule together with the chain rule. The gradients match the automatic differentiation of PennyLane to machine precision on PennyLane, Qiskit and Braket. CUDA Quantum does not support products of two symbols at the moment.

Building an inversion circuit

A common pattern is to build U(x_1) followed by the adjoint of U(x_2), to read out the fidelity |<0...0| U^dag(x_2) U(x_1) |0...0>|^2. This is what QuantumKernel uses, and it serves any fidelity measure in the style of a swap test.

from eigenkit import Circuit, CircuitBuilder
from eigenkit.core.ir import Negate

n_features = 3
qc = CircuitBuilder(n_qubits=n_features)
x1 = qc.add_inputs(n_features)
x2 = qc.add_inputs(n_features)

# Forward U(x_1).
for q in range(n_features):
    qc.rx(x1[q], qubit=q)

# Adjoint U^dag(x_2): reversed order, negated angles.
for q in reversed(range(n_features)):
    qc.rx(Negate(x2[q]), qubit=q)

circuit = Circuit(blocks=qc.build())
# No trainable weights here, so pass an empty weight vector; the data is inputs.
print(circuit.zero_state_probability([], [0.4, -0.2, 0.6, 0.4, -0.2, 0.6]))  # ~1.0

See Quantum Kernels for the full implementation of the kernel.

Inspecting the gates

circuit.draw() returns a drawing, as text over several lines, of the circuit that you have just built. It is the easiest way to check a circuit written by hand before training:

print(circuit.draw())
q_0: ──RX(x_0)──RX(-x_3)──

q_1: ──RX(x_1)──RX(-x_4)──

q_2: ──RX(x_2)──RX(-x_5)──

The drawing works in the same way for circuits assembled from blocks. See Basic Circuit for an example with Encoding and Variational.

Composing with blocks

qc.to_block() wraps the current state of the builder as a CustomBlock, which you can use anywhere a Block is accepted. The assembler moves the local WeightRef and InputRef indices of the block into the numbering of the whole circuit. In a list of blocks, you can also leave out to_block() and put qc in the list directly. The assembler then makes the same call for you.

from eigenkit import Circuit, CircuitBuilder, Variational, all_z
from eigenkit.core.ir import Scale

# Hand-written encoding: scale every input by 2*pi.
qc = CircuitBuilder(n_qubits=4)
inputs = qc.add_inputs(4)
for q in range(4):
    qc.ry(Scale(inputs[q], factor=2 * 3.14159), qubit=q)
my_encoding = qc.to_block()

# Compose with a standard variational block.
circuit = Circuit(
    blocks=[my_encoding, Variational(depth=2)],
    observables=all_z(4),
)

When to use the builder

  • You need a sequence of gates that no ready-made block expresses.
  • You want full control over the parameter expressions, with your own patterns of Multiply, Sum and Scale.
  • You want to write a part of a circuit gate by gate, and then use it in a list of blocks through .to_block().
  • You are following a paper that uses a specific decomposition into gates.

For the usual shape of an encoding followed by a variational block, prefer the block interface (Encoding, Variational, Reuploading). It is shorter, and it stays readable as the circuit grows.

API reference

eigenkit.core.builder.CircuitBuilder

Build a CircuitSpec one gate at a time.

Example
qc = CircuitBuilder(n_qubits=3)
weights = qc.add_weights(6)
inputs = qc.add_inputs(3)
for qubit in range(3):
    qc.ry(inputs[qubit], qubit=qubit)
for depth in range(2):
    for qubit in range(3):
        qc.ry(weights[depth * 3 + qubit], qubit=qubit)
    for qubit in range(2):
        qc.cnot(qubit, qubit + 1)
spec = qc.build()

Parameters:

Name Type Description Default
n_qubits int

The number of qubits of the circuit being built. Every qubit index that a gate uses must be between 0 and n_qubits - 1.

required

Raises:

Type Description
ValueError

If n_qubits is not positive.

n_inputs property

The number of input features that add_inputs() has reserved so far.

The built CircuitSpec has the same number.

n_qubits property

The number of qubits of the circuit, as given to the constructor.

Every qubit index that a gate uses must be smaller than this number, and the number never changes.

n_weights property

The number of trainable weights that add_weights() has reserved so far.

The built CircuitSpec has the same number.

add_inputs(count)

Reserve count input features and return references to them.

This is the counterpart of add_weights() for data instead of trainable parameters.

Parameters:

Name Type Description Default
count int

The number of input features to reserve.

required

Returns:

Type Description
list[InputRef]

One InputRef for each reserved feature, in order.

add_weights(count)

Reserve count trainable weights and return references to them.

Pass a returned WeightRef wherever a gate takes an angle. The indices are local to this builder and start at 0. When the block is combined with others, assemble shifts them to their positions in the whole circuit.

Parameters:

Name Type Description Default
count int

The number of weights to reserve.

required

Returns:

Type Description
list[WeightRef]

One WeightRef for each reserved weight, in order.

build()

Return the current state of the builder as an immutable CircuitSpec.

cnot(control, target)

Add a controlled-NOT (CNOT) gate.

Parameters:

Name Type Description Default
control int

The qubit that controls the flip.

required
target int

The qubit that is flipped.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

Raises:

Type Description
ValueError

If control and target are the same qubit.

cphase(angle, control, target)

Add a controlled phase shift gate.

The gate is symmetric: the control and the target play the same role.

Parameters:

Name Type Description Default
angle ParameterLike

The phase. It can be a number or a symbolic reference.

required
control int

One of the two qubits.

required
target int

The other qubit. The gate acts in the same way if the two qubits are exchanged.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

cry(angle, control, target)

Add a controlled RY gate.

Parameters:

Name Type Description Default
angle ParameterLike

The rotation angle. It can be a number or a symbolic reference.

required
control int

The qubit that controls the rotation.

required
target int

The qubit that is rotated.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

h(qubit)

Add a Hadamard gate.

Parameters:

Name Type Description Default
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

mcx(*qubits)

Add a multi-controlled X gate.

All the qubits except the last one are controls.

Parameters:

Name Type Description Default
*qubits int

Two or more distinct qubits. The controls come first, and the target is the last one.

()

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

Raises:

Type Description
ValueError

If fewer than two qubits are given, or if a qubit is repeated.

mcz(*qubits)

Add a multi-controlled Z gate.

All the qubits except the last one are controls.

Parameters:

Name Type Description Default
*qubits int

Two or more distinct qubits. The controls come first, and the target is the last one.

()

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

Raises:

Type Description
ValueError

If fewer than two qubits are given, or if a qubit is repeated.

pauli_exponential(paulis, angle, coefficient=1.0)

Add the exponential of a product of Pauli operators.

The gate is exp(-i * coefficient * angle * P), where P is the product of paulis. Each element of paulis is a pair (op, qubit), where op is "X", "Y" or "Z", and the qubits are distinct.

This is the Trotter building block that the quantum approximate optimisation algorithm (QAOA) needs for its cost and mixer layers, and that the unitary coupled-cluster singles and doubles (UCCSD) ansatz needs for its excitation operators. It is built from the core gates only. First, H gates (for X) and RX(π/2) gates (for Y) change each factor to the Z basis. Then a ladder of controlled-NOT (CNOT) gates accumulates the parity on the last qubit, and a central RZ(2 * coefficient * angle) is applied (because RZ(φ) = exp(-i φ Z / 2)). Finally, the inverse of the first steps is applied.

Parameters:

Name Type Description Default
paulis Sequence[tuple[str, int]]

The Pauli product, as (operator, qubit) pairs, where the operator is "X", "Y" or "Z" and the qubits are distinct. An empty sequence is the identity, and nothing is added.

required
angle ParameterLike

The rotation angle. It can be a number or a symbolic reference.

required
coefficient float

A number that multiplies the angle. This is where the weight of a Hamiltonian term goes. Default: 1.0.

1.0

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

phase_shift(angle, qubit)

Add a phase shift gate R(φ).

The gate multiplies |1⟩ by e^(iφ) and leaves |0⟩ unchanged.

Parameters:

Name Type Description Default
angle ParameterLike

The phase φ. It can be a number or a symbolic reference.

required
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

rx(angle, qubit)

Add a rotation about the X axis.

Parameters:

Name Type Description Default
angle ParameterLike

The rotation angle. It can be a number, or a WeightRef, an InputRef or an expression, which is evaluated when the circuit is called.

required
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

ry(angle, qubit)

Add a rotation about the Y axis.

Parameters:

Name Type Description Default
angle ParameterLike

The rotation angle. It can be a number, or a WeightRef, an InputRef or an expression.

required
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

rz(angle, qubit)

Add a rotation about the Z axis.

Parameters:

Name Type Description Default
angle ParameterLike

The rotation angle. It can be a number, or a WeightRef, an InputRef or an expression.

required
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

s(qubit)

Add an S gate, which is a quarter turn about the Z axis.

Its inverse is sdg().

Parameters:

Name Type Description Default
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

sdg(qubit)

Add an S-dagger gate (Sdg), which is the inverse of s().

Parameters:

Name Type Description Default
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

swap(qubit0, qubit1)

Add a SWAP gate, which exchanges the states of two qubits.

Parameters:

Name Type Description Default
qubit0 int

The first qubit.

required
qubit1 int

The second qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

to_block()

Wrap the current state of the builder in a CustomBlock.

This is useful to build a part of a circuit one gate at a time, and then to combine it with other blocks. When the blocks are assembled, the indices of the WeightRef and InputRef objects of this block are shifted to their positions in the whole circuit.

x(qubit)

Add a Pauli X gate, which is a bit flip.

Parameters:

Name Type Description Default
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.

z(qubit)

Add a Pauli Z gate, which is a phase flip.

Parameters:

Name Type Description Default
qubit int

The target qubit.

required

Returns:

Type Description
'CircuitBuilder'

This builder, so that calls can be chained.