Skip to content

Hardware Execution Guide

The hardware package provides the full stack from circuit compilation to QPU execution, noise modelling, classical reference computation, and multi-backend support, including gate-model, photonic, analog and annealing adapters. The generated module catalog records the current source inventory.

Why this page exists

This page gives operators and integrators one entry point for how experiments flow from circuit definition to reference comparisons. It is the practical route for teams that need to control submission policy, evidence classes, and reproducibility boundaries before running local or provider workloads.

Hardware evidence status:

Device Family Campaign Highlight
ibm_fez Heron r2, 156 q Legacy March 2026 baseline artifacts Artifact-backed Bell/QKD/VQE/ZNE/UPDE observations; quote only rows named in the hardware ledger.
ibm_kingston Heron r2, 156 q April 2026 Phase 1, 342 circuits Promoted raw-count DLA parity dataset: peak \(+17.48\,\%\) at depth 6, reproduced by scripts/run_dla_parity_suite.py.

V2, frontier, queued-job, placeholder, and aggregate-only IBM outputs are not promoted unless the hardware ledger names raw counts, private retrieval map, analysis code, and review status.

Recoverable asynchronous batches

AsyncHardwareRunner.submit_one_async accepts an optional ProviderJobJournal and canonical UUID attempt_id. Supply both together. The journal records the exact compiled native QPY batch, backend name, requested shots and experiment before dispatch. A batch is bounded to256 circuits and sixteen mebibytes of encoded QPY. Existing calls without these arguments retain the original memory-only behaviour.

An attempted dispatch changes the durable state to submission_unknown before crossing the provider boundary. A lost response or process exit cannot turn that state into a new submission. recover_job retrieves an existing stored handle; when no handle was returned, an operator must supply the original provider job ID. Recovery compares the provider's native inputs, target and shots with the stored request. A mismatch leaves the original attempt unchanged. This is read-only reconciliation, not another submission or renewed spending authority.

wait_for_job_async retains the original SDK result before decoding counts, then records a completed batch only after every publication and sample total matches. Repeated reads of a completed attempt use detached journal results. observe_job_async reads status without submitting. cancel_job_async first records cancellation intent; acknowledgement alone remains cancellation_requested until the provider confirms cancellation. A concurrent completed result remains available. Partial results and previous observations stay in ordered history. Billing stays unknown: completion, cancellation and an empty response do not establish the provider's actual debit.

The journal uses local SQLite transactions with full synchronisation and an explicit schema version. Keep its directory under the operator's own custody. Unknown journal versions refuse rather than migrate automatically. Local SDK sampling and transport fault tests qualify this lifecycle contract; they do not prove a physical provider session, execution or billing record.

Native workload and result semantics

HAL workload builders accept capture_semantics=True to attach a source-bound request. The resulting QuantumJobRef.submission records the original encoded program, requested/effective sample count and selected target. QuantumJobResult.provider_observation carries the matching native output. The existing raw payload and legacy counts view keep their established format; the new companion uses schema provider_semantics.v1.

Gate-model requests use WorkloadSemantics from hardware.provider_semantics. Qiskit QPY preserves native parameter UUIDs, shared instruction uses and global-phase parameters. Supply one complete finite parameter_bindings mapping to the QPY builder for execution; binding leaves the stored original source unchanged. The QASM 3 builder accepts an already bound circuit. Braket preserves shared native symbols and their supplied bindings in its OpenQASM source. The native SDK owns binding and compilation.

The supported measurement contract is static final readout with explicit qubit-to-classical-bit assignments. Partial, permuted and multiple-register Qiskit measurements retain their declared order and marginals. Dynamic control, reset, operations after measurement and unsupported instructions are refused before backend execution. A changed or incompatible selected target cannot reuse an admitted compiled payload or trigger an untargeted compilation retry.

For example, this local Aer run prepares q2=1, q0=0 and measures them into c0, c1. Qiskit places c1 on the left of its count string:

from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from scpn_quantum_control.hardware.hal import HardwareAbstractionLayer
from scpn_quantum_control.hardware.hal_qiskit import (
    QiskitAerHALAdapter, qiskit_circuit_to_workload,
)

circuit = QuantumCircuit(3, 2)
circuit.x(2)
circuit.measure(2, 0)
circuit.measure(0, 1)
hal = HardwareAbstractionLayer.with_builtin_profiles()
hal.register_backend(QiskitAerHALAdapter(
    hal.profile("local_qiskit_aer"),
    backend=AerSimulator(max_parallel_threads=1, seed_simulator=7),
))
workload = qiskit_circuit_to_workload(
    circuit, workload_id="partial_readout", shots=32, capture_semantics=True,
)
job = hal.submit("local_qiskit_aer", workload)
result = hal.result(job)
assert result.counts == {"01": 32}
assert job.submission.request.measurement_map == ((2, 0), (0, 1))
assert result.provider_observation.measurement_map == ((2, 0), (0, 1))

Braket native readout for the same ordered wires uses "10": its first classical output is on the left. GateModelObservation.raw_counts retains the provider labels separately from the validated count view. A Qiskit Runtime result must contain exactly one PUB for the submitted circuit. Native register BitArray buffers are retained as immutable bytes with their bit width, shape and register name; padding, sample count and registration must agree. Multiple PUBs or broadcast parameter axes cannot masquerade as a single observation.

Other modalities use ModalitySemantics and output types from hardware.provider_modalities:

Modality Workload builder Native observation
Photonic quandela_perceval_workload PhotonicObservation retains ordered mode occupations, including occupations above one, original native labels and exact occurrences.
Analog pasqal_pulser_workload, quera_bloqade_workload AnalogObservation retains native site order and the original count or per-shot readout channel, including repeated samples. Readout polarity and atom-loss interpretation remain unknown.
Annealing dwave_bqm_workload AnnealingObservation retains native variable order, BINARY/SPIN samples, occurrences and available energies. Structured records also retain their exact dtype, shape and bytes. Missing energies remain unknown.

These native axes represent modes, sites or variables. The historical workload width field does not turn them into gate-model qubits. In particular, the legacy binary count projection of a SPIN sample does not replace its native -1/+1 values. Incompatible output channels, widths, types or occurrence totals refuse without replacing earlier retained results.

Use requested_target with semantics capture to require an exact selected target. An explicitly empty selector is invalid. Submission provenance distinguishes a native SDK target from an adapter selector, and target-compiled data from native-provider or caller-precompiled input. A selector or opaque prepared-sequence digest does not establish physical placement or calibrated device compilation. Cloud routes retain their existing explicit approval gate. Adapters that do not declare support refuse a semantics companion before submission; omitting capture keeps the established legacy route.

Local Aer, Runtime on a local Aer backend and Braket Local runs exercise installed gate-model software. IQM-compatible Aer execution does not qualify the IQM SDK or an IQM device. Controlled photonic, analog and annealing I/O fixtures verify the adapter boundary; installed vendor SDK and physical-device qualification require their own evidence. No hardware result or performance claim follows from native semantics capture alone.

Operator admission before transport

Configure HardwareAbstractionLayer(profiles, operator_policy=policy) with an immutable OperatorPolicy from hardware.operator_policy_contracts. Every submission through that configured HAL then requires an OperatorRequest and a known, request-bound PricingEstimate. The request binds the original workload source and complete native semantics, exact backend, native target, region, shots, declared concurrency, declared time limit and unattended flag.

hal.assess_operator_policy(backend_id, workload, request, estimate=estimate) returns an immutable offline verdict with ordered refusal reasons and rejected substitutions. Optional now is an explicit UTC-second clock for offline assessment. hal.submit(..., operator_request=request, pricing_estimate=estimate) reassesses using current UTC before adapter transport and accepts no caller clock or stored decision. Existing cloud approval and native capability checks remain required.

Equality at a ceiling is admitted; excess refuses without changing shots or other values. Target and region must agree with the original native workload and governing profile. Missing cloud target/region bindings, a future or expired policy, and unknown, stale, future, wrong-currency or request-mismatched pricing refuse before transport. Amounts are nonnegative decimal strings with at most nine fractional digits; no floating-point price, exchange rate or tariff is inferred. Expiry is exclusive, including at the exact second.

This admission checks the declared plan. It does not authenticate the supplied estimate, predict elapsed time, charge an account or enforce cross-process concurrency. Configure this boundary for automated execution; the original unconfigured HAL keeps its established local and explicitly approved cloud routes. Passing policy admission does not prove device availability, account credit, calibration or a hardware observation.

For the complete original settings/provenance export and browser inspection, see Operator policy decisions.

Architecture

HAL job identity custody

QuantumWorkload.metadata contains application annotations, not execution settings. It rejects these adapter-owned keys before any provider operation: approval_id, provider_job_id, execution_mode, backend_name, quantum_computer, ir_format, n_qubits, shots, target, workload_id and broker. Put requested resources in the typed workload fields and route, target and approval choices in the relevant adapter/router arguments. For descriptive annotations, use application-specific names such as campaign or analysis_target. The restriction applies even when the duplicate value agrees with the typed setting; no value is silently discarded or replaced. Job and result metadata remain allowed to carry adapter-owned provenance.

The Qiskit Runtime, AWS Braket, Azure Quantum, qBraid and Strangeworks adapters resolve supplied handles against their retained submission record before result, status or cancellation access, including cached results. Backend/workload mismatches raise ValueError; an unknown job ID raises KeyError. Supplied status or metadata annotations do not replace the stored expected shots, qubit width or approval/provenance fields. Cancellation retains those fields. This supports reconstructed handles only while the adapter still retains the original record; it does not provide persistence or recovery after process loss. For IBM Runtime and AWS Braket, cancel checks the provider state before and after a cancellation request and reports the observed state. A job already completed is not cancelled, and a request still reported as running is not labelled cancelled. A locally decoded completed result remains retrievable even when cancellation arrives later. This is an offline-tested lifecycle contract, not proof of an actual provider race or successful hardware job.

IBM Runtime and AWS Braket adapters also accept a paired capability_probe and max_calibration_age_seconds at construction. When configured, submit calls the no-submit probe immediately before the provider run and rechecks its timestamp against the current UTC time. It refuses an offline or uncertain target, stale/missing/future calibration, wrong provider/backend/target/IR, insufficient qubits, or an unknown/exceeded shot limit before provider run(). The returned job records the calibration timestamp, check time and age limit. This opt-in gate relies on the caller's probe; it does not authenticate provider metadata. Existing approval-only submissions without the pair remain possible and make no fresh-calibration claim.

HardwareAbstractionLayer checks adapter-returned handles before exposing them: submission must preserve the selected backend_id and requested workload_id; result retrieval and cancellation must also preserve job_id. A mismatch raises ValueError. Recovered handles are supported without a router-local submission cache. Status and metadata may change as the job progresses; accepted results retain the adapter's original counts, shots and metadata without rewriting.

The local deterministic, Qiskit Aer, Braket Local and Cirq adapters complete synchronously. Their status, result and cancel methods require the retained job, backend and workload identity. A cancellation request after completion returns the existing completed handle and leaves its raw counts intact; it does not relabel the job as cancelled. This local lifecycle rule does not predict a provider's cancellation race or confer a hardware result. The standard Cirq simulator result has no provider job ID; its HAL handle is generated locally and labelled job_id_origin=hal_local_generated, without a provider_job_id claim. Injected non-Cirq results still require a real source-provided identifier.

QuantumJobResult rejects a positive observed shot total unless the returned counts sum to it, including an empty count map. Partial status may remain in a typed local result, but the result-pack bridge refuses to mint provenance until the result status is completed.

These checks bind identities, not scientific fidelity, shot-setting agreement or provider authenticity. They neither submit a replacement job nor promote a cancellation request to confirmed cancellation. A mismatch detected after submit or cancel does not roll back that provider operation. Reconcile its outcome before any resubmission; do not automatically retry a rejected submit response.

Experiment Definition (experiments.py)
    │
    ├── HardwareRunner (runner.py)
    │   ├── connect() → IBM Runtime / AerSimulator
    │   ├── transpile() → native gate set
    │   ├── run_circuit() → JobResult
    │   └── run_with_zne() → ZNE-mitigated result
    │
    ├── Noise Model (noise_model.py)
    │   └── heron_r2_noise_model() → NoiseModel (thermal + depolarizing)
    │
    ├── Classical Reference (classical.py)
    │   ├── classical_kuramoto_reference() → Euler integration
    │   ├── classical_exact_diag() → full eigendecomposition
    │   ├── classical_exact_evolution() → matrix expm
    │   └── classical_brute_mpc() → brute-force MPC
    │
    ├── Multi-Backend
    │   ├── PennyLane (pennylane_adapter.py)
    │   ├── Cirq (cirq_adapter.py)
    │   ├── Trapped Ion (trapped_ion.py)
    │   ├── GPU (gpu_accel.py, jax_accel.py)
    │   └── Plugin Registry (plugin_registry.py)
    │
    └── Circuit Tools
        ├── Circuit Cutting (circuit_cutting.py, cutting_runner.py)
        ├── QASM Export (qasm_export.py)
        ├── Circuit Export (circuit_export.py)
        └── QCVV (qcvv.py)

Prerequisites

IBM Quantum

  1. Account: https://quantum.cloud.ibm.com
  2. Credentials (use ibm_cloud channel, NOT deprecated ibm_quantum):
    export IBM_QUANTUM_TOKEN="your-token-here"
    export IBM_QUANTUM_CRN="your-crn-instance-id"
    
  3. Install IBM runtime:
    pip install -e ".[ibm]"
    

HardwareRunner (runner.py)

The primary execution interface. Handles authentication, backend selection, transpilation, job submission, and result collection.

Connection

from scpn_quantum_control.hardware import HardwareRunner

# Real hardware
runner = HardwareRunner(use_simulator=False)
runner.connect()  # Authenticates with IBM_QUANTUM_TOKEN env var

# Local simulator (default)
runner = HardwareRunner(use_simulator=True, results_dir="results/")
runner.connect()

When use_simulator=True, uses AerSimulator with the Heron r2 noise model for realistic local testing without QPU budget consumption.

Transpilation

transpiled = runner.transpile(circuit, optimization_level=3)

Uses Qiskit's preset pass manager with Heron r2 target. Optimization level 3 performs heavy gate cancellation and routing.

Execution

result = runner.run_circuit(
    circuit,
    experiment_name="kuramoto_4osc",
    shots=10000,
)
# result: JobResult with counts, wall_time_s, metadata

ZNE Execution

result = runner.run_with_zne(
    circuit,
    experiment_name="kuramoto_zne",
    noise_scales=[1, 3, 5],
    shots=10000,
)

Internally calls gate_fold_circuit from the mitigation package.

JobResult

Field Type Description
job_id str IBM job ID or "simulator"
backend_name str Backend identifier
experiment_name str User-specified experiment name
counts dict or None Measurement counts
expectation_values ndarray or None Computed expectations
metadata dict Arbitrary metadata
wall_time_s float Total execution time
timestamp str ISO timestamp

Results are serialised to JSON via to_dict() and saved to results_dir.


Noise Model (noise_model.py)

IBM Heron r2 calibration (ibm_fez, 2026-03-29 median snapshot; source: backend.properties(datetime=2026-03-29), retrieved read-only 2026-07-18):

Parameter Value Description
T1 146.7 us Longitudinal relaxation
T2 109.3 us Transverse relaxation
CZ error 0.262% Two-qubit gate error rate
Readout error 1.5% Measurement error rate
Single-gate time 0.024 us SX/X/RZ duration
Two-gate time 0.068 us CZ/ECR duration

heron_r2_noise_model(t1_us, t2_us, cz_error, readout_error)

Constructs a Qiskit-Aer NoiseModel: - Single-qubit gates: thermal relaxation only - Two-qubit gates (ECR/CZ): thermal relaxation + depolarizing - Readout: symmetric bit-flip error

Qiskit-Aer is imported lazily when the noise-model function is called. Importing scpn_quantum_control.hardware does not require a working local Aer installation unless a simulator noise model is actually built.

from scpn_quantum_control.hardware import heron_r2_noise_model

model = heron_r2_noise_model()
# Use with AerSimulator:
from qiskit_aer import AerSimulator
backend = AerSimulator(noise_model=model)

Classical Reference (classical.py)

Exact classical computations for hardware experiment comparison. Every quantum result should be compared against these references.

classical_kuramoto_reference(n_osc, t_max, dt, K=None, omega=None)

Euler integration of the classical Kuramoto model:

d(theta_i)/dt = omega_i + sum_j K[i,j] * sin(theta_j - theta_i)

Returns {times, theta, R} — phase trajectories and order parameter.

Integration grid. The trajectory takes floor(t_max / dt) steps and reports sample s at s · dt, which is where the integrator actually put the state. A duration that is not a multiple of dt therefore ends short of t_max rather than mislabelling its last sample: for t_max = 1, dt = 0.3 and omega = 1, an uncoupled oscillator reaches phase 0.9 and the last reported time is 0.9. t_max = 0 returns the initial condition alone. A quotient within INTEGRATION_GRID_RELATIVE_TOLERANCE of an integer is snapped to it, because 0.3 / 0.1 is 2.9999999999999996 in binary and a bare floor would drop the caller's last step.

The Rust kernel and the Julia symplectic and delayed kernels already report s · dt, so all tiers return identical times for identical inputs. The shared rule is exposed as integration_step_count(t_max, dt) and integration_times(n_steps, dt).

Rust acceleration: scpn_quantum_engine.kuramoto_euler() at 33x speedup for n >= 8.

classical_exact_diag(n, K=None, omega=None)

Full eigendecomposition of the XY Hamiltonian. Returns eigenvalues, eigenvectors, ground state, and ground energy.

For n <= 14: direct dense diagonalisation via numpy.linalg.eigh. For n > 14: sparse ARPACK via scipy.sparse.linalg.eigsh.

classical_exact_evolution(n, t_max, dt, K=None, omega=None)

Matrix exponential evolution: psi(t+dt) = exp(-iHdt) psi(t).

Returns time series of R(t) and energy E(t) for direct comparison with Trotter evolution on quantum hardware.

Uses the same integration grid as classical_kuramoto_reference: the propagator is applied floor(t_max / dt) times and sample s is reported at s · dt. t_max = 0 returns the initial state alone rather than applying the propagator once.

classical_brute_mpc(B_matrix, target, horizon)

Brute-force binary model predictive control: enumerate all 2^horizon on/off action sequences and select the one minimising the tracking cost

C(u) = sum_t ||u_t * v - target||^2,   v = B_matrix @ ones

where u_t in {0, 1} switches the whole actuation vector on or off at timestep t. The residual stays a vector, so the target's sign and its direction relative to B_matrix both change the optimum; a norm-only cost would treat target and -target as identical.

B_matrix is (dim, dim), target has length dim, and bit t of an index into the returned all_costs carries u_t.

Rust acceleration: scpn_quantum_engine.brute_mpc() with rayon parallel enumeration. See the performance table for the current measurement status.


PennyLane Adapter (pennylane_adapter.py)

PennyLaneRunner exposes the same Kuramoto-XY Hamiltonian through any PennyLane-compatible device. run_trotter() evaluates the energy after Trotterized time evolution and reconstructs the Kuramoto order parameter from local transverse expectations:

theta_i = atan2(<Y_i>, <X_i>)
R = |mean_i exp(i theta_i)|

run_vqe() uses the optimized hardware-efficient ansatz for both the energy objective and the post-optimization observable pass. The returned order_parameter is therefore measured from the final ansatz via the same X/Y phase reconstruction; it is not a sentinel, simulator-only statevector value, or unmeasured placeholder.

Provider and plugin routing is intentionally delegated to PennyLane: unknown device strings are forwarded to qml.device(...) so installed plugins can own their validation. The adapter still fails closed before plugin dispatch for empty device names, control-character payloads, invalid finite-shot counts, non-finite physics inputs, non-square Kuramoto coupling matrices, and omega vectors whose width does not match K; shots=None remains the analytic/simulator route and finite-shot runs require a positive integer.


Experiments (experiments.py)

Pre-defined experiment configurations for systematic QPU characterisation.

ALL_EXPERIMENTS

Registry of all 20 experiment functions:

Experiment Qubits Description
kuramoto_4osc 4 Basic Trotter evolution, R(t)
kuramoto_4osc_trotter2 4 Suzuki-Trotter 2nd order
kuramoto_4osc_zne 4 ZNE-mitigated Kuramoto
kuramoto_8osc 8 8-qubit Kuramoto dynamics
kuramoto_8osc_zne 8 ZNE-mitigated 8-qubit
vqe_4q 4 VQE ground state search
vqe_8q 8 VQE with physics-informed ansatz
vqe_8q_hardware 8 VQE targeting real QPU
vqe_landscape 4 Energy landscape scan
qaoa_mpc_4 4 QAOA-based MPC
upde_16_snapshot 16 Full 16-qubit UPDE state snapshot
upde_16_dd 16 UPDE with dynamical decoupling
noise_baseline 4 Noise characterisation baseline
ansatz_comparison_hw 4 Compare ansatz architectures
sync_threshold 4 Synchronisation threshold detection
decoherence_scaling 4 Depth vs fidelity scaling
zne_higher_order 4 Higher-order ZNE extrapolation
bell_test_4q 4 CHSH Bell test on hardware
correlator_4q 4 XY correlator measurement
qkd_qber_4q 4 QBER measurement for BB84-family validation

Each experiment function returns a dict with circuit, shots, n_qubits, and experiment-specific metadata.

QPU Budget

Free tier: 10 minutes/month on ibm_fez (Heron r2, 156 qubits).

Experiment Circuits Shots QPU Seconds
kuramoto_4osc (1 step) 3 10k ~15
vqe_4q (100 COBYLA iter) ~100 10k ~15
qaoa_mpc_4 (p=1) ~30 10k ~100
upde_16 snapshot 3 20k ~60

Multi-Backend Support

PennyLane Adapter (pennylane_adapter.py)

from scpn_quantum_control.hardware.pennylane_adapter import PennyLaneRunner

runner = PennyLaneRunner(K, omega, device="default.qubit")
result = runner.run_trotter(t=0.5, reps=2)
# result: PennyLaneResult(energy, order_parameter, n_qubits, device_name, statevector)

Device strings are trimmed before dispatch. Malformed native-gate payloads are rejected before qml.device(...) is called, including unsupported gates, wrong gate arities, non-integer or duplicate wires, wrong rotation-parameter counts, boolean parameters, and non-finite rotation parameters. Vendor-specific keyword arguments are forwarded verbatim; no allow-list is maintained in the adapter, and mocked provider-breadth tests do not touch live hardware.

VQE via PennyLane optimisers:

result = runner.run_vqe(ansatz_depth=1, maxiter=5, seed=42)

Differentiable VQE surface:

result = runner.vqe_value_and_grad(params, ansatz_depth=1)
result.value       # VQE energy
result.gradient    # PennyLane autodiff gradient over ansatz parameters
result.method      # "pennylane_autodiff"

For framework-native gradients that do not require PennyLane, use scpn_quantum_control.differentiable.parameter_shift_gradient.

Requires: pip install pennylane

Cirq Adapter (cirq_adapter.py)

from scpn_quantum_control.hardware.cirq_adapter import CirqRunner

runner = CirqRunner(K, omega)
result = runner.run_trotter(t=0.5, reps=2)

Enables targeting Google Sycamore/Weber QPUs via Cirq.

Requires: pip install cirq-core

Trapped Ion (trapped_ion.py)

from scpn_quantum_control.hardware import transpile_for_trapped_ion, trapped_ion_noise_model

ion_circuit = transpile_for_trapped_ion(circuit, allow_proxy_basis=True)
model = trapped_ion_noise_model()

Representative target: all-to-all QCCD-style trapped-ion devices. The helper emits a CX-basis proxy for MS/RXX-style entangling operations, records that proxy in circuit metadata, and is not a vendor-native IonQ or Quantinuum compiler path.

GPU Acceleration (gpu_accel.py)

cuQuantum integration for large-scale statevector simulation. Falls back to CPU when CUDA is not available.

JAX Acceleration (jax_accel.py)

JAX-based compilation for VQE parameter optimisation. Enables automatic differentiation of quantum circuits.

Plugin Registry (plugin_registry.py)

Dynamic backend registration. Third-party backends register via:

from scpn_quantum_control.hardware.plugin_registry import register_backend

register_backend("my_backend", MyBackendClass)

Provider Route Inventory

build_provider_route_catalogue inventories every declared aggregator/provider route without contacting a provider. It reads the declared route table and recorded evidence only: no credential is read, no workload is submitted, and no network call is made.

Each row is keyed by provider, broker, device, modality and observation date. A route reached without a broker records broker as None, so the same provider and device offered through a broker is a separate row rather than a merged one.

The five-field inventory_key groups shared backend/profile identity, while route_key prefixes it with route_id for unambiguous row identity. Declared aliases such as strangeworks/ibm_quantum and strangeworks/qiskit_runtime share the former but not the latter. Store or index rows by route_key (or the exported route_id plus the five fields); never merge alias observations. The inventory retains both rows without inventing different physical devices.

The export contract is provider_route_catalogue.v2. modality now comes from the authoritative HAL profile (for example, superconducting_gate_model, quantum_annealing or photonic_gate_model); target_family retains the route's original family label. Dynamic broker profiles retain their declared provider-agnostic modality rather than guessing one from the provider name. Version 1 incorrectly used the target-family label as modality. Regenerate inventories from route/profile sources when migrating; do not relabel a saved v1 row as v2. Consumers must check the exported contract before interpreting the inventory key. Raw provider observations are not rewritten.

Rows also expose sdk_package, adapter_module and credential_configuration_refs. SDK names are declared dependencies, not installation or availability checks. Credential configuration references point to real adapter constructor inputs in module:Class.__init__.parameter form: configured clients, factories, or explicitly named credential parameters. They contain no values and do not inspect SDK stores or environment variables. For example, direct IQM references IQMHALAdapter.__init__.backend; brokered IQM references the broker's client/configuration inputs, not IQM's direct ones. Authentication remains with the supplied client or SDK. Unknown/custom adapter references are None, not an assertion that no credentials are needed.

Custom backend routes require explicit profiles= when no built-in profile exists. Duplicate backend profiles and route/profile SDK disagreement are refused. Profile metadata does not promote any verb to observed or ready.

observed_at, declared_on and observed_on must be real Gregorian calendar dates in zero-padded ASCII YYYY-MM-DD format, with years 0001–9999. Impossible dates, non-ASCII digits, whitespace and timestamps raise ValueError naming the affected field. Optional evidence dates may remain None; when provided, they are validated even for unknown or negative support. Validation does not compare against the workstation clock or certify that an observation occurred.

from scpn_quantum_control.hardware.provider_capability_discovery import (
    build_provider_route_catalogue,
)

catalogue = build_provider_route_catalogue(observed_at="2026-09-05")
direct = next(row for row in catalogue if row.route_id == "direct/iqm")
brokered = next(row for row in catalogue if row.route_id == "qbraid/iqm")

direct.is_direct        # True; broker is None
brokered.broker         # "qbraid"
direct.inventory_key    # ("iqm", None, "iqm_cloud", "superconducting_gate_model", "2026-09-05")
direct.unverified       # True until evidence is supplied

The original discovery facade re-exports the exact catalogue classes, constants and builder from provider_capability_core; it does not keep a second registry.

Support is recorded twice, and unknown stays unknown

Every operation — metadata, compile, submit, retrieve, cancel and result_formats — carries a RouteVerbSupport record holding source-declared support and dated observed support in separate fields. None means unknown and is preserved as unknown; it is never narrowed to True or False to make a row look complete.

Provenance is mandatory for any positive claim. Declaring support requires the source it was read from, the date it was read and a conformance owner. Recording an observation requires the date and a canonical repository-relative tests/test_*.py conformance owner, and an observation that contradicts an explicit non-declaration is refused outright rather than silently accepted. Whitespace-only source labels are rejected. Catalogue rows require immutable tuples of verb records and an actual boolean approval flag; strings such as "false" are not interpreted as policy decisions.

from scpn_quantum_control.hardware.provider_capability_core import RouteVerbSupport

RouteVerbSupport(
    verb="metadata",
    declared=True,
    declared_source="hardware/aggregators.py route table",
    declared_on="2026-09-05",
    observed=True,
    observed_on="2026-09-05",
    conformance_owner="tests/test_hardware_hal_iqm_adapters.py",
)

Positive records require explicit conformance_root (an absolute source checkout directory) and conformance_owners when constructing the catalogue or an entry directly. The latter maps (route_id, verb) to its independently reviewed test owner. Do not construct this registry from the incoming claims: doing so would merely echo their assertions. A positive record must match the exact route and operation binding and resolve to a regular, non-symlink Python test file under the given root. Missing owners, unregistered bindings, traversal paths and relative roots raise ValueError. The current working directory is not used as a default source root, and private absolute paths are not exported.

For example, a reviewed metadata-owner binding can be supplied as conformance_owners={("direct/iqm", "metadata"): "tests/test_hardware_hal_iqm_adapters.py"} together with the absolute checkout path as conformance_root=Path(...). The caller must retain the independent review and dated observation evidence. This lookup does not execute a test or certify a real provider run. observed_verbs reflects supplied observations with resolved owners; unverified=False is not a hardware-readiness or approval gate.

A route with no evidence reports unverified and every operation unknown; unknown-only inventory needs no source checkout. A source declaration with a resolved owner still does not imply observed support. Existing positive-evidence callers must now supply the explicit context; a RouteVerbSupport alone remains a provenance-shaped assertion, not a qualified catalogue row. Reconstructing a positive row (including dataclass replacement) requires the context again.

Circuit Tools

Circuit Cutting (circuit_cutting.py, cutting_runner.py)

Decomposes large circuits (> available qubits) into subcircuits connected by classical communication. Enables running n-qubit circuits on n/2-qubit hardware with polynomial overhead.

from scpn_quantum_control.hardware.circuit_cutting import partition_circuit
from scpn_quantum_control.hardware.cutting_runner import CuttingRunner

subcircuits = partition_circuit(circuit, max_partition_size=8)
runner = CuttingRunner(backend)
result = runner.run_partitioned(subcircuits)

QASM Export (qasm_export.py)

Export circuits to OpenQASM 2.0/3.0 for platform-independent storage and submission to third-party systems.

Circuit Export (circuit_export.py)

Export circuits to JSON, LaTeX (Qiskit drawer), and SVG formats for documentation and publication.

QCVV (qcvv.py)

Quantum Characterisation, Verification, and Validation protocols. Randomised benchmarking and gate set tomography for hardware qualification.


Decoherence Reference

Depth Range Expected Error Recommendation
< 50 < 5% Publishable as-is
50-150 5-15% Publishable with error bars
150-250 15-25% Apply ZNE mitigation
250-400 25-40% Qualitative trends only
> 400 > 40% Do not trust individual values

On deep campaigns above this table (e.g. WIDTH-1 at transpiled depth 538–2103). This reference bounds the trust in individual per-circuit values. The maximum-width Kuramoto-XY sweep deliberately runs far past depth 400, and is consistent with — not a violation of — this rule: it reports a collective order parameter R(n) against an exact MPS baseline (a noise characterisation), never individual per-circuit values, and it carries an explicit no-advantage boundary (shallow 1-D circuits are classically simulable by construction). Read that campaign as "how the collective observable degrades with depth", exactly the regime where this table says individual values are untrustworthy. See docs/campaigns/max_width_kuramoto_xy_prereg_2026-07-16.md.

Native Gate Set (Heron r2)

Gate Description Duration
CZ Two-qubit entangling (native) 0.068 us
RZ(theta) Z rotation (virtual) 0 us
SX sqrt(X) 0.024 us
X Pauli-X 0.024 us
ID Identity (delay) 0.024 us

Transpilation from Qiskit standard gates increases depth. Typical expansion: 1 CNOT → 2 SX + 1 CZ + RZ gates.

Rust Acceleration

The classical.py module transparently uses Rust via scpn_quantum_engine when available:

Python Function Rust Function Speedup Method
classical_kuramoto_reference kuramoto_euler, kuramoto_trajectory 33x rayon parallel Euler steps
_expectation_pauli expectation_pauli_fast 3-10x Bitwise Pauli ops
classical_brute_mpc brute_mpc 5-50x rayon parallel 2^horizon enumeration
_state_order_param state_order_param_sparse 2-5x SIMD-friendly inner loop
_order_parameter (Floquet) all_xy_expectations 5-20x Batch bitwise, single FFI call

All Rust functions accept split real/imaginary arrays (no complex64 across FFI). Python fallback always available when the Rust crate is not installed.

Interpreting Results

Order parameter R from qubit expectation values:

R = (1/N) × |sum_i (<X_i> + i×<Y_i>)|

where <X_i> = 2×P(|0>)_x - 1 from X-basis measurement. Requires 3 measurement bases (X, Y, Z) for full reconstruction.

Compare hw_R against exact_R (from classical_kuramoto_reference or classical_exact_evolution) to quantify hardware error.

Testing

The hardware surface is covered by these test owners:

  • test_runner.py — HardwareRunner lifecycle, simulator mode, job serialisation
  • test_noise_model.py — NoiseModel construction, error rates, parameter overrides
  • test_classical.py — Kuramoto reference, exact diag, evolution parity
  • test_experiments.py — Registered experiment definitions and circuit validity
  • test_pennylane_adapter.py — PennyLane Trotter, VQE, device selection
  • test_cirq_adapter.py — Cirq Trotter, simulator parity
  • test_circuit_cutting.py — Partitioning, recombination, overhead bounds
  • test_qcvv.py — RB, gate set tomography, fidelity extraction

DynQ Topology-Agnostic Qubit Placement

scpn_quantum_control.hardware.qubit_mapper (added April 2026) implements the DynQ method (Liu et al., arXiv:2601.19635) for selecting an execution region on a heavy-hex device based on the live calibration data. The QPU is modelled as a graph weighted by inverse two-qubit gate errors, and Louvain community detection partitions it into high-fidelity sub-regions; the sub-region with the best composite quality score is chosen for the circuit. Quality scoring is Rust-accelerated.

See dynq_qubit_mapping.md for the full theory, the dynq_initial_layout() API, and a Qiskit transpiler integration recipe.

GUESS Symmetry-Decay Error Mitigation

scpn_quantum_control.mitigation.symmetry_decay (added April 2026) provides physics-informed zero-noise extrapolation specifically for the XY Hamiltonian, using its conserved \(\sum Z_i\) as the guide observable. GUESS is the recommended default for any SCPN Kuramoto-XY hardware run because the symmetry observable is measured for free in the standard Z-basis read-out, so the mitigation adds zero shot overhead.

See symmetry_decay_guess.md for the full theory, API, and a worked example calibrating \(\alpha\) from the Phase 1 ibm_kingston dataset.

Phase 1 Campaign Protocol (April 2026)

The Phase 1 campaign is recorded in <private-local-record> and IBM_EXECUTION_LOG.md, and the analysis is reproduced by scripts/analyse_phase1_dla_parity.py. The four sub-phases progressively increased the per-point repetition count from 2 to 21 to drive the per-depth uncertainty below the 5 % asymmetry signal:

Sub-phase Circuits Wall time Reps per (depth, sector) point
Pipe cleaner 2 ~0.1 s sanity check
Phase 1 (A/B/C) 42 44.1 s 2
Phase 1.5 (D/E) 72 56.7 s +4 → 6
Phase 2 exhaust (F/G/H/I) 138 97.5 s +6 → 12
Phase 2.5 final burn (J) 90 65.1 s +9 → 21 (at the 5 strongest depths)
Total \(n=4\) 342 ~264 s wall up to 21

Headline result: \(+10.8\,\%\) mean asymmetry for depths \(\ge 4\), peak \(+17.48\,\%\) at depth 6, Welch combined \(p \ll 10^{-16}\).

Pipeline Performance

Measured on ML350 Gen8 (128 GB RAM, Xeon E5-2620v2):

Operation System Wall Time
HardwareRunner.connect (simulator) — 50 ms
runner.transpile (opt level 3) 4 qubits 120 ms
runner.run_circuit (simulator) 4 qubits, 10k shots 800 ms
classical_kuramoto_reference (Rust) 8 oscillators 0.3 ms
classical_kuramoto_reference (Python) 8 oscillators 12 ms
classical_exact_diag 8 qubits 15 ms
classical_exact_evolution 8 qubits 120 ms
heron_r2_noise_model — 5 ms
PennyLaneRunner.run_trotter 3 qubits 50 ms
partition_circuit 16 → 2×8 qubits 25 ms
dynq_initial_layout (156-qubit graph, 5-qubit circuit) — < 100 ms
learn_symmetry_decay (5 noise scales, Rust) — < 1 µs
guess_extrapolate_batch (1,000 observables, Rust) — < 50 µs
hypergeometric_envelope (10,000 points, Rust) — 2.6 ms
ici_three_level_evolution (2,000 points, Rust) — 0.04 ms