Qx.Hardware.Ibm (Qx - Quantum Computing Simulator v0.8.0)
View SourceHTTP client for IBM Quantum (Qiskit Runtime REST API).
Wraps Req; callers never touch HTTP details directly.
Auth
IBM Cloud splits identity into:
- API key — exchanged at
iam.cloud.ibm.com/identity/tokenfor a 1-hour bearer token (access_token). - Service-CRN — sent on every API request to identify which Quantum instance the request is for. Cannot be derived from the API key.
- Region — encoded into the CRN; the API base URL must match.
Tokens are 1-hour TTL; long queue waits routinely outlive them. Every
authed call is wrapped in with_iam_refresh/2, which catches 401, runs
a fresh IAM exchange once, and retries.
Sessions are optional — we don't use them
IBM's current spec (2026-05) treats sessions as optional and supports
direct POST /jobs. Empirically verified against a production-proven
reference (qx_server, last working 2026-02 and no relevant IBM API
changes since per the changelog). Dropping sessions removes a request,
an error path, and a leakage class.
Iron Law #1
IBM job-status values arrive as binaries from the wire (Pascal-Case per
the documented enum: "Queued", "Running", "Completed",
"Cancelled", "Cancelled - Ran too long", "Failed"). They are
matched against @known_statuses and returned as binaries — never
String.to_atom/1-ed.
Privacy invariant
This module never sees the qxportal token, and Qx.Hardware.Portal
never sees the IBM API key or CRN. Two independent clients, two
independent auth flows; the shared Qx.Hardware.Config struct is the
only point of contact and each side reads only its own fields.
Summary
Functions
Returns the IBM Quantum API base URL for a region.
Cancels a running job. Returns :ok on 200/204 (including the
"already terminal" idempotent path) and on 404 (job already gone).
Returns the coupling_map, basis_gates, and num_qubits for a
backend. These are the fields qxportal's /api/v1/transpile payload
requires.
Fetches the result of a finished Sampler job and aggregates the individual shot samples into a counts map.
Exchanges the user's API key for a 1-hour IAM bearer token.
Lists backends available on the user's instance.
Returns the current job status as a binary.
Submits a Sampler job. The PUB-format wrapping
(pubs: [[qasm, nil, shots]]) is done here so callers never build the
raw shape — forgetting the outer list is a 400. The 3-element PUB
carries qasm + null observable + shot count (matches the
production-proven qx_server wire format).
Functions
Returns the IBM Quantum API base URL for a region.
Known regions resolve to documented hosts; other allowlisted regions
follow the standard <region>.quantum.cloud.ibm.com/api/v1 pattern.
@spec cancel_job(Qx.Hardware.Config.t(), String.t()) :: :ok | {:error, term()}
Cancels a running job. Returns :ok on 200/204 (including the
"already terminal" idempotent path) and on 404 (job already gone).
Uses POST /jobs/{id}/cancel per IBM's current spec.
@spec fetch_backend_configuration(Qx.Hardware.Config.t(), String.t()) :: {:ok, %{ coupling_map: [[non_neg_integer()]], basis_gates: [String.t()], num_qubits: non_neg_integer() }} | {:error, term()}
Returns the coupling_map, basis_gates, and num_qubits for a
backend. These are the fields qxportal's /api/v1/transpile payload
requires.
IBM serves this static device shape from
GET /v1/backends/{name}/configuration (NOT /properties, which
returns time-varying per-gate / per-qubit calibration data). IBM names
the qubit count n_qubits on the wire; we expose it as :num_qubits.
@spec fetch_results(Qx.Hardware.Config.t(), String.t()) :: {:ok, %{counts: map(), metadata: map()}} | {:error, term()}
Fetches the result of a finished Sampler job and aggregates the individual shot samples into a counts map.
IBM's Sampler V2 response shape (verified live 2026-05):
{
"results": [{
"data": {
"<classical_register_name>": {
"samples": ["0x0", "0x3", "0x3", ...],
"num_bits": 2
}
},
"metadata": {...}
}],
"metadata": {...}
}IBM does NOT pre-aggregate counts — each shot is returned as a hex
bitstring under data.<reg>.samples. We aggregate ourselves: hex →
integer → fixed-width binary → frequency map.
Returns the IBM-side merged metadata for downstream display.
Errors:
:unexpected_response— body doesn't haveresults: [_ | _]:unsupported_result— first result has no recognizable data shape (e.g. Estimator tensor format)
@spec iam_exchange(Qx.Hardware.Config.t()) :: {:ok, Qx.Hardware.Config.t()} | {:error, term()}
Exchanges the user's API key for a 1-hour IAM bearer token.
Returns the input config with :access_token and :token_expires_at
populated.
@spec list_backends(Qx.Hardware.Config.t()) :: {:ok, [%{name: String.t(), status: String.t() | nil, num_qubits: integer() | nil}]} | {:error, term()}
Lists backends available on the user's instance.
Decodes only :name, :status, :num_qubits from each entry.
@spec poll_job(Qx.Hardware.Config.t(), String.t()) :: {:ok, %{status: String.t(), reason: String.t() | nil}} | {:error, term()}
Returns the current job status as a binary.
IBM's GET /jobs/{id} returns BOTH a top-level status and a nested
state.status (the schema-required path). We read state.status and
fall back to top-level.
Status is matched against @known_statuses; an unknown value becomes
{:error, {:unknown_status, raw}} so an API drift surfaces loudly
rather than silently being misclassified.
@spec submit_sampler(Qx.Hardware.Config.t(), String.t(), String.t(), pos_integer()) :: {:ok, String.t()} | {:error, term()}
Submits a Sampler job. The PUB-format wrapping
(pubs: [[qasm, nil, shots]]) is done here so callers never build the
raw shape — forgetting the outer list is a 400. The 3-element PUB
carries qasm + null observable + shot count (matches the
production-proven qx_server wire format).
No session is opened.