Skip to content

✨ Add program serializers to the Qiskit backend and hand IQM JSON to QDMI-on-IQM - #2114

Merged
burgholzer merged 10 commits into
mainfrom
agent/2094-move-iqm-conversion
Aug 18, 2026
Merged

✨ Add program serializers to the Qiskit backend and hand IQM JSON to QDMI-on-IQM#2114
burgholzer merged 10 commits into
mainfrom
agent/2094-move-iqm-conversion

Conversation

@marcelwa

@marcelwa marcelwa commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Description

This adds a registration seam that lets any package supply the conversion from a Qiskit circuit into a program format, and moves MQT Core's own vendor-specific conversion out through it.

The Qiskit backend used to decide in its own code how to turn a circuit into a program, with one branch per format. It now takes every format from a registered program serializer, and MQT Core registers its OpenQASM 2 and OpenQASM 3 serializers the same way as everyone else. _serialize_circuit is one ordered walk with no format-specific branch left.

That makes the vendor carve-out possible. MQT Core carried qiskit_to_iqm_json and an IQM MoveGate inside its vendor-neutral adapter; nothing in Core used either, and the sole consumer is the external iqm-qdmi package. Removing them alone would have broken IQM execution, because the IQM QDMI device advertises only QIR_BASE_STRING and IQM_JSON, with no OpenQASM to fall back to. Registered through the seam, IQM JSON keeps working from outside Core.

The seam

A new mqt.core.plugins.qiskit.serializers module holds a registry keyed by ProgramFormat. A program serializer turns one circuit into one program in one format.

A format fixes the kind of payload it carries, so there are two signatures:

def serialize(circuit: QuantumCircuit, backend: QDMIBackend) -> str: ...   # text format
def serialize(circuit: QuantumCircuit, backend: QDMIBackend) -> bytes: ...  # binary format

The serializer takes the backend, so it can reach the device through the new QDMIBackend.device property and the supported operations through QDMIBackend.target. The API is register_program_serializer(fmt, fn, *, replace=False), unregister_program_serializer(fmt), program_serializer(fmt), and preferred_program_formats(formats).

A package advertises a serializer through an entry point group:

[project.entry-points."mqt.core.qiskit.program_serializers"]
IQM_JSON = "iqm.qdmi.serializers:qiskit_to_iqm_json"

Entry points rather than only a runtime call, because QDMIProvider builds a plain QDMIBackend for every registered device: a user can reach a registered IQM device without ever importing iqm.qdmi, and an import-time registration would miss that path. An entry point naming an unknown format, naming a format with no program payload, or failing to import warns and is skipped, so one broken package cannot hide the others.

Core registers its own OpenQASM 2 and OpenQASM 3 serializers through the same registry, so _serialize_circuit is one ordered walk with no format-specific branch, and a provider can replace the built-ins with replace=True.

Which format wins

PROGRAM_FORMAT_PREFERENCE states the order explicitly, most preferred first:

IQM_JSON, CUSTOM1 ... CUSTOM5,
QIR_ADAPTIVE_MODULE, QIR_ADAPTIVE_STRING,
QPY, QASM3,
QIR_BASE_MODULE, QIR_BASE_STRING,
QASM2

A device-native format comes first, because a package that registers a serializer for its own device's format wants that format used, and that is the precedence IQM JSON had. The standardized formats follow in order of what a circuit may contain: the QIR adaptive profile allows classical control, QPY carries a Qiskit circuit without loss, and OpenQASM 3 expresses control flow, while the QIR base profile forbids classical feedback and OpenQASM 2 has no control flow at all. Encoding only breaks a tie within one profile. CALIBRATION and BATCH_JOB are absent because a serialized circuit is not what they carry. The list decides, not the order the device reports — test_backend_respects_format_preference covers that.

Text and binary payloads

Core's C++ client already knew which formats are binary and which carry no program at all: src/qdmi/Client.cpp had isBinaryProgramFormat and hasNoGenericProgramPayload in an anonymous namespace, and the text submitJob overload rejects a binary format. Rather than restate that in Python, both move to include/mqt-core/qdmi/Client.hpp and are bound as mqt.core.qdmi.is_binary_program_format and mqt.core.qdmi.has_program_payload. The backend checks a serializer's return type against its format and raises TranslationError on a mismatch. Device.submit_job already overloads on str | bytes, so a binary payload reaches the device unchanged.

The gate

MoveGate moves to iqm.qdmi.gates. QDMIBackend gains an empty _EXTRA_GATES class variable and an __init_subclass__ that rebuilds the gate maps per subclass, so a backend for a device with a native gate outside Qiskit's standard library supplies it without global state. _map_operation_to_gate and _map_qiskit_gate_to_operation_names become classmethods so a subclass reads its own maps. Every Qiskit provider with a non-standard native gate does the same: GPIGate and friends in qiskit-ionq, HamiltonianGate in qiskit-pasqal-provider, MoveGate in qiskit-on-iqm.

Deliberately left alone

  • The "r": {"prx"} alias stays in _GATE_ALIASES. prx is IQM terminology, but Core's own SC device model uses it and 🧹 MQT Core v4 cleanup and ownership tracking #2085 keeps that device supported.
  • json/sc/iqm-garnet.json and json/sc/iqm-emerald.json stay: device models, not conversion logic.
  • TranslationError keeps its name. It belongs to a hierarchy shared with the PennyLane plugin (PennyLaneTranslationError and friends), so renaming it is a separate cross-plugin change.

Related

The downstream half is iqm-finland/QDMI-on-IQM#189, which adds the serializer, the MoveGate, and the entry point. It pins this branch and stays open until MQT Core v4 ships. It must follow this PR's renames: iqm.qdmi.serializers, the mqt.core.qiskit.program_serializers group, and the (circuit, backend) signature.

This also reframes #1348: QIR and QPY support becomes a serializer registered by whoever owns the format rather than another branch in the backend.

Design notes, including the Qiskit ecosystem cross-check and the decision log, are in .agent/plans/2094-iqm-conversion-carveout.md.

Fixes #2094

Validation

  • uv run --no-sync pytest test/python — 619 passed, 4 skipped
  • uv run --no-sync pytest test/python/plugins/qiskit and test/python/qdmi — 356 passed
  • ./build/release/test/qdmi/mqt-core-qdmi-test — 274 passed, no compiler warnings
  • uvx nox -s stubs — the stub diff contains only the two new functions
  • uvx nox --non-interactive -s docs — passes with -W -n
  • uvx nox -s lint — passes, full prek hook set

Built against LLVM/MLIR 22.1.0. Not verified locally: the cross-repository check with iqm-qdmi installed alongside, which needs QDMI-on-IQM#189.

Checklist

  • The pull request only contains commits that are focused and relevant to this change.
  • I have added appropriate tests that cover the new/changed functionality.
  • I have updated the documentation to reflect these changes.
  • I have added entries to the changelog for any noteworthy additions, changes, fixes, or removals.
  • I have added migration instructions to the upgrade guide (if needed).
  • The changes follow the project's style guidelines and introduce no new warnings.
  • The changes are fully tested and pass the CI checks.
  • I have reviewed my own code changes.

If PR contains AI-assisted content:

  • Any agent that created, edited, or submitted GitHub content was explicitly authorized for that scope, as required by our AI Usage Guidelines.
  • Every agent-authored or agent-edited public text body begins with the visible disclosure 🤖 *AI text below* 🤖 (titles are exempt).
  • I have disclosed AI assistance in the PR description.
  • I confirm that I have personally reviewed and understood all AI-generated content, and accept full responsibility for it.

AI assistance: written by Claude Opus 5 via Claude Code, under my direction. The last checkbox is left for me to tick after my own review.

@marcelwa marcelwa self-assigned this Aug 14, 2026
@marcelwa marcelwa added refactor Anything related to code refactoring QDMI Anything related to QDMI labels Aug 14, 2026
@mergify mergify Bot added the conflict label Aug 14, 2026
@marcelwa marcelwa added the python Anything related to Python code label Aug 14, 2026
@marcelwa
marcelwa requested a review from burgholzer August 14, 2026 16:07
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.25373% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
python/mqt/core/plugins/qiskit/backend.py 97.9% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@burgholzer burgholzer added this to the QDMI Support milestone Aug 17, 2026

@burgholzer burgholzer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @marcelwa 👋🏼
Thanks a lot! I generally did not think too much about the replacement of the custom converters in MQT Core when the initial issue was created and thought it could simply be removed. But I now fully see and agree that it needs some kind of conversion registration that external packages can provide.

I purposely only reviewed the ExecPlan here and added requests for changes to it because I think this most effectively steers the implementation in the right direction.
I hope the comments themselves make sense.

Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
Comment thread .agent/plans/2094-iqm-conversion-carveout.md Outdated
marcelwa added a commit that referenced this pull request Aug 17, 2026
Address the review on #2114: rename the seam from "program codec" to
program serializer, split the signature into text and binary payloads,
encode the format preference as one explicit order, register MQT Core's
own OpenQASM serializers through the same registry, record the Qiskit
provider cross-check and the mid-term mqt-cc outlook, and restructure the
plan to follow .agent/PLANS.md.
@mergify mergify Bot added conflict and removed conflict labels Aug 17, 2026
@marcelwa
marcelwa requested a review from burgholzer August 18, 2026 08:48

@burgholzer burgholzer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @marcelwa 🙏🏼 This is looking great already!
I have a couple of requests for changes inline; some of them are related to pre-existing code that may not be fully ideal.
More generally, I believe this PR's title and description should be updated to better match what the PR adds.

Comment thread bindings/qdmi/qdmi.cpp Outdated
Comment thread docs/qdmi/qdmi_backend.md Outdated
Comment thread python/mqt/core/plugins/qiskit/backend.py
Comment thread python/mqt/core/plugins/qiskit/serializers.py Outdated
Comment thread CHANGELOG.md
Comment thread UPGRADING.md
@marcelwa marcelwa changed the title 🚚 Move IQM conversion support to QDMI-on-IQM ✨ Add program serializers to the Qiskit backend and hand IQM JSON to QDMI-on-IQM Aug 18, 2026

@burgholzer burgholzer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it is ready to go in 🚀
I'll wait for the stacked PR to also turn green and merge this as one stack afterwards! Then, I'll likely kick off a backport after the PennyLane PR is merged so that this makes it onto the v3.x branch and into the 3.9 release.

@mergify mergify Bot added the conflict label Aug 18, 2026
marcelwa and others added 10 commits August 18, 2026 17:59
MQT Core's Qiskit adapter converted circuits to IQM JSON and shipped an IQM
MoveGate, putting one vendor's format inside a generic adapter. No device MQT
Core ships advertises IQM JSON.

Add a program codec registry: a package that owns a device registers the
conversion into its format, either through the mqt.core.qiskit.program_codecs
entry point group or through register_program_codec. The backend prefers a
registered codec over its built-in OpenQASM 3 and OpenQASM 2 exporters, so
installing iqm-qdmi keeps IQM JSON submission working, including for a plain
QDMIBackend opened through QDMIProvider.

A subclass supplies a device-native gate outside Qiskit's standard library
through _EXTRA_GATES. Remove qiskit_to_iqm_json and MoveGate.

Assisted-by: claude-opus-5 via Claude Code
Assisted-by: claude-opus-5 via Claude Code
Address the review on #2114: rename the seam from "program codec" to
program serializer, split the signature into text and binary payloads,
encode the format preference as one explicit order, register MQT Core's
own OpenQASM serializers through the same registry, record the Qiskit
provider cross-check and the mid-term mqt-cc outlook, and restructure the
plan to follow .agent/PLANS.md.
`src/qdmi/Client.cpp` already decided which program formats carry a binary
payload and which carry no program payload at all, but it kept both predicates
in an anonymous namespace. The Python side needs the same answers to choose a
program serializer, and a second copy would drift the first time QDMI adds a
format.

Move `isBinaryProgramFormat` and `hasProgramPayload` into the public
`include/mqt-core/qdmi/Client.hpp` and bind them as
`mqt.core.qdmi.is_binary_program_format` and
`mqt.core.qdmi.has_program_payload`. The private helper stated the negative,
`hasNoGenericProgramPayload`; the public one states the positive. Both
`Device::submitJob` overloads behave as before.

The new C++ test states its expectations through a switch with no default case,
so a program format added to QDMI later produces an unhandled-enumerator
warning instead of an unnoticed classification.
…egistry

Qiskit calls this operation serialization: `qiskit.qasm3.dumps` serializes a
circuit into a string and `qiskit.qpy.dump` writes the binary form. IQM uses the
same word. "Codec" is not established in this community, and "translator" would
collide with basis translation, which the same adapter relies on. Rename the
registry module to `serializers.py` and the entry point group to
`mqt.core.qiskit.program_serializers`.

Split the callable into `TextProgramSerializer`, which returns `str`, and
`BinaryProgramSerializer`, which returns `bytes`. A program format fixes the kind
of payload it carries, so one registry keyed by format is enough and the backend
checks the returned type before submission. A serializer takes the backend rather
than the device, because MQT Core's own OpenQASM 3 serializer needs the Target to
decide which gate definitions to suppress. `QDMIBackend` gains a public `device`
property so a serializer can still reach the device.

Add `PROGRAM_FORMAT_PREFERENCE`, an ordered tuple, and `preferred_program_formats`,
which applies it to the formats a device reports and drops any that carry no
program payload. A device-native format comes first, because a package that
registers a serializer for its own device's format wants that format used. One
tuple in one module means the whole policy can be read, and changed, in one place.

Register MQT Core's own OpenQASM 2 and OpenQASM 3 serializers through the same
registry and reduce `_convert_circuit` to `_serialize_circuit`: one walk over the
preferred formats that calls the first registered serializer it finds. The
backend now holds no format-specific branch, and a provider whose device needs a
different OpenQASM 3 export can replace ours the way it registers anything else.
A binary payload travels to `submit_job` as `bytes`.

`register_program_serializer` does not read the entry points. A registration must
be able to precede them, because that is what gives it precedence, and because
`backend.py` registers its own formats while the adapter is still importing.

Milestone 4 of the plan needed no code change: the `_EXTRA_GATES` seam, the
classmethod gate maps, and the removal of `converters.py` and `gates.py` already
match the revised design. Only the package exports moved to the serializer names.
Rewrite the "Program Codecs" section of the QDMI backend guide as "Program
Serializers": what a serializer is, the two payload signatures and how the
program format decides between them, the entry point declaration, the runtime
call, and the preference order with the reasoning behind it. Update the numbered
list that describes what happens when a circuit runs.

Bring the changelog and upgrade guide to the serializer names, add an entry for
the two new `mqt.core.qdmi` functions, and give the upgrade guide an example
serializer that takes the backend and returns bytes.

Record the outcome, the two ordering discoveries, and the parameter-name change
in the ExecPlan.
An earlier draft grouped the QIR formats together and ranked both profiles
above QPY and OpenQASM 3, which treats QIR as one capability tier. The QIR
base profile forbids classical feedback and mid-circuit control, while QPY
carries a Qiskit circuit without loss and OpenQASM 3 expresses control flow,
so both belong between the two QIR profiles.

Shorten the changelog entries to one sentence each, as the surrounding
entries are, and split the upgrade guide into one section for the
serializer concept and one for the IQM JSON move.
An earlier revision also bound `has_program_payload`, renamed from the
private `hasNoGenericProgramPayload`. Dropping "generic" made the name
claim more than QDMI supports: QDMI declares `CALIBRATION` as `void*` "A
calibration program" and says only that triggering a calibration run does
not require a program to be set, so its payload is optional rather than
absent, while `BATCH_JOB` takes a list of job handles rather than a byte
blob. One predicate cannot state both.

Withdraw that binding and restore the private helper under its original
name, so `Device::submitJob` behaves and reads as before. The serializer
registry now states what it actually needs on its own terms: which formats
can hold a serialized circuit.
Two module globals guarded discovery with a boolean that meant both
"loading" and "loaded", and the tests reached in to monkey-patch them.
`functools.cache` cannot replace the flag: it records its result only after
the call returns, so it does not guard the re-entry that happens while an
entry point imports third-party code.

Move both to a private registry object. It publishes the discovered
serializers in one step at the end, so a re-entrant lookup sees the same
thing whatever order the entry points arrive in; it restores the cold state
when discovery aborts, so unreadable distribution metadata no longer marks
the registry loaded forever; and it takes the discovery function as an
argument, so a test builds an isolated registry rather than patching module
state. Discovery is still not thread-safe, as before, and says so.

Cover re-entrant discovery, registration during discovery, retry after an
aborted discovery, and reading the entry points once.
Replace the process-wide registry in the facade test so the test does not load installed entry points or retain serializers for later tests. State the registry concurrency limit without describing one possible race.

Assisted-by: GPT-5 via Codex
@burgholzer
burgholzer force-pushed the agent/2094-move-iqm-conversion branch from 7d6fcc4 to b8d2902 Compare August 18, 2026 18:04
@mergify mergify Bot removed the conflict label Aug 18, 2026
@burgholzer
burgholzer merged commit f668087 into main Aug 18, 2026
44 of 46 checks passed
@burgholzer
burgholzer deleted the agent/2094-move-iqm-conversion branch August 18, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Anything related to Python code QDMI Anything related to QDMI refactor Anything related to code refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🚚 Move IQM conversion support to QDMI-on-IQM

2 participants