Pipelines¶
Every quantum program in Divi executes circuits through a circuit pipeline.
The pipeline models the journey from a high-level specification (e.g. a
Hamiltonian or a MetaCircuit) to final, reduced results as a sequence of
composable stages.
This guide explains how the pipeline works, lists the built-in stages shipped with Divi, and shows two practical examples of extending Divi with custom algorithms.
Note
If you are using built-in algorithms like VQE, QAOA, or TimeEvolution you
don’t need to interact with the pipeline directly — each algorithm
constructs its own pipeline internally. This guide is for users who want to
understand the internals or extend Divi with new algorithms and stages.
How the Pipeline Works¶
A CircuitPipeline is an ordered list of stages.
Execution has three phases:
Expand (forward pass) — Each stage transforms its input into an increasingly concrete representation. The first stage (a
SpecStage) converts the initial specification into a keyed batch ofMetaCircuitobjects. Subsequent stages (allBundleStageinstances) transform or fan-out that batch — for example, splitting observables into compatible measurement groups, binding parameter values, or applying error-mitigation circuit variants.Execute — The final batch is compiled to OpenQASM and submitted to the configured backend (
CircuitRunner). This step is handled automatically.Reduce (backward pass) — Stages are visited in reverse order and each one collapses or aggregates the raw results using a token it saved during the expand pass. The pipeline returns the fully reduced result to the caller.
flowchart TB
subgraph row1["Expand (Forward)"]
direction LR
A[SpecStage] --> B[BundleStage #1]
B --> C[BundleStage …]
end
subgraph row2["Execute"]
EXEC[Execute]
end
subgraph row3["Reduce (Backward)"]
direction RL
R1[Raw results] --> R2[Intermediate result]
R2 --> R3[Final result]
end
row1 --> row2
row2 --> row3
style row1 fill:#CC3366,stroke:#e8e8e8
style row2 fill:#CC3366,stroke:#e8e8e8
style row3 fill:#CC3366,stroke:#e8e8e8
Pipeline data model¶
Batches and results are keyed by node keys so that multi-stage expansion and reduction stay consistent:
NodeKey (from
divi.pipeline): A tuple of(axis_name, value)pairs. A single-circuit batch has a key like(("circuit", 0),). As stages fan out the batch, axes are appended — e.g.(("circuit", 0), ("obs_group", 2))after measurement grouping. Keys are preserved from the spec stage’sexpandthrough execute and into each stage’sreduce.MetaCircuitBatch: A
dict[NodeKey, MetaCircuit]. The spec stage produces this; bundle stages consume and produce batches (or expansion results) keyed by the same or extended keys.Flow: Spec
expand→ one batch ofMetaCircuit→ bundle stages add axes (e.g. parameter sets, measurement groups) → execute compiles to OpenQASM and runs on the backend → reduce in reverse order collapses results back to the final shape (e.g. a single expectation value or a dict of bitstring probabilities per key).Reading single-circuit results: Use
valuefor the natural shape — a scalar for single-observable expectation values, alist[float]for multi-observable runs, adictfor probabilities and counts. For the canonical pipeline-internal form regardless of length (always a list for expectation values), index the result directly viaresult[()].
Built-in Stages¶
Divi ships with the following built-in stages:
Stage |
Type |
Description |
|---|---|---|
Spec |
Passes a single |
|
Spec |
Converts PennyLane |
|
Spec |
Converts Qiskit |
|
Spec |
Generates Trotterized circuits from a Hamiltonian for time-evolution and
|
|
Bundle |
Splits multi-observable Hamiltonians into compatible measurement groups (using qubit-wise commutativity or other strategies) and declares the result format (counts, probabilities, or expectation values). |
|
Bundle |
Substitutes symbolic parameters with concrete numerical values to produce one circuit variant per parameter set. |
|
Bundle |
Applies a |
|
Bundle |
Generates randomized Pauli-twirl variants of each circuit.
Used alongside |
|
Bundle |
Computes the custom counts-based objective for |
Dry Run¶
Before executing any circuits you can inspect the pipeline to understand the
total circuit count and how each stage contributes to it. Call
dry_run() on any quantum program, then pass
the resulting dict to format_dry_run() for the rich
tree output:
from divi.pipeline import format_dry_run
vqe = VQE(
molecule=h2_molecule,
qem_protocol=QuEPP(truncation_order=1, n_twirls=10),
backend=QiskitSimulator(qiskit_backend="auto"),
)
# Runs the forward pass without executing circuits, then pretty-print.
format_dry_run(vqe.dry_run())
format_dry_run prints a tree for each pipeline showing the per-stage
factor (fan-out or reduction) and metadata:
cost
├── CircuitSpecStage [circuit] → 14
│ ├── n_qubits: 4
│ ├── n_gates: 4
│ └── n_2q_gates: 2
├── QEMStage [qem_quepp] → ×10
│ ├── protocol: quepp
│ ├── n_paths: 9
│ └── n_clifford_sims: 9
├── PauliTwirlStage [twirl] → ×10
│ └── n_twirls: 10
├── MeasurementStage [obs_group] → ÷2.8
│ ├── strategy: qwc
│ ├── n_groups: 5
│ └── n_terms: 14
├── ParameterBindingStage [param_set] → 1
│ └── n_params: 3
└── Total: 14 × 10 × 10 ÷ 2.8 = 500 circuits
The spec stage’s number (here 14) is the naive baseline: one circuit per
Pauli term in the observable. Stages that fan out show up as ×K (QEM
path enumeration, Pauli twirling); stages that reduce show up as ÷K
(observable grouping collapsing commuting Pauli terms into shared
measurement circuits). Use this to estimate cloud costs, tune
truncation_order or n_twirls, and see at a glance how much grouping
saves — all before spending a single shot.
dry_run() itself is print-free — it returns a
dict[str, DryRunReport] keyed by pipeline name (e.g. "cost",
"measurement"), so you can inspect the report programmatically instead of
(or in addition to) rendering it:
reports = vqe.dry_run()
print(reports["cost"].total_circuits) # 500
print(reports["cost"].stages[3].metadata) # QEM stage metadata dict
When Dry Run Falls Back¶
The analytic dry path emits shared DAG references across the branches it
fans out — safe for any downstream stage that either treats those DAGs as
read-only or has its own dry-mode override. When a downstream stage
instead claims to consume DAG bodies (consumes_dag_bodies=True) and
provides no dry_expand, the pipeline would risk feeding the same DAG
reference into repeated in-place mutations. To stay safe, it demotes the
upstream dry-aware stage back to its real expand for that run and
emits a DiviPerformanceWarning naming both
stages. The circuit count stays correct; only the analytic speedup
for the demoted stage is forfeited.
The warning is actionable in two ways: implement dry_expand on the
downstream stage (the preferred fix — it restores the speedup for every
pipeline that uses it), or, if that stage does not actually mutate body
DAGs in place, declare consumes_dag_bodies=False on it so the
pipeline no longer sees it as unsafe.
How Existing Algorithms Build Pipelines¶
Every algorithm constructs its pipelines in a _build_pipelines method. For
example, VQE builds two pipelines:
# Simplified from variational_quantum_algorithm.py
def _build_cost_pipeline(self, spec_stage):
return CircuitPipeline(stages=[
spec_stage, # SpecStage → MetaCircuit batch
QEMStage(protocol=...), # Apply error mitigation variants
PauliTwirlStage(...), # Randomised Pauli twirls (if requested)
MeasurementStage(...), # Split observables into groups
ParameterBindingStage(), # Bind symbolic params → numeric (last!)
])
def _build_measurement_pipeline(self):
return CircuitPipeline(stages=[
CircuitSpecStage(), # Single-circuit spec
MeasurementStage(), # Probability measurement
ParameterBindingStage(), # Bind best params
])
The cost pipeline evaluates expectation values during optimization (with optional error mitigation), while the measurement pipeline samples the probability distribution after optimization to extract the solution.
Stage ordering affects performance. Because each stage in the expand pass fans out the batch it receives, any work-multiplying stage placed early forces every downstream stage to repeat its logic across a larger batch. Conversely, placing a fan-out stage late keeps the batch small for as long as possible.
The most concrete example is ParameterBindingStage. By default it runs
last — structural stages process the symbolic circuit once instead of repeating
work per parameter set. When using
QuEPP, this means QuEPP cannot normalize rotation
angles, which may produce more Pauli paths. If this is a concern (check with
dry_run()), set QuEPP(bind_before_mitigation=True) to bind parameters
first — fewer paths per circuit, but more total mitigation work across parameter
sets.
Example 1: Custom Algorithm with CustomVQA¶
The simplest way to run a custom parameterized circuit through the pipeline is
CustomVQA. It wraps a PennyLane QuantumScript (or a
Qiskit QuantumCircuit) and optimizes its parameters end-to-end, reusing all
the VQA infrastructure.
The following example finds the ground-state energy of a two-qubit transverse- field Ising model:
import pennylane as qp
from divi.qprog import CustomVQA
from divi.qprog.optimizers import ScipyOptimizer, ScipyMethod
from divi.backends import MaestroSimulator
# 1. Define the Hamiltonian (observable to minimize)
H = -1.0 * qp.Z(0) @ qp.Z(1) + 0.5 * qp.X(0) + 0.5 * qp.X(1)
# 2. Build a parameterized ansatz as a QuantumScript
ops = [
qp.RY(0.0, wires=0),
qp.RY(0.0, wires=1),
qp.CNOT(wires=[0, 1]),
qp.RY(0.0, wires=0),
qp.RY(0.0, wires=1),
]
measurements = [qp.expval(H)]
qscript = qp.tape.QuantumScript(ops=ops, measurements=measurements)
# Mark only the gate parameters as trainable (freeze Hamiltonian coefficients)
qscript.trainable_params = [0, 1, 2, 3]
# 3. Create the CustomVQA program — it builds a pipeline internally
program = CustomVQA(
qscript,
param_shape=(4,),
max_iterations=10,
backend=MaestroSimulator(),
optimizer=ScipyOptimizer(method=ScipyMethod.COBYLA),
seed=42,
)
# 4. Run — the pipeline handles circuit compilation, submission, and reduction
program.run()
print(f"Ground-state energy: {program.best_loss:.4f}")
print(f"Optimal parameters: {program.best_params}")
Under the hood, CustomVQA builds a cost pipeline identical to VQE’s:
CircuitSpecStage → QEMStage → MeasurementStage → ParameterBindingStage
You receive all VQA features (loss history, best parameters, checkpointing) without writing any pipeline or stage code.
Example 2: Standalone Pipelines with PennyLane and Qiskit¶
You can run PennyLane or Qiskit circuits directly through a pipeline using the
converter spec stages — no QuantumProgram required.
PennyLane QuantumScript:
import pennylane as qp
from divi.pipeline import CircuitPipeline, PipelineEnv
from divi.pipeline.stages import PennyLaneSpecStage, MeasurementStage
from divi.backends import MaestroSimulator
qscript = qp.tape.QuantumScript(
ops=[qp.Hadamard(0), qp.CNOT(wires=[0, 1])],
measurements=[qp.probs()],
)
pipeline = CircuitPipeline(stages=[
PennyLaneSpecStage(),
MeasurementStage(),
])
env = PipelineEnv(backend=MaestroSimulator())
result = pipeline.run(initial_spec=qscript, env=env)
print(result.value) # {"00": ~0.5, "11": ~0.5}
Qiskit QuantumCircuit:
from qiskit import QuantumCircuit
from divi.pipeline import CircuitPipeline, PipelineEnv
from divi.pipeline.stages import QiskitSpecStage, MeasurementStage
from divi.backends import MaestroSimulator
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
pipeline = CircuitPipeline(stages=[
QiskitSpecStage(),
MeasurementStage(),
])
env = PipelineEnv(backend=MaestroSimulator())
result = pipeline.run(initial_spec=qc, env=env)
print(result.value) # {"00": ~0.5, "11": ~0.5}
Both stages accept single circuits, sequences, or mappings as input.
Tip
result.value returns the natural shape: a scalar float for a
single qp.expval(...) measurement, a list[float] for several
qp.expval(...) measurements (or when the user explicitly wrapped a
single observable in a list at the program layer), and a dict for
qp.probs / qp.counts. For the canonical pipeline-internal form
regardless of length (always a list[float] for expectation values),
use result[()].
Example 3: Writing a Custom SpecStage¶
For full control you can write a custom SpecStage and
construct a CircuitPipeline directly. This is useful
when the built-in spec stages don’t cover your circuit-generation logic.
A SpecStage must implement two methods:
expand(spec, env)— Convert an input specification into a keyed batch ofMetaCircuitobjects and return a token for later use.reduce(results, env, token)— Aggregate the per-key results back into a single output using the stored token.
The following example implements a spec stage that creates a simple Bell-state circuit and measures its probabilities:
from qiskit import QuantumCircuit
from qiskit.converters import circuit_to_dag
from divi.circuits import MetaCircuit
from divi.pipeline import CircuitPipeline, PipelineEnv, SpecStage
from divi.pipeline.abc import MetaCircuitBatch
from divi.pipeline.stages import MeasurementStage
from divi.backends import MaestroSimulator
class BellSpecStage(SpecStage):
"""Spec stage that produces a Bell-state circuit."""
def __init__(self):
super().__init__(name="bell")
@property
def axis_name(self):
return None # No fan-out axis
@property
def stateful(self):
return False # Deterministic — safe to cache
def expand(self, spec, env):
# Build the Bell-state circuit as a Qiskit QuantumCircuit and
# lower it to a DAG — MetaCircuit stores tagged DAGs as its
# working IR. The empty tuple ``()`` is this body's tag
# (``QASMTag``); downstream stages extend the tag as they
# rewrite the body.
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
meta = MetaCircuit(
circuit_bodies=(((), circuit_to_dag(qc)),),
measured_wires=(0, 1), # probs() over both qubits
)
# NodeKey: tuple of (axis_name, value); one entry for a single circuit
batch: MetaCircuitBatch = {(("bell", 0),): meta}
return batch, None # No reduce token needed
def reduce(self, results, env, token):
return results # Pass results through unchanged
# Build a minimal pipeline
pipeline = CircuitPipeline(stages=[
BellSpecStage(),
MeasurementStage(), # Declares probability-mode results
])
# Run the pipeline
backend = MaestroSimulator()
env = PipelineEnv(backend=backend)
result = pipeline.run(initial_spec=None, env=env)
print(result)
# Result is keyed by NodeKey: result[(("bell", 0),)] ≈ {"00": ~0.5, "11": ~0.5}
This pattern composes naturally — you can insert any BundleStage between the
spec stage and the measurement stage to add parameter binding, error mitigation,
or any custom transformation.
Adaptive Shot Allocation¶
By default, every measurement group produced by
MeasurementStage is sampled with the
backend’s full shot count — even tiny terms with little impact on the
final energy. Setting the shot_distribution argument splits the same
total budget across groups according to their importance, reducing
estimator variance without spending more shots:
from divi.pipeline.stages import MeasurementStage
# Concentrate shots on dominant Hamiltonian terms
MeasurementStage(grouping_strategy="qwc", shot_distribution="weighted")
The available strategies (see ShotDistStrategy):
"uniform"— equal split across groups."weighted"— proportional to per-group coefficient L1 norm; dominant Hamiltonian terms get more shots (largest-remainder rounding preserves the total exactly)."weighted_random"— multinomial sample of the same probabilities. Reproducible whenenv.rngis seeded; may drop more low-weight groups than the deterministic"weighted"for the same budget.A callable
(group_l1_norms, total_shots) -> per_group_shotsfor fully custom allocation.
Variational algorithms accept the same option directly as a constructor
keyword (e.g. VQE(..., shot_distribution="weighted")); it is threaded
through to the cost pipeline’s measurement stage. See
Ground-State Energy Estimation with VQE for an end-to-end example.
When a group ends up with zero allocated shots its measurement circuit is
skipped and its observables contribute zero to the final estimate. The
stage emits a UserWarning reporting the dropped fraction of the
Hamiltonian’s L1 norm so you can quantify the resulting bias.
Adaptive shot allocation only applies to sampling-based execution.
Combining shot_distribution with the analytical
grouping_strategy="_backend_expval" path (which divi auto-selects on
expval-capable backends like MaestroSimulator)
raises a ValueError — pass an explicit
grouping_strategy="qwc" (or "wires" / None) to opt into
sampling.
Stage Validation¶
The pipeline validates stage ordering at construction time. Built-in stages
declare their own constraints — for example, QEMStage
with QuEPP requires a measurement-handling stage after it.
Custom stages can participate in this by overriding the validate method:
from divi.pipeline.abc import ContractViolation
class MyStage(BundleStage):
def validate(self, before, after):
if not any(isinstance(s, MeasurementStage) for s in after):
raise ContractViolation(
"MyStage requires a MeasurementStage after it."
)
The before and after arguments are tuples of stage instances, so you can
inspect any property (handles_measurement, axis_name, protocol
attributes, etc.) to decide whether the pipeline is valid. Violations raise
ContractViolation with an actionable error message.
Stages that don’t override validate impose no constraints — the default is a
no-op.
What’s Next¶
Pipeline — pipeline and stage classes
Improving Results with Error Mitigation —
QEMProtocoland error mitigationAlgorithms —
CustomVQAand custom circuitsProgram Ensembles and Workflows — parameter sweeps and orchestration