Skip to content

Core ideas

QProgram is a fluent builder for a small AST. Everything else in the library reads that one tree: qp.dumps writes it out, qp.validate classifies its nodes against a platform, qp.optimize rewrites it, and a platform's execute interprets it.

The shape of a program

A QProgram owns a label and an optional description, a body, a list of declared variables, and optionally a BusSchema.

The body is the root Block: every operation and sub-block you append lands in it, in order. variables hands back a fresh list of the placeholders you declared, in declaration order. buses is recomputed on every access by walking the body.

import qprogram as qp

program = qp.QProgram(label="rabi")
program.body  # Block, read-only
program.variables  # list[Variable], in declaration order
program.buses  # set[str], recomputed from the body on each access

QProgram.buses returns body.buses(), which unions each child's buses(), and an operation's buses() reads the attributes its class lists in BUS_ATTRS: ("bus",) for most operations, ("targets",) for Sync, and empty for Call, which overrides buses() instead of reading the list. Two things follow for a program that uses fragments. A bus named only inside a fragment body stays invisible until program.expand() has substituted the call arguments, and a Call reports every string-valued argument bound at its site, because a Parameter is untyped and a string argument may be a bus, a waveform alias, or neither. The set is therefore an over-approximation while calls are unexpanded.

Calling something like program.play(...) does not run hardware. It appends a typed Play node to the currently active block.

Blocks and operations

Every node in the AST is one of two things. Operations are the leaves: Play, Measure, Wait, Sync, SetFrequency, SetGain, SetOffset, SetPhase, ResetPhase, SetParameter, GetParameter, and Call. Blocks are the containers: Block (a plain grouping with no extra semantics), Sweep, Average, Parallel, and Conditional.

Every block carries an elements list of children, operations or nested blocks. The property returns the block's own list rather than a copy, and append is the sanctioned way to extend it. Reading a program back is a matter of indexing into that list, as here for a program that plays one pulse and then averages a gain sweep:

[type(el).__name__ for el in program.body.elements]
# ['Play', 'Average']
[type(el).__name__ for el in program.body.elements[1].elements]
# ['Sweep']
[type(el).__name__ for el in program.body.elements[1].elements[0].elements]
# ['SetGain', 'Play', 'Measure']

Two blocks keep children somewhere other than elements. A Conditional holds arms, a list of (condition, body) pairs in source order, plus an optional else_body; there is no shared body to append to, so Conditional.append raises ValidationError and the arms are populated through the builder methods. A Parallel keeps its composed loop headers on loops and only the shared body in elements, which is why it occupies one repetition level rather than one per composed loop.

Blocks that re-run their body set the class attribute REPEATS to True: Sweep, Average (averaging is repetition), and Parallel. A plain Block and a Conditional leave it False, since branching selects a body rather than iterating over one. Validation reads REPEATS to compute loop nesting depth, so a vendor block that repeats is counted against a platform's limit without any change to the core.

Walking the tree

Block.walk() yields the block itself first, then every descendant in pre-order: depth first, children in declaration order. Operation.walk() yields just that operation, so a caller can walk any node without first testing its type.

The two blocks whose children live outside elements extend the walk. A Conditional yields itself, then each arm body's nodes in source order, then the else body's; the arm conditions are Expressions rather than AST nodes, so they are not yielded and code that needs them reads arms. A Parallel yields itself, then each composed Sweep header with its own descendants, then the shared body, so a consumer meets the loops that bind the variables before the operations that read them.

program = qp.QProgram()
handle = program.measure("readout_q0", "readout", "weights")
with program.if_(handle.state == 1):
    program.play("drive_q0", "pi_pulse")
with program.else_():
    program.wait("drive_q0", 40)

[type(node).__name__ for node in program.body.walk()]
# ['Block', 'Measure', 'Conditional', 'Block', 'Play', 'Block', 'Wait']

The first Block in that list is the body itself; the other two are the arm bodies of the conditional.

Block.buses(), Block.waveforms(), and Block.variables() aggregate the same subtree into a set. Three blocks add what elements cannot reach: Sweep.variables() adds the variable the loop binds, Conditional.variables() adds the variables read by the arm conditions, and Parallel.variables() adds the variables its loop headers bind.

Structural equality

Operations, blocks, and waveforms all compare the same way. The two objects must be of exactly the same class, since the check is type(self) is type(other) and a subclass therefore never equals its base. Then every entry of vars() must match, private attributes included: equality is over the whole instance __dict__, not a curated subset, so a block compares its _elements list and a Sweep also compares its variable and source. The per-value verdict comes from ast_eq, which recurses through list, dict, and numpy.ndarray and defers to the value's own == for everything else.

That delegation is what makes whole-tree comparison work. A Variable compares by its id string, so a variable's currently assigned value never enters the comparison and the body of a program loaded back from .qp compares equal to the body that was written. A Constant compares by value and a BusRef compares as the str it subclasses, so a schema-backed reference equals the raw bus name it resolves to. A MeasurementHandle compares by name, which means two auto-named measurements on the same bus are not equal: their handles are m0 and m1. An array only ever compares equal to another array, so a list of samples and the equivalent ndarray stay distinct.

Hashing walks the same attributes through ast_hash and combines the class name with the sorted (key, hash) pairs, but it is not an exact mirror of equality. An array hashes by (shape, value.tobytes()), which is dtype-sensitive where ast_eq compares contents only, so two nodes that differ only in a sample array's dtype compare equal yet land in different buckets of a dict or set. Both __eq__ and __hash__ read live attributes, so a node used as a dictionary key must not be mutated afterwards; QProgram.rebind and with_waveforms rewrite a deepcopy for that reason.

QProgram itself defines no __eq__, so two programs compare by identity. Compare a.body == b.body for the tree, and the label and description separately.

def build():
    program = qp.QProgram()
    t = program.variable("t")
    with program.sweep(t, qp.Range(0, 100, 10)):
        program.wait("drive_q0", t)
    return program


a, b = build(), build()
a.body == b.body  # True
a == b  # False: QProgram has no __eq__
a.variables[0].set_value(50)
a.body == b.body  # still True: a Variable compares by id, not by value

Context managers push and pop blocks

Control flow lives inside with blocks. Each one pushes a new block onto a stack, lets you append children to it, then pops it on exit. The active block is the innermost one still open, and the program body is the outermost, so an operation appended after a with exits lands back in the enclosing block.

with program.average(shots=1000):
    with program.sweep(freq, qp.Range(4e9, 6e9, 1e6)):
        program.set_frequency("drive_q0", freq)
        program.play("drive_q0", "pi_pulse")
        program.measure("readout_q0", "readout", "weights")

The block context managers (sweep, average, block, and the if_ / elif_ / else_ chain) are described in Control flow.

Real-time and host-side execution

QProgram makes no syntactic distinction between a real-time sweep and a host-side sweep. The same sweep over play may run on the sequencer while the same sweep over set_parameter runs as a Python loop. What decides is not the shape of the source but a computation qp.validate performs against the platform's capability declaration.

Every node reports the capability tokens it needs, in isolation, from required_capabilities(). The tokens are instance-aware, so what a node asks for depends on the arguments it was built with:

program = qp.QProgram()
t = program.variable("t")
with program.sweep(t, qp.Range(0, 100, 10)):
    program.play("drive_q0", qp.waveforms.IQDrag(0.5, 40, 8, 0.1))
    program.wait("drive_q0", t)

sweep = program.body.elements[0]
sorted(sweep.required_capabilities())
# ['block.sweep', 'sweep.linear', 'sweep.range']
sorted(sweep.elements[0].required_capabilities())
# ['op.play', 'waveform.iq', 'waveform.iq_drag']
sorted(sweep.elements[1].required_capabilities())
# ['expr.variable', 'op.wait']

A platform declares which of those tokens it carries per slot, a slot being a (bus, domain) pair with the domains real-time (rt) and host-side (host). Validation routes each node to a slot, blocks and bus-less operations to the platform-wide slot and bus-touching operations to the slot of each bus they name, then checks the node's tokens there. An operation's domains are the halves of its slot that carry every token it asked for and whose predicates found nothing wrong with it. A block's are its own allowance intersected with the consensus of its immediate operation children, which is why a sweep holding one host-side-only operation is host-side as a whole. When a block that could have run in real time is pulled to the host that way, the operations inside it still run in real time; what moves to the host is the block's iteration, one real-time shot dispatched per point.

The validator never raises. qp.validate returns the Diagnostics together with an ExecutionPlan mapping each visited node to the domains it can run in, and leaves the reaction to the caller: ReferencePlatform.execute raises UnsupportedOperationError on any diagnostic of severity "error", re-emits warnings through warnings.warn as ExecutionWarning, and drops info-level ones.

Because the domains come from the platform's declaration and not from the source text, the same sweep in the same .qp file can run real-time on one backend and host-side on another. Capabilities, diagnostics, and profiles has the routing table, the classification rules in full, the ten diagnostic codes, the numeric limits, and the predicate protocol.

Numbers, variables, and expressions

The same operation often accepts a number, a Variable, or an Expression:

t = program.variable("t")
freq = program.variable("freq")

program.wait("drive_q0", 100)  # int
program.wait("drive_q0", t)  # Variable
program.wait("drive_q0", 100 + t * 2)  # Expression
program.set_frequency("drive_q0", 5e9 + freq * 1e6)  # arithmetic

This is how you sweep waveform parameters too. The waveform holds the variable, and the sweep that binds it decides the values:

amp = program.variable("amp")
with program.sweep(amp, qp.Range(0.0, 1.0, 0.01)):
    program.play("drive_q0", qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8))

An expression built this way is a tree of Expression nodes, and its shape is what produces the expr.* tokens the validator checks against caps.platform. See Variables and expressions for the operators, the math functions, and how binding works at run time.

Buses are strings, with optional metadata

Every operation targets a bus by name. You can use a plain string ("drive_q0") or a BusRef that comes from a BusSchema. The AST stores exactly the same thing in both cases, because BusRef subclasses str. The schema-backed form additionally carries element, idx, kind, channel, acquires, and its producing schema as attributes, which is what lets the builder reject a wrong target at the call site rather than at run time.

schema = qp.BusSchema.transmon()
q = schema.q

program.play(q[0].drive, qp.waveforms.IQDrag(0.5, 40, 8, 0.1))  # OK
program.play(q[0].drive, qp.waveforms.Square(0.5, 100))  # ValidationError
program.measure(q[0].drive, "readout", "weights")  # ValidationError

The second call fails because q[0].drive has channel="IQ" and Square is a single-channel Waveform:

Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Square).
Use an IQWaveform (e.g. IQPair, IQDrag) instead.

The third fails because q[0].drive has acquires=False, so it has no ADC to measure with. Routing uses the same metadata: caps.for_bus(q[0].drive) looks up caps.bus[("q", "drive")] and falls back to caps.default_bus_profile when there is no entry, while a plain string always takes the default profile. See Buses and schemas for the built-in schemas and the naming conventions.

Waveforms are pure data

Waveform instances describe an envelope. They compare and hash structurally, they can carry Variables as parameters, and they only get evaluated to a sample array when something asks for .envelope().

g = qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8)
g.envelope()  # UnassignedVariableError before amp is bound
amp.set_value(0.7)
g.envelope()  # numpy array of 40 float64 samples, peak 0.6986

The peak sample falls short of the requested amplitude because the Gaussian is centered in the sample window and an even sample count straddles the center rather than landing on it.

A program can carry waveforms inline or by string alias. Inline is concrete; the alias contributes a waveform.alias token instead of a per-class one and gets resolved later via with_waveforms, usually from calibration data the platform owns.

Measurements return handles

program.measure(...) returns a MeasurementHandle. Its name survives .qp round-trips and identifies the record in the result object after execution. When you do not pass a name, one is allocated: a BusRef gives the bus path followed by /m and a per-bus counter (q0/readout/m0, q0/readout/m1, ...), while raw-string buses share one global m0, m1, ... counter. The counters are derived from the AST on each call rather than stored on the program, which keeps deepcopy, with_waveforms, and .qp round-trips free of hidden state at the cost of one walk per measurement.

m0 = program.measure(q[0].readout, "readout", "weights")
m1 = program.measure(q[0].readout, "readout", "weights")

# After running ...
data0 = result.get(m0)
data1 = result.get(m1)

program.measurement_handles() returns the same handle instances the AST holds, in declaration order, which is how a conditional reading m0.state sees the value the runtime wrote. Measurements and results covers naming rules, the fields argument, and access patterns.