---
type: "article"
title: "How qBraid routes between quantum SDKs: a tour of the transpiler's conversion graph"
summary: "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.\nqBraid'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.\nI 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/.\nNote: I'm a contributor, not a maintainer. This is my reading of the code as of writing; corrections are welcome.\nThe mental model: nodes and edges\nNodes 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.\nEdges are conversion functions, each pointing one way: source → target.\nTranspiling 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):\nHow an edge is declared: naming is registration\nThere 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.\nThe graph then turns those names back into edges. From graph.py:\nconversion_functions: list[str] = getattr(transpiler, \"conversion_functions\", [])\nedges = [conversion.split(\"_to_\") for conversion in conversion_functions]\n\nSo 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:\nfrom qbraid.transpiler.annotations import weight\n\n@weight(1)\ndef pyquil_to_qasm3(program: pyquil.quil.Program) -> Qasm3StringType:\n    ...\n    return \"\\n\".join(lines) + \"\\n\"\n\nThat's the whole contract: take the source object, return the target object, name it correctly, drop it in the right folder.\nTwo decorators that tune the graph\nconversions/annotations.py defines two small but important decorators.\n@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.)\ndef weight(value: float) -> Callable[[F], F]:\n    if not 0 <= value <= 1:\n        raise ValueError(\"Weight value must be between 0 and 1.\")\n    ...\n    setattr(wrapper, \"weight\", value)\n\n@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:\n@requires_extras(\"pytket.extensions.qiskit\")\ndef pytket_to_qiskit(circuit: pytket.circuit.Circuit) -> qiskit.QuantumCircuit:\n    ...\n\nThe 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.\nFrom functions to a graph: the Conversion and ConversionGraph objects\nEach discovered function is wrapped in a Conversion (edge.py). The constructor records more than just the endpoints:\nclass Conversion:\n    def __init__(self, source, target, conversion_func,\n                 weight=None, bias=None):\n        self._source = source\n        self._target = target\n        self._conversion_func = conversion_func\n        self._bias = bias if bias is not None else 0\n        self._weight = self._get_adjusted_weight(weight)\n        self._extras = getattr(conversion_func, \"requires_extras\", [])\n        self._native = self._is_module_native(conversion_func)\n        self._supported = self._is_conversion_supported()\n\nA few things worth calling out:\nweight is taken from the @weight attribute (the coverage fraction), then transformed into a routing cost (next section).\nbias fine-tunes the depth-vs-coverage tradeoff and is added once per hop. From the docstring: a bias of 0.25 slightly favours a single conversion at weight 0.8 over two conversions at weight 1.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.\"\n_native flags whether the converter is internal to qBraid.\n_supported checks (via _extras) whether the optional deps are importable, so unusable edges can be excluded.\nConversionGraph (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:\nself.add_edge(\n    ...,\n    {\"native\": edge.native, \"func\": edge.convert, \"weight\": edge.weight},\n)\n\nPathfinding: Dijkstra over rustworkx\nThe 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:\nimport rustworkx as rx\n\npath = rx.dijkstra_shortest_paths(\n    self._graph,\n    ...,\n    weight_fn=lambda edge: edge[\"weight\"],\n)\n\nCoverage → cost: how the @weight value is used\nA 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:\n# effective_weight is the @weight coverage fraction, in [0, 1]\nrx_adjusted_weight = float(\"inf\") if effective_weight == 0 else np.log(1 / effective_weight)\nadjusted_weight = rx_adjusted_weight + self._bias\n\nSo each edge's cost is log(1 / coverage) + bias:\nFull coverage (1.0) → log(1) = 0, cost is just the bias.\nLower coverage → larger log(1/coverage) → more expensive.\nbias is paid once per hop, so a single direct edge beats a multi-hop chain unless the chain's edges are substantially higher-coverage.\nWhy 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 -log converts one into the other: max ∏ coverageᵢ becomes min Σ log(1/coverageᵢ), because log is monotonic and turns products into sums. It's the same trick used for maximum-probability paths (e.g. Viterbi). The bias is then a small per-hop penalty layered on top to break ties toward shorter paths.\nA 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.\nWhere the coverage number actually comes from\nThe 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:\nALL_TARGETS = [(\"braket\", 0.85), (\"pyquil\", 0.74), (\"pytket\", 0.87), (\"qiskit\", 0.87)]\n\nWhen 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.\nfind_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).\nThat'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.\nWhere my edges fit\nqBraid can render the live graph itself - ConversionGraph().plot(...) (matplotlib). Here is the current graph, with the nodes my unitaryHACK edges touch outlined:\nfrom qbraid.transpiler import ConversionGraph\n\nConversionGraph().plot(\n    legend=True,\n    target_nodes=[\"pyquil\", \"qasm3\", \"pytket\", \"pyqir\"],\n    save_path=\"qbraid_conversion_graph.png\",\n)\n\nBlue 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.\nopenqasm3 → pyquil (conversions/openqasm3/openqasm3_to_pyquil.py, #1179): rather than parse OpenQASM by hand, it reuses qBraid's pyqasm engine - loads → validate → unroll the program into a flat, basis-gate AST, then walks unrolled_ast.statements and 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_seconds over _TIME_UNIT_SECONDS) so timing-bearing programs survive, and raises ProgramConversionError on anything unsupported. Leaning on pyqasm for the heavy lifting is the same pattern as openqasm3_to_cudaq.\npyquil → qasm3 (conversions/pyquil/pyquil_to_qasm3.py, #1200/#1208): the reverse direction, written from scratch over pyQuil's instruction model. Together with openqasm3 → pyquil it completes a PyQuil ↔ OpenQASM 3 round trip and replaces the old lossy pyquil → cirq → qasm2 → qasm3 chain with a single edge.\nPyTKET interop via extras (#1208): cirq_to_pytket and pytket_to_cirq (via pytket.extensions.cirq) and pytket_to_pyqir (via pytket.qir, wrapped in pyqir.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.\nA 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.\nEach is just one node-to-node edge, but each widens what the rest of the graph can reach.\nTesting conversions\nConversions 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.\nA 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.\nWhat I took away\nThe naming convention is the plugin system. {source}_to_{target} plus folder placement is all the registration there is - a clean, discoverable contract.\nA graph turns N² into N. Adding one well-placed edge (especially to a hub like qasm3) multiplies reachable pairs without writing direct converters.\nIt'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.\nRouting 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.\nrequires_extras keeps the dependency surface honest - optional SDKs stay optional, and the graph simply omits edges it can't run.\nIf 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.\nCorrections and improvements welcome - especially from the qBraid maintainers, who know this code far better than I do."
newsletter: "Engineering With Ashmit"
newsletter_handle: "engineeringwithashmit"
newsletter_url: "https://usecommune.com/n/engineeringwithashmit"
author: "Ashmit JaiSarita Gupta (@ashmitjsg)"
published: "2026-06-23T18:01:43.000Z"
canonical_url: "https://usecommune.com/n/engineeringwithashmit/a/how-qbraid-routes-between-quantum-sdks-a-tour-of-the-transpi"
markdown_url: "https://usecommune.com/n/engineeringwithashmit/a/how-qbraid-routes-between-quantum-sdks-a-tour-of-the-transpi.md"
chat_url: "https://usecommune.com/n/engineeringwithashmit/a/how-qbraid-routes-between-quantum-sdks-a-tour-of-the-transpi/chat"
source_url: "https://engineeringwithashmit.hashnode.dev/how-qbraid-routes-between-quantum-sdks"
body_source: "imported"
likes: 1
replies: 0
body_words: 2285
---

# How qBraid routes between quantum SDKs: a tour of the transpiler's conversion graph

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](https://github.com/qBraid/qBraid/pull/1179)) and PyTKET interop plus a `pyquil → qasm3` conversion ([#1200](https://github.com/qBraid/qBraid/pull/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):

![](https://cdn.hashnode.com/uploads/covers/637e3426361eabd5d57eac79/f7949757-2a3b-4c9c-8384-e639558caa76.png)

## 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`:

```python
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:

```python
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.)

```python
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`:

```python
@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:

```python
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:

- `weight` is taken from the `@weight` attribute (the coverage fraction), then transformed into a routing cost (next section).
- `bias` fine-tunes the depth-vs-coverage tradeoff and is added once per hop. From the docstring: a bias of `0.25` slightly favours a single conversion at weight `0.8` over two conversions at weight `1.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."
- `_native` flags whether the converter is internal to qBraid.
- `_supported` checks (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:

```python
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:

```python
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:

```python
# 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 the `bias`.
- **Lower coverage** → larger `log(1/coverage)` → more expensive.
- `bias` **is 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 `-log` converts one into the other: `max ∏ coverageᵢ` becomes `min Σ log(1/coverageᵢ)`, because `log` is monotonic and turns products into sums. It's the same trick used for maximum-probability paths (e.g. Viterbi). The `bias` is 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:

```python
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:

![](https://cdn.hashnode.com/uploads/covers/637e3426361eabd5d57eac79/47c60755-1f28-463f-a4d5-d9aa9518af8c.png)

```python
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's `pyqasm` engine - `loads → validate → unroll` the program into a flat, basis-gate AST, then walks `unrolled_ast.statements` and 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_seconds` over `_TIME_UNIT_SECONDS`) so timing-bearing programs survive, and raises `ProgramConversionError` on anything unsupported. Leaning on `pyqasm` for the heavy lifting is the same pattern as `openqasm3_to_cudaq`.
- `pyquil → qasm3` (`conversions/pyquil/pyquil_to_qasm3.py`, #1200/#1208): the reverse direction, written from scratch over pyQuil's instruction model. Together with `openqasm3 → pyquil` it completes a **PyQuil ↔ OpenQASM 3** round trip and replaces the old lossy `pyquil → cirq → qasm2 → qasm3` chain with a single edge.
- **PyTKET interop via extras** (#1208): `cirq_to_pytket` and `pytket_to_cirq` (via `pytket.extensions.cirq`) and `pytket_to_pyqir` (via `pytket.qir`, wrapped in `pyqir.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_extras` **keeps 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.*

***

## Discussion

No replies yet.
