Quantum software has a Babel problem. A circuit can live as a Qiskit QuantumCircuit, a Cirq Circuit, a pyQuil Program, a PyTKET Circuit, an OpenQASM 2 or 3 string, a Braket circuit, a PyQIR module, and so on. Tools, simulators, and hardware each speak a subset of these. If you want any format to reach any other, the naive answer is a converter for every ordered pair - and that count grows as N×(N−1). It does not scale, and most of those converters would duplicate each other's work.
qBraid's transpiler takes a different approach: it turns the problem into a weighted shortest-path search over a directed graph - the same shape as network routing, GPS directions, or dependency resolution. Program types are nodes, converters are weighted edges, and "convert A to B" becomes "find the cheapest path from A to B and apply each edge along it." You add one edge; the graph gives you many new routes for free.
I recently added a few of those edges during unitaryHACK 2026 - an openqasm3 → pyquil conversion (#1179) and PyTKET interop plus a pyquil → qasm3 conversion (#1200). This post is a walk-through of the subsystem that those edges plug into. All file references are under qbraid/transpiler/.
Note: I'm a contributor, not a maintainer. This is my reading of the code as of writing; corrections are welcome.
The mental model: nodes and edges
Nodes are program types -
"qiskit","cirq","pyquil","pytket","qasm2","qasm3","braket","pyqir","qrisp", … (the same aliases qBraid uses in its program registry,QPROGRAM_REGISTRY). The set keeps growing - Qrisp was added in #1211.Edges are conversion functions, each pointing one way:
source → target.
Transpiling from A to B becomes "find a path from node A to node B and apply each edge's function in order." If there is no direct A→B edge but there is A→qasm3→B, the graph finds and composes that route. A simplified slice (my unitaryHACK edges highlighted):
How an edge is declared: naming is registration
There is no central list you edit to register a converter. A conversion function is discovered by its name and location. You write a function named {source}_to_{target} in qbraid/transpiler/conversions/{source}/, and the package in conversions/__init__.py collects it into a module-level conversion_functions list by inspecting the submodules.
The graph then turns those names back into edges. From graph.py:
conversion_functions: list[str] = getattr(transpiler, "conversion_functions", [])
edges = [conversion.split("_to_") for conversion in conversion_functions]
So pyquil_to_qasm3 becomes the edge ("pyquil", "qasm3"). The naming convention is the API. My pyquil_to_qasm3 lives in conversions/pyquil/pyquil_to_qasm3.py and looks like:
from qbraid.transpiler.annotations import weight
@weight(1)
def pyquil_to_qasm3(program: pyquil.quil.Program) -> Qasm3StringType:
...
return "\n".join(lines) + "\n"
That's the whole contract: take the source object, return the target object, name it correctly, drop it in the right folder.
Two decorators that tune the graph
conversions/annotations.py defines two small but important decorators.
@weight(value) attaches a number in [0, 1] to the edge. Crucially, this value is not a hand-tuned "cost" - it's the gate-set coverage of the conversion: the fraction of a reference gate set that the converter handles correctly. A higher weight means a more complete conversion, and 1.0 means full coverage. (How that fraction is measured, and how it becomes a routing cost, is in the pathfinding section below.)
def weight(value: float) -> Callable[[F], F]:
if not 0 <= value <= 1:
raise ValueError("Weight value must be between 0 and 1.")
...
setattr(wrapper, "weight", value)
@requires_extras(*packages) marks edges that need optional dependencies beyond the function's own package. For example, PyTKET's cross-framework converters rely on the pytket.extensions.* packages, so in conversions/pytket/pytket_extras.py:
@requires_extras("pytket.extensions.qiskit")
def pytket_to_qiskit(circuit: pytket.circuit.Circuit) -> qiskit.QuantumCircuit:
...
The decorator records the requirement on the function (requires_extras attribute). The graph reads it when deciding whether an edge is actually usable in the current environment - which keeps optional, heavy SDKs from being hard requirements.
From functions to a graph: the Conversion and ConversionGraph objects
Each discovered function is wrapped in a Conversion (edge.py). The constructor records more than just the endpoints:
class Conversion:
def __init__(self, source, target, conversion_func,
weight=None, bias=None):
self._source = source
self._target = target
self._conversion_func = conversion_func
self._bias = bias if bias is not None else 0
self._weight = self._get_adjusted_weight(weight)
self._extras = getattr(conversion_func, "requires_extras", [])
self._native = self._is_module_native(conversion_func)
self._supported = self._is_conversion_supported()
A few things worth calling out:
weightis taken from the@weightattribute (the coverage fraction), then transformed into a routing cost (next section).biasfine-tunes the depth-vs-coverage tradeoff and is added once per hop. From the docstring: a bias of0.25slightly favours a single conversion at weight0.8over two conversions at weight1.0; a smaller bias requires the single hop to have even higher coverage before it wins. This is how the router is steered between "fewest hops" and "highest-coverage hops."_nativeflags whether the converter is internal to qBraid._supportedchecks (via_extras) whether the optional deps are importable, so unusable edges can be excluded.
ConversionGraph (graph.py) builds the actual graph. load_default_conversions() constructs a Conversion per discovered function, and create_conversion_graph() adds them as edges, stashing the callable and weight on each edge's payload:
self.add_edge(
...,
{"native": edge.native, "func": edge.convert, "weight": edge.weight},
)
Pathfinding: Dijkstra over rustworkx
The graph is backed by rustworkx (imported as rx), a Rust-accelerated graph library - chosen over the pure-Python networkx because the core operation is graph traversal, where a compiled implementation pays off. Routing is plain Dijkstra (O(E log V) with a binary heap) with the edge cost as the weight function:
import rustworkx as rx
path = rx.dijkstra_shortest_paths(
self._graph,
...,
weight_fn=lambda edge: edge["weight"],
)
Coverage → cost: how the @weight value is used
A higher-coverage edge should be cheaper to traverse, and Dijkstra minimises total cost, so the coverage fraction is inverted and log-transformed before it goes on the edge. Conversion._get_adjusted_weight (edge.py) does exactly that:
# effective_weight is the @weight coverage fraction, in [0, 1]
rx_adjusted_weight = float("inf") if effective_weight == 0 else np.log(1 / effective_weight)
adjusted_weight = rx_adjusted_weight + self._bias
So each edge's cost is log(1 / coverage) + bias:
Full coverage (
1.0) →log(1) = 0, cost is just thebias.Lower coverage → larger
log(1/coverage)→ more expensive.biasis paid once per hop, so a single direct edge beats a multi-hop chain unless the chain's edges are substantially higher-coverage.
Why the log? What you actually want is the path that maximizes the product of the per-edge coverages (the chance the whole chain round-trips faithfully). Dijkstra minimizes a sum, not a product. Taking
-logconverts one into the other:max ∏ coverageᵢbecomesmin Σ log(1/coverageᵢ), becauselogis monotonic and turns products into sums. It's the same trick used for maximum-probability paths (e.g. Viterbi). Thebiasis then a small per-hop penalty layered on top to break ties toward shorter paths.
A worked example (default bias = 0.25): one direct edge at coverage 0.8 costs log(1/0.8) + 0.25 ≈ 0.47; two chained edges at coverage 1.0 cost (0 + 0.25) × 2 = 0.50. The direct edge wins - barely - which is the intended "prefer one good hop over two perfect ones" behaviour. This is also why adding pyquil → qasm3 matters: replacing the old pyquil → cirq → qasm2 → qasm3 chain (three biases, three lossy hops) with one edge is a big cost drop, and the router switches to it automatically.
Where the coverage number actually comes from
The part that surprised me: weights aren't guessed - they're measured. Each source type has a coverage benchmark in tests/transpiler/<source>/test_coverage_from_<source>.py. The test builds a circuit for every gate in a reference gate set, transpiles it to each target, and checks equivalence; the fraction that round-trips correctly is the weight. The Cirq benchmark even encodes the per-target baselines directly:
ALL_TARGETS = [("braket", 0.85), ("pyquil", 0.74), ("pytket", 0.87), ("qiskit", 0.87)]
When I added the openqasm3 source edges, there was no test_coverage_from_openqasm3.py yet, so the workflow was: write the benchmark, run it, and set @weight from the measured coverage - with the maintainer's guidance to "aim as close to 1.0 as we can." That's a nice property: the routing policy is grounded in tested behaviour, not vibes, and a regression in coverage shows up as a failing benchmark rather than a silently worse route.
find_shortest_conversion_path returns the ordered conversion functions to apply; find_top_shortest_conversion_paths(..., top_n=...) returns several candidates (handy as fallbacks when a conversion fails at runtime). On top sit has_path, shortest_path / all_paths (human-readable routes like "pyquil -> qasm3 -> qiskit"), and closest_target (pick the cheapest reachable target from a set - useful when a backend accepts several input formats).
That's the payoff: I only wrote pyquil → qasm3, and because qasm3 already reaches qiskit, cirq, braket, and more, pyQuil now converts to all of them - no pyquil → qiskit written by hand.
Where my edges fit
qBraid can render the live graph itself - ConversionGraph().plot(...) (matplotlib). Here is the current graph, with the nodes my unitaryHACK edges touch outlined:
from qbraid.transpiler import ConversionGraph
ConversionGraph().plot(
legend=True,
target_nodes=["pyquil", "qasm3", "pytket", "pyqir"],
save_path="qbraid_conversion_graph.png",
)
Blue nodes are qBraid-native program types, grey are external; solid edges are native conversions, lighter edges are requires_extras ones. Notice how qasm3 sits at the centre as the interchange hub - that's why adding a single edge to or from it unlocks so many routes.
openqasm3 → pyquil(conversions/openqasm3/openqasm3_to_pyquil.py, #1179): rather than parse OpenQASM by hand, it reuses qBraid'spyqasmengine -loads → validate → unrollthe program into a flat, basis-gate AST, then walksunrolled_ast.statementsand maps each node onto pyQuil (QubitDeclaration→ flat qubit indices,ClassicalDeclaration→declare("ro", "BIT", n),QuantumGate→pyquil.gates.*, measurement →MEASURE). It also converts duration literals to seconds (_duration_secondsover_TIME_UNIT_SECONDS) so timing-bearing programs survive, and raisesProgramConversionErroron anything unsupported. Leaning onpyqasmfor the heavy lifting is the same pattern asopenqasm3_to_cudaq.pyquil → qasm3(conversions/pyquil/pyquil_to_qasm3.py, #1200/#1208): the reverse direction, written from scratch over pyQuil's instruction model. Together withopenqasm3 → pyquilit completes a PyQuil ↔ OpenQASM 3 round trip and replaces the old lossypyquil → cirq → qasm2 → qasm3chain with a single edge.PyTKET interop via extras (#1208):
cirq_to_pytketandpytket_to_cirq(viapytket.extensions.cirq) andpytket_to_pyqir(viapytket.qir, wrapped inpyqir.Module.from_ir), each@requires_extras-gated and auto-discovered. These replace lossy multi-hop detours (e.g.cirq → qasm2 → pytket) with direct 1-hop edges and give PyTKET a direct outbound edge to PyQIR.
A practical lesson from this PR: I originally proposed pyquil ↔ pytket via pytket-pyquil, but that extra pins pyquil<5 and conflicts with qBraid's pyquil>=5 in a single CI env, so it would have silently downgraded pyquil and broken existing conversions. Checking the dependency graph before committing to an edge saved a broken PR - the conflict-free set (cirq↔pytket, pytket→pyqir, plus the from-scratch pyquil→qasm3) closed the issue instead.
Each is just one node-to-node edge, but each widens what the rest of the graph can reach.
Testing conversions
Conversions are easy to get subtly wrong, so the tests are deliberately strict. The rule I internalised from review: an A_to_B test should compare in the source and target types themselves and not lean on a third framework to "check the answer." If your pyquil → qasm3 test secretly parses the qasm3 with Qiskit and compares to a Qiskit-built circuit, you're now testing three libraries' agreement, not your one conversion - and a failure no longer tells you where the bug is. Keeping the test isolated to the source and target keeps it honest and the signal sharp.
A second subtlety is the global phase. Adding a direct pytket -> cirq edge made it the default 1-hop route, replacing the old pytket -> qasm2 -> cirq path. The direct converter represents ZZPhase(t) as cirq.ZZ ** t, which is unitary-equivalent up to an unobservable global phase. So the default-route assertion uses circuits_allclose(..., strict_gphase=False), while the native pytket -> qasm2 -> cirq route is still checked exactly (including global phase) via a separate require_native=True graph in the same test. The lesson: when you change which route is the default, check what equivalence the new route actually guarantees, and assert at that level - not a stricter one it can't meet, nor a looser one that hides bugs.
What I took away
The naming convention is the plugin system.
{source}_to_{target}plus folder placement is all the registration there is - a clean, discoverable contract.A graph turns N² into N. Adding one well-placed edge (especially to a hub like qasm3) multiplies reachable pairs without writing direct converters.
It's a textbook algorithm under a domain skin. Strip away the quantum vocabulary and this is weighted shortest-path: model the domain as a graph, pick a cost that makes the metric you care about (coverage) additive via
-log, run Dijkstra. The hard part isn't the algorithm - it's recognising the problem is that algorithm.Routing is a measured policy, not a hardcode. Edge cost is
log(1/coverage) + bias, and the coverage number comes from a per-source benchmark test - so the graph's choices are grounded in tested behaviour, and a coverage regression fails a test instead of silently picking a worse route.requires_extraskeeps the dependency surface honest - optional SDKs stay optional, and the graph simply omits edges it can't run.
If you want to read the code, start at qbraid/transpiler/graph.py (the graph and pathfinding), then edge.py (the Conversion wrapper) and annotations.py (the decorators), and finally browse conversions/<source>/ for concrete edges.
Corrections and improvements welcome - especially from the qBraid maintainers, who know this code far better than I do.