Skip to content

Primitives and PUBs

A Job carries an ordered list of PUBs — Primitive Unit Blocs. A PUB is one circuit plus everything needed to run it, and its contents determine what kind of result it returns.

A PUB holding is a and returns
a circuit sampler CountsResult — measured bitstrings
a circuit and observables estimator ExpectationValuesResult — a number per observable
either, plus parameter rows a sweep one value per row

client.jobs.submit(circuit=…) builds a Job of exactly one PUB. That is the common case, and it stays a one-line call for all three shapes.

A PUB whose circuit measures returns counts.

BELL = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c = measure q;
"""
job = client.jobs.submit(backend_id="sim-statevector-26q", circuit=BELL, shots=1024)
client.jobs.wait(job.id)
print(client.jobs.result(job.id).counts) # {"00": 512, "11": 512}

An estimator returns the expectation value of an operator on the state a circuit prepares — a single number per observable instead of a histogram. Variational algorithms such as VQE and QAOA are optimisers wrapped around that number, which is why it is provided as a primitive.

An observable is a weighted sum of Pauli strings. The representation is sparse: a term names the qubits it acts on, so a two-qubit term on a 26-qubit circuit stays two characters.

from qubitra import Observable, PauliTerm
# <ZZ> on a Bell pair: +1 on "00" and "11", -1 on "01" and "10", so a perfectly
# correlated state gives exactly +1.
ZZ = Observable(num_qubits=2, terms=(PauliTerm(pauli="ZZ", qubits=(0, 1), coefficient=1.0),))
job = client.jobs.submit(
backend_id="sim-statevector-26q",
circuit=BELL_UNMEASURED,
observables=[ZZ],
shots=4096,
)
client.jobs.wait(job.id)
print(client.jobs.result(job.id).values) # (1.0,)

values is index-aligned with observables: submitting three observables against one circuit returns three numbers in that order. ExpectationValuesResult.stds carries the standard error of each where the backend reports one; an exact simulator computes the expectation from the state vector rather than sampling, so it may report none.

A variational loop evaluates the same circuit at many parameter values. Submitting one job per value is slow — each queues, schedules and is accounted separately — so a PUB can carry the whole sweep.

The binding lives in the circuit. An OpenQASM 3 input declaration names a value the program does not fix; parameter_values then supplies one row per run, each row holding one value per input in declaration order.

import math
from qubitra import Observable, PauliTerm
ROTATION = """
OPENQASM 3.0;
include "stdgates.inc";
input float[64] theta;
qubit[1] q;
ry(theta) q[0];
"""
Z = Observable(num_qubits=1, terms=(PauliTerm(pauli="Z", qubits=(0,), coefficient=1.0),))
job = client.jobs.submit(
backend_id="sim-statevector-26q",
circuit=ROTATION,
observables=[Z],
parameter_values=[[0.0], [math.pi / 2], [math.pi]],
shots=4096,
)
client.jobs.wait(job.id)
print(client.jobs.result(job.id).values) # approximately (1.0, 0.0, -1.0)

Rotating by theta about Y walks the state from |0> to |1>, so <Z> traces a cosine: +1, 0, -1. The whole sweep is expressed in the circuit text and the parameter rows; no other mechanism is involved.

client.jobs.run submits a list. Use it when the circuits belong to one logical measurement — a set of observables that must be read against the same run, or a batch you want reported and charged as one unit.

from qubitra import Pub
job = client.jobs.run(
backend_id="sim-statevector-26q",
pubs=[
Pub(circuit=BELL),
Pub(circuit=GHZ, shots=8192), # its own shot count
Pub(circuit=ROTATION, parameter_values=[[0.0], [3.14159]]),
],
shots=1024, # the job's default
name="comparison batch",
)

shots on the job is the default; a PUB carrying its own shot count overrides it for that PUB alone.

JobResult.pubs is index-aligned with what you submitted: entry i belongs to pubs[i], regardless of what happened to the others.

from qubitra import CountsResult, ErrorResult, ExpectationValuesResult
for index, pub_result in enumerate(client.jobs.result(job.id).pubs):
match pub_result:
case CountsResult(counts=counts):
print(index, "counts", counts)
case ExpectationValuesResult(values=values):
print(index, "values", values)
case ErrorResult(detail=detail):
print(index, "failed:", detail)

A PUB that failed occupies its own slot as an ErrorResult; the entries that succeeded are still present.

For a single-circuit submission, the convenience properties read the first entry and return None when it is not that shape:

result = client.jobs.result(job.id)
result.kind # "counts" | "probabilities" | "expectation_values" | "error"
result.counts # dict, or None
result.values # tuple of floats, or None
result.probabilities # dict, or None
result.shots # int, or None