Skip to content

Framework adapters

An adapter converts one framework’s circuit objects into the universal CircuitSnapshot and SimulationResult. Add one file, register one spec, and the editor, circuit renderer, Bloch sphere, and histogram all work for the new framework.

Exec-mode (Qiskit, Cirq, CUDA-Q): the executor exec()s the user’s Python, then hands your adapter the resulting namespace. Implement the four-method ABC:

MethodContract
detect(code) -> boolRegex check (mirrors the spec’s detect_pattern; the executor uses the spec, but the method keeps adapters independently testable).
find_circuit(namespace)Return the framework’s circuit object from the exec’d namespace, or None. Convention: newest wins when several exist.
extract_snapshot(circuit_obj)Gate list + layering, no simulation — this runs on every debounced keystroke.
simulate(circuit_obj, shots, seed=None)Full simulation; only on explicit execute. seed is optional (protocol v1.1) — seed your backend when given and set SimulationResult.seed_honored (True/False); leave it None when no seed was requested.

Source-mode (Q# today): the code never reaches Python exec() — your adapter owns the whole pipeline by overriding parse_source(code) and execute_source(code, shots, params=None, seed=None), which return the executor’s own tuple shapes (snapshot, stdout, stderr, error) and (result, snapshot, stdout, stderr, error) so the executor passes them through unchanged. Set source_mode=True on the spec. The Q# adapter (kernel/adapters/qsharp_adapter.py) is the reference implementation, including protocol v1.1’s parameter binding and seeding.

You never raise to signal user errors — return a KernelError (in source-mode) or just raise and let the executor classify: exceptions from find_circuit/extract_snapshot become adapter_error, from simulate become simulation_error, and ImportErrors anywhere normalize to missing_dependency. See errors.

Emit canonical gate names (H, CNOT, RZ, Toffoli, Measure, …) — the renderer keys off them. Unknown gates: uppercase passthrough, like the Qiskit adapter’s fallback.

Two helpers in kernel/adapters/_math.py exist so every framework’s layout and Bloch coordinates agree — use them, don’t reimplement:

  • assign_layer(qubit_layers, qubits) — greedy layering: places each gate in the earliest layer where all its qubits are free (depth is max(qubit_layers.values()) afterwards).
  • partial_trace_qubit(statevector, n_qubits, qubit) — single-qubit reduced density matrix for Bloch coords: x = 2·Re ρ₀₁, y = 2·Im ρ₀₁, z = ρ₀₀ − ρ₁₁. The qubit argument is the C-order reshape axis (axis 0 = MSB of the statevector index), so big-endian states (Cirq, Q#) pass qubit i directly while little-endian states (Qiskit) must pass n_qubits - 1 - i — the frontend renders bloch_coords[i] as qubit i.

Adapters are lazy-loaded — the executor imports your module only when the detect regex matches, so a missing SDK costs nothing until someone uses it. Add a spec to the ADAPTER_SPECS tuple in kernel/executor.py:

FieldMeaning
frameworkSnapshot’s framework string and error tagging.
module / class_nameImport path (kernel.adapters.foo_adapter) and adapter class.
detect_patternCompiled regex tried against the raw source.
dependenciesPackage names whose ModuleNotFoundError should normalize to missing_dependency instead of a raw traceback.
source_modeTrue only for non-Python frameworks (Q#).

Two more registration touchpoints:

  1. Friendly error name — add your dependency to the display-name map in _missing_dependency_message (executor.py:79-89) so users see “FooSDK is not installed…” instead of a pip name.
  2. Install wizard — add a FrameworkInfo entry to CATALOG in src-tauri/src/commands/frameworks.rs:32 (id, label, description, pip_name, import_name, group: "core", size estimate, recommended) so the desktop app can offer the package in its first-launch checklist and install it into the managed venv. Field semantics: framework catalog.

A complete exec-mode adapter for a fake micro-framework. This is not pseudo-code — the file below lives at docs-site/fixtures/examples/toy_adapter.py and kernel/tests/test_docs_examples.py registers it against a real Executor, runs TOY_SNIPPET through parse and execute, and asserts the snapshot layering and the deterministic result. If an adapter interface changes, that test fails before this page can lie.

docs-site/fixtures/examples/toy_adapter.py
"""Worked example for /docs/extending/framework-adapters/ — a complete
exec-mode adapter for a fake micro-framework called `toyq`.
In a real integration `toyq` would be a pip package and the adapter would
live at kernel/adapters/toyq_adapter.py. Both halves share this one file so
the docs can embed it verbatim and kernel/tests/test_docs_examples.py can
run it unchanged against the real Executor — the example cannot rot.
"""
import re
import time
from kernel.adapters._math import assign_layer
from kernel.adapters.base import FrameworkAdapter
from kernel.executor import AdapterSpec
from kernel.models.snapshot import CircuitSnapshot, Gate, SimulationResult
# ── The fake micro-framework (stands in for the real package) ────────────
class ToyCircuit:
"""What a user of the imaginary `toyq` framework builds."""
def __init__(self, qubits: int):
self.qubits = qubits
# (canonical gate name, control qubits, target qubits)
self.ops: list[tuple[str, list[int], list[int]]] = []
def h(self, q: int) -> None:
self.ops.append(("H", [], [q]))
def cx(self, control: int, target: int) -> None:
self.ops.append(("CNOT", [control], [target]))
def measure_all(self) -> None:
for q in range(self.qubits):
self.ops.append(("Measure", [], [q]))
# The exact user code the docs walk through; the test executes this string.
TOY_SNIPPET = """\
import toyq
c = toyq.ToyCircuit(2)
c.h(0)
c.cx(0, 1)
c.measure_all()
"""
# ── The adapter ──────────────────────────────────────────────────────────
class ToyAdapter(FrameworkAdapter):
def detect(self, code: str) -> bool:
return bool(re.search(r"import\s+toyq|from\s+toyq\s+import", code))
def find_circuit(self, namespace: dict):
# Scan the exec() namespace for circuit objects; newest wins,
# matching how the Qiskit adapter behaves when students iterate.
circuits = [v for v in namespace.values() if isinstance(v, ToyCircuit)]
return circuits[-1] if circuits else None
def extract_snapshot(self, circuit_obj: ToyCircuit) -> CircuitSnapshot:
# assign_layer gives every framework identical greedy layering, so
# toy circuits lay out in the renderer exactly like Qiskit ones.
qubit_layers: dict[int, int] = {}
gates = [
Gate(
type=name,
targets=targets,
controls=controls,
params=[],
layer=assign_layer(qubit_layers, controls + targets),
)
for name, controls, targets in circuit_obj.ops
]
return CircuitSnapshot(
framework="toy",
qubit_count=circuit_obj.qubits,
classical_bit_count=circuit_obj.qubits,
depth=max(qubit_layers.values()) if qubit_layers else 0,
gates=gates,
)
def simulate(
self, circuit_obj: ToyCircuit, shots: int, seed: int | None = None
) -> SimulationResult:
# toyq's "simulator" is deliberately fake: every circuit collapses
# to |0...0⟩ regardless of seed. A real adapter would seed its
# backend here and set SimulationResult.seed_honored accordingly.
start = time.time()
zeros = "0" * circuit_obj.qubits
return SimulationResult(
state_vector=[{"re": 1.0, "im": 0.0}]
+ [{"re": 0.0, "im": 0.0}] * (2**circuit_obj.qubits - 1),
probabilities={zeros: 1.0},
measurements={zeros: shots},
bloch_coords=[{"x": 0.0, "y": 0.0, "z": 1.0}] * circuit_obj.qubits,
execution_time_ms=round((time.time() - start) * 1000, 1),
shot_count=shots,
)
# ── Registration ─────────────────────────────────────────────────────────
# Real adapters add an entry like this to ADAPTER_SPECS in kernel/executor.py.
# Ordering matters: insert BEFORE any spec whose regex could also match your
# code (Q# sits first for exactly that reason).
TOY_SPEC = AdapterSpec(
framework="toy",
module="toyq", # in-tree adapters use "kernel.adapters.<name>_adapter"
class_name="ToyAdapter",
detect_pattern=re.compile(r"import\s+toyq|from\s+toyq\s+import"),
dependencies=("toyq",),
)

What the test verifies, which is exactly what the kernel guarantees:

  • parse(TOY_SNIPPET) → snapshot with framework: "toy", gates [H@0, CNOT@1, Measure@2, Measure@2], depth 3 — the greedy layering from assign_layer.
  • execute(TOY_SNIPPET, shots=64)probabilities {"00": 1.0}, measurements {"00": 64}, shot_count 64.
  • Prepending TOY_SPEC does not shadow the other frameworks’ detection.

Kernel tests live in kernel/tests/ (plain pytest, no SDK installs required in CI — mock or skip-guard anything that imports a real framework). The established patterns:

PatternModel
Unit-test the executor pipeline with a stub adapter + monkeypatched seams (_detect_adapter_spec, _load_adapter, _run_code)kernel/tests/test_executor.py
Skip-guard tests that need the real SDK (pytest.importorskip)kernel/tests/test_qsharp_adapter.py
Register a real adapter from a file via ADAPTER_SPECS monkeypatchkernel/tests/test_docs_examples.py

Run with python3 -m pytest kernel/tests -q from the repo root. A new adapter PR should cover: detection (positive + negative), snapshot extraction including layering and control/target splits, the no-circuit case, and at least one simulated result.