Circuits and gates

Building circuits

Circuit stores a list of operations. Gates are attributes; qubits are selected with [...]; parametric gates take their parameters as a call before the qubit indexing. Everything chains:

import math
from blueqat import Circuit

Circuit().h[0].cx[0, 1].rz(math.pi / 4)[1].m[:]

Qubit 0 is always the least-significant bit of the statevector index ('10' means qubit 1 is 1, qubit 0 is 0 – the same convention as Qiskit’s Statevector).

Gate set

Single-qubit gates

i, x, y, z, h, s, sdg, t, tdg, sx, sxdg, phase(theta) (aliases p, r), rx(theta), ry(theta), rz(theta), u(theta, phi, lam[, gamma]), mat1(matrix) (arbitrary 2x2 unitary).

Two-qubit gates

cx (alias cnot), cy, cz, ch, swap, iswap, iswapdg, cphase(theta) (aliases cp, cr), crx, cry, crz, cu(theta, phi, lam[, gamma]), rxx(theta), ryy(theta), rzz(theta), zz, zzdg, exch(theta) (Heisenberg exchange pulse, see Exchange-only spin qubits).

Three-qubit gates

ccx (alias toffoli), ccz, cswap.

Other operations

m / measure (optionally m(key="name") for keyed mid-circuit measurement), reset, barrier.

Gates that take no parameters raise ValueError if parameters are passed (e.g. x(0.5)[0] is rejected rather than silently ignored).

Introspection

c = Circuit(3).h[:].cx[0, 1].cx[1, 2].m[:]
c.n_qubits      # 3
c.depth()       # 4  (parallel gates count once; barriers don't count)
c.count_ops()   # Counter({'h': 3, 'cx': 2, 'measure': 3})

Measurement probabilities (differentiable, optionally marginalized onto selected qubits) and Hamiltonian expectation values:

from blueqat.utils import Z

Circuit(2).h[0].cx[0, 1].probs()          # tensor([0.5, 0., 0., 0.5])
Circuit(2).h[0].cx[0, 1].probs([1])       # marginal of qubit 1
Circuit(1).rx(0.4)[0].expect(1.0 * Z[0])  # <Z> = cos(0.4)

Any Pauli expression works as the observable, including sums: c.expect(1.0 * Z[0] * Z[1] - 0.5 * X[2]), equivalently c.run(hamiltonian=...). The value is computed term by term over the statevector rather than by building the Hamiltonian as a 2**n x 2**n matrix, so it costs O(terms * 2**n) and stays usable well past the ~13 qubits at which the matrix form becomes impractical.

Pauli exponentials

exp_pauli() appends exp(-i * theta * P) for a Pauli product P, the building block of Trotter steps and of most chemistry and QAOA ansatz circuits. The operator is given as a mapping from qubit index to Pauli letter, so it carries no bit-order ambiguity and sparse products stay short:

Circuit().exp_pauli({0: 'X', 1: 'X', 2: 'Z', 3: 'Y'}, 0.3)  # exp(-0.3i XXZY)
Circuit().exp_pauli({5: 'Z'}, t)                            # == rz(2t)[5]

Because P**2 == I, this is exactly cos(theta) - i sin(theta) P. The convention (no factor of 1/2) is the same as get_time_evolution(), which builds the same sequence from a Term. theta may be a torch.Tensor, so the parameter stays differentiable; 'I' entries are ignored.

Inverse circuits

dagger() returns the Hermitian conjugate (gates reversed and conjugated). Measurement and reset have no inverse; dagger(ignore_measurement=True) drops them instead of raising:

c = Circuit(3)  # ... build ...
identity = c + c.dagger()   # uncomputes back to |0...0>

OpenQASM 2.0

qasm = Circuit(2).h[0].cx[0, 1].to_qasm()

from blueqat.circuit_funcs import from_qasm
c = from_qasm(qasm)

Matrices into circuits

A single-qubit matrix goes straight in as mat1. A two-qubit one is decomposed by decompose_two_qubit():

from blueqat.decompose import decompose_two_qubit

c = decompose_two_qubit(matrix)                       # on qubits 0 and 1
c = decompose_two_qubit(matrix, targets=(2, 5), n_qubits=6)

It is exact up to global phase. The route is the Cartan (KAK) factorization,

U = phase * (A1 (x) A2) exp(i(a XX + b YY + c ZZ)) (A3 (x) A4),

with the interaction emitted as rxx/ryy/rzz – three of them for a general unitary, six CX once compiled. Canonical angles that vanish are left out, so structured gates cost less without being special-cased: cx, cz, cy and ch each come back as one rotation, iswap as two, swap as three.

Six CX is not the optimal three, and on hardware – where a CX budget of around fifteen decides whether a result survives – that difference matters. There are two ways down, and they trade different things.

basis='cx' stays exact and costs four:

decompose_two_qubit(matrix, basis='cx')   # four CX, still exact
decompose_unitary(matrix, basis='cx')     # and for any number of qubits

Under CX conjugation an rx on one wire becomes an XX term and an rz on the other becomes a ZZ term, so a single CX sandwich carries both at once. The three interaction terms commute, so they split into (XX, ZZ) and (YY): two sandwiches, four CX, against six. YY is the odd one out – nothing in the sandwich maps to it – and conjugating the XX sandwich by s supplies it, since S X S^H == Y. A gate with no YY content is cheaper still: cx, cz and zz cost two, the identity none. Across a whole Shannon decomposition the saving compounds: 36 CX to 28 at three qubits, 168 to 136 at four.

synthesize_two_qubit() reaches the optimal three, but by fitting a three-CX circuit to the target with gradient descent rather than solving for it:

from blueqat.decompose import synthesize_two_qubit

synthesize_two_qubit(matrix)              # exactly three CX

The fit is checked, not assumed: it must reach an infidelity below tol or the call raises. Note what tol bounds, though – an infidelity, not the matrix. At the default the individual amplitudes land about 1e-7 from the target, and tightening tol to 1e-14 only reaches 1e-8: a fit does not get to machine precision. So: basis='cx' when the numbers matter, synthesize_two_qubit when the last CX does.

Larger unitaries and isometries

decompose_unitary() handles any 2**n x 2**n unitary by the Quantum Shannon decomposition, and decompose_isometry() a 2**n x 2**k isometry:

from blueqat.decompose import decompose_unitary, decompose_isometry

decompose_unitary(matrix)                 # n qubits
decompose_isometry(v)                     # k input qubits, padded to n

An isometry’s circuit reproduces it when the qubits above the input register start in |0>. That is the shape a matrix product state takes when written as a sequential circuit, where each site’s tensor maps the bond to the bond plus the new site.

Cost grows as 4**n: 3 CX at two qubits, 24 two-qubit gates at three, 120 at four. These are correct constructions, not optimized ones – for a circuit headed to hardware, count the gates before assuming it fits.

Note

SciPy is used for the cosine-sine decomposition when it is installed, and only then are degenerate cases handled exactly. They are not exotic: a Toffoli gate and the unitary completion of an isometry both have repeated cosines. Without SciPy the built-in fallback covers matrices whose cosines are distinct and raises on the rest, rather than returning factors that are quietly not unitary.

To go through another toolchain instead – for a decomposition tuned harder than these – import its output as QASM:

# In Qiskit: transpile to a basis blueqat's parser reads, then dump.
#   qc = transpile(circuit, basis_gates=["u", "cx"])
#   text = qasm2.dumps(qc)

from blueqat.circuit_funcs.qasm_parser import from_qasm
c = from_qasm(text)
c.run(shots=200000, seed=1)

u, cx, reset, barrier and measure all survive the trip. Measurement keys do not – OpenQASM 2.0 has nowhere to record them – so add m(key=...) on the blueqat side if the results need naming.

JSON serialization

Circuits round-trip through a versioned, JSON-compatible schema (this is also the cloud submission wire format):

from blueqat.circuit_funcs.json_serializer import serialize, deserialize

data = serialize(Circuit(2).h[0].cx[0, 1])
c = deserialize(data)

Drawing

run(backend='draw') renders the circuit with matplotlib. Every registered gate is drawable; unknown (user-registered) gates are omitted with a UserWarning.

Named gate blocks

Real algorithms are nests of subroutines – Shor’s order finding is initialization, controlled modular multiplications and an inverse QFT, each built from smaller pieces. Named blocks keep that structure in the circuit object without changing execution (every backend transparently sees the inner gates):

c = Circuit(7)
with c.block("order-finding"):
    with c.block("superposition"):
        c.h[4, 5, 6]
    with c.block("c-U^1"):
        c.cswap[4, 2, 3].cswap[4, 1, 2].cswap[4, 0, 1]
    # place a library circuit as a block, shifted to qubits 4..6
    c.append_block("IQFT", qft_circuit(3).dagger(), offset=4)

print(c.tree())
# Circuit(7)
# └─ order-finding
#    ├─ superposition
#    │  └─ h[4, 5, 6]
#    ├─ c-U^1
#    │  └─ ...
#    └─ IQFT
#       └─ ...

Blocks nest arbitrarily, show up in repr() and tree(), and survive dagger() as mirrored blocks ("order-finding†"). depth() / count_ops() count the contained gates; flatten() / JSON serialization expand blocks into plain gates (the flat wire format keeps no hierarchy). The circuit drawer renders a block as a single labeled box spanning its qubits; a circuit wrapped entirely in one block automatically descends so its child blocks appear as boxes. Pass expand_blocks=n to open n levels of blocks, or expand_blocks=True to draw every inner gate. See examples/shor_15.py for a complete Shor-at-15 program written this way.

Ancilla qubits

c = Circuit(4).h[:]
with c.ancilla() as a:        # allocates a fresh qubit
    c.cx[0, a[0]]
    c.cx[0, a[0]]
# the ancilla is reset to |0> on exit (reset=True by default)

Macros and custom gates

Register a function as a circuit method, or a gate class into the gate set:

from blueqat import BlueqatGlobalSetting
from blueqat.decorators import circuitmacro

@circuitmacro
def bell(c, a, b):
    return c.h[a].cx[a, b]

Circuit(2).bell(0, 1)

BlueqatGlobalSetting.register_gate('mygate', MyGateClass)