Emitters & Absorbers

Any OpenQARP block can be emitted to four Python SDKs (Qiskit, Qulacs, PyTKET, PennyLane) plus OpenQASM 3 and OpenQASM 2, and absorbed back from any of them. This is the circuit-interchange boundary: use it to hand a OpenQARP-built circuit to another SDK’s tooling, or to bring a circuit built elsewhere into OpenQARP’s block/engine pipeline.

The block-level methods are the ergonomic entry point, symmetric by construction:

import math
from qarp.blocks import SimpleBlock

block = SimpleBlock(2, name="bell")
block.h(0)
block.cx(0, 1)
block.rz(1, math.pi / 4)
block.build()

qc = block.to_qiskit()          # -> qiskit.QuantumCircuit
back = SimpleBlock.from_qiskit(qc)   # -> SimpleBlock

print("round trip ==:", back == block)

to_qasm3() / SimpleBlock.from_qasm3(...) are the OpenQASM 3 pair and to_qasm2() / SimpleBlock.from_qasm2(...) the OpenQASM 2 one; neither needs an SDK installed (the emitters/absorbers are built into qarpx). The dialect is always in the method name — the two languages carry different things, so the call site says which one it got, and matches the can_emit_to("qasm2") / can_emit_to("qasm3") pre-flight that guards it. to_qir() emits QIR (LLVM IR) the same way. The four SDK pairs to_qiskit/from_qiskit, to_qulacs/from_qulacs, to_pytket/from_pytket, to_pennylane/from_pennylane need the corresponding package installed; each is a thin wrapper around the matching class in qarp.emit / qarp.absorb:

from qarp.emit import QiskitEmitter, QulacsEmitter, PytketEmitter, PennylaneEmitter
from qarp.emit import QASM3Emitter, QASM2Emitter, QIREmitter
from qarp.absorb import QiskitAbsorber, QulacsAbsorber, PytketAbsorber, PennylaneAbsorber, QASM3Absorber, QASM2Absorber

The rest of this page uses these classes directly, since several examples emit the same block to multiple SDKs side by side.

Checking a round trip: Block.__eq__

block1 == block2 compares qubit count and the flattened command sequence (gate types, qubits, cbits, classical conditions, rotation angles or symbols) within a small numerical tolerance (default 1e-9). It is the tool every example on this page uses to check that what comes back out of a round trip is the same circuit, not just an equivalent-looking one.

Parametric gates

Rotation angles survive the round trip even when the target SDK uses a different angle convention internally (Qulacs negates rotation angles; PyTKET uses half-turns), because == compares the logical parameter value, not the SDK’s internal representation:

param_block = SimpleBlock(2)
param_block.crx(0, 1, math.pi / 4).rzz(0, 1, math.pi / 6)
param_block.build()

for name, emitter, absorber in [
    ("Qiskit", QiskitEmitter(), QiskitAbsorber()),
    ("Qulacs", QulacsEmitter(), QulacsAbsorber()),
    ("PyTKET", PytketEmitter(), PytketAbsorber()),
    ("PennyLane", PennylaneEmitter(), PennylaneAbsorber()),
]:
    native = emitter.emit(param_block.flatten(), param_block.n_qubits)
    absorbed = absorber.absorb(native)
    print(f"{name:10s} absorbed == param_block: {absorbed == param_block}")

Symbolic parameters

Qiskit and PyTKET support symbolic (unbound) parameters natively; the round trip preserves the symbol name through ==, not just a numeric value:

import qarpx as qx

symbolic_block = SimpleBlock(1)
symbolic_block.rx(0, qx.Param.symbol("theta"))
symbolic_block.build()

qc = QiskitEmitter().emit(symbolic_block.flatten(), 1)
print("Qiskit  absorbed == symbolic_block:", QiskitAbsorber().absorb(qc) == symbolic_block)

circ = PytketEmitter().emit(symbolic_block.flatten(), 1)
print("PyTKET  absorbed == symbolic_block:", PytketAbsorber().absorb(circ) == symbolic_block)

Across the SDK boundary a symbolic parameter travels as a linear form c*x + d in one symbol — that is what Qiskit’s ParameterExpression arithmetic and PyTKET’s half-turn sympy expressions can carry losslessly. Both directions probe the expression at three points and refuse anything else (Param.symbol("t") * Param.symbol("t"), a two-symbol sum) with CapabilityError; can_emit_to reports the same without the SDK. OpenQASM 3 writes the expression verbatim and has no such limit.

Qulacs and PennyLane have no symbolic-parameter concept. Emitting a block with an unbound symbol to either raises rather than silently dropping or freezing the symbol:

from qarp.errors import CapabilityError

try:
    QulacsEmitter().emit(symbolic_block.flatten(), 1)
except CapabilityError as e:
    print("Qulacs raises as expected:", e, "on command", e.command)

Emit/absorb rejections follow the same contract Errors documents for engines: a circuit the target cannot represent raises CapabilityError, carrying the offending command on its command attribute; a missing SDK raises ImportError with a pip install hint; only parse and internal failures stay RuntimeError. The check runs inside emit() via the emitter’s validate(), so it fires before the SDK is even imported. Use can_emit_to() to ask the same question without raising.

Mid-circuit measurement

Measure-then-reuse on the same qubit survives the round trip, in order, with the correct classical-bit mapping:

mcm_block = SimpleBlock(1)
mcm_block.x(0)
mcm_block.measure(0, 0)
mcm_block.x(0)
mcm_block.measure(0, 1)
mcm_block.build()

for name, emitter, absorber in [
    ("Qiskit", QiskitEmitter(), QiskitAbsorber()),
    ("Qulacs", QulacsEmitter(), QulacsAbsorber()),
    ("PyTKET", PytketEmitter(), PytketAbsorber()),
]:
    native = emitter.emit(mcm_block.flatten(), mcm_block.n_qubits)
    absorbed = absorber.absorb(native)
    print(f"{name:10s} absorbed == mcm_block: {absorbed == mcm_block}")

PennyLane has no classical-register concept, so it requires cbit == qubit on every measurement, so the equivalent circuit for PennyLane measures the same qubit into its own index both times:

mcm_block_pl = SimpleBlock(1)
mcm_block_pl.x(0)
mcm_block_pl.measure(0, 0)
mcm_block_pl.x(0)
mcm_block_pl.measure(0, 0)
mcm_block_pl.build()

tape = PennylaneEmitter().emit(mcm_block_pl.flatten(), mcm_block_pl.n_qubits)
absorbed_pl = PennylaneAbsorber().absorb(tape)
print("PennyLane  absorbed == mcm_block_pl:", absorbed_pl == mcm_block_pl)

Classical conditionals and barriers

A ConditionalBlock crosses to Qiskit as an IfElseOp (single-bit condition; a wider ClassicalRegister comparison raises CapabilityError on the way back) and to PyTKET as per-command Conditional ops — PyTKET has no block-level branch, so the else arm is written as a second run on the complemented bit and the absorber folds it back. Either way the absorbed block compares == to the original, else arm and GPhase inside the branch included:

from qarp.blocks import CompositeBlock, ConditionalBlock

meas = SimpleBlock(2)
meas.h(0).measure(0, 0)
meas.build()
flip = SimpleBlock(2)
flip.x(1)
flip.build()
cond = ConditionalBlock(cbits=[0], values=[True], then_body=flip)
cond.target_cbits = [0]  # reads the cbit `meas` wrote (§8)
feedforward = CompositeBlock([meas, cond], 2)
feedforward.build()

qc = QiskitEmitter().emit(feedforward.flatten(), 2)
print("Qiskit  absorbed == feedforward:", QiskitAbsorber().absorb(qc) == feedforward)
circ = PytketEmitter().emit(feedforward.flatten(), 2)
print("PyTKET  absorbed == feedforward:", PytketAbsorber().absorb(circ) == feedforward)

Barriers are kept, not dropped, on both SDKs — a Barrier command comes back as a Barrier on the same qubits, so a scheduling fence placed before emission survives the round trip. Qiskit’s circuit.global_phase (float or ParameterExpression) and its legacy u1/u2/u3/r gates absorb exactly, so a circuit that went through qiskit.transpile — which folds phases into global_phase — equals its qiskit.quantum_info.Operator in qarp’s own LSB layout.

Nested CompositeBlock\ s

Block.__eq__ compares flattened command sequences, so a nested CompositeBlock compares equal to the flat SimpleBlock an absorber reconstructs, since the comparison doesn’t care that one side has sub-block structure and the other doesn’t:

from qarp.blocks import CompositeBlock

sub1 = SimpleBlock(2)
sub1.h(0).cx(0, 1)
sub1.build()

sub2 = SimpleBlock(2)
sub2.rz(1, math.pi / 4).cz(0, 1)
sub2.build()

comp = CompositeBlock([sub1, sub2], 2)
comp.build()

qc = QiskitEmitter().emit(comp.flatten(), comp.n_qubits)
absorbed = QiskitAbsorber().absorb(qc)
print("absorbed == comp:", absorbed == comp)

QASM3 round trips and tolerance

For circuits with rotation angles, a target format’s exported numeric precision can matter. If a round trip through a given backend doesn’t compare equal at the default 1e-9 tolerance, use qx.commands_equal(a, b, atol=...) (or Block.equals(other, atol=...)) directly with a wider tolerance rather than concluding the round trip is broken:

qasm_block = SimpleBlock(2, name="qasm_demo")
qasm_block.h(0).cx(0, 1).rz(1, math.pi / 4)
qasm_block.build()

qasm_str = QASM3Emitter().emit(qasm_block.flatten(), qasm_block.n_qubits, qasm_block.name)
absorbed_qasm = QASM3Absorber().absorb(qasm_str)

print("bare ==:                  ", absorbed_qasm == qasm_block)
print("commands_equal(atol=1e-4):", qx.commands_equal(absorbed_qasm.flatten(), qasm_block.flatten(), atol=1e-4))

OpenQASM 2: a narrower language

OpenQASM 2 is what most hardware submission endpoints, older simulators and published circuit files speak, so to_qasm2() exists alongside to_qasm3(). It is a genuinely smaller language, and the emitter says so rather than quietly dropping what it cannot write — symbolic parameters, GPhase and MCZ each raise CapabilityError:

from qarp.errors import CapabilityError

q2_block = SimpleBlock(2, name="qasm2_demo")
q2_block.h(0).cx(0, 1).rzz(0, 1, math.pi / 4)
q2_block.build()

print(q2_block.to_qasm2())
print("can_emit_to:", q2_block.can_emit_to("qasm2"))

phase_block = SimpleBlock(1, name="phased")
phase_block.h(0).gphase(0.25)
phase_block.build()
try:
    phase_block.to_qasm2()
except CapabilityError as exc:
    print("rejected:", exc)

The spec’s qelib1.inc is only 23 gates, so anything outside it — swap, rzz, ecr, cu and the rest — is written with its own gate definition in the program’s prelude, each body phase-exact. from_qasm2 deliberately accepts more than to_qasm2 writes (multiple registers, whole-register measure q -> c;, the qiskit-extended qelib1.inc names), so files OpenQARP did not produce parse too.

What == catches

A different rotation angle, a different symbol name, or a different qubit count all correctly compare unequal, so == is a real equality check, not a shape/type check:

a = SimpleBlock(2)
a.h(0).cx(0, 1).rz(1, math.pi / 4)
a.build()

b_diff_angle = SimpleBlock(2)
b_diff_angle.h(0).cx(0, 1).rz(1, math.pi / 3)
b_diff_angle.build()

print("different rotation angle:", a == b_diff_angle)
assert not (a == b_diff_angle)

See examples/emit_absorb/mwe_emitters_absorbers.ipynb for the complete worked notebook this page is drawn from.