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.
The two adapter shapes
Section titled “The two adapter shapes”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:
| Method | Contract |
|---|---|
detect(code) -> bool | Regex 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.
Canonical gate names and shared math
Section titled “Canonical gate names and shared math”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 ismax(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 = ρ₀₀ − ρ₁₁. Thequbitargument is the C-order reshape axis (axis 0 = MSB of the statevector index), so big-endian states (Cirq, Q#) pass qubitidirectly while little-endian states (Qiskit) must passn_qubits - 1 - i— the frontend rendersbloch_coords[i]as qubiti.
Registration: AdapterSpec
Section titled “Registration: AdapterSpec”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:
| Field | Meaning |
|---|---|
framework | Snapshot’s framework string and error tagging. |
module / class_name | Import path (kernel.adapters.foo_adapter) and adapter class. |
detect_pattern | Compiled regex tried against the raw source. |
dependencies | Package names whose ModuleNotFoundError should normalize to missing_dependency instead of a raw traceback. |
source_mode | True only for non-Python frameworks (Q#). |
Two more registration touchpoints:
- 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. - Install wizard — add a
FrameworkInfoentry toCATALOGinsrc-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.
Worked example: the toyq adapter
Section titled “Worked example: the toyq adapter”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.
"""Worked example for /docs/extending/framework-adapters/ — a completeexec-mode adapter for a fake micro-framework called `toyq`.
In a real integration `toyq` would be a pip package and the adapter wouldlive at kernel/adapters/toyq_adapter.py. Both halves share this one file sothe docs can embed it verbatim and kernel/tests/test_docs_examples.py canrun it unchanged against the real Executor — the example cannot rot."""
import reimport time
from kernel.adapters._math import assign_layerfrom kernel.adapters.base import FrameworkAdapterfrom kernel.executor import AdapterSpecfrom 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 withframework: "toy", gates[H@0, CNOT@1, Measure@2, Measure@2], depth 3 — the greedy layering fromassign_layer.execute(TOY_SNIPPET, shots=64)→probabilities {"00": 1.0},measurements {"00": 64},shot_count 64.- Prepending
TOY_SPECdoes not shadow the other frameworks’ detection.
Testing expectations
Section titled “Testing expectations”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:
| Pattern | Model |
|---|---|
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 monkeypatch | kernel/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.